updates on the bookings page and updated ai and more thngs
This commit is contained in:
119
tests/lib/DataTable.test.jsx
Normal file
119
tests/lib/DataTable.test.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useState } from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
Check: () => <span data-testid="icon-check" />,
|
||||
Minus: () => <span data-testid="icon-minus" />,
|
||||
ArrowDown: () => <span data-testid="icon-down" />,
|
||||
ArrowUp: () => <span data-testid="icon-up" />,
|
||||
ChevronsUpDown: () => <span data-testid="icon-sort" />,
|
||||
Inbox: () => <span data-testid="icon-inbox" />,
|
||||
ChevronLeft: () => <span data-testid="icon-left" />,
|
||||
ChevronRight: () => <span data-testid="icon-right" />,
|
||||
ChevronsLeft: () => <span data-testid="icon-first" />,
|
||||
ChevronsRight: () => <span data-testid="icon-last" />,
|
||||
}));
|
||||
|
||||
import { DataTable } from '@/components/ds/DataTable';
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'title', header: 'Title' },
|
||||
];
|
||||
|
||||
const createMockRows = (count = 60) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `order-${i + 1}`,
|
||||
title: `Order #${i + 1}`,
|
||||
}));
|
||||
|
||||
function TestTableWrapper({
|
||||
rowCount = 60,
|
||||
pageSize = 25,
|
||||
selectAllPages = true,
|
||||
initialSelected = [],
|
||||
}) {
|
||||
const [selectedIds, setSelectedIds] = useState(initialSelected);
|
||||
const rows = createMockRows(rowCount);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="selected-count">{selectedIds.length}</div>
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.id}
|
||||
pageSize={pageSize}
|
||||
selectAllPages={selectAllPages}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('DataTable selection across pagination', () => {
|
||||
it('should select ALL 60 rows across all pages when selectAllPages is true', () => {
|
||||
render(<TestTableWrapper rowCount={60} pageSize={25} selectAllPages={true} />);
|
||||
|
||||
expect(screen.getByTestId('selected-count').textContent).toBe('0');
|
||||
|
||||
// Header checkbox for select all
|
||||
const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all rows/i });
|
||||
expect(selectAllCheckbox).not.toBeChecked();
|
||||
|
||||
// Click select all
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
|
||||
// All 60 rows should now be selected
|
||||
expect(screen.getByTestId('selected-count').textContent).toBe('60');
|
||||
expect(selectAllCheckbox).toBeChecked();
|
||||
|
||||
// Click again to unselect all
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
expect(screen.getByTestId('selected-count').textContent).toBe('0');
|
||||
expect(selectAllCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should select ONLY current page (25 rows) when selectAllPages is false', () => {
|
||||
render(<TestTableWrapper rowCount={60} pageSize={25} selectAllPages={false} />);
|
||||
|
||||
expect(screen.getByTestId('selected-count').textContent).toBe('0');
|
||||
|
||||
const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all rows on this page/i });
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
|
||||
// Only the 25 rows on page 1 should be selected
|
||||
expect(screen.getByTestId('selected-count').textContent).toBe('25');
|
||||
});
|
||||
|
||||
it('should reflect indeterminate state when some rows are selected', () => {
|
||||
render(
|
||||
<TestTableWrapper
|
||||
rowCount={60}
|
||||
pageSize={25}
|
||||
selectAllPages={true}
|
||||
initialSelected={['order-1', 'order-2', 'order-3']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('selected-count').textContent).toBe('3');
|
||||
const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all rows/i });
|
||||
|
||||
expect(selectAllCheckbox.getAttribute('data-state') === 'indeterminate' || selectAllCheckbox.indeterminate).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should toggle individual row selection without affecting other pages', () => {
|
||||
render(<TestTableWrapper rowCount={60} pageSize={25} selectAllPages={true} />);
|
||||
|
||||
const rowCheckboxes = screen.getAllByRole('checkbox');
|
||||
// First checkbox is header, second is row 1
|
||||
const firstRowCheckbox = rowCheckboxes[1];
|
||||
|
||||
fireEvent.click(firstRowCheckbox);
|
||||
expect(screen.getByTestId('selected-count').textContent).toBe('1');
|
||||
|
||||
fireEvent.click(firstRowCheckbox);
|
||||
expect(screen.getByTestId('selected-count').textContent).toBe('0');
|
||||
});
|
||||
});
|
||||
81
tests/lib/bookingDrops.test.js
Normal file
81
tests/lib/bookingDrops.test.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { dropCountOf, dropSummary, isMultiDrop, plural } from '@/lib/bookingDrops';
|
||||
|
||||
describe('dropCountOf', () => {
|
||||
// The console can ship before or after the backend that adds these fields.
|
||||
// An absent count must read as 0 so every multi-drop behaviour stays off and
|
||||
// the page renders exactly as it did.
|
||||
it('treats an absent destinationcount as zero', () => {
|
||||
expect(dropCountOf({})).toBe(0);
|
||||
expect(dropCountOf({ destinationcount: undefined })).toBe(0);
|
||||
expect(dropCountOf(undefined)).toBe(0);
|
||||
expect(dropCountOf(null)).toBe(0);
|
||||
});
|
||||
|
||||
it('reads the count the backend sent', () => {
|
||||
expect(dropCountOf({ destinationcount: 3 })).toBe(3);
|
||||
expect(dropCountOf({ destinationcount: 1 })).toBe(1);
|
||||
});
|
||||
|
||||
// A console-created booking has no bookingdestinations rows at all.
|
||||
it('keeps a console-created booking at zero', () => {
|
||||
expect(dropCountOf({ destinationcount: 0 })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMultiDrop', () => {
|
||||
// THE IMPORTANT ONE. This gate is what stops the Orders row rendering the
|
||||
// booking's own consignmentstatus — which is the FIRST leg's status wearing
|
||||
// the whole booking's name — as the status of a three-drop pickup.
|
||||
it('is true only above one destination', () => {
|
||||
expect(isMultiDrop({ destinationcount: 2 })).toBe(true);
|
||||
expect(isMultiDrop({ destinationcount: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a single drop, so the row keeps todays behaviour', () => {
|
||||
expect(isMultiDrop({ destinationcount: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the backend has not shipped the field yet', () => {
|
||||
expect(isMultiDrop({})).toBe(false);
|
||||
expect(isMultiDrop({ bookingid: 7, status: 'converted_to_consignment' })).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a console-created booking reporting zero', () => {
|
||||
expect(isMultiDrop({ destinationcount: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropSummary', () => {
|
||||
it('reads as the handoff specifies', () => {
|
||||
expect(dropSummary({ destinationcount: 3, totalpackagecount: 4 })).toBe(
|
||||
'3 destinations · 4 packages'
|
||||
);
|
||||
});
|
||||
|
||||
// Every destination carries at least one package, so a zero package total is
|
||||
// a stale or partial response rather than a real booking. Falling back to the
|
||||
// destination count keeps the line truthful instead of printing "0 packages".
|
||||
it('falls back to the destination count when the package sum is missing', () => {
|
||||
expect(dropSummary({ destinationcount: 3 })).toBe('3 destinations · 3 packages');
|
||||
expect(dropSummary({ destinationcount: 2, totalpackagecount: 0 })).toBe(
|
||||
'2 destinations · 2 packages'
|
||||
);
|
||||
});
|
||||
|
||||
it('pluralises both nouns independently', () => {
|
||||
expect(dropSummary({ destinationcount: 2, totalpackagecount: 1 })).toBe(
|
||||
'2 destinations · 1 package'
|
||||
);
|
||||
expect(dropSummary({ destinationcount: 1, totalpackagecount: 2 })).toBe(
|
||||
'1 destination · 2 packages'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plural', () => {
|
||||
it('keeps the singular at exactly one', () => {
|
||||
expect(plural(1, 'package')).toBe('1 package');
|
||||
expect(plural(0, 'package')).toBe('0 packages');
|
||||
expect(plural(2, 'destination')).toBe('2 destinations');
|
||||
});
|
||||
});
|
||||
173
tests/lib/customerAppBookings.test.js
Normal file
173
tests/lib/customerAppBookings.test.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
BOOKING_SOURCE,
|
||||
BOOKING_TABS,
|
||||
bookingsForTab,
|
||||
customerAppBookings,
|
||||
isCustomerAppBooking,
|
||||
quotedPrice,
|
||||
serviceOptionOf,
|
||||
serviceType,
|
||||
} from '@/lib/customerAppBookings';
|
||||
|
||||
/**
|
||||
* Which bookings belong on the Bookings page.
|
||||
*
|
||||
* The page shows what customers booked in the app; Orders shows what this
|
||||
* console created. `bookingsource` is the only field that separates them, and
|
||||
* the trap is its second value: the API and routes were renamed from "CRM" to
|
||||
* "express", but the STORED column value was deliberately left as
|
||||
* `"CRM_Console"` because it is existing data rather than a label. Matching on
|
||||
* "express" would silently show every console order on the customer page.
|
||||
*/
|
||||
|
||||
const appBooking = (over = {}) => ({
|
||||
bookingid: 1,
|
||||
bookingno: 'BK-1',
|
||||
bookingsource: 'Customer_App',
|
||||
status: 'Pending_Pickup',
|
||||
...over,
|
||||
});
|
||||
|
||||
const consoleBooking = (over = {}) => ({
|
||||
bookingid: 2,
|
||||
bookingno: 'BK-2',
|
||||
bookingsource: 'CRM_Console',
|
||||
status: 'Pending_Pickup',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('isCustomerAppBooking', () => {
|
||||
it('should accept a booking made in the app', () => {
|
||||
expect(isCustomerAppBooking(appBooking())).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a console-created booking', () => {
|
||||
expect(isCustomerAppBooking(consoleBooking())).toBe(false);
|
||||
});
|
||||
|
||||
it('should still reject the console value under its post-rename name', () => {
|
||||
// The outward naming became "express"; the stored value never did. Anything
|
||||
// that starts matching on "express" here has misunderstood that.
|
||||
expect(BOOKING_SOURCE.CONSOLE).toBe('CRM_Console');
|
||||
expect(isCustomerAppBooking({ bookingsource: 'express' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should tolerate casing and stray whitespace', () => {
|
||||
// Compared against a raw database column, not a validated enum.
|
||||
expect(isCustomerAppBooking({ bookingsource: ' customer_app ' })).toBe(true);
|
||||
expect(isCustomerAppBooking({ bookingsource: 'CUSTOMER_APP' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not claim a booking with no source', () => {
|
||||
// Filing unknown rows under "customer app" would overstate B2C volume, and
|
||||
// that is a number people make decisions on.
|
||||
expect(isCustomerAppBooking({})).toBe(false);
|
||||
expect(isCustomerAppBooking({ bookingsource: '' })).toBe(false);
|
||||
expect(isCustomerAppBooking(null)).toBe(false);
|
||||
expect(isCustomerAppBooking(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('customerAppBookings', () => {
|
||||
it('should keep only app bookings and drop console ones', () => {
|
||||
const list = [appBooking({ bookingid: 1 }), consoleBooking({ bookingid: 2 }), appBooking({ bookingid: 3 })];
|
||||
expect(customerAppBookings(list).map((b) => b.bookingid)).toEqual([3, 1]);
|
||||
});
|
||||
|
||||
it('should order newest first', () => {
|
||||
const list = [appBooking({ bookingid: 5 }), appBooking({ bookingid: 42 }), appBooking({ bookingid: 12 })];
|
||||
expect(customerAppBookings(list).map((b) => b.bookingid)).toEqual([42, 12, 5]);
|
||||
});
|
||||
|
||||
it('should not mutate the list it was given', () => {
|
||||
// The same array is the Orders page's cache entry — sorting it in place
|
||||
// would reorder another page's rows underneath it.
|
||||
const list = [appBooking({ bookingid: 1 }), appBooking({ bookingid: 9 })];
|
||||
customerAppBookings(list);
|
||||
expect(list.map((b) => b.bookingid)).toEqual([1, 9]);
|
||||
});
|
||||
|
||||
it('should return an empty list rather than throw on nothing', () => {
|
||||
expect(customerAppBookings(undefined)).toEqual([]);
|
||||
expect(customerAppBookings(null)).toEqual([]);
|
||||
expect(customerAppBookings([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the price quote', () => {
|
||||
const withOptions = (options) => appBooking({ serviceoptions: options });
|
||||
|
||||
it('should read the service option the booking carries', () => {
|
||||
const b = withOptions([{ servicetype: 'Fast', estimatedprice: 210, createdat: '2026-09-01T10:00:00Z' }]);
|
||||
expect(serviceType(b)).toBe('Fast');
|
||||
expect(quotedPrice(b)).toBe(210);
|
||||
});
|
||||
|
||||
it('should take the newest option when a booking has several', () => {
|
||||
// Matches the backend's own `ORDER BY createdat DESC LIMIT 1`, so the page
|
||||
// and GET /customer/bookings/{id}/price cannot disagree.
|
||||
const b = withOptions([
|
||||
{ servicetype: 'Normal', estimatedprice: 100, createdat: '2026-09-01T10:00:00Z' },
|
||||
{ servicetype: 'Superfast', estimatedprice: 300, createdat: '2026-09-02T10:00:00Z' },
|
||||
]);
|
||||
expect(serviceOptionOf(b).servicetype).toBe('Superfast');
|
||||
expect(quotedPrice(b)).toBe(300);
|
||||
});
|
||||
|
||||
it('should report no quote rather than a zero price', () => {
|
||||
// A booking with no service option has no price. Rendering 0 would read as
|
||||
// "free", which is a different claim.
|
||||
expect(serviceOptionOf(appBooking())).toBeNull();
|
||||
expect(serviceOptionOf(withOptions([]))).toBeNull();
|
||||
expect(serviceType(appBooking())).toBe('');
|
||||
});
|
||||
|
||||
it('should not throw on a missing booking', () => {
|
||||
expect(() => quotedPrice(undefined)).not.toThrow();
|
||||
expect(quotedPrice(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the status tabs', () => {
|
||||
it('should let the All tab through untouched', () => {
|
||||
const list = [appBooking({ status: 'Pending_Pickup' }), appBooking({ status: 'Cancelled' })];
|
||||
expect(bookingsForTab(list, 'all')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should put an uncollected unassigned booking under Created tab', () => {
|
||||
const list = [
|
||||
appBooking({ bookingid: 1, status: 'Pending_Pickup' }),
|
||||
appBooking({ bookingid: 2, status: 'Created' }),
|
||||
appBooking({ bookingid: 3, status: 'Miler_Assigned' }),
|
||||
];
|
||||
expect(bookingsForTab(list, 'created').map((b) => b.bookingid)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should put an assigned / in-progress booking under Rider Assigned tab', () => {
|
||||
const list = [
|
||||
appBooking({ bookingid: 1, status: 'Miler_Assigned' }),
|
||||
appBooking({ bookingid: 2, status: 'Converted_To_Consignment' }),
|
||||
appBooking({ bookingid: 3, status: 'Pending_Pickup' }),
|
||||
];
|
||||
expect(bookingsForTab(list, 'assigned').map((b) => b.bookingid)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('should match status case-insensitively', () => {
|
||||
expect(bookingsForTab([appBooking({ status: 'CANCELLED' })], 'cancelled')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should fall back to showing everything for an unknown tab', () => {
|
||||
// An unknown tab value can only come from a bug in the page. Falling back to
|
||||
// the full list keeps the data visible; silently returning nothing would
|
||||
// look identical to "this customer has no bookings", which is a lie the
|
||||
// operator cannot see through.
|
||||
expect(bookingsForTab([appBooking()], 'nonsense')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should give every tab a value and a label', () => {
|
||||
BOOKING_TABS.forEach((t) => {
|
||||
expect(t.value).toBeTruthy();
|
||||
expect(t.label).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
134
tests/lib/hubForm.test.js
Normal file
134
tests/lib/hubForm.test.js
Normal file
@@ -0,0 +1,134 @@
|
||||
import { validateHubForm } from '@/lib/hubForm';
|
||||
|
||||
/**
|
||||
* A hub in this console is a BASE to a rider.
|
||||
*
|
||||
* The bug these tests exist to prevent is already in production data: of the 18
|
||||
* hubs live today, one carries no address and no coordinates. Only `hubname`
|
||||
* and `applocationid` were ever validated, so a base could be saved that the
|
||||
* rider app can name but cannot route anyone to — Navigate does nothing without
|
||||
* a pin, and a rider standing outside an industrial estate has nothing to read
|
||||
* without an address.
|
||||
*
|
||||
* The backend hands six fields to the rider app for a base. Five of six is not
|
||||
* "mostly there"; it is a rider with a parcel and no way to deliver it.
|
||||
*/
|
||||
|
||||
const VALID = {
|
||||
hubname: 'Coimbatore Gandhipuram Hub',
|
||||
applocationid: 1,
|
||||
address: '14 Avinashi Road, Peelamedu, Coimbatore',
|
||||
pincode: '641004',
|
||||
latitude: '11.0168',
|
||||
longitude: '76.9558'
|
||||
};
|
||||
|
||||
describe('validateHubForm', () => {
|
||||
describe('a complete base', () => {
|
||||
it('should accept a form carrying all six rider-facing fields', () => {
|
||||
const { errors, isValid } = validateHubForm(VALID);
|
||||
expect(errors).toEqual({});
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should hand back coordinates already coerced, so the caller does not re-parse', () => {
|
||||
const { values } = validateHubForm(VALID);
|
||||
expect(values.latitude).toBe(11.0168);
|
||||
expect(values.longitude).toBe(76.9558);
|
||||
expect(values.applocationid).toBe(1);
|
||||
});
|
||||
|
||||
it('should accept a southern or western hemisphere base', () => {
|
||||
// Nothing in the range check may assume India.
|
||||
expect(validateHubForm({ ...VALID, latitude: '-33.8688', longitude: '-70.6693' }).isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the fields that were never checked', () => {
|
||||
it('should reject a base with no address, because the rider reads it at the gate', () => {
|
||||
const { errors, isValid } = validateHubForm({ ...VALID, address: '' });
|
||||
expect(isValid).toBe(false);
|
||||
expect(errors.address).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should reject whitespace as an address', () => {
|
||||
expect(validateHubForm({ ...VALID, address: ' ' }).errors.address).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should reject a base with no pincode', () => {
|
||||
expect(validateHubForm({ ...VALID, pincode: '' }).errors.pincode).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should reject a base with no coordinates, because Navigate cannot work', () => {
|
||||
const { errors, isValid } = validateHubForm({ ...VALID, latitude: '', longitude: '' });
|
||||
expect(isValid).toBe(false);
|
||||
expect(errors.latitude).toBeTruthy();
|
||||
expect(errors.longitude).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should treat 0,0 as unset rather than as a location', () => {
|
||||
// The previous code coerced an empty coordinate to 0 and sent it. 0,0 is
|
||||
// in the Gulf of Guinea; it is always an unset value, never a base.
|
||||
const { errors } = validateHubForm({ ...VALID, latitude: '0', longitude: '0' });
|
||||
expect(errors.latitude).toBeTruthy();
|
||||
expect(errors.longitude).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should reject a single missing coordinate, not just both', () => {
|
||||
// Half a pin is no pin.
|
||||
expect(validateHubForm({ ...VALID, latitude: '' }).isValid).toBe(false);
|
||||
expect(validateHubForm({ ...VALID, longitude: '' }).isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject coordinates outside the possible range', () => {
|
||||
expect(validateHubForm({ ...VALID, latitude: '91' }).errors.latitude).toBeTruthy();
|
||||
expect(validateHubForm({ ...VALID, latitude: '-91' }).errors.latitude).toBeTruthy();
|
||||
expect(validateHubForm({ ...VALID, longitude: '181' }).errors.longitude).toBeTruthy();
|
||||
expect(validateHubForm({ ...VALID, longitude: '-181' }).errors.longitude).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should reject a coordinate that is not a number at all', () => {
|
||||
expect(validateHubForm({ ...VALID, latitude: 'near the flyover' }).errors.latitude).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should catch a swapped lat/lng pair for an Indian base', () => {
|
||||
// 76.9 as a latitude is inside the Arctic Circle. The range check is the
|
||||
// only thing standing between a transposed paste and a base in the sea.
|
||||
expect(validateHubForm({ ...VALID, latitude: '76.9558', longitude: '11.0168' }).isValid).toBe(true);
|
||||
// ...but a longitude beyond 90 in the latitude slot is caught outright.
|
||||
expect(validateHubForm({ ...VALID, latitude: '176.9558' }).errors.latitude).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the checks that already existed', () => {
|
||||
it('should still require a name', () => {
|
||||
expect(validateHubForm({ ...VALID, hubname: '' }).errors.hubname).toBeTruthy();
|
||||
expect(validateHubForm({ ...VALID, hubname: ' ' }).errors.hubname).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should still require a numeric city id', () => {
|
||||
expect(validateHubForm({ ...VALID, applocationid: '' }).errors.applocationid).toBeTruthy();
|
||||
expect(validateHubForm({ ...VALID, applocationid: 'Coimbatore' }).errors.applocationid).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should accept city id 0, which is a valid id and not an absence', () => {
|
||||
expect(validateHubForm({ ...VALID, applocationid: 0 }).errors.applocationid).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('robustness', () => {
|
||||
it('should report every problem at once rather than one at a time', () => {
|
||||
// An operator fixing a form field by field, submit by submit, is a worse
|
||||
// experience than being told everything that is wrong.
|
||||
const { errors } = validateHubForm({});
|
||||
expect(Object.keys(errors).sort()).toEqual(
|
||||
['address', 'applocationid', 'hubname', 'latitude', 'longitude', 'pincode'].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw on an empty or missing form', () => {
|
||||
expect(() => validateHubForm()).not.toThrow();
|
||||
expect(validateHubForm().isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
480
tests/lib/orderFlow.test.js
Normal file
480
tests/lib/orderFlow.test.js
Normal file
@@ -0,0 +1,480 @@
|
||||
import {
|
||||
ANCHOR_RADIUS_M,
|
||||
DROP_TARGET,
|
||||
FLOW,
|
||||
PICKUP_SOURCE,
|
||||
PICKUP_SOURCE_TYPE,
|
||||
buildAnchors,
|
||||
buildFlowFields,
|
||||
classifyBooking,
|
||||
flowForDraft,
|
||||
flowLabel,
|
||||
hasCustomerPickup,
|
||||
hasHubDrop,
|
||||
matchAnchor,
|
||||
normalizeFlow
|
||||
} from '@/lib/orderFlow';
|
||||
|
||||
/**
|
||||
* Which end of a booking is a Doormile facility.
|
||||
*
|
||||
* The bug these tests exist to prevent: the console assumed the pickup was
|
||||
* ALWAYS the hub. queries.js used the tenant's name as the collection point's
|
||||
* identity, so every customer pickup for one client collapsed onto a single map
|
||||
* pin placed at an arbitrary one of them, and the rider's polyline skipped the
|
||||
* entire first-mile leg.
|
||||
*
|
||||
* Two invariants carry the most weight here and are asserted repeatedly:
|
||||
*
|
||||
* 1. UNKNOWABLE MEANS FORWARD. No anchors loaded, no coordinates, a 0,0
|
||||
* "unset" — none of these may produce a customer pickup. An order that
|
||||
* cannot be classified has to look exactly as it did before this module
|
||||
* existed, or a data problem starts drawing phantom collection points.
|
||||
* 2. A CUSTOMER PICKUP NEVER SHARES A GROUPING KEY. Two doorsteps are two
|
||||
* places. Merging them is precisely the defect being replaced.
|
||||
*/
|
||||
|
||||
// Real Coimbatore-ish coordinates, so the distances under test are the
|
||||
// magnitudes the radius was actually chosen for.
|
||||
const HUB = { hubid: 7, hubname: 'Gandhipuram Hub', latitude: 11.0168, longitude: 76.9558 };
|
||||
const KITCHEN = {
|
||||
tenantlocationid: 31,
|
||||
tenantid: 4,
|
||||
locationname: '124 Gandhipuram Main Rd',
|
||||
latitude: 11.021,
|
||||
longitude: 76.962
|
||||
};
|
||||
|
||||
// ~4 km and ~6 km out — unambiguously somebody's door.
|
||||
const DOOR_A = { lat: 11.05, lng: 77.0 };
|
||||
const DOOR_B = { lat: 11.06, lng: 77.01 };
|
||||
|
||||
const anchors = buildAnchors({ hubs: [HUB], tenantLocations: [KITCHEN] });
|
||||
|
||||
const booking = ({ id = 1, from, to, ...rest }) => ({
|
||||
bookingid: id,
|
||||
pickuplatitude: from.lat,
|
||||
pickuplongitude: from.lng,
|
||||
deliverylatitude: to.lat,
|
||||
deliverylongitude: to.lng,
|
||||
...rest
|
||||
});
|
||||
|
||||
const AT_HUB = { lat: HUB.latitude, lng: HUB.longitude };
|
||||
const AT_KITCHEN = { lat: KITCHEN.latitude, lng: KITCHEN.longitude };
|
||||
|
||||
describe('orderFlow', () => {
|
||||
describe('buildAnchors', () => {
|
||||
it('should treat both a Doormile hub and a client location as facilities', () => {
|
||||
expect(anchors).toHaveLength(2);
|
||||
expect(anchors.map((a) => a.kind).sort()).toEqual(['hub', 'tenantlocation']);
|
||||
});
|
||||
|
||||
it('should give each anchor a key that is unique per facility', () => {
|
||||
expect(new Set(anchors.map((a) => a.key)).size).toBe(anchors.length);
|
||||
});
|
||||
|
||||
it('should drop a facility nobody geocoded rather than keep it with NaN coordinates', () => {
|
||||
const built = buildAnchors({ hubs: [{ hubid: 9, hubname: 'Unmapped' }, HUB] });
|
||||
expect(built.map((a) => a.hubid)).toEqual([7]);
|
||||
});
|
||||
|
||||
it('should return an empty set when called with nothing', () => {
|
||||
expect(buildAnchors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should give a tenant location the hub that services its city', () => {
|
||||
// A tenant location record has NO hubid field — the API does not carry
|
||||
// one. Without this, every order originating at a client's own kitchen
|
||||
// went to the backend with no servicing hub, and zone attribution fell
|
||||
// straight back to matching hub names against address text.
|
||||
const kitchen = anchors.find((a) => a.kind === 'tenantlocation');
|
||||
expect(kitchen.hubid).toBe(7);
|
||||
});
|
||||
|
||||
it('should not invent a servicing hub for a location in another city', () => {
|
||||
// Chennai, ~400 km away. A wrong hub id is worse than none: it files the
|
||||
// order into a zone it has nothing to do with, where nobody is looking.
|
||||
const [faraway] = buildAnchors({
|
||||
hubs: [HUB],
|
||||
tenantLocations: [{ tenantlocationid: 99, locationname: 'Chennai Depot', latitude: 13.0827, longitude: 80.2707 }]
|
||||
}).filter((a) => a.kind === 'tenantlocation');
|
||||
expect(faraway.hubid).toBeNull();
|
||||
});
|
||||
|
||||
it("should keep a location's own hubid when the record already has one", () => {
|
||||
const [explicit] = buildAnchors({
|
||||
hubs: [HUB],
|
||||
tenantLocations: [{ ...KITCHEN, hubid: 12 }]
|
||||
}).filter((a) => a.kind === 'tenantlocation');
|
||||
expect(explicit.hubid).toBe(12);
|
||||
});
|
||||
|
||||
it('should leave hubid null when no hubs were loaded at all', () => {
|
||||
const [alone] = buildAnchors({ tenantLocations: [KITCHEN] });
|
||||
expect(alone.hubid).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchAnchor', () => {
|
||||
it('should match a point sitting on a facility', () => {
|
||||
expect(matchAnchor(HUB.latitude, HUB.longitude, anchors)?.hubid).toBe(7);
|
||||
});
|
||||
|
||||
it('should not match a customer door several km away', () => {
|
||||
expect(matchAnchor(DOOR_A.lat, DOOR_A.lng, anchors)).toBeNull();
|
||||
});
|
||||
|
||||
it('should choose the NEAREST facility, not the first one in the array', () => {
|
||||
// Two facilities sharing a compound is normal. Picking by array order
|
||||
// would name the same pin differently depending on what /admin/hubs
|
||||
// happened to return first.
|
||||
const twinA = { hubid: 1, hubname: 'Sorting Centre', latitude: 11.0168, longitude: 76.9558 };
|
||||
const twinB = { hubid: 2, hubname: 'Client Depot', latitude: 11.01685, longitude: 76.95585 };
|
||||
const both = buildAnchors({ hubs: [twinA, twinB] });
|
||||
expect(matchAnchor(twinB.latitude, twinB.longitude, both)?.hubid).toBe(2);
|
||||
expect(matchAnchor(twinA.latitude, twinA.longitude, both)?.hubid).toBe(1);
|
||||
});
|
||||
|
||||
it('should never match a 0,0 "unset" coordinate against a real facility', () => {
|
||||
expect(matchAnchor(0, 0, anchors)).toBeNull();
|
||||
});
|
||||
|
||||
it('should treat the radius as a boundary in metres, not degrees', () => {
|
||||
// ~100 m north of the hub: inside. ~500 m: outside.
|
||||
const inside = HUB.latitude + 0.0009;
|
||||
const outside = HUB.latitude + 0.0045;
|
||||
expect(matchAnchor(inside, HUB.longitude, anchors)).not.toBeNull();
|
||||
expect(matchAnchor(outside, HUB.longitude, anchors)).toBeNull();
|
||||
expect(ANCHOR_RADIUS_M).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyBooking — the four flows', () => {
|
||||
it('should classify hub to customer as Forward', () => {
|
||||
const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors);
|
||||
expect(r.flow).toBe(FLOW.FORWARD);
|
||||
expect(r.pickupSource).toBe(PICKUP_SOURCE.HUB);
|
||||
expect(r.dropTarget).toBe(DROP_TARGET.CUSTOMER);
|
||||
});
|
||||
|
||||
it('should classify customer to hub as Reverse — the customer pickup', () => {
|
||||
const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors);
|
||||
expect(r.flow).toBe(FLOW.REVERSE);
|
||||
expect(r.pickupSource).toBe(PICKUP_SOURCE.CUSTOMER);
|
||||
expect(r.dropTarget).toBe(DROP_TARGET.HUB);
|
||||
});
|
||||
|
||||
it('should classify customer to customer as P2P', () => {
|
||||
const r = classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), anchors);
|
||||
expect(r.flow).toBe(FLOW.P2P);
|
||||
});
|
||||
|
||||
it('should classify hub to hub as Linehaul', () => {
|
||||
const r = classifyBooking(booking({ from: AT_KITCHEN, to: AT_HUB }), anchors);
|
||||
expect(r.flow).toBe(FLOW.LINEHAUL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyBooking — unknowable input stays Forward', () => {
|
||||
it('should fall back to Forward when no facility list has loaded', () => {
|
||||
// Geometry proves nothing without anchors. Anything but Forward here
|
||||
// would relabel the whole board during the first render, before
|
||||
// /admin/hubs has answered.
|
||||
expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), []).flow).toBe(FLOW.FORWARD);
|
||||
expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B })).flow).toBe(FLOW.FORWARD);
|
||||
});
|
||||
|
||||
it('should fall back to Forward for a pickup that failed to geocode', () => {
|
||||
// 0,0 is the backend's "unset". A pickup that never geocoded is a data
|
||||
// problem, not a doorstep collection — calling it one would put a
|
||||
// phantom violet pin on the map off the coast of Africa.
|
||||
const unset = classifyBooking(booking({ id: 9, from: { lat: 0, lng: 0 }, to: DOOR_A }), anchors);
|
||||
expect(unset.flow).toBe(FLOW.FORWARD);
|
||||
|
||||
const missing = classifyBooking({ bookingid: 10, deliverylatitude: DOOR_A.lat, deliverylongitude: DOOR_A.lng }, anchors);
|
||||
expect(missing.flow).toBe(FLOW.FORWARD);
|
||||
});
|
||||
|
||||
it('should not crash on a null or empty booking', () => {
|
||||
expect(classifyBooking(null, anchors).flow).toBe(FLOW.FORWARD);
|
||||
expect(classifyBooking({}, anchors).flow).toBe(FLOW.FORWARD);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyBooking — an explicit ordertype wins over geometry', () => {
|
||||
it('should trust a stored ordertype even when the coordinates disagree', () => {
|
||||
// Coordinates say Forward; the record says otherwise. Once the backend
|
||||
// persists the field it is the authority, because it records what the
|
||||
// operator actually booked.
|
||||
const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A, ordertype: 'Reverse' }), anchors);
|
||||
expect(r.flow).toBe(FLOW.REVERSE);
|
||||
expect(r.derived).toBe(false);
|
||||
});
|
||||
|
||||
it('should mark a geometry-decided classification as derived', () => {
|
||||
expect(classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors).derived).toBe(true);
|
||||
});
|
||||
|
||||
it('should fall through to geometry when the stored value means nothing', () => {
|
||||
const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB, ordertype: 'banana' }), anchors);
|
||||
expect(r.flow).toBe(FLOW.REVERSE);
|
||||
expect(r.derived).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyBooking — the pickup point grouping key', () => {
|
||||
it('should give every order off one facility the SAME key', () => {
|
||||
const a = classifyBooking(booking({ id: 1, from: AT_KITCHEN, to: DOOR_A }), anchors);
|
||||
const b = classifyBooking(booking({ id: 2, from: AT_KITCHEN, to: DOOR_B }), anchors);
|
||||
expect(a.pickupPointKey).toBe(b.pickupPointKey);
|
||||
});
|
||||
|
||||
it('should give every customer pickup its OWN key', () => {
|
||||
// The defect being replaced: these two collapsed onto one map pin
|
||||
// because both resolved to the tenant's name.
|
||||
const a = classifyBooking(booking({ id: 1, from: DOOR_A, to: AT_HUB }), anchors);
|
||||
const b = classifyBooking(booking({ id: 2, from: DOOR_B, to: AT_HUB }), anchors);
|
||||
expect(a.pickupPointKey).not.toBe(b.pickupPointKey);
|
||||
});
|
||||
|
||||
it('should name a facility pickup after the facility and a customer pickup after its address', () => {
|
||||
const hub = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors);
|
||||
expect(hub.pickupPointName).toBe('124 Gandhipuram Main Rd');
|
||||
|
||||
const door = classifyBooking(
|
||||
booking({ from: DOOR_A, to: AT_HUB, pickupaddress: '22 Ponvizha Nagar' }),
|
||||
anchors
|
||||
);
|
||||
expect(door.pickupPointName).toBe('22 Ponvizha Nagar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyBooking — servicing hub', () => {
|
||||
it('should prefer an id the booking already carries', () => {
|
||||
const r = classifyBooking(booking({ from: DOOR_A, to: DOOR_B, sourcehubid: 42 }), anchors);
|
||||
expect(r.servicingHubId).toBe(42);
|
||||
});
|
||||
|
||||
it('should fall back to the DESTINATION hub on a customer pickup', () => {
|
||||
// Nothing at the pickup end names a facility, but an inbound collection
|
||||
// plainly belongs to the hub it is being brought to. Without this, every
|
||||
// customer pickup would lose its zone.
|
||||
const r = classifyBooking(booking({ from: DOOR_A, to: AT_HUB }), anchors);
|
||||
expect(r.servicingHubId).toBe(7);
|
||||
});
|
||||
|
||||
it('should resolve through a tenant location, not only through a Doormile hub', () => {
|
||||
// The case that was silently broken: the pickup matches the client's own
|
||||
// kitchen, which is not itself a hub. Before the kitchen inherited a
|
||||
// servicing hub this returned null and the order reached the backend
|
||||
// with no zone.
|
||||
const r = classifyBooking(booking({ from: AT_KITCHEN, to: DOOR_A }), anchors);
|
||||
expect(r.originAnchor.kind).toBe('tenantlocation');
|
||||
expect(r.servicingHubId).toBe(7);
|
||||
});
|
||||
|
||||
it('should be null on a P2P run where neither end is a facility', () => {
|
||||
// Deliberate: a fabricated hub id here would file the order under a zone
|
||||
// it has nothing to do with. Null lets ZoneContext fall back to radius.
|
||||
expect(classifyBooking(booking({ from: DOOR_A, to: DOOR_B }), anchors).servicingHubId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFlow', () => {
|
||||
it.each([
|
||||
['reverse', FLOW.REVERSE],
|
||||
['REVERSE', FLOW.REVERSE],
|
||||
['customer_pickup', FLOW.REVERSE],
|
||||
['customer pickup', FLOW.REVERSE],
|
||||
['customer-pickup', FLOW.REVERSE],
|
||||
['hub_pickup', FLOW.FORWARD],
|
||||
['outbound', FLOW.FORWARD],
|
||||
['p2p', FLOW.P2P],
|
||||
['point_to_point', FLOW.P2P],
|
||||
['hyperlocal', FLOW.P2P],
|
||||
['line_haul', FLOW.LINEHAUL]
|
||||
])('should read %s as %s', (input, expected) => {
|
||||
expect(normalizeFlow(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return null for anything it does not recognise, never throw', () => {
|
||||
[undefined, null, '', ' ', 'banana', 42, {}].forEach((value) => {
|
||||
expect(normalizeFlow(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('flow predicates', () => {
|
||||
it('should identify which flows require travelling out to collect', () => {
|
||||
expect(hasCustomerPickup(FLOW.REVERSE)).toBe(true);
|
||||
expect(hasCustomerPickup(FLOW.P2P)).toBe(true);
|
||||
expect(hasCustomerPickup(FLOW.FORWARD)).toBe(false);
|
||||
expect(hasCustomerPickup(FLOW.LINEHAUL)).toBe(false);
|
||||
});
|
||||
|
||||
it('should identify which flows end at a facility', () => {
|
||||
expect(hasHubDrop(FLOW.REVERSE)).toBe(true);
|
||||
expect(hasHubDrop(FLOW.LINEHAUL)).toBe(true);
|
||||
expect(hasHubDrop(FLOW.FORWARD)).toBe(false);
|
||||
expect(hasHubDrop(FLOW.P2P)).toBe(false);
|
||||
});
|
||||
|
||||
it('should label every flow, and fall back rather than render blank', () => {
|
||||
Object.values(FLOW).forEach((f) => expect(flowLabel(f)).toBeTruthy());
|
||||
expect(flowLabel(FLOW.REVERSE)).toBe('Customer Pickup');
|
||||
expect(flowLabel('nonsense')).toBe(flowLabel(FLOW.FORWARD));
|
||||
});
|
||||
});
|
||||
|
||||
describe('flowForDraft — what the create form books', () => {
|
||||
it('should book a hub pickup to a typed address as Forward', () => {
|
||||
const r = flowForDraft({
|
||||
pickupSource: PICKUP_SOURCE.HUB,
|
||||
dropLat: DOOR_A.lat,
|
||||
dropLng: DOOR_A.lng,
|
||||
anchors
|
||||
});
|
||||
expect(r.flow).toBe(FLOW.FORWARD);
|
||||
});
|
||||
|
||||
it('should recognise a drop that lands on a hub as a return, not a delivery', () => {
|
||||
// Without measuring the drop end, a rider bringing a parcel back to the
|
||||
// hub would be booked P2P and routed as if the hub were a house.
|
||||
const r = flowForDraft({
|
||||
pickupSource: PICKUP_SOURCE.CUSTOMER,
|
||||
dropLat: HUB.latitude,
|
||||
dropLng: HUB.longitude,
|
||||
anchors
|
||||
});
|
||||
expect(r.flow).toBe(FLOW.REVERSE);
|
||||
expect(r.destAnchor?.hubid).toBe(7);
|
||||
});
|
||||
|
||||
it('should book a customer pickup to a customer address as P2P', () => {
|
||||
const r = flowForDraft({
|
||||
pickupSource: PICKUP_SOURCE.CUSTOMER,
|
||||
dropLat: DOOR_B.lat,
|
||||
dropLng: DOOR_B.lng,
|
||||
anchors
|
||||
});
|
||||
expect(r.flow).toBe(FLOW.P2P);
|
||||
});
|
||||
|
||||
it('should default to a hub pickup when the form has told it nothing', () => {
|
||||
expect(flowForDraft().flow).toBe(FLOW.FORWARD);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFlowFields — what goes on the wire', () => {
|
||||
it('should always name the flow', () => {
|
||||
expect(buildFlowFields({ flow: FLOW.REVERSE }).ordertype).toBe(FLOW.REVERSE);
|
||||
});
|
||||
|
||||
it('should write BOTH hub id fields, which the console has only ever read', () => {
|
||||
// queries.js has always read b.hubid / b.sourcehubid and the console has
|
||||
// never written either, which is why zone attribution had to fall back to
|
||||
// matching hub names against concatenated address text.
|
||||
const fields = buildFlowFields({ flow: FLOW.FORWARD, servicingHubId: 7, tenantLocationId: 31 });
|
||||
expect(fields.sourcehubid).toBe(7);
|
||||
expect(fields.hubid).toBe(7);
|
||||
expect(fields.tenantlocationid).toBe(31);
|
||||
});
|
||||
|
||||
it('should omit ids it was not given rather than send null', () => {
|
||||
// pickup_source_type is NOT an id and is always present — see the next
|
||||
// block for why an omission would say the wrong thing.
|
||||
expect(buildFlowFields({ flow: FLOW.P2P })).toEqual({
|
||||
ordertype: FLOW.P2P,
|
||||
pickup_source_type: PICKUP_SOURCE_TYPE.CUSTOMER
|
||||
});
|
||||
expect(buildFlowFields({ flow: FLOW.P2P, servicingHubId: '' })).toEqual({
|
||||
ordertype: FLOW.P2P,
|
||||
pickup_source_type: PICKUP_SOURCE_TYPE.CUSTOMER
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to Forward rather than put an invented type on the wire', () => {
|
||||
expect(buildFlowFields({ flow: 'banana' }).ordertype).toBe(FLOW.FORWARD);
|
||||
expect(buildFlowFields().ordertype).toBe(FLOW.FORWARD);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickup_source_type — what kind of place the parcel leaves', () => {
|
||||
const baseAnchor = anchors.find((a) => a.kind === 'hub');
|
||||
const siteAnchor = anchors.find((a) => a.kind === 'tenantlocation');
|
||||
|
||||
it('should name a base pickup and carry the base id', () => {
|
||||
const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupAnchor: baseAnchor });
|
||||
expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.HUB);
|
||||
expect(fields.pickuphubid).toBe(HUB.hubid);
|
||||
});
|
||||
|
||||
it("should call a client's own site a merchant, not a base", () => {
|
||||
// Both are facilities to the geometry, but a rider arriving at a kitchen
|
||||
// is not arriving at a base — different signage, different person to ask
|
||||
// for. Collapsing them is what made every logistics pickup read as the
|
||||
// rider's own base.
|
||||
const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupAnchor: siteAnchor });
|
||||
expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.MERCHANT);
|
||||
expect(fields.pickuphubid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should send "customer" as a value rather than leaving it off', () => {
|
||||
// An omission reads as "nobody filled this in", which is a different fact
|
||||
// from "this is somebody's front door".
|
||||
expect(buildFlowFields({ flow: FLOW.P2P, pickupAnchor: null }).pickup_source_type).toBe(
|
||||
PICKUP_SOURCE_TYPE.CUSTOMER
|
||||
);
|
||||
});
|
||||
|
||||
it('should never put an unknown source type on the wire', () => {
|
||||
const fields = buildFlowFields({ flow: FLOW.FORWARD, pickupSourceType: 'banana', pickupAnchor: baseAnchor });
|
||||
expect(fields.pickup_source_type).toBe(PICKUP_SOURCE_TYPE.HUB);
|
||||
});
|
||||
|
||||
it('should not name a base on a pickup that is not from one', () => {
|
||||
const fields = buildFlowFields({
|
||||
flow: FLOW.REVERSE,
|
||||
pickupSourceType: PICKUP_SOURCE_TYPE.CUSTOMER,
|
||||
pickupAnchor: baseAnchor
|
||||
});
|
||||
expect(fields.pickuphubid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prefer the type stored on the booking over geometry', () => {
|
||||
// A booking collected at a base whose coordinates were later edited must
|
||||
// keep reading as a base pickup — the row recorded what actually happened.
|
||||
const c = classifyBooking(
|
||||
{
|
||||
bookingid: 1,
|
||||
pickup_source_type: PICKUP_SOURCE_TYPE.HUB,
|
||||
pickuphubid: HUB.hubid,
|
||||
pickuplatitude: DOOR_A.lat,
|
||||
pickuplongitude: DOOR_A.lng,
|
||||
deliverylatitude: DOOR_B.lat,
|
||||
deliverylongitude: DOOR_B.lng
|
||||
},
|
||||
anchors
|
||||
);
|
||||
expect(c.pickupSourceType).toBe(PICKUP_SOURCE_TYPE.HUB);
|
||||
expect(c.pickupHubId).toBe(HUB.hubid);
|
||||
expect(c.sourceTypeFromServer).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify an older booking from geometry when the row says nothing', () => {
|
||||
const c = classifyBooking(
|
||||
{
|
||||
bookingid: 2,
|
||||
pickuplatitude: HUB.latitude,
|
||||
pickuplongitude: HUB.longitude,
|
||||
deliverylatitude: DOOR_B.lat,
|
||||
deliverylongitude: DOOR_B.lng
|
||||
},
|
||||
anchors
|
||||
);
|
||||
expect(c.pickupSourceType).toBe(PICKUP_SOURCE_TYPE.HUB);
|
||||
expect(c.sourceTypeFromServer).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
180
tests/lib/routingSummary.test.js
Normal file
180
tests/lib/routingSummary.test.js
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
baseLine,
|
||||
nextActionLabel,
|
||||
routingReason,
|
||||
routingRoute,
|
||||
summariseRouting
|
||||
} from '@/lib/routingSummary';
|
||||
|
||||
/**
|
||||
* The routing block, turned into sentences.
|
||||
*
|
||||
* This is what a support agent reads to a rider who has rung asking why his
|
||||
* screen says hand over at a base instead of deliver to the customer. Two
|
||||
* things carry the weight here:
|
||||
*
|
||||
* 1. A PROJECTION MUST NOT READ AS A DECISION. Before pickup nothing has been
|
||||
* routed; `decided: false` means the answer is what WILL happen. Stating a
|
||||
* forecast as a fact is a wrong answer delivered confidently.
|
||||
* 2. THE WIRE SAYS HUB, PEOPLE SAY BASE. The translation happens at this edge
|
||||
* and nowhere else, and it never travels back up the wire.
|
||||
*/
|
||||
|
||||
const HUB_ROUTED = {
|
||||
pickup_source_type: 'customer',
|
||||
pickup_source_name: 'Anitha R',
|
||||
from_address: '12 Race Course Road, Coimbatore',
|
||||
from_pincode: '641018',
|
||||
to_address: '44 Mount Road, Chennai',
|
||||
destination_pincode: '600002',
|
||||
is_hyperlocal: false,
|
||||
consignment_state: 'Created',
|
||||
next_action: 'inward_at_hub',
|
||||
next_hub: {
|
||||
id: 1,
|
||||
name: 'Coimbatore Hub',
|
||||
address: '14 Avinashi Road, Peelamedu, Coimbatore',
|
||||
pincode: '641004',
|
||||
latitude: 11.0272,
|
||||
longitude: 76.9905
|
||||
},
|
||||
inwardedat: null,
|
||||
decided: true
|
||||
};
|
||||
|
||||
const HYPERLOCAL = {
|
||||
...HUB_ROUTED,
|
||||
destination_pincode: '641004',
|
||||
is_hyperlocal: true,
|
||||
consignment_state: 'Out_for_Delivery',
|
||||
next_action: 'deliver',
|
||||
next_hub: null
|
||||
};
|
||||
|
||||
describe('routingReason — why it is going that way', () => {
|
||||
it('should say a base is involved when the areas differ', () => {
|
||||
expect(routingReason(HUB_ROUTED)).toMatch(/through a base/i);
|
||||
});
|
||||
|
||||
it('should say the rider carries it straight there when the areas match', () => {
|
||||
expect(routingReason(HYPERLOCAL)).toMatch(/straight to the receiver/i);
|
||||
});
|
||||
|
||||
it('should never say "hub" to an operator', () => {
|
||||
// Internal vocabulary. Screens say base.
|
||||
expect(routingReason(HUB_ROUTED).toLowerCase()).not.toContain('hub');
|
||||
});
|
||||
|
||||
it('should return an empty string rather than throw when there is no routing block', () => {
|
||||
// An older booking, or a backend that has not shipped this yet.
|
||||
expect(routingReason(null)).toBe('');
|
||||
expect(routingReason(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('routingRoute — the from → to line', () => {
|
||||
it('should show both pincodes', () => {
|
||||
expect(routingRoute(HUB_ROUTED)).toContain('641018');
|
||||
expect(routingRoute(HUB_ROUTED)).toContain('600002');
|
||||
});
|
||||
|
||||
it('should not caveat a decision that has actually been taken', () => {
|
||||
expect(routingRoute(HUB_ROUTED)).not.toMatch(/not yet collected/i);
|
||||
});
|
||||
|
||||
it('should mark an undecided booking as a forecast, not a fact', () => {
|
||||
const undecided = { ...HUB_ROUTED, decided: false };
|
||||
expect(routingRoute(undecided)).toMatch(/not yet collected/i);
|
||||
expect(routingRoute(undecided)).toMatch(/will happen, not what has/i);
|
||||
});
|
||||
|
||||
it('should show a dash for a pincode the booking never carried', () => {
|
||||
// The delivery-pincode column is genuinely empty on plenty of rows; a blank
|
||||
// gap in the line reads as a rendering bug rather than as missing data.
|
||||
expect(routingRoute({ ...HUB_ROUTED, destination_pincode: '' })).toContain('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextActionLabel — what the rider does next', () => {
|
||||
it('should translate every action the backend can send', () => {
|
||||
expect(nextActionLabel('pickup')).toMatch(/collect/i);
|
||||
expect(nextActionLabel('inward_at_hub')).toMatch(/base/i);
|
||||
expect(nextActionLabel('start_delivery')).toMatch(/delivery run/i);
|
||||
expect(nextActionLabel('deliver')).toMatch(/receiver/i);
|
||||
expect(nextActionLabel('handed_to_hub')).toMatch(/handed over/i);
|
||||
expect(nextActionLabel('none')).toMatch(/nothing further/i);
|
||||
});
|
||||
|
||||
it('should render inward_at_hub as a BASE handover, never as a hub one', () => {
|
||||
expect(nextActionLabel('inward_at_hub').toLowerCase()).not.toContain('hub');
|
||||
});
|
||||
|
||||
it('should show an unknown action as itself rather than swallow it', () => {
|
||||
// The backend may add actions. A blank row tells the operator nothing; the
|
||||
// raw word at least tells them something new exists.
|
||||
expect(nextActionLabel('return_to_sender')).toBe('return_to_sender');
|
||||
});
|
||||
|
||||
it('should show a dash when there is no action at all', () => {
|
||||
expect(nextActionLabel(undefined)).toBe('—');
|
||||
expect(nextActionLabel('')).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseLine — naming the base', () => {
|
||||
it('should read the base with its pincode', () => {
|
||||
expect(baseLine(HUB_ROUTED.next_hub)).toBe('Coimbatore Hub · 641004');
|
||||
});
|
||||
|
||||
it('should fall back to the name alone when a base has no pincode', () => {
|
||||
expect(baseLine({ name: 'Coimbatore Hub' })).toBe('Coimbatore Hub');
|
||||
});
|
||||
|
||||
it('should be null when no base is involved', () => {
|
||||
// A hyperlocal parcel has no base. That is the correct answer, not a gap.
|
||||
expect(baseLine(null)).toBeNull();
|
||||
expect(baseLine({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('summariseRouting — everything the drawer renders', () => {
|
||||
it('should carry the base, its address and the next step for a hub-routed parcel', () => {
|
||||
const s = summariseRouting(HUB_ROUTED);
|
||||
expect(s.base).toBe('Coimbatore Hub · 641004');
|
||||
expect(s.baseAddress).toBe('14 Avinashi Road, Peelamedu, Coimbatore');
|
||||
expect(s.nextAction).toMatch(/base/i);
|
||||
expect(s.consignmentState).toBe('Created');
|
||||
expect(s.decided).toBe(true);
|
||||
});
|
||||
|
||||
it('should carry no base for a hyperlocal parcel', () => {
|
||||
const s = summariseRouting(HYPERLOCAL);
|
||||
expect(s.base).toBeNull();
|
||||
expect(s.baseAddress).toBeNull();
|
||||
expect(s.nextAction).toMatch(/receiver/i);
|
||||
});
|
||||
|
||||
it('should surface the received-at time once the parcel is actually inwarded', () => {
|
||||
const s = summariseRouting({
|
||||
...HUB_ROUTED,
|
||||
consignment_state: 'Inwarded_at_Hub',
|
||||
next_action: 'handed_to_hub',
|
||||
inwardedat: '2026-09-02T14:22:10Z'
|
||||
});
|
||||
expect(s.inwardedAt).toBe('2026-09-02T14:22:10Z');
|
||||
expect(s.nextAction).toMatch(/handed over/i);
|
||||
});
|
||||
|
||||
it('should show a dash for a parcel that has no consignment yet', () => {
|
||||
const s = summariseRouting({ ...HUB_ROUTED, consignment_state: '', decided: false });
|
||||
expect(s.consignmentState).toBe('—');
|
||||
expect(s.decided).toBe(false);
|
||||
});
|
||||
|
||||
it('should be null when the booking carries no routing block, so the panel hides', () => {
|
||||
// An older backend, or a booking fetched before this shipped. The drawer
|
||||
// renders nothing rather than a panel of dashes.
|
||||
expect(summariseRouting(null)).toBeNull();
|
||||
expect(summariseRouting(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user