139 lines
5.5 KiB
JavaScript
139 lines
5.5 KiB
JavaScript
import { BATCHES, getBatchForHour, batchRangeLabel, getRowBatchId, getBatchLabel } from '@/lib/batchBucket';
|
||
|
||
/**
|
||
* Morning/Afternoon/Evening wave bucketing.
|
||
*
|
||
* The bug these tests exist to prevent: the windows used to cover only 14.5 of
|
||
* 24 hours, so an order placed at 2:43pm belonged to no batch and vanished from
|
||
* every batch filter. The invariant is TOTAL COVERAGE — every hour of the day
|
||
* lands in exactly one batch — plus the field the bucketing reads (`orderdate`,
|
||
* never `assigntime` or `expecteddeliverytime`).
|
||
*/
|
||
describe('batchBucket', () => {
|
||
describe('BATCHES definition', () => {
|
||
it('should define exactly the three operator-facing waves', () => {
|
||
expect(BATCHES.map((b) => b.id)).toEqual(['morning', 'afternoon', 'evening']);
|
||
});
|
||
|
||
it('should cover the whole day with no gap and no overlap', () => {
|
||
const sorted = [...BATCHES].sort((a, b) => a.startHour - b.startHour);
|
||
expect(sorted[0].startHour).toBe(0);
|
||
expect(sorted[sorted.length - 1].endHour).toBe(24);
|
||
sorted.slice(1).forEach((batch, index) => {
|
||
expect(batch.startHour).toBe(sorted[index].endHour);
|
||
});
|
||
});
|
||
|
||
it('should assign every quarter-hour of the day to exactly one batch', () => {
|
||
for (let hour = 0; hour < 24; hour += 0.25) {
|
||
const matches = BATCHES.filter((b) => hour >= b.startHour && hour < b.endHour);
|
||
expect(matches).toHaveLength(1);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('getBatchForHour', () => {
|
||
it.each([
|
||
[0, 'morning'],
|
||
[8.99, 'morning'],
|
||
[9, 'afternoon'],
|
||
[14.716, 'afternoon'],
|
||
[15.99, 'afternoon'],
|
||
[16, 'evening'],
|
||
[23.99, 'evening']
|
||
])('should bucket hour %p into %s', (hour, expected) => {
|
||
expect(getBatchForHour(hour)).toBe(expected);
|
||
});
|
||
|
||
it('should treat every window as half-open [start, end)', () => {
|
||
expect(getBatchForHour(9)).toBe('afternoon');
|
||
expect(getBatchForHour(8.999999)).toBe('morning');
|
||
expect(getBatchForHour(16)).toBe('evening');
|
||
expect(getBatchForHour(15.999999)).toBe('afternoon');
|
||
});
|
||
|
||
it.each([-0.1, 24, 25, NaN])('should return null for the out-of-range hour %p', (hour) => {
|
||
expect(getBatchForHour(hour)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('getRowBatchId', () => {
|
||
it('should bucket on orderdate — the booking creation time', () => {
|
||
expect(getRowBatchId({ orderdate: '2026-08-27T07:15:00' })).toBe('morning');
|
||
expect(getRowBatchId({ orderdate: '2026-08-27T10:00:00' })).toBe('afternoon');
|
||
expect(getRowBatchId({ orderdate: '2026-08-27T18:30:00' })).toBe('evening');
|
||
});
|
||
|
||
it('should bucket the 2:43pm order that used to fall into the old 12:30-4pm black hole', () => {
|
||
expect(getRowBatchId({ orderdate: '2026-08-27T14:43:00' })).toBe('afternoon');
|
||
});
|
||
|
||
it('should use fractional hours so the back half of an hour is not mis-bucketed', () => {
|
||
expect(getRowBatchId({ orderdate: '2026-08-27T15:45:00' })).toBe('afternoon');
|
||
expect(getRowBatchId({ orderdate: '2026-08-27T16:01:00' })).toBe('evening');
|
||
});
|
||
|
||
it('should strip a false trailing Z rather than shifting the row into another batch', () => {
|
||
// 20:00 with a false Z read as a real UTC instant becomes 01:30 the next
|
||
// day in IST, which would move an evening order into Morning.
|
||
expect(getRowBatchId({ orderdate: '2026-08-27T20:00:00Z' })).toBe('evening');
|
||
});
|
||
|
||
it('should refuse to bucket a bare date, which has no time component', () => {
|
||
// Midnight is a real morning-batch hour, so guessing would silently pile
|
||
// every date-only row into Morning.
|
||
expect(getRowBatchId({ orderdate: '2026-08-27' })).toBeNull();
|
||
expect(getRowBatchId({ orderdate: ' 2026-08-27 ' })).toBeNull();
|
||
});
|
||
|
||
it.each([
|
||
['a row with no orderdate', {}],
|
||
['a null orderdate', { orderdate: null }],
|
||
['an empty orderdate', { orderdate: '' }],
|
||
['a null row', null],
|
||
['an undefined row', undefined]
|
||
])('should return null for %s', (_label, row) => {
|
||
expect(getRowBatchId(row)).toBeNull();
|
||
});
|
||
|
||
it('should return null for an unparseable orderdate rather than throwing', () => {
|
||
expect(getRowBatchId({ orderdate: 'yesterday-ish' })).toBeNull();
|
||
});
|
||
|
||
it('should ignore assigntime and expecteddeliverytime entirely', () => {
|
||
// Both were tried and rejected: assigntime is re-stamped by any status
|
||
// change, expecteddeliverytime describes the promised slot.
|
||
const row = {
|
||
orderdate: '2026-08-27T07:00:00',
|
||
assigntime: '2026-08-27T18:00:00',
|
||
expecteddeliverytime: '2026-08-27T16:30:00'
|
||
};
|
||
expect(getRowBatchId(row)).toBe('morning');
|
||
});
|
||
});
|
||
|
||
describe('labels', () => {
|
||
it('should describe the morning and afternoon windows as ranges', () => {
|
||
expect(batchRangeLabel(BATCHES[0])).toBe('12 AM–9 AM');
|
||
expect(batchRangeLabel(BATCHES[1])).toBe('9 AM–4 PM');
|
||
});
|
||
|
||
it('should describe a window that runs to end of day as "After X"', () => {
|
||
expect(batchRangeLabel(BATCHES[2])).toBe('After 4 PM');
|
||
});
|
||
|
||
it('should render a half-hour boundary with minutes', () => {
|
||
expect(batchRangeLabel({ startHour: 12.5, endHour: 16 })).toBe('12:30 PM–4 PM');
|
||
});
|
||
|
||
it('should return the human label for a known batch id', () => {
|
||
expect(getBatchLabel('morning')).toBe('Morning Batch');
|
||
expect(getBatchLabel('evening')).toBe('Evening Batch');
|
||
});
|
||
|
||
it('should fall back to the id itself for an unknown batch', () => {
|
||
expect(getBatchLabel('midnight')).toBe('midnight');
|
||
});
|
||
});
|
||
});
|