update on the agents and registry side

This commit is contained in:
2026-09-30 12:03:13 +05:30
parent 92e8da2842
commit 53199127ba
73 changed files with 7268 additions and 1134 deletions

View File

@@ -1,204 +1,469 @@
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} />;
},
}
);
});
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// 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}</>,
/**
* Settings → Skills & Tools, reading the backend agent registry.
*
* The real page, hooks and adapters run; only the HTTP functions are mocked,
* with responses shaped exactly like /admin/ai/* (see doormile_backend
* internal/ai/registry). What this proves: the page shows what the registry
* says, sends the writes the registry expects, and refuses in the UI what the
* server would refuse. What it does not: that the server accepts them — the
* backend's own route and Postgres tests cover that side.
*/
jest.mock('lucide-react', () =>
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
);
jest.mock('@/api/doormile', () => ({
getAiAgents: jest.fn(),
getAiSkills: jest.fn(),
getAiTools: jest.fn(),
updateAiSkill: jest.fn(),
createAiSkill: jest.fn(),
updateAiAgent: jest.fn(),
getAiInsights: jest.fn(),
getAiDecisions: jest.fn(),
runAiPlayground: jest.fn(),
}));
import SkillCard from '@/pages/doormile/settings/agentStudio/SkillCard';
import SkillCardsGrid from '@/pages/doormile/settings/agentStudio/SkillCardsGrid';
import ToolRegistryView from '@/pages/doormile/settings/agentStudio/ToolRegistryView';
jest.mock('@/api/doormile/notify', () => ({
OpenToast: jest.fn(),
messageOf: (err, fallback = 'Something went wrong') => err?.response?.data?.message || err?.message || fallback,
}));
describe('Agent Studio — Data Layer & Persistence', () => {
beforeEach(() => {
localStorage.clear();
let mockUser = { role: 'admin' };
jest.mock('@/lib/AuthContext', () => ({ useAuth: () => ({ user: mockUser }) }));
import * as api from '@/api/doormile';
import AgentStudio from '@/pages/doormile/settings/agentStudio/AgentStudio';
import { playgroundErrorMessage } from '@/pages/doormile/settings/agentStudio/AgentPlayground';
import {
buildStudioModel,
canEditRegistry,
schemaToParameters,
toUiAgent,
} from '@/pages/doormile/settings/agentStudio/registryAdapters';
// ── Registry fixtures (the backend's wire shape) ────────────────────────────
const AGENTS = [
{
agentid: 'EXCEPTION_AGENT', name: 'Exception', runtime: 'engine', classref: 'AI_engine/agents/exception_agent.py:106',
purpose: 'Detects stalled riders.', wakeon: 'TRACKING miler.stalled', status: 'live', llmdecision: 'decide_stall_response',
hasautonomygate: true, autonomous: false, model: '', skillcount: 1, toolcount: 2,
},
{
agentid: 'HUB_AGENT', name: 'Hub', runtime: 'engine', classref: 'AI_engine/agents/hub_agent.py:35',
purpose: 'Hub capacity over 8 fictional hubs.', wakeon: 'Direct task', status: 'simulation', llmdecision: '',
hasautonomygate: false, autonomous: false, model: '', skillcount: 0, toolcount: 0,
},
{
agentid: 'CONSOLE_OPS_AGENT', name: 'Console Ops Agent', runtime: 'console', classref: 'branch',
purpose: 'Ops briefing.', wakeon: 'Console page load', status: 'unmerged', llmdecision: '',
hasautonomygate: false, autonomous: false, model: '', skillcount: 1, toolcount: 1,
},
];
const SKILLS = [
{
skillid: 'stall_response', agentid: 'EXCEPTION_AGENT', title: 'Stalled-rider response', category: 'rider_operations',
description: 'Detect a stalled rider and respond.', sampleprompt: '', source: 'engine', enabled: true, version: 1,
tools: ['reassign_booking', 'nearby_milers'],
thresholds: { stallMinutes: 10 },
thresholdsschema: [{ key: 'stallMinutes', label: 'Stall threshold', unit: 'min', default: 10, min: 5, max: 60, step: 5 }],
},
{
skillid: 'skill_doorstep_stall', agentid: 'CONSOLE_OPS_AGENT', title: 'Doorstep Stall Rescuer', category: 'rider_operations',
description: 'Flags riders stalled at a doorstep.', sampleprompt: '', source: 'console', enabled: true, version: 1,
tools: ['lookup_order'], thresholds: {}, thresholdsschema: [],
},
];
const TOOLS = [
{
toolname: 'reassign_booking', description: 'Reassign a booking whose rider has stalled.', kind: 'write',
target: 'doormile_backend POST /internal/bookings/:id/reassign', implementedat: 'exception_agent.py:466', requiresconfirmation: true,
inputschema: { type: 'object', properties: { booking_id: { type: 'integer', description: 'Booking to reassign' } }, required: ['booking_id'] },
},
{
toolname: 'nearby_milers', description: 'Find riders near a point.', kind: 'read', target: 'Redis GEO', implementedat: 'dispatch_agent.py',
requiresconfirmation: false, inputschema: { type: 'object', properties: {} },
},
{
toolname: 'lookup_order', description: 'Find an order.', kind: 'read', target: 'GET /admin/bookings', implementedat: 'tools.js',
requiresconfirmation: false, inputschema: { type: 'object', properties: { identifier: { type: 'string' } }, required: ['identifier'] },
},
];
// /admin/ai/insights and /admin/ai/decisions, in their wire shape.
const INSIGHTS = {
days: 7,
since: '2026-09-22T19:00:00Z',
receiving: true,
runs: {
total: 42,
failed: 3,
peragent: [{ agentid: 'EXCEPTION_AGENT', runs: 42, failed: 3, avgdurationms: 412, lastrunat: '2026-09-29T18:40:00Z' }],
},
decisions: {
total: 12,
bytype: [{ decisiontype: 'miler_assignment', total: 12, outcomes: { success: 9, pending: 3 } }],
},
live: [{ agentid: 'EXCEPTION_AGENT', status: 'idle', currenttask: null, taskscompleted: 40, tasksfailed: 3, lastseenat: '2026-09-29T18:59:00Z' }],
};
const DECISIONS = [
{ id: 88, decisiontype: 'miler_assignment', bookingid: 501, decision: { miler_id: 8 }, reasoning: 'Nearest idle rider, 1.2 km', outcome: 'success', createdat: '2026-09-29T18:30:00Z' },
];
const renderStudio = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<AgentStudio />
</QueryClientProvider>
);
};
// Pick an agent from the navigator's switcher.
const switchTo = async (name) => {
const header = screen.getAllByRole('button').find((b) => within(b).queryByText('AI_ENGINE AGENT') || within(b).queryByText('CONSOLE AGENT'));
fireEvent.click(header);
const menu = screen.getByText('Switch Agent Scope').parentElement;
fireEvent.click(within(menu).getByText(name).closest('button'));
};
beforeEach(() => {
localStorage.clear();
mockUser = { role: 'admin' };
jest.clearAllMocks();
api.getAiAgents.mockResolvedValue(AGENTS);
api.getAiSkills.mockResolvedValue(SKILLS);
api.getAiTools.mockResolvedValue(TOOLS);
api.updateAiSkill.mockResolvedValue({ success: true });
api.createAiSkill.mockResolvedValue({ success: true });
api.updateAiAgent.mockResolvedValue({ success: true });
api.getAiInsights.mockResolvedValue(INSIGHTS);
api.getAiDecisions.mockResolvedValue(DECISIONS);
});
// ── Adapters ────────────────────────────────────────────────────────────────
describe('registry adapters', () => {
it('maps an agent and gives every status a readable badge', () => {
const a = toUiAgent(AGENTS[1]);
expect(a).toMatchObject({ id: 'HUB_AGENT', runtime: 'engine', hasAutonomyGate: false });
expect(a.statusMeta.label).toBe('Simulation');
expect(toUiAgent({ ...AGENTS[1], status: 'something_new' }).statusMeta.label).toBe('Something_new');
});
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('turns a JSON-schema into parameter rows, keeping required', () => {
expect(schemaToParameters(TOOLS[0].inputschema)).toEqual([
{ name: 'booking_id', type: 'integer', description: 'Booking to reassign', required: true },
]);
expect(schemaToParameters(undefined)).toEqual([]);
});
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('names the skills that use each tool', () => {
const { tools, skills } = buildStudioModel(AGENTS, SKILLS, TOOLS);
expect(tools.find((t) => t.name === 'reassign_booking').parentSkill).toBe('Stalled-rider response');
expect(skills.find((s) => s.id === 'stall_response').integration).toBe('Exception');
});
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');
it('lets only the admin role edit', () => {
expect(canEditRegistry({ role: 'admin' })).toBe(true);
expect(canEditRegistry({ role: 'Admin' })).toBe(true);
expect(canEditRegistry({ role: 'manager' })).toBe(false);
expect(canEditRegistry(null)).toBe(false);
});
});
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'],
// ── The page ────────────────────────────────────────────────────────────────
describe('Agent Studio page', () => {
it('says on screen which agents the registry drives, and when', async () => {
renderStudio();
const note = screen.getByRole('note');
expect(note).toHaveTextContent(/saved to the Doormile agent registry/);
// The engine reads it only once its Phase 5 build is deployed; say so, not "live".
expect(note).toHaveTextContent(/once its registry-reading build is deployed/);
expect(note).toHaveTextContent(/environment defaults/);
expect(note).toHaveTextContent(/The Test tab runs real Claude calls/);
expect(note).not.toHaveTextContent(/The Test tab is a simulation/);
// Since Phase 3 the console skills DO run on these settings; the note must not say otherwise.
expect(note).toHaveTextContent(/run on these settings now/);
expect(note).not.toHaveTextContent(/not merged/);
});
it("shows only the selected agent's skills", async () => {
renderStudio();
expect(await screen.findAllByText('Stalled-rider response')).not.toHaveLength(0);
expect(screen.queryAllByText('Doorstep Stall Rescuer')).toHaveLength(0);
await switchTo('Console Ops Agent');
expect(await screen.findAllByText('Doorstep Stall Rescuer')).not.toHaveLength(0);
expect(screen.queryAllByText('Stalled-rider response')).toHaveLength(0);
});
it('marks a simulated agent as a simulation in the switcher', async () => {
renderStudio();
await screen.findAllByText('Stalled-rider response');
const header = screen.getAllByRole('button').find((b) => within(b).queryByText('AI_ENGINE AGENT'));
fireEvent.click(header);
const hubRow = within(screen.getByText('Switch Agent Scope').parentElement).getByText('Hub').closest('button');
expect(within(hubRow).getByText('Simulation')).toBeInTheDocument();
});
it('sends a skill toggle to the registry', async () => {
renderStudio();
fireEvent.click(await screen.findByText('Details'));
fireEvent.click(screen.getByRole('switch', { name: 'Stalled-rider response enabled' }));
await waitFor(() => expect(api.updateAiSkill).toHaveBeenCalledWith('stall_response', { enabled: false }));
});
it('saves only the thresholds that changed, and blocks an out-of-range value', async () => {
renderStudio();
fireEvent.click(await screen.findByText('Details'));
const input = screen.getByRole('spinbutton', { name: 'Stall threshold' });
const save = screen.getByRole('button', { name: 'Save thresholds' });
fireEvent.change(input, { target: { value: '99' } });
expect(screen.getByText('Between 5 and 60')).toBeInTheDocument();
expect(save).toBeDisabled();
fireEvent.change(input, { target: { value: '12' } });
expect(screen.getByText('In steps of 5')).toBeInTheDocument();
expect(save).toBeDisabled();
fireEvent.change(input, { target: { value: '15' } });
expect(save).toBeEnabled();
fireEvent.click(save);
await waitFor(() => expect(api.updateAiSkill).toHaveBeenCalledWith('stall_response', { thresholds: { stallMinutes: 15 } }));
});
it('shows the server’s refusal in the drawer', async () => {
api.updateAiSkill.mockRejectedValue({ response: { data: { message: 'stallMinutes must be between 5 and 60' } } });
renderStudio();
fireEvent.click(await screen.findByText('Details'));
fireEvent.change(screen.getByRole('spinbutton', { name: 'Stall threshold' }), { target: { value: '20' } });
fireEvent.click(screen.getByRole('button', { name: 'Save thresholds' }));
expect(await screen.findByRole('alert')).toHaveTextContent('stallMinutes must be between 5 and 60');
});
it('registers a new skill on a console agent with the registry payload', async () => {
renderStudio();
await screen.findAllByText('Stalled-rider response');
await switchTo('Console Ops Agent');
fireEvent.click(await screen.findByText('New skill'));
fireEvent.change(screen.getByLabelText('Skill Title *'), { target: { value: 'Night shift watch' } });
fireEvent.change(screen.getByLabelText('Description *'), { target: { value: 'Watch the night shift.' } });
const dialog = screen.getByRole('dialog');
fireEvent.click(within(dialog).getByText('lookup_order').closest('button'));
fireEvent.click(within(dialog).getByRole('button', { name: 'Add Skill' }));
await waitFor(() =>
expect(api.createAiSkill).toHaveBeenCalledWith({
agentid: 'CONSOLE_OPS_AGENT',
title: 'Night shift watch',
category: 'logistics',
description: 'Watch the night shift.',
sampleprompt: '',
tools: ['lookup_order'],
})
);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});
it('does not offer a new skill on an AI_engine agent', async () => {
renderStudio();
await screen.findAllByText('Stalled-rider response');
expect(screen.getByText('New skill').closest('button')).toBeDisabled();
});
it('needs the agent id typed before switching autonomy on', async () => {
renderStudio();
await screen.findAllByText('Stalled-rider response');
fireEvent.click(screen.getByText('Configure'));
fireEvent.click(screen.getByRole('switch', { name: 'Autonomous' }));
const save = screen.getByRole('button', { name: /Save changes/ });
expect(save).toBeDisabled();
const confirm = screen.getByText(/to confirm/).closest('label').querySelector('input');
fireEvent.change(confirm, { target: { value: 'exception_agent' } });
expect(save).toBeDisabled();
fireEvent.change(confirm, { target: { value: 'EXCEPTION_AGENT' } });
expect(save).toBeEnabled();
fireEvent.click(save);
await waitFor(() =>
expect(api.updateAiAgent).toHaveBeenCalledWith('EXCEPTION_AGENT', { autonomous: true, confirm: 'EXCEPTION_AGENT' })
);
});
it('offers no autonomy switch on an agent without a gate', async () => {
renderStudio();
await screen.findAllByText('Stalled-rider response');
await switchTo('Hub');
fireEvent.click(screen.getByText('Configure'));
expect(screen.queryByRole('switch', { name: 'Autonomous' })).not.toBeInTheDocument();
expect(screen.getByText(/has no autonomy switch/)).toBeInTheDocument();
});
it('is read-only for a manager', async () => {
mockUser = { role: 'manager' };
renderStudio();
await screen.findAllByText('Stalled-rider response');
expect(screen.getByText('You can view the registry. Only an admin can change it.')).toBeInTheDocument();
expect(screen.getByText('New skill').closest('button')).toBeDisabled();
fireEvent.click(screen.getByText('Details'));
expect(screen.getByRole('switch', { name: 'Stalled-rider response enabled' })).toBeDisabled();
expect(screen.getByRole('spinbutton', { name: 'Stall threshold' })).toBeDisabled();
expect(screen.queryByRole('button', { name: 'Save thresholds' })).not.toBeInTheDocument();
});
it('labels read-only tools honestly, not as autonomous', async () => {
renderStudio();
await screen.findAllByText('Stalled-rider response');
fireEvent.click(screen.getAllByText('Tools')[0]);
const row = screen.getAllByText('nearby_milers').map((el) => el.closest('tr')).find(Boolean);
expect(within(row).getByText('Read-only')).toBeInTheDocument();
expect(within(row).getByText('No confirmation needed')).toBeInTheDocument();
const gated = screen.getAllByText('reassign_booking').map((el) => el.closest('tr')).find(Boolean);
expect(within(gated).getByText('Writes')).toBeInTheDocument();
expect(within(gated).getByText('Confirmation Required')).toBeInTheDocument();
expect(screen.queryByText('Autonomous')).not.toBeInTheDocument();
});
it('shows real run, failure and decision figures on Insights', async () => {
renderStudio();
await screen.findAllByText('Stalled-rider response');
fireEvent.click(screen.getByText('Insights'));
const runs = (await screen.findByText('Agent runs')).parentElement;
expect(runs).toHaveTextContent('42');
expect(screen.getByText('7% of runs').parentElement).toHaveTextContent('Failed3');
expect(screen.getByText('Agents reporting').parentElement).toHaveTextContent('1 / 2');
const row = screen.getByRole('table', { name: 'Runs per AI_engine agent' });
const exception = within(row).getByText('Exception').closest('tr');
expect(exception).toHaveTextContent('idle');
expect(exception).toHaveTextContent('412 ms');
// The silent agent is shown as silent with zero runs, not left out.
expect(within(row).getByText('Hub').closest('tr')).toHaveTextContent('silent');
expect(screen.getByText('success · 9')).toBeInTheDocument();
expect(await screen.findByText('Nearest idle rider, 1.2 km')).toBeInTheDocument();
expect(screen.queryByText('99.4%')).not.toBeInTheDocument();
});
it('asks the insights endpoint for the chosen window', async () => {
renderStudio();
await screen.findAllByText('Stalled-rider response');
fireEvent.click(screen.getByText('Insights'));
await screen.findByText('Agent runs');
expect(api.getAiInsights).toHaveBeenLastCalledWith(7);
fireEvent.click(screen.getByRole('button', { name: '30 days' }));
await waitFor(() => expect(api.getAiInsights).toHaveBeenLastCalledWith(30));
});
it('says plainly when no telemetry is being received', async () => {
api.getAiInsights.mockResolvedValue({ ...INSIGHTS, receiving: false, runs: { total: 0, failed: 0, peragent: [] }, live: [] });
renderStudio();
await screen.findAllByText('Stalled-rider response');
fireEvent.click(screen.getByText('Insights'));
expect(await screen.findByText(/Not receiving agent telemetry/)).toBeInTheDocument();
expect(screen.getByText('No runs can be recorded until telemetry is received.')).toBeInTheDocument();
});
it('says why when the registry cannot be read', async () => {
api.getAiAgents.mockRejectedValue({ response: { data: { message: 'available to Doormile staff only' } } });
renderStudio();
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Could not load the agent registry');
expect(alert).toHaveTextContent('available to Doormile staff only');
});
});
// ── Test tab (Phase 6): a real backend run, not a simulation ───────────────
const TRACE = {
agentid: 'EXCEPTION_AGENT', skillid: '', model: 'claude-opus-5-5', turns: 2, inputtokens: 1200, outputtokens: 300,
ms: 4200, stopreason: 'end_turn', final: 'I would reassign it.',
steps: [
{ kind: 'tool', tool: 'nearby_milers', toolkind: 'read', input: { lat: 11, lon: 77 }, outcome: 'executed', result: { count: 2 }, ms: 12 },
{ kind: 'tool', tool: 'reassign_booking', toolkind: 'write', input: { booking_id: 5 }, outcome: 'proposed', result: { executed: false }, ms: 0 },
{ kind: 'text', text: 'I would reassign it.', ms: 900 },
],
};
describe('Agent Studio Test tab', () => {
const openTest = async () => {
renderStudio();
await screen.findAllByText('Stalled-rider response');
fireEvent.click(screen.getByText('Test'));
};
it('renders title, description, category, and prompt trigger bubble', () => {
render(<SkillCard skill={sampleSkill} onTest={() => {}} onDetails={() => {}} />);
it('runs the prompt on the backend and shows its real trace', async () => {
api.runAiPlayground.mockResolvedValue(TRACE);
await openTest();
fireEvent.change(screen.getByLabelText('Playground prompt'), { target: { value: 'booking 5 is stuck' } });
fireEvent.click(screen.getByText('Run'));
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();
await screen.findByText('I would reassign it.');
expect(api.runAiPlayground).toHaveBeenCalledWith(
{ agentid: 'EXCEPTION_AGENT', skillid: undefined, prompt: 'booking 5 is stuck' },
expect.anything()
);
const tools = screen.getAllByTestId('trace-tool');
expect(tools[0]).toHaveTextContent('nearby_milers()');
expect(tools[0]).toHaveTextContent('executed');
expect(tools[1]).toHaveTextContent('reassign_booking()');
expect(tools[1]).toHaveTextContent('proposal only — not executed');
expect(screen.getByTestId('trace-meta')).toHaveTextContent('2 turns · 1200 in / 300 out tokens · 4.2 s');
expect(screen.getByText('claude-opus-5-5')).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('sends the chosen skill', async () => {
api.runAiPlayground.mockResolvedValue(TRACE);
await openTest();
fireEvent.change(screen.getByLabelText('Skill:'), { target: { value: 'stall_response' } });
fireEvent.change(screen.getByLabelText('Playground prompt'), { target: { value: 'x' } });
fireEvent.click(screen.getByText('Run'));
await waitFor(() => expect(api.runAiPlayground).toHaveBeenCalled());
expect(api.runAiPlayground.mock.calls[0][0].skillid).toBe('stall_response');
});
it('fires onDetails when Details button is clicked', () => {
const handleDetails = jest.fn();
render(<SkillCard skill={sampleSkill} onTest={() => {}} onDetails={handleDetails} />);
it('says plainly when the server has no Claude client', async () => {
api.runAiPlayground.mockRejectedValue({ response: { status: 503, data: { code: 'PLAYGROUND_NOT_CONFIGURED' } } });
await openTest();
fireEvent.change(screen.getByLabelText('Playground prompt'), { target: { value: 'hi' } });
fireEvent.click(screen.getByText('Run'));
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('not switched on for this server yet');
expect(alert).toHaveTextContent('Nothing was run');
});
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();
it('does not let a non-admin run it', async () => {
mockUser = { role: 'manager' };
await openTest();
expect(screen.getByLabelText('Playground prompt')).toBeDisabled();
expect(screen.getByText('Run').closest('button')).toBeDisabled();
expect(screen.getByText('Only an admin can run the Test playground.')).toBeInTheDocument();
expect(api.runAiPlayground).not.toHaveBeenCalled();
});
it('maps server errors to operator messages', () => {
expect(playgroundErrorMessage({ response: { status: 429, data: { message: 'Playground limit reached: 10 runs per 10 minutes.' } } }))
.toMatch(/10 runs per 10 minutes/);
expect(playgroundErrorMessage({ response: { status: 403, data: {} } })).toMatch(/Only an admin/);
expect(playgroundErrorMessage({ response: { status: 502, data: { message: 'The Claude request failed; nothing was changed.' } } }))
.toMatch(/nothing was changed/);
expect(playgroundErrorMessage(new Error('Network Error'))).toMatch(/Nothing was changed/);
});
});

View File

@@ -12,96 +12,80 @@ jest.mock('lucide-react', () =>
);
import Agents from '@/pages/doormile/agents/Agents';
import { SNAPSHOT_AT, SNAPSHOT_LABEL, networkStats } from '@/lib/agentNetwork';
/**
* The Agents page.
*
* It renders a hand-maintained table (lib/agentNetwork.js) rather than a live
* feed, so the risk here is not stale data — agentNetwork.test.js guards the
* table — it is that the page silently drops something. A detail panel that
* renders an empty string, or a card whose status has no colour, looks like a
* quiet page rather than a broken one.
* It is drawn from a hand-maintained snapshot (lib/agentNetwork.js) plus a
* scenario player (AgentFlowVisualizer) — neither is a feed from AI_engine,
* which exposes nothing this console can read. The risk these tests guard is
* the page PRESENTING that as live: a status board that looks real-time and is
* days old is worse than one that says how old it is.
*
* Rewritten 2026-09-29 for the flow-visualizer layout (the card grid these
* tests used to target is gone). The honesty checks were kept, not dropped.
*/
describe('the Agents page', () => {
it('lists all nine agents', () => {
it('says how old its figures are, as a machine-readable date', () => {
render(<Agents />);
['JARVIS', 'Dispatch', 'Exception', 'Express Dispatch', 'Order', 'Route Optimizer', 'Hub', 'Fleet', 'Customer'].forEach(
const stamp = screen.getByText(new RegExp(SNAPSHOT_LABEL));
expect(stamp.tagName).toBe('TIME');
expect(stamp).toHaveAttribute('dateTime', SNAPSHOT_AT);
expect(stamp).toHaveTextContent(/Snapshot/);
});
it('does not call itself real-time or live', () => {
const { container } = render(<Agents />);
expect(screen.queryByText(/Real-Time/i)).not.toBeInTheDocument();
expect(screen.queryByText(/^Live Activity$/i)).not.toBeInTheDocument();
// The pulsing "on air" dot is gone from the header.
expect(container.querySelector('h1').parentElement.querySelector('.animate-pulse')).toBeNull();
});
it('labels the activity panel as a simulation', () => {
render(<Agents />);
expect(screen.getByText('Sample Activity')).toBeInTheDocument();
expect(screen.getByText(/Simulation · not live data/)).toBeInTheDocument();
});
it('takes its status counts from the snapshot, not hard-coded numbers', () => {
render(<Agents />);
const s = networkStats();
expect(screen.getByText(`${s.working} Processing`)).toBeInTheDocument();
expect(screen.getByText(`${s.degraded} Warning`)).toBeInTheDocument();
expect(screen.getByText(`${s.total - s.working - s.degraded} Idle`)).toBeInTheDocument();
});
it('states that no autonomy gate is on, out of the agents that have one', () => {
// The page's central claim: the swarm observes and alerts, and does not
// write to the system. The denominator is gated agents, not all agents.
render(<Agents />);
const s = networkStats();
expect(s.autonomyOn).toBe(0);
expect(screen.getByText(`Autonomy gates on: 0 / ${s.gated}`)).toBeInTheDocument();
});
it('lists all nine agents in the flow', () => {
render(<Agents />);
['JARVIS Orchestrator', 'Order', 'Dispatch', 'Exception', 'Route Optimizer', 'Fleet', 'Hub', 'Express Dispatch', 'Customer'].forEach(
(name) => {
expect(screen.getAllByText(name).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name })).toBeInTheDocument();
}
);
});
it('opens on Dispatch rather than an empty panel', () => {
// It is the only agent with anything to show. Landing on a blank detail
// panel reads as a broken page.
it('opens the technical record of the agent that was clicked', () => {
render(<Agents />);
expect(screen.getByText('DISPATCH_AGENT')).toBeInTheDocument();
expect(screen.getByText(/only agent doing real work/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Exception' }));
fireEvent.click(screen.getByRole('button', { name: /Technical Specs/ }));
expect(screen.getAllByText(/EXCEPTION_AGENT/).length).toBeGreaterThan(0);
});
it('swaps the whole detail panel when another agent is picked', () => {
it('shows the grouped error counts from the run log', () => {
render(<Agents />);
fireEvent.click(screen.getByRole('button', { name: /Exception/ }));
expect(screen.getByText('EXCEPTION_AGENT')).toBeInTheDocument();
expect(screen.getByText(/17,188 of the 18,146 lines/)).toBeInTheDocument();
// and the previous agent's record is gone, not merely scrolled away
expect(screen.queryByText('DISPATCH_AGENT')).not.toBeInTheDocument();
});
it('marks the selected card for a screen reader, not just with a colour', () => {
render(<Agents />);
const exception = screen.getByRole('button', { name: /Exception/ });
expect(exception).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(exception);
expect(exception).toHaveAttribute('aria-pressed', 'true');
});
it('says a task-driven agent listens to nothing, rather than leaving a blank', () => {
render(<Agents />);
fireEvent.click(screen.getByRole('button', { name: /Fleet/ }));
expect(screen.getByText(/runs only when JARVIS hands it a task/i)).toBeInTheDocument();
});
it('shows the pipeline, both halves of it', () => {
render(<Agents />);
expect(screen.getByText('doormile_backend')).toBeInTheDocument();
expect(screen.getByText('NATS JetStream')).toBeInTheDocument();
expect(screen.getByText('Event-driven')).toBeInTheDocument();
expect(screen.getByText('Task-driven')).toBeInTheDocument();
expect(screen.getByText('Alert, never act')).toBeInTheDocument();
});
it('states that nothing is switched on, in the KPI row', () => {
// The page's central claim: the swarm observes and alerts, and does not
// write to the system.
render(<Agents />);
expect(screen.getByText(/Gates on/)).toBeInTheDocument();
expect(screen.getByText(/All autonomy gates OFF/)).toBeInTheDocument();
});
it('admits the figures are a snapshot, not a feed', () => {
// A status board that looks live and is four days old is worse than one
// that says how old it is.
render(<Agents />);
expect(screen.getAllByText(/Last run · 16–20 Sep 2026/).length).toBeGreaterThan(0);
});
it('shows the repeated-error count on the log rows', () => {
render(<Agents />);
const errs = screen.getAllByText('×8,594');
expect(errs).toHaveLength(2);
});
it('every card carries a real trigger label, not undefined', () => {
const { container } = render(<Agents />);
const cards = container.querySelectorAll('button[aria-pressed]');
expect(cards).toHaveLength(9);
cards.forEach((card) => {
const text = within(card).getByText(/NATS event|NATS pull|JARVIS task|In-process/);
expect(text).toBeInTheDocument();
});
const diagnostics = screen.getByText('System Diagnostics').closest('div.flex-col');
expect(within(diagnostics).getByText('8,594 occurrences')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,221 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
/**
* Clients → Onboard client. The real page, hooks and validation run; only the
* HTTP functions are mocked, shaped like POST /admin/clients/onboard and
* GET /admin/clients/onboarded. The server's own gate and transaction are
* covered by doormile_backend's route tests and the live local run.
*/
// Radix's Switch measures itself; jsdom has no ResizeObserver.
beforeAll(() => {
global.ResizeObserver = global.ResizeObserver || class { observe() {} unobserve() {} disconnect() {} };
});
jest.mock('lucide-react', () =>
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
);
jest.mock('@/api/doormile', () => ({
onboardClient: jest.fn(),
getOnboardedClients: jest.fn(),
getOnboardingCities: jest.fn(),
}));
let mockAuth = { user: { email: 'admin@doormile.com', role: 'admin', tenantid: null }, isClient: false };
jest.mock('@/lib/AuthContext', () => ({ useAuth: () => mockAuth }));
import * as api from '@/api/doormile';
import ClientOnboarding from '@/pages/doormile/clients/ClientOnboarding';
import { canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding } from '@/lib/clientOnboarding';
const CITIES = [
{ applocationid: 1, applocationname: 'Coimbatore', status: 'Active' },
{ applocationid: 2, applocationname: 'Hyderabad', status: 'Active' },
{ applocationid: 0, applocationname: 'All', status: 'Active' },
];
const renderPage = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<ClientOnboarding />
</MemoryRouter>
</QueryClientProvider>
);
};
const fill = (label, value) => fireEvent.change(screen.getByLabelText(label, { exact: false }), { target: { value } });
const fillValidForm = async () => {
await screen.findByRole('option', { name: 'Coimbatore' });
fill('Company name', 'Acme Foods');
fill('Contact person', 'Priya Raman');
fill('Mobile number', '98765 43210');
fill('Login email', ' Ops@Acme.Example ');
fill('Operating city', '1');
fireEvent.change(document.getElementById('onboard-password'), { target: { value: 's3cure-pass' } });
fill('Confirm password', 's3cure-pass');
};
beforeEach(() => {
mockAuth = { user: { email: 'admin@doormile.com', role: 'admin', tenantid: null }, isClient: false };
jest.clearAllMocks();
api.getOnboardingCities.mockResolvedValue(CITIES);
api.getOnboardedClients.mockResolvedValue([
{ tenantid: 12, tenantname: 'Older Client', primaryemail: 'a@old.example', primarycontact: '9000000000', status: 'Active', loginemail: 'a@old.example', loginrole: 'manager', logincreatedat: '2026-09-28T10:00:00Z' },
]);
});
describe('Client onboarding page', () => {
it('onboards a client with a normalised payload and shows the new login once', async () => {
api.onboardClient.mockResolvedValue({
success: true,
data: {
tenant: { tenantid: 42, tenantname: 'Acme Foods', status: 'Active' },
login: { email: 'ops@acme.example', role: 'manager', userid: 7, tenantid: 42 },
},
});
renderPage();
await fillValidForm();
fireEvent.click(screen.getByRole('button', { name: /Onboard client/ }));
await screen.findByText('Acme Foods is onboarded');
expect(api.onboardClient.mock.calls[0][0]).toEqual({
companyname: 'Acme Foods',
contactname: 'Priya Raman',
email: 'ops@acme.example',
phone: '9876543210',
password: 's3cure-pass',
applocationid: 1,
requiredeliveryotp: false,
});
expect(screen.getByRole('status')).toHaveTextContent('ops@acme.example');
expect(screen.getByRole('status')).toHaveTextContent('only time the password is shown');
// The form is cleared so the password does not linger on screen.
expect(document.getElementById('onboard-password')).toHaveValue('');
});
it('lists clients that already have a console login, without the "All" pseudo-city', async () => {
renderPage();
expect(await screen.findByText('Older Client')).toBeInTheDocument();
expect(screen.getByText('a@old.example')).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'All' })).not.toBeInTheDocument();
});
it('refuses an incomplete form without calling the server', async () => {
renderPage();
await screen.findByRole('option', { name: 'Coimbatore' });
fireEvent.change(document.getElementById('onboard-password'), { target: { value: 's3cure-pass' } });
fill('Confirm password', 'different-1');
fireEvent.click(screen.getByRole('button', { name: /Onboard client/ }));
expect(await screen.findByText('Enter the company name')).toBeInTheDocument();
expect(screen.getByText('The passwords do not match')).toBeInTheDocument();
expect(screen.getByText("Choose the client's operating city")).toBeInTheDocument();
expect(api.onboardClient).not.toHaveBeenCalled();
});
it('puts a server conflict under the field it is about', async () => {
api.onboardClient.mockRejectedValue({ response: { status: 409, data: { success: false, message: 'this email already has a console login' } } });
renderPage();
await fillValidForm();
fireEvent.click(screen.getByRole('button', { name: /Onboard client/ }));
expect(await screen.findByText('this email already has a console login')).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('says so when the server refuses the login', async () => {
api.onboardClient.mockRejectedValue({ response: { status: 403, data: { success: false } } });
renderPage();
await fillValidForm();
fireEvent.click(screen.getByRole('button', { name: /Onboard client/ }));
expect(await screen.findByRole('alert')).toHaveTextContent('not allowed to onboard clients');
});
it('fills both password fields when generating one', async () => {
renderPage();
await screen.findByRole('option', { name: 'Coimbatore' });
fireEvent.click(screen.getByRole('button', { name: /Generate password/ }));
const pw = document.getElementById('onboard-password').value;
expect(pw).toHaveLength(14);
expect(screen.getByLabelText('Confirm password', { exact: false })).toHaveValue(pw);
});
it.each([
['another Doormile admin', { user: { email: 'suriya@doormile.com', role: 'admin' }, isClient: false }],
['the owner email on a client login', { user: { email: 'admin@doormile.com', role: 'admin', tenantid: 7 }, isClient: true }],
['the owner email as a manager', { user: { email: 'admin@doormile.com', role: 'manager' }, isClient: false }],
])('is closed to %s, and calls nothing', async (_name, auth) => {
mockAuth = auth;
renderPage();
expect(screen.getByText('Client onboarding is restricted')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Onboard client/ })).not.toBeInTheDocument();
await waitFor(() => expect(api.getOnboardedClients).not.toHaveBeenCalled());
expect(api.getOnboardingCities).not.toHaveBeenCalled();
});
});
describe('Client onboarding when the city list fails', () => {
it('explains a server without the onboarding endpoints and blocks submit', async () => {
api.getOnboardingCities.mockRejectedValue({ response: { status: 404, data: {} } });
renderPage();
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Client onboarding is not available right now');
expect(alert).toHaveTextContent('does not have client onboarding yet');
expect(screen.getByRole('option', { name: 'Cities could not be loaded' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Onboard client/ })).toBeDisabled();
});
it('retries on demand and recovers', async () => {
api.getOnboardingCities.mockRejectedValueOnce({ response: { status: 500, data: { message: 'failed to list cities' } } });
renderPage();
expect(await screen.findByRole('alert')).toHaveTextContent('failed to list cities');
api.getOnboardingCities.mockResolvedValue(CITIES);
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(await screen.findByRole('option', { name: 'Coimbatore' })).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: /Onboard client/ })).not.toBeDisabled();
});
});
describe('client onboarding helpers', () => {
it('allows only the owner email, as Doormile staff admin, case-insensitively', () => {
expect(canOnboardClients({ email: 'Admin@Doormile.com', role: 'admin' }, false)).toBe(true);
expect(canOnboardClients({ email: 'admin@doormile.com', roleid: 1 }, false)).toBe(true);
expect(canOnboardClients({ email: 'admin@doormile.com', role: 'admin' }, true)).toBe(false);
expect(canOnboardClients({ email: 'suriya@doormile.com', role: 'admin' }, false)).toBe(false);
expect(canOnboardClients(null, false)).toBe(false);
});
it('generates passwords the validation accepts', () => {
for (let i = 0; i < 50; i += 1) {
const pw = generateClientPassword();
expect(pw).toMatch(/\d/);
expect(pw).toMatch(/[a-z]/i);
const errors = validateOnboarding({
companyname: 'Acme', contactname: 'Priya', email: 'a@b.co', phone: '9876543210', applocationid: 1, password: pw, confirm: pw,
});
expect(errors).toEqual({});
}
});
it('explains each failure plainly', () => {
expect(onboardingUnavailableMessage({ response: { status: 404 } })).toMatch(/deployed/);
expect(onboardingUnavailableMessage({ response: { status: 403 } })).toMatch(/admin@doormile.com/);
expect(onboardingUnavailableMessage({ response: { status: 401 } })).toMatch(/Sign in again/);
expect(onboardingUnavailableMessage(new Error('Network Error'))).toMatch(/Could not reach/);
});
it('mirrors the server rules', () => {
const base = { companyname: 'Acme', contactname: 'Priya', email: 'a@b.co', phone: '9876543210', applocationid: 1, password: 'longenough1', confirm: 'longenough1' };
expect(validateOnboarding({ ...base, phone: '5876543210' }).phone).toBeTruthy();
expect(validateOnboarding({ ...base, email: 'Ops <a@b.co>' }).email).toBeTruthy();
expect(validateOnboarding({ ...base, password: 'a@b.co', confirm: 'a@b.co' }).password).toBeTruthy();
expect(validateOnboarding({ ...base, password: 'short', confirm: 'short' }).password).toBeTruthy();
});
});

View File

@@ -0,0 +1,108 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
/**
* Create Order → Order Summary: driving distance and duration for short legs.
*
* Pickup and drop on the same address showed "1 km" and "15 mins". OSRM rounds
* to whole km (a 120 m leg is 0 km), the page then did `Math.max(1, d || 1)`
* and `minutes || 15`, so every leg under 500 m read as 1 km / 15 mins. The
* card must show the real road length in metres and "< 1 min".
*/
jest.mock('lucide-react', () =>
new Proxy({}, {
get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />),
})
);
jest.mock('leaflet', () => ({ __esModule: true, default: { DivIcon: function DivIcon() {} } }));
jest.mock('react-leaflet', () => ({
MapContainer: () => null, Marker: () => null, Polyline: () => null, TileLayer: () => null, useMap: () => ({}),
}));
// The autocomplete is replaced by a button that "selects" one fixed place, so
// the test drives the real handlePlaceSelect → route effect → summary card.
const RS_PURAM = { latitude: 11.0086, longitude: 76.9502, formatted_address: 'RS Puram, Coimbatore, Tamil Nadu, 641002' };
jest.mock('@/components/doormile/AddressAutocomplete', () => ({
__esModule: true,
default: ({ id, onPlaceSelected }) => (
<button type="button" data-testid={`select-${id}`} onClick={() => onPlaceSelected(RS_PURAM)}>select</button>
),
}));
jest.mock('@/components/doormile/MapPinPickerModal', () => ({ __esModule: true, default: () => null }));
jest.mock('@/components/doormile/HubLocationSelect', () => ({ HubLocationSelect: () => null }));
jest.mock('@/lib/doormileHooks', () => ({ useHubs: () => ({ data: [] }) }));
jest.mock('@/lib/geocodingService', () => ({ buildCityScope: () => null, geocodeAddress: jest.fn(), saveRecentAddress: jest.fn() }));
jest.mock('@/components/third-party/OpenToast', () => ({ OpenToast: jest.fn() }));
jest.mock('@/api/doormile/endpoints', () => ({
createExpressBooking: jest.fn(),
createTenantCustomer: jest.fn(),
getAdminCustomers: jest.fn(() => Promise.resolve([])),
getAdminPricing: jest.fn(() => Promise.resolve([])),
getAdminTenants: jest.fn(() => Promise.resolve([])),
getTenantCustomers: jest.fn(() => Promise.resolve([])),
getTenantLocations: jest.fn(() => Promise.resolve([])),
}));
import CreateOrder from '@/pages/doormile/orders/CreateOrder';
const osrm = (meters, seconds) => ({
ok: true,
json: async () => ({ routes: [{ distance: meters, duration: seconds, geometry: { coordinates: [] } }] }),
});
const renderPage = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<CreateOrder />
</MemoryRouter>
</QueryClientProvider>
);
};
// The value box sits directly under its label in the summary card.
const metric = (label) => screen.getByText(label).nextElementSibling.textContent;
const pinBothEnds = () => {
fireEvent.click(screen.getByTestId('select-pickup-address'));
fireEvent.click(screen.getByTestId('select-drop-address'));
};
describe('Create Order — route summary for short legs', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
it('shows 0 m and "< 1 min" when pickup and drop are the same address — not 1 km / 15 mins', async () => {
global.fetch.mockResolvedValue(osrm(0, 0));
renderPage();
pinBothEnds();
await waitFor(() => expect(metric('Driving Dist.')).toBe('0 m'));
expect(metric('Est. Duration')).toBe('< 1 min');
expect(screen.getByText('0 m road path')).toBeInTheDocument();
expect(screen.queryByText('1 km')).not.toBeInTheDocument();
expect(screen.queryByText('15 mins')).not.toBeInTheDocument();
});
it('shows a ~140 m leg in metres', async () => {
global.fetch.mockResolvedValue(osrm(142, 25));
renderPage();
pinBothEnds();
await waitFor(() => expect(metric('Driving Dist.')).toBe('140 m'));
expect(metric('Est. Duration')).toBe('< 1 min');
});
it('still shows longer legs in km with one decimal and real minutes', async () => {
global.fetch.mockResolvedValue(osrm(1908, 130));
renderPage();
pinBothEnds();
await waitFor(() => expect(metric('Driving Dist.')).toBe('1.9 km'));
expect(metric('Est. Duration')).toBe('2 mins');
});
});

View File

@@ -29,6 +29,9 @@ jest.mock('@/components/doormile/HubLocationSelect', () => ({ HubLocationSelect:
jest.mock('@/lib/doormileHooks', () => ({ useHubs: () => ({ data: [] }) }));
jest.mock('@/lib/geocodingService', () => ({ geocodeAddress: jest.fn(), saveRecentAddress: jest.fn() }));
jest.mock('@/lib/distance', () => ({
// The formatters are pure — keep the real ones so the summary card renders.
formatRouteDistance: jest.requireActual('@/lib/distance').formatRouteDistance,
formatRouteDuration: jest.requireActual('@/lib/distance').formatRouteDuration,
calculateDrivingDistance: jest.fn(() => Promise.resolve(null)),
calculateDrivingRoute: jest.fn(() => Promise.resolve(null)),
calculateTotalCharge: jest.fn(() => 0),