updates on the ui changes and new changes as well
This commit is contained in:
124
tests/ds/datePicker.test.jsx
Normal file
124
tests/ds/datePicker.test.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState } from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
Calendar: () => <span data-testid="icon-calendar" />,
|
||||
ChevronLeft: () => <span data-testid="icon-left" />,
|
||||
ChevronRight: () => <span data-testid="icon-right" />,
|
||||
ChevronDown: () => <span data-testid="icon-down" />,
|
||||
}));
|
||||
|
||||
import { DatePicker } from '@/components/ds/DatePicker';
|
||||
import { toDateRange } from '@/lib/dateRange';
|
||||
|
||||
/**
|
||||
* What the picker hands its consumer, for each control.
|
||||
*
|
||||
* "This Month is not even responding" was the report. It emits `{from, to}`,
|
||||
* not a date string — and the Deliveries page forced it through
|
||||
* `dayjs().format()`, which is Invalid Date for an object. Worse, the handler
|
||||
* called a setter that did not exist, and the ReferenceError propagated out of
|
||||
* `commit` BEFORE `setOpen(false)`, so the popover never even closed. Clicking
|
||||
* the preset genuinely did nothing visible.
|
||||
*/
|
||||
|
||||
// Radix Popover needs these in jsdom.
|
||||
beforeAll(() => {
|
||||
if (!window.ResizeObserver) {
|
||||
window.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
}
|
||||
if (!Element.prototype.hasPointerCapture) {
|
||||
Element.prototype.hasPointerCapture = () => false;
|
||||
Element.prototype.setPointerCapture = () => {};
|
||||
Element.prototype.releasePointerCapture = () => {};
|
||||
}
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {};
|
||||
});
|
||||
|
||||
const today = dayjs().startOf('day');
|
||||
const todayStr = today.format('YYYY-MM-DD');
|
||||
|
||||
// The collapsed picker renders three buttons — prev-day, the trigger,
|
||||
// next-day — so the trigger is selected by its popup role, not by position.
|
||||
const openCalendar = () => fireEvent.click(document.querySelector('[aria-haspopup="dialog"]'));
|
||||
|
||||
const openPicker = (onChange, value = todayStr) => {
|
||||
render(<DatePicker value={value} onChange={onChange} />);
|
||||
openCalendar();
|
||||
};
|
||||
|
||||
describe('DatePicker — what each control emits', () => {
|
||||
it('This Month emits a RANGE object, not a date string', () => {
|
||||
const onChange = jest.fn();
|
||||
openPicker(onChange);
|
||||
fireEvent.click(screen.getByText('This Month'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const emitted = onChange.mock.calls[0][0];
|
||||
expect(typeof emitted).toBe('object');
|
||||
expect(emitted).toEqual({ from: today.startOf('month').format('YYYY-MM-DD'), to: todayStr });
|
||||
});
|
||||
|
||||
it('Last 7 Days emits a range covering today and the six before it', () => {
|
||||
const onChange = jest.fn();
|
||||
openPicker(onChange);
|
||||
fireEvent.click(screen.getByText('Last 7 Days'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
from: today.subtract(6, 'day').format('YYYY-MM-DD'),
|
||||
to: todayStr
|
||||
});
|
||||
});
|
||||
|
||||
it('All Dates emits the string "all", which is not a date', () => {
|
||||
const onChange = jest.fn();
|
||||
openPicker(onChange);
|
||||
fireEvent.click(screen.getByText('All Dates'));
|
||||
expect(onChange).toHaveBeenCalledWith('all');
|
||||
});
|
||||
|
||||
it('Yesterday emits a plain YYYY-MM-DD string', () => {
|
||||
const onChange = jest.fn();
|
||||
openPicker(onChange);
|
||||
fireEvent.click(screen.getByText('Yesterday'));
|
||||
expect(onChange).toHaveBeenCalledWith(today.subtract(1, 'day').format('YYYY-MM-DD'));
|
||||
});
|
||||
|
||||
it('every emitted shape survives toDateRange with real bounds', () => {
|
||||
// The contract the Deliveries page depends on: whatever the picker emits,
|
||||
// the page can turn it into usable bounds without inspecting its type.
|
||||
const emitted = [];
|
||||
const onChange = (v) => emitted.push(v);
|
||||
|
||||
['This Month', 'Last 7 Days', 'Yesterday'].forEach((label) => {
|
||||
const { unmount } = render(<DatePicker value={todayStr} onChange={onChange} />);
|
||||
openCalendar();
|
||||
fireEvent.click(screen.getByText(label));
|
||||
unmount();
|
||||
});
|
||||
|
||||
expect(emitted).toHaveLength(3);
|
||||
emitted.forEach((v) => {
|
||||
const { from, to } = toDateRange(v);
|
||||
expect(from).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(to).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(from <= to).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatePicker — the popover after a choice', () => {
|
||||
it('closes once a preset is chosen', () => {
|
||||
const onChange = jest.fn();
|
||||
openPicker(onChange);
|
||||
expect(screen.getByText('This Month')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('This Month'));
|
||||
expect(screen.queryByText('This Month')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
204
tests/integration/agentStudio.test.jsx
Normal file
204
tests/integration/agentStudio.test.jsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import {
|
||||
DEFAULT_SKILLS,
|
||||
DEFAULT_TOOLS,
|
||||
AGENTS,
|
||||
loadStoredSkills,
|
||||
saveStoredSkills,
|
||||
loadStoredTools,
|
||||
loadActiveAgentId,
|
||||
saveActiveAgentId,
|
||||
} from '@/pages/doormile/settings/agentStudio/agentRegistryData';
|
||||
// Stub lucide-react ESM exports for Jest
|
||||
jest.mock('lucide-react', () => {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (target, prop) => {
|
||||
if (prop === '__esModule') return true;
|
||||
return (props) => <span data-testid={`icon-${String(prop)}`} {...props} />;
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Stub framer-motion layout animations for jsdom
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...rest }) => <div {...rest}>{children}</div>,
|
||||
span: ({ children, ...rest }) => <span {...rest}>{children}</span>,
|
||||
},
|
||||
AnimatePresence: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import SkillCard from '@/pages/doormile/settings/agentStudio/SkillCard';
|
||||
import SkillCardsGrid from '@/pages/doormile/settings/agentStudio/SkillCardsGrid';
|
||||
import ToolRegistryView from '@/pages/doormile/settings/agentStudio/ToolRegistryView';
|
||||
|
||||
describe('Agent Studio — Data Layer & Persistence', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('pre-seeds default agents and surfaces correctly', () => {
|
||||
expect(AGENTS.length).toBeGreaterThanOrEqual(2);
|
||||
expect(AGENTS.some((a) => a.name === 'MileTruth AI Agent')).toBe(true);
|
||||
expect(AGENTS.some((a) => a.name === 'MileTruth AI Dispatcher')).toBe(true);
|
||||
});
|
||||
|
||||
it('pre-seeds default skills matching reference image', () => {
|
||||
expect(DEFAULT_SKILLS.some((s) => s.title === 'MileTruth Skill Management')).toBe(true);
|
||||
expect(DEFAULT_SKILLS.some((s) => s.title === 'Learning Analysis')).toBe(true);
|
||||
const learningSkill = DEFAULT_SKILLS.find((s) => s.title === 'Learning Analysis');
|
||||
expect(learningSkill.prompt).toBe('How is training progressing?');
|
||||
expect(learningSkill.category).toBe('WORKFORCE');
|
||||
});
|
||||
|
||||
it('persists modified skills to localStorage and restores them', () => {
|
||||
const customSkill = {
|
||||
id: 'custom-skill-1',
|
||||
title: 'Custom Automation Skill',
|
||||
category: 'LOGISTICS',
|
||||
scope: 'agent',
|
||||
enabled: true,
|
||||
description: 'Test custom capability',
|
||||
tools: ['create_single_order'],
|
||||
};
|
||||
|
||||
saveStoredSkills([customSkill]);
|
||||
const restored = loadStoredSkills();
|
||||
expect(restored.length).toBe(1);
|
||||
expect(restored[0].title).toBe('Custom Automation Skill');
|
||||
});
|
||||
|
||||
it('saves and loads active agent id', () => {
|
||||
saveActiveAgentId('miletruth-agent');
|
||||
expect(loadActiveAgentId()).toBe('miletruth-agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Studio — SkillCard Component', () => {
|
||||
const sampleSkill = {
|
||||
id: 'test-skill-1',
|
||||
title: 'MileTruth Skill Management',
|
||||
category: 'AUTHORING',
|
||||
scope: 'agent',
|
||||
integration: 'MileTruth AI',
|
||||
enabled: true,
|
||||
description: 'Create workforce skills, training and verification in MileTruth AI.',
|
||||
prompt: 'How is training progressing?',
|
||||
tools: ['open_add_training'],
|
||||
};
|
||||
|
||||
it('renders title, description, category, and prompt trigger bubble', () => {
|
||||
render(<SkillCard skill={sampleSkill} onTest={() => {}} onDetails={() => {}} />);
|
||||
|
||||
expect(screen.getByText('MileTruth Skill Management')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Create workforce skills, training and verification in MileTruth AI.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('AUTHORING')).toBeInTheDocument();
|
||||
expect(screen.getByText('"How is training progressing?"')).toBeInTheDocument();
|
||||
expect(screen.getByText('MileTruth AI')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onTest when Test in Playground is clicked', () => {
|
||||
const handleTest = jest.fn();
|
||||
render(<SkillCard skill={sampleSkill} onTest={handleTest} onDetails={() => {}} />);
|
||||
|
||||
const testBtn = screen.getByText('Test in Playground');
|
||||
fireEvent.click(testBtn);
|
||||
expect(handleTest).toHaveBeenCalledWith(sampleSkill);
|
||||
});
|
||||
|
||||
it('fires onDetails when Details button is clicked', () => {
|
||||
const handleDetails = jest.fn();
|
||||
render(<SkillCard skill={sampleSkill} onTest={() => {}} onDetails={handleDetails} />);
|
||||
|
||||
const detailsBtn = screen.getByText('Details');
|
||||
fireEvent.click(detailsBtn);
|
||||
expect(handleDetails).toHaveBeenCalledWith(sampleSkill);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Studio — SkillCardsGrid Component', () => {
|
||||
it('renders skill cards and filters by search query', () => {
|
||||
render(
|
||||
<SkillCardsGrid
|
||||
skills={DEFAULT_SKILLS}
|
||||
activeAgent={AGENTS[0]}
|
||||
onTestSkill={() => {}}
|
||||
onOpenDetails={() => {}}
|
||||
onNewSkill={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('MileTruth Skill Management')).toBeInTheDocument();
|
||||
expect(screen.getByText('Learning Analysis')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search skills, capabilities or pages...');
|
||||
fireEvent.change(searchInput, { target: { value: 'Learning' } });
|
||||
|
||||
expect(screen.queryByText('MileTruth Skill Management')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Learning Analysis')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters by category pill selection', () => {
|
||||
render(
|
||||
<SkillCardsGrid
|
||||
skills={DEFAULT_SKILLS}
|
||||
activeAgent={AGENTS[0]}
|
||||
onTestSkill={() => {}}
|
||||
onOpenDetails={() => {}}
|
||||
onNewSkill={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click 'Authoring' category pill
|
||||
const authoringPill = screen.getByText('Authoring');
|
||||
fireEvent.click(authoringPill);
|
||||
|
||||
expect(screen.getByText('MileTruth Skill Management')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Learning Analysis')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('triggers onNewSkill when clicking New skill button', () => {
|
||||
const handleNewSkill = jest.fn();
|
||||
render(
|
||||
<SkillCardsGrid
|
||||
skills={DEFAULT_SKILLS}
|
||||
activeAgent={AGENTS[0]}
|
||||
onTestSkill={() => {}}
|
||||
onOpenDetails={() => {}}
|
||||
onNewSkill={handleNewSkill}
|
||||
/>
|
||||
);
|
||||
|
||||
const newSkillBtn = screen.getByText('New skill');
|
||||
fireEvent.click(newSkillBtn);
|
||||
expect(handleNewSkill).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Studio — ToolRegistryView Component', () => {
|
||||
it('renders tool registry table and displays argument schemas', () => {
|
||||
render(<ToolRegistryView tools={DEFAULT_TOOLS} />);
|
||||
|
||||
expect(screen.getByText('open_add_skill_training')).toBeInTheDocument();
|
||||
expect(screen.getByText('create_single_order')).toBeInTheDocument();
|
||||
expect(screen.getByText('rebalance_riders')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters tool list by search input', () => {
|
||||
render(<ToolRegistryView tools={DEFAULT_TOOLS} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'Filter tools by function name, category or description...'
|
||||
);
|
||||
fireEvent.change(searchInput, { target: { value: 'rebalance' } });
|
||||
|
||||
expect(screen.getByText('rebalance_riders')).toBeInTheDocument();
|
||||
expect(screen.queryByText('open_add_skill_training')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
54
tests/lib/dateRange.test.js
Normal file
54
tests/lib/dateRange.test.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { toDateRange } from '@/lib/dateRange';
|
||||
|
||||
/**
|
||||
* The three shapes the DatePicker emits, and what a page must do with them.
|
||||
*
|
||||
* The bug: the Deliveries page ran every emitted value through
|
||||
* `dayjs(d).format('YYYY-MM-DD')`. For a range preset that is
|
||||
* `dayjs({ from, to })` and for All Dates it is `dayjs('all')` — both Invalid
|
||||
* Date — so the filter compared every row against the string "Invalid Date".
|
||||
*/
|
||||
describe('toDateRange', () => {
|
||||
it('turns a single day into a one-day range', () => {
|
||||
expect(toDateRange('2026-09-22')).toEqual({ from: '2026-09-22', to: '2026-09-22' });
|
||||
});
|
||||
|
||||
it('passes a range preset straight through — This Month, Last 7 Days', () => {
|
||||
expect(toDateRange({ from: '2026-09-01', to: '2026-09-22' })).toEqual({
|
||||
from: '2026-09-01',
|
||||
to: '2026-09-22'
|
||||
});
|
||||
});
|
||||
|
||||
it('turns All Dates into no bounds, not into a date', () => {
|
||||
expect(toDateRange('all')).toEqual({ from: '', to: '' });
|
||||
});
|
||||
|
||||
it('treats a cleared picker as no bounds', () => {
|
||||
expect(toDateRange(null)).toEqual({ from: '', to: '' });
|
||||
expect(toDateRange(undefined)).toEqual({ from: '', to: '' });
|
||||
expect(toDateRange('')).toEqual({ from: '', to: '' });
|
||||
});
|
||||
|
||||
it('keeps a half-open range open on the missing side', () => {
|
||||
// An empty bound means unbounded there, which is what the API layer's
|
||||
// inRange already expects — not "filter everything out".
|
||||
expect(toDateRange({ from: '2026-09-01' })).toEqual({ from: '2026-09-01', to: '' });
|
||||
expect(toDateRange({ to: '2026-09-22' })).toEqual({ from: '', to: '2026-09-22' });
|
||||
expect(toDateRange({})).toEqual({ from: '', to: '' });
|
||||
});
|
||||
|
||||
it('never produces "Invalid Date" — the actual defect, in one assertion', () => {
|
||||
const values = ['all', null, undefined, {}, { from: '2026-09-01', to: '2026-09-22' }, '2026-09-22'];
|
||||
values.forEach((v) => {
|
||||
const { from, to } = toDateRange(v);
|
||||
expect(from).not.toMatch(/Invalid/);
|
||||
expect(to).not.toMatch(/Invalid/);
|
||||
});
|
||||
});
|
||||
|
||||
it('is empty, not a crash, for a shape nobody should pass', () => {
|
||||
expect(toDateRange(20260922)).toEqual({ from: '', to: '' });
|
||||
expect(toDateRange(true)).toEqual({ from: '', to: '' });
|
||||
});
|
||||
});
|
||||
211
tests/lib/dispatchPreviewLiveRun.test.js
Normal file
211
tests/lib/dispatchPreviewLiveRun.test.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
assignmentSummary,
|
||||
editNeedsReconcile,
|
||||
extractRiders,
|
||||
extractUnassigned,
|
||||
flattenRiders,
|
||||
moveOrderInPreviewData,
|
||||
withUnassignedZones
|
||||
} from '@/lib/dispatchPreview';
|
||||
|
||||
/**
|
||||
* One real failed run, start to finish.
|
||||
*
|
||||
* LIVE_RUN is the verbatim reply from
|
||||
* `routes.workolik.com/api/v1/optimization/riderassign?hypertuning_params=balanced`
|
||||
* on 2026-09-22 for booking DM-107871 — the run in the screenshots. It is
|
||||
* kept whole rather than trimmed: the defect these guard against was an
|
||||
* assumption about the response shape, and a fixture written from the same
|
||||
* assumption proves nothing.
|
||||
*
|
||||
* What it records: the solver processed the order correctly (zone resolved,
|
||||
* profit computed) and had no rider to give it to — `active_riders_pool: 0`,
|
||||
* before any filtering, with nothing excluded.
|
||||
*/
|
||||
const LIVE_RUN = {
|
||||
code: 200,
|
||||
zone_summary: [
|
||||
{
|
||||
zone_name: 'North West',
|
||||
total_orders: 1,
|
||||
assigned_orders: 0,
|
||||
unassigned_orders_count: 1,
|
||||
active_riders_count: 0,
|
||||
total_delivery_kms: 0.0,
|
||||
total_profit: 56.0
|
||||
}
|
||||
],
|
||||
zones: [
|
||||
{
|
||||
zone_name: 'North West',
|
||||
total_orders: 1,
|
||||
active_riders_count: 0,
|
||||
assigned_orders: 0,
|
||||
unassigned_orders_count: 1,
|
||||
total_delivery_kms: 0.0,
|
||||
total_profit: 56.0,
|
||||
riders: [],
|
||||
unassigned_orders: [
|
||||
{
|
||||
bookingid: 1737,
|
||||
bookingno: 'DM-107871',
|
||||
tenantid: 1,
|
||||
pickupaddress: '124 Gandhipuram Main Rd',
|
||||
pickuplatitude: 11.0168,
|
||||
pickuplongitude: 76.9558,
|
||||
deliveryaddress: 'NO . 26, JMV GARDEN, Subramaniapalayam, Coimbatore, Tamil Nadu 641029',
|
||||
deliverylatitude: 11.0590674,
|
||||
deliverylongitude: 76.9298476,
|
||||
status: 'Pending_Pickup',
|
||||
assignedmileruserid: null,
|
||||
consignmentid: null,
|
||||
deliveryid: 0,
|
||||
deliverydate: '2026-09-23 17:56:04',
|
||||
assigntime: '2026-09-22 13:54:39',
|
||||
orderstatus: 'pending',
|
||||
orderamount: 56,
|
||||
droplat: '11.0590674',
|
||||
droplon: '76.9298476',
|
||||
pickuplat: 11.0168,
|
||||
pickuplon: 76.9558,
|
||||
deliverycharges: 56,
|
||||
pickuplocation: '124 Gandhipuram Main Rd',
|
||||
deliverylocation: 'NO . 26, JMV GARDEN, Subramaniapalayam, Coimbatore, Tamil Nadu 641029',
|
||||
hubid: 2,
|
||||
deliverylat: '11.0590674',
|
||||
deliverylong: '76.9298476',
|
||||
unassigned_reason: 'No riders found (check partner online status).',
|
||||
zone_name: 'North West',
|
||||
rider_charge: 0.0,
|
||||
profit: 56.0,
|
||||
profit_class: 'HighMargin'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
details: [],
|
||||
message: 'Success',
|
||||
status: true,
|
||||
meta: {
|
||||
total_orders: 1,
|
||||
utilized_riders: 0,
|
||||
active_riders_pool: 0,
|
||||
assigned_orders: 0,
|
||||
unassigned_orders: 1,
|
||||
total_profit: 56.0,
|
||||
unassigned_details: [{ orderid: null, reason: 'No riders found (check partner online status).' }],
|
||||
absent_riders_excluded: [],
|
||||
absent_riders_unknown: [],
|
||||
solver_mode: '2phase_heuristic'
|
||||
}
|
||||
};
|
||||
|
||||
// The order id every surface keys this booking by. The refusal list carries
|
||||
// no `orderid` of its own — `bookingno` is what it has.
|
||||
const ORDER_ID = 'DM-107871';
|
||||
|
||||
describe('the run as the page reads it', () => {
|
||||
it('reports the refusal instead of the 200', () => {
|
||||
expect(assignmentSummary(LIVE_RUN)).toMatchObject({
|
||||
assigned: 0,
|
||||
unassigned: 1,
|
||||
total: 1,
|
||||
riders: 0,
|
||||
failed: true
|
||||
});
|
||||
});
|
||||
|
||||
it('has nothing to commit before a miler is picked', () => {
|
||||
// `details: []` — so the Assign Orders fallback is empty too, and the
|
||||
// page must not offer to dispatch anything.
|
||||
expect(flattenRiders(extractRiders(LIVE_RUN))).toEqual([]);
|
||||
expect(LIVE_RUN.details).toEqual([]);
|
||||
});
|
||||
|
||||
it('puts the order on the board with its coordinates, so it can be pinned', () => {
|
||||
const order = withUnassignedZones(LIVE_RUN).zones[0].riders[0].orders[0];
|
||||
expect(order.orderid).toBe(ORDER_ID);
|
||||
expect(Number(order.droplat)).toBeCloseTo(11.0590674);
|
||||
expect(Number(order.droplon)).toBeCloseTo(76.9298476);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recovering the run by hand', () => {
|
||||
// The operator opens the zone, presses "change rider" on DM-107871 and
|
||||
// picks a real Doormile miler. Everything below has to follow from that.
|
||||
const recovered = () =>
|
||||
moveOrderInPreviewData(LIVE_RUN, { orderId: ORDER_ID, newRiderId: 12, newRiderName: 'Vijay Bangalore Rider' });
|
||||
|
||||
it('finds the order by its bookingno, which is the only id it has', () => {
|
||||
const rider = extractRiders(recovered())[0];
|
||||
expect(rider).toMatchObject({ rider_id: 12, rider_name: 'Vijay Bangalore Rider' });
|
||||
expect(rider.orders).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('clears the refusal, so the banner and the Reconcile list empty out', () => {
|
||||
expect(extractUnassigned(recovered())).toEqual([]);
|
||||
expect(assignmentSummary(recovered())).toMatchObject({ assigned: 1, unassigned: 0, failed: false });
|
||||
});
|
||||
|
||||
it('produces a commit payload carrying the real miler id', () => {
|
||||
const [delivery] = flattenRiders(extractRiders(recovered()));
|
||||
expect(delivery).toMatchObject({ orderid: ORDER_ID, bookingid: 1737, rider_id: 12, userid: 12 });
|
||||
});
|
||||
|
||||
it('keeps the booking fields the commit needs', () => {
|
||||
const [delivery] = flattenRiders(extractRiders(recovered()));
|
||||
// `POST /admin/bookings/:id/assign-miler` is keyed on the booking, so
|
||||
// losing bookingid here would make the order uncommittable.
|
||||
expect(delivery.bookingid).toBe(1737);
|
||||
expect(delivery.deliverycharges).toBe(56);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whether the recovery still has to go through Reconcile', () => {
|
||||
const assignTo = (preview, newRiderId) => ({
|
||||
before: preview,
|
||||
after: moveOrderInPreviewData(preview, { orderId: ORDER_ID, newRiderId, newRiderName: 'Vijay' })
|
||||
});
|
||||
|
||||
it('does not, for a refused order given a miler with nothing else on', () => {
|
||||
// The dead end this exists to break: reconcile-steps is the same service
|
||||
// that had no riders, so gating Assign Orders on it would strand the
|
||||
// order permanently.
|
||||
const { before, after } = assignTo(LIVE_RUN, 12);
|
||||
expect(editNeedsReconcile(before, after, { orderId: ORDER_ID, newRiderId: 12 })).toBe(false);
|
||||
});
|
||||
|
||||
it('does, when the refused order is dropped into an existing route', () => {
|
||||
const withPlan = {
|
||||
...LIVE_RUN,
|
||||
zones: [
|
||||
{
|
||||
...LIVE_RUN.zones[0],
|
||||
riders: [{ rider_id: 12, rider_name: 'Vijay', orders: [{ orderid: 'DM-100001' }, { orderid: 'DM-100002' }] }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const { before, after } = assignTo(withPlan, 12);
|
||||
expect(editNeedsReconcile(before, after, { orderId: ORDER_ID, newRiderId: 12 })).toBe(true);
|
||||
});
|
||||
|
||||
it('does, for an ordinary rider-to-rider move inside the plan', () => {
|
||||
const planned = {
|
||||
zones: [
|
||||
{
|
||||
zone_name: 'North West',
|
||||
riders: [
|
||||
{ rider_id: 7, rider_name: 'Rajan', orders: [{ orderid: 'DM-100001' }] },
|
||||
{ rider_id: 12, rider_name: 'Vijay', orders: [{ orderid: 'DM-100002' }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
const after = moveOrderInPreviewData(planned, {
|
||||
orderId: 'DM-100001',
|
||||
newRiderId: 12,
|
||||
newRiderName: 'Vijay'
|
||||
});
|
||||
expect(editNeedsReconcile(planned, after, { orderId: 'DM-100001', newRiderId: 12 })).toBe(true);
|
||||
});
|
||||
});
|
||||
378
tests/lib/dispatchPreviewUnassigned.test.js
Normal file
378
tests/lib/dispatchPreviewUnassigned.test.js
Normal file
@@ -0,0 +1,378 @@
|
||||
import {
|
||||
assignmentSummary,
|
||||
extractRiders,
|
||||
extractUnassigned,
|
||||
moveOrderInPreviewData,
|
||||
withUnassignedZones,
|
||||
UNASSIGNED_RIDER_ID
|
||||
} from '@/lib/dispatchPreview';
|
||||
|
||||
/**
|
||||
* The orders the optimiser refuses, and how the console reports them.
|
||||
*
|
||||
* The bug this covers: pressing "AI assign" produced a green "Orders
|
||||
* optimised" toast over an empty preview board. The optimiser had not
|
||||
* failed silently — it answered 200 with every order in
|
||||
* `zones[].unassigned_orders[]`, each carrying an `unassigned_reason` — and
|
||||
* nothing in the console read that key. `grep unassigned Preview.jsx`
|
||||
* returned zero matches.
|
||||
*
|
||||
* REAL_RESPONSE below is not invented. It is the live reply from
|
||||
* routes.workolik.com on 2026-09-22 to a single Coimbatore delivery, trimmed
|
||||
* to the fields under test. That matters: the whole defect was a shape
|
||||
* assumption, so a fixture written from the same assumption would pass while
|
||||
* the page stayed broken.
|
||||
*/
|
||||
|
||||
const REAL_RESPONSE = {
|
||||
code: 200,
|
||||
status: true,
|
||||
zone_summary: [
|
||||
{
|
||||
zone_name: 'North East',
|
||||
total_orders: 1,
|
||||
assigned_orders: 0,
|
||||
unassigned_orders_count: 1,
|
||||
active_riders_count: 0
|
||||
}
|
||||
],
|
||||
zones: [
|
||||
{
|
||||
zone_name: 'North East',
|
||||
total_orders: 1,
|
||||
active_riders_count: 0,
|
||||
assigned_orders: 0,
|
||||
unassigned_orders_count: 1,
|
||||
riders: [],
|
||||
unassigned_orders: [
|
||||
{
|
||||
deliveryid: 0,
|
||||
bookingid: 901,
|
||||
bookingno: 'DM-TEST-1',
|
||||
orderstatus: 'pending',
|
||||
deliverycharges: 120,
|
||||
deliverylocation: 'Gandhipuram, Coimbatore',
|
||||
unassigned_reason: 'No riders found (check partner online status).'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/** The other real shape: empty input gives `details: {}` — an object. */
|
||||
const EMPTY_INPUT_RESPONSE = {
|
||||
code: 200,
|
||||
status: true,
|
||||
details: {},
|
||||
message: 'No orders found to assign.',
|
||||
meta: { active_riders_count: 0 }
|
||||
};
|
||||
|
||||
const assignedZone = (orders) => ({
|
||||
zones: [{ zone_name: 'North East', riders: [{ rider_id: 7, rider_name: 'Rajan A', orders }] }]
|
||||
});
|
||||
|
||||
describe('extractUnassigned', () => {
|
||||
it('finds the orders the live optimiser refused', () => {
|
||||
const rows = extractUnassigned(REAL_RESPONSE);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].bookingno).toBe('DM-TEST-1');
|
||||
});
|
||||
|
||||
it('keeps the reason, which is the whole point', () => {
|
||||
expect(extractUnassigned(REAL_RESPONSE)[0].unassigned_reason).toBe(
|
||||
'No riders found (check partner online status).'
|
||||
);
|
||||
});
|
||||
|
||||
it('tags each order with the zone it came from', () => {
|
||||
expect(extractUnassigned(REAL_RESPONSE)[0].zone_name).toBe('North East');
|
||||
});
|
||||
|
||||
it('gives every order a stable orderid for the list key', () => {
|
||||
expect(extractUnassigned(REAL_RESPONSE)[0].orderid).toBe('DM-TEST-1');
|
||||
});
|
||||
|
||||
it('falls back through the id spellings the services disagree on', () => {
|
||||
const rows = extractUnassigned({
|
||||
zones: [{ unassigned_orders: [{ booking_id: 55 }, { order_id: 66 }, { id: 77 }] }]
|
||||
});
|
||||
expect(rows.map((r) => r.orderid)).toEqual(['55', '66', '77']);
|
||||
});
|
||||
|
||||
it('synthesises an id rather than dropping an order that has none', () => {
|
||||
// A row with no id at all must still reach the operator; a missing key
|
||||
// is not a reason to lose an order they are waiting on.
|
||||
const rows = extractUnassigned({ zones: [{ unassigned_orders: [{ deliverylocation: 'Somewhere' }] }] });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].orderid).toBeTruthy();
|
||||
});
|
||||
|
||||
it('says so when no reason was given, rather than showing blank', () => {
|
||||
const rows = extractUnassigned({ zones: [{ unassigned_orders: [{ bookingno: 'X' }] }] });
|
||||
expect(rows[0].unassigned_reason).toBe('No reason given');
|
||||
});
|
||||
|
||||
it('accepts the alternative reason spellings', () => {
|
||||
const rows = extractUnassigned({
|
||||
zones: [{ unassigned_orders: [{ bookingno: 'A', reason: 'r' }, { bookingno: 'B', message: 'm' }] }]
|
||||
});
|
||||
expect(rows.map((r) => r.unassigned_reason)).toEqual(['r', 'm']);
|
||||
});
|
||||
|
||||
it('reads a top-level list too, not only per-zone', () => {
|
||||
// The envelope is demonstrably not fixed — the empty-input reply has no
|
||||
// zones at all — and an order in neither place is one nobody hears about.
|
||||
const rows = extractUnassigned({ unassigned_orders: [{ bookingno: 'TOP' }] });
|
||||
expect(rows.map((r) => r.bookingno)).toEqual(['TOP']);
|
||||
});
|
||||
|
||||
it('collects across several zones and de-duplicates', () => {
|
||||
const rows = extractUnassigned({
|
||||
zones: [
|
||||
{ zone_name: 'A', unassigned_orders: [{ bookingno: 'ONE' }] },
|
||||
{ zone_name: 'B', unassigned_orders: [{ bookingno: 'TWO' }, { bookingno: 'ONE' }] }
|
||||
]
|
||||
});
|
||||
expect(rows.map((r) => r.bookingno)).toEqual(['ONE', 'TWO']);
|
||||
});
|
||||
|
||||
it('is empty, not a crash, for the shapes that carry nothing', () => {
|
||||
expect(extractUnassigned(null)).toEqual([]);
|
||||
expect(extractUnassigned(undefined)).toEqual([]);
|
||||
expect(extractUnassigned({})).toEqual([]);
|
||||
// `details: {}` is an object here, not an array — the real empty reply.
|
||||
expect(extractUnassigned(EMPTY_INPUT_RESPONSE)).toEqual([]);
|
||||
expect(extractUnassigned({ zones: [] })).toEqual([]);
|
||||
expect(extractUnassigned({ zones: [{ riders: [] }] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignmentSummary', () => {
|
||||
it('reports a total refusal as failed — the bug in one assertion', () => {
|
||||
const s = assignmentSummary(REAL_RESPONSE);
|
||||
expect(s).toMatchObject({ assigned: 0, unassigned: 1, total: 1, riders: 0, failed: true });
|
||||
});
|
||||
|
||||
it('surfaces the reason so the toast can name it', () => {
|
||||
expect(assignmentSummary(REAL_RESPONSE).reasons).toEqual([
|
||||
{ reason: 'No riders found (check partner online status).', count: 1 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('is NOT failed when everything was assigned', () => {
|
||||
const s = assignmentSummary(assignedZone([{ bookingno: 'A' }, { bookingno: 'B' }]));
|
||||
expect(s).toMatchObject({ assigned: 2, unassigned: 0, riders: 1, failed: false });
|
||||
});
|
||||
|
||||
it('is NOT failed on a partial run — some work did land', () => {
|
||||
const preview = {
|
||||
zones: [
|
||||
{
|
||||
zone_name: 'North East',
|
||||
riders: [{ rider_id: 7, rider_name: 'Rajan A', orders: [{ bookingno: 'A' }] }],
|
||||
unassigned_orders: [{ bookingno: 'B', unassigned_reason: 'Out of range' }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const s = assignmentSummary(preview);
|
||||
expect(s).toMatchObject({ assigned: 1, unassigned: 1, total: 2, failed: false });
|
||||
});
|
||||
|
||||
it('ranks reasons by frequency, so one line explains a run', () => {
|
||||
const preview = {
|
||||
zones: [
|
||||
{
|
||||
unassigned_orders: [
|
||||
{ bookingno: 'A', unassigned_reason: 'No riders found' },
|
||||
{ bookingno: 'B', unassigned_reason: 'Out of range' },
|
||||
{ bookingno: 'C', unassigned_reason: 'No riders found' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
expect(assignmentSummary(preview).reasons).toEqual([
|
||||
{ reason: 'No riders found', count: 2 },
|
||||
{ reason: 'Out of range', count: 1 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('is not failed when there was simply no work — that is not a failure', () => {
|
||||
// An empty run must not shout. `failed` means "was given orders and
|
||||
// placed none", not "had nothing to do".
|
||||
expect(assignmentSummary(EMPTY_INPUT_RESPONSE)).toMatchObject({
|
||||
assigned: 0,
|
||||
unassigned: 0,
|
||||
total: 0,
|
||||
failed: false
|
||||
});
|
||||
expect(assignmentSummary(null)).toMatchObject({ total: 0, failed: false });
|
||||
});
|
||||
|
||||
it('counts orders across multiple riders', () => {
|
||||
const preview = {
|
||||
zones: [
|
||||
{
|
||||
riders: [
|
||||
{ rider_id: 1, rider_name: 'A', orders: [{ bookingno: 'A1' }, { bookingno: 'A2' }] },
|
||||
{ rider_id: 2, rider_name: 'B', orders: [{ bookingno: 'B1' }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
expect(assignmentSummary(preview)).toMatchObject({ assigned: 3, riders: 2, failed: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('withUnassignedZones', () => {
|
||||
it('puts the refused order on the board, under its own zone', () => {
|
||||
const folded = withUnassignedZones(REAL_RESPONSE);
|
||||
expect(folded.zones).toHaveLength(1);
|
||||
expect(folded.zones[0].zone_name).toBe('North East');
|
||||
expect(folded.zones[0].riders).toHaveLength(1);
|
||||
expect(folded.zones[0].riders[0].orders).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('files it under the key Dispatch skips, so no miler is invented', () => {
|
||||
const bucket = withUnassignedZones(REAL_RESPONSE).zones[0].riders[0];
|
||||
expect(bucket.rider_id).toBe(UNASSIGNED_RIDER_ID);
|
||||
expect(bucket.rider_name).toBe('Unassigned');
|
||||
});
|
||||
|
||||
it('carries the reason through to the order on the board', () => {
|
||||
const order = withUnassignedZones(REAL_RESPONSE).zones[0].riders[0].orders[0];
|
||||
expect(order.unassigned_reason).toBe('No riders found (check partner online status).');
|
||||
expect(order.orderid).toBe('DM-TEST-1');
|
||||
});
|
||||
|
||||
it('numbers the orders so the step chips have something to show', () => {
|
||||
const orders = withUnassignedZones({
|
||||
zones: [{ zone_name: 'Z', unassigned_orders: [{ bookingno: 'A' }, { bookingno: 'B' }] }]
|
||||
}).zones[0].riders[0].orders;
|
||||
expect(orders.map((o) => o.step)).toEqual([1, 2]);
|
||||
expect(orders.every((o) => o.trip_number === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the riders that DID get work, alongside the bucket', () => {
|
||||
const folded = withUnassignedZones({
|
||||
zones: [
|
||||
{
|
||||
zone_name: 'North East',
|
||||
riders: [{ rider_id: 7, rider_name: 'Rajan A', orders: [{ bookingno: 'A' }] }],
|
||||
unassigned_orders: [{ bookingno: 'B', unassigned_reason: 'Out of range' }]
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(folded.zones[0].riders.map((r) => r.rider_id)).toEqual([7, UNASSIGNED_RIDER_ID]);
|
||||
});
|
||||
|
||||
it('creates a zone for a top-level refusal that belongs to none', () => {
|
||||
const folded = withUnassignedZones({ unassigned_orders: [{ bookingno: 'TOP' }] });
|
||||
expect(folded.zones).toHaveLength(1);
|
||||
expect(folded.zones[0].zone_name).toBe('Unzoned');
|
||||
});
|
||||
|
||||
it('is idempotent — folding twice does not double the bucket', () => {
|
||||
const once = withUnassignedZones(REAL_RESPONSE);
|
||||
const twice = withUnassignedZones(once);
|
||||
expect(twice.zones[0].riders).toHaveLength(1);
|
||||
expect(twice.zones[0].riders[0].orders).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns the input untouched when nothing was refused', () => {
|
||||
const clean = assignedZone([{ bookingno: 'A' }]);
|
||||
expect(withUnassignedZones(clean)).toBe(clean);
|
||||
expect(withUnassignedZones(EMPTY_INPUT_RESPONSE)).toBe(EMPTY_INPUT_RESPONSE);
|
||||
expect(withUnassignedZones(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mutate the response it was given', () => {
|
||||
const before = JSON.stringify(REAL_RESPONSE);
|
||||
withUnassignedZones(REAL_RESPONSE);
|
||||
expect(JSON.stringify(REAL_RESPONSE)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assigning a rider to an order the optimiser refused', () => {
|
||||
/**
|
||||
* This is the recovery path the board now offers. Before the refused orders
|
||||
* were drawn, the "change rider" button was unreachable for them; drawing
|
||||
* them without this would put a button on screen that silently did nothing.
|
||||
*/
|
||||
const move = (preview) =>
|
||||
moveOrderInPreviewData(preview, { orderId: 'DM-TEST-1', newRiderId: 7, newRiderName: 'Rajan A' });
|
||||
|
||||
it('takes the order out of the refusal list', () => {
|
||||
const moved = move(REAL_RESPONSE);
|
||||
expect(moved.zones[0].unassigned_orders).toHaveLength(0);
|
||||
expect(extractUnassigned(moved)).toEqual([]);
|
||||
});
|
||||
|
||||
it('puts it on the chosen rider, in the zone it came from', () => {
|
||||
const moved = move(REAL_RESPONSE);
|
||||
const rider = moved.zones[0].riders.find((r) => r.rider_id === 7);
|
||||
expect(rider.orders.map((o) => o.bookingno)).toEqual(['DM-TEST-1']);
|
||||
expect(moved.zones[0].zone_name).toBe('North East');
|
||||
});
|
||||
|
||||
it('stamps the orderid the board keyed it by, so the commit agrees', () => {
|
||||
const rider = move(REAL_RESPONSE).zones[0].riders.find((r) => r.rider_id === 7);
|
||||
expect(rider.orders[0].orderid).toBe('DM-TEST-1');
|
||||
});
|
||||
|
||||
it('turns the run from failed into assigned', () => {
|
||||
expect(assignmentSummary(REAL_RESPONSE)).toMatchObject({ assigned: 0, unassigned: 1, failed: true });
|
||||
expect(assignmentSummary(move(REAL_RESPONSE))).toMatchObject({ assigned: 1, unassigned: 0, failed: false });
|
||||
});
|
||||
|
||||
it('adds to a rider who already has work rather than duplicating them', () => {
|
||||
const preview = {
|
||||
zones: [
|
||||
{
|
||||
zone_name: 'North East',
|
||||
riders: [{ rider_id: 7, rider_name: 'Rajan A', orders: [{ orderid: 'A', bookingno: 'A' }] }],
|
||||
unassigned_orders: [{ bookingno: 'DM-TEST-1' }]
|
||||
}
|
||||
]
|
||||
};
|
||||
const moved = move(preview);
|
||||
expect(moved.zones[0].riders).toHaveLength(1);
|
||||
expect(moved.zones[0].riders[0].orders.map((o) => o.orderid)).toEqual(['A', 'DM-TEST-1']);
|
||||
});
|
||||
|
||||
it('handles a zone that holds refusals and no riders array at all', () => {
|
||||
const moved = move({ zones: [{ zone_name: 'North East', unassigned_orders: [{ bookingno: 'DM-TEST-1' }] }] });
|
||||
expect(moved.zones[0].riders[0].rider_id).toBe(7);
|
||||
expect(moved.zones[0].unassigned_orders).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles a refusal reported at the top level, with no zones', () => {
|
||||
const moved = move({ unassigned_orders: [{ bookingno: 'DM-TEST-1' }] });
|
||||
expect(moved.zones).toHaveLength(1);
|
||||
expect(moved.zones[0].riders[0].orders[0].orderid).toBe('DM-TEST-1');
|
||||
expect(moved.unassigned_orders).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('never loses the order — it is on a rider or still refused, never neither', () => {
|
||||
[REAL_RESPONSE, { unassigned_orders: [{ bookingno: 'DM-TEST-1' }] }].forEach((preview) => {
|
||||
const moved = move(preview);
|
||||
const onRider = extractRiders(moved).flatMap((r) => r.orders).length;
|
||||
expect(onRider + extractUnassigned(moved).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an order it cannot find alone, rather than moving the wrong one', () => {
|
||||
const moved = moveOrderInPreviewData(REAL_RESPONSE, {
|
||||
orderId: 'NOT-HERE',
|
||||
newRiderId: 7,
|
||||
newRiderName: 'Rajan A'
|
||||
});
|
||||
expect(extractUnassigned(moved)).toHaveLength(1);
|
||||
expect(extractRiders(moved)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the synthetic bucket out of the commit payload', () => {
|
||||
// Folded data must never reach the payload builder as a rider.
|
||||
expect(extractRiders(withUnassignedZones(REAL_RESPONSE))).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user