update on the agents and registry side
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
221
tests/integration/clientOnboarding.test.jsx
Normal file
221
tests/integration/clientOnboarding.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
108
tests/integration/createOrderRouteSummary.test.jsx
Normal file
108
tests/integration/createOrderRouteSummary.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
66
tests/lib/AgentFactory.test.js
Normal file
66
tests/lib/AgentFactory.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { AgentFactory } from '@/lib/assistant/agent/AgentFactory';
|
||||
import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
|
||||
|
||||
describe('AgentFactory', () => {
|
||||
beforeEach(() => {
|
||||
SkillRegistry.clearRegistryConfig();
|
||||
});
|
||||
|
||||
it('synthesizes an agent from active skills with instructions and tools', () => {
|
||||
const agent = AgentFactory.synthesizeDefaultAgent();
|
||||
|
||||
expect(agent.name).toBe('Doormile Fleet Orchestrator');
|
||||
expect(agent.activeSkillCount).toBeGreaterThanOrEqual(5);
|
||||
expect(agent.systemPrompt).toContain('Doormile Autonomous Logistics Fleet Orchestrator');
|
||||
expect(agent.tools.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('evaluates telemetry findings across active skills', () => {
|
||||
const agent = AgentFactory.synthesizeDefaultAgent();
|
||||
|
||||
const sampleRows = [
|
||||
{
|
||||
bookingid: 501,
|
||||
bookingno: 'BK-501',
|
||||
orderstatus: 'pending',
|
||||
expecteddeliverytime: '2026-08-31 10:00:00', // overdue compared to 12:00:00
|
||||
orderdate: '2026-08-31 09:00:00'
|
||||
}
|
||||
];
|
||||
|
||||
const findings = agent.evaluateTelemetry(sampleRows, '2026-08-31 12:00:00');
|
||||
expect(findings.length).toBeGreaterThan(0);
|
||||
expect(findings.some((f) => f.id === 'sla_breached')).toBe(true);
|
||||
});
|
||||
|
||||
it('omits disabled skills from telemetry evaluation', () => {
|
||||
SkillRegistry.applyRegistryConfig([{ skillid: 'skill_sla_guardian', enabled: false, thresholds: {} }]);
|
||||
const agent = AgentFactory.synthesizeDefaultAgent();
|
||||
|
||||
const sampleRows = [
|
||||
{
|
||||
bookingid: 501,
|
||||
bookingno: 'BK-501',
|
||||
orderstatus: 'pending',
|
||||
expecteddeliverytime: '2026-08-31 10:00:00',
|
||||
orderdate: '2026-08-31 09:00:00'
|
||||
}
|
||||
];
|
||||
|
||||
const findings = agent.evaluateTelemetry(sampleRows, '2026-08-31 12:00:00');
|
||||
expect(findings.some((f) => f.id === 'sla_breached')).toBe(false);
|
||||
});
|
||||
|
||||
it('executes active skill tools', async () => {
|
||||
const agent = AgentFactory.synthesizeDefaultAgent();
|
||||
const result = await agent.executeSkillTool('reassign_sla_critical_order', {
|
||||
bookingId: 999,
|
||||
targetMilerId: 42,
|
||||
reason: 'Urgent priority'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.proposal.action).toBe('reassign_miler');
|
||||
expect(result.proposal.bookingId).toBe(999);
|
||||
});
|
||||
});
|
||||
134
tests/lib/AgentOperationsBanner.test.jsx
Normal file
134
tests/lib/AgentOperationsBanner.test.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import AgentOperationsBanner from '@/components/doormile/AgentOperationsBanner';
|
||||
import * as scanModule from '@/lib/assistant/scan';
|
||||
import { AgentFactory } from '@/lib/assistant/agent/AgentFactory';
|
||||
import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
|
||||
|
||||
jest.mock('lucide-react', () =>
|
||||
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
|
||||
);
|
||||
jest.mock('@/lib/assistant/scan');
|
||||
jest.mock('@/lib/assistant/agent/AgentFactory');
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
function Where() {
|
||||
const loc = useLocation();
|
||||
return <div data-testid="where">{loc.pathname + loc.search}</div>;
|
||||
}
|
||||
|
||||
const renderBanner = (props = {}) =>
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MemoryRouter initialEntries={['/doormile/exceptions']}>
|
||||
<Routes>
|
||||
<Route path="*" element={<><AgentOperationsBanner {...props} /><Where /></>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const SLA_FINDING = {
|
||||
id: 'sla_breached',
|
||||
skillId: 'skill_sla_guardian',
|
||||
severity: 'critical',
|
||||
title: '1 parcel is past the promised delivery time',
|
||||
why: 'Estimated delivery time has passed.',
|
||||
count: 1,
|
||||
rows: [{ bookingid: 101, bookingno: 'BK-101' }],
|
||||
detail: ['BK-101 — 35 min overdue'],
|
||||
proposal: {
|
||||
label: 'Notify the assigned riders',
|
||||
tool: 'notifyRider',
|
||||
scope: [{ bookingid: 101 }],
|
||||
blastRadius: 'Sends a message per rider.',
|
||||
},
|
||||
};
|
||||
|
||||
describe('AgentOperationsBanner', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
SkillRegistry.clearRegistryConfig();
|
||||
});
|
||||
|
||||
it('says nothing is flagged, naming how many skills looked', async () => {
|
||||
scanModule.scanBookings.mockResolvedValue({ rows: [], truncated: false });
|
||||
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [] });
|
||||
renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Nothing flagged by the 5 enabled skills/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /Configure skills/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders remediation cards when findings are detected', async () => {
|
||||
scanModule.scanBookings.mockResolvedValue({ rows: [{ bookingid: 101 }], truncated: false });
|
||||
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [SLA_FINDING] });
|
||||
renderBanner({ title: 'Custom Title' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Custom Title/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 parcel is past the promised delivery time/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters findings by category', async () => {
|
||||
scanModule.scanBookings.mockResolvedValue({ rows: [{ bookingid: 101 }], truncated: false });
|
||||
AgentFactory.synthesizeDefaultAgent.mockReturnValue({
|
||||
evaluateTelemetry: () => [
|
||||
{ ...SLA_FINDING, proposal: undefined },
|
||||
{
|
||||
id: 'rider_critical_battery',
|
||||
skillId: 'skill_rider_battery_safety', // category: rider_safety
|
||||
severity: 'warning',
|
||||
title: 'Rider battery critical',
|
||||
why: 'Low battery',
|
||||
count: 1,
|
||||
rows: [{ bookingid: 102 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
renderBanner({ category: 'rider_safety', title: 'Rider Safety Only' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Rider Safety Only/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Rider battery critical/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/1 parcel is past the promised delivery time/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The branch opened a localStorage-only settings modal here. Settings now
|
||||
// live in the agent registry, edited on one page.
|
||||
it('"Configure skills" goes to Settings → Skills & Tools', async () => {
|
||||
scanModule.scanBookings.mockResolvedValue({ rows: [], truncated: false });
|
||||
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [] });
|
||||
renderBanner();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Configure skills/i }));
|
||||
expect(screen.getByTestId('where')).toHaveTextContent('/doormile/settings?tab=skills');
|
||||
});
|
||||
|
||||
it('says when it is running on default thresholds, and stops once the registry answers', async () => {
|
||||
scanModule.scanBookings.mockResolvedValue({ rows: [], truncated: false });
|
||||
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [] });
|
||||
renderBanner();
|
||||
|
||||
expect(await screen.findByText(/Running on default thresholds/i)).toBeInTheDocument();
|
||||
|
||||
SkillRegistry.applyRegistryConfig([{ skillid: 'skill_sla_guardian', enabled: true, thresholds: {} }]);
|
||||
await waitFor(() => expect(screen.queryByText(/Running on default thresholds/i)).toBeNull());
|
||||
});
|
||||
|
||||
it('never calls anything autonomous', async () => {
|
||||
scanModule.scanBookings.mockResolvedValue({ rows: [], truncated: false });
|
||||
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [SLA_FINDING] });
|
||||
renderBanner();
|
||||
await screen.findByText(/1 parcel is past the promised delivery time/i);
|
||||
expect(screen.queryByText(/autonomous/i)).toBeNull();
|
||||
expect(screen.queryByText(/self-healing/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
82
tests/lib/SkillRegistry.test.js
Normal file
82
tests/lib/SkillRegistry.test.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { SkillRegistry, DEFAULT_SKILLS } from '@/lib/assistant/skills/SkillRegistry';
|
||||
|
||||
/**
|
||||
* The SkillRegistry joins the rules (code) with the settings (the backend
|
||||
* agent registry, /admin/ai/skills). Rows below are shaped exactly like that
|
||||
* endpoint's response.
|
||||
*/
|
||||
|
||||
const row = (skillid, enabled, thresholds = {}) => ({ skillid, enabled, thresholds });
|
||||
|
||||
describe('SkillRegistry', () => {
|
||||
beforeEach(() => {
|
||||
SkillRegistry.clearRegistryConfig();
|
||||
});
|
||||
|
||||
it('registers the eight built-in logistics skills', () => {
|
||||
const ids = SkillRegistry.getAllSkills().map((s) => s.id);
|
||||
expect(ids).toHaveLength(8);
|
||||
expect(ids).toEqual(DEFAULT_SKILLS.map((s) => s.id));
|
||||
expect(ids).toContain('skill_sla_guardian');
|
||||
expect(ids).toContain('skill_rider_battery_safety');
|
||||
});
|
||||
|
||||
it('runs on code defaults until the registry answers, and says so', () => {
|
||||
expect(SkillRegistry.getSource()).toBe('defaults');
|
||||
const sla = SkillRegistry.getSkill('skill_sla_guardian');
|
||||
expect(sla.enabled).toBe(true);
|
||||
expect(sla.effectiveThresholds.slaRiskWindowMin.currentValue).toBe(45);
|
||||
});
|
||||
|
||||
it('takes enabled and thresholds from the registry', () => {
|
||||
SkillRegistry.applyRegistryConfig([
|
||||
row('skill_fleet_balancer', false),
|
||||
row('skill_sla_guardian', true, { slaRiskWindowMin: 30 })
|
||||
]);
|
||||
expect(SkillRegistry.getSource()).toBe('registry');
|
||||
expect(SkillRegistry.getSkill('skill_fleet_balancer').enabled).toBe(false);
|
||||
expect(SkillRegistry.getSkill('skill_sla_guardian').effectiveThresholds.slaRiskWindowMin.currentValue).toBe(30);
|
||||
// A threshold the registry did not send keeps its default.
|
||||
expect(SkillRegistry.getSkill('skill_sla_guardian').effectiveThresholds.unassignedAgingMin.currentValue).toBe(60);
|
||||
});
|
||||
|
||||
it('can switch on a skill that is off by default', () => {
|
||||
SkillRegistry.applyRegistryConfig([row('skill_cash_exposure', true)]);
|
||||
expect(SkillRegistry.getSkill('skill_cash_exposure').enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores an out-of-range stored value rather than running on it', () => {
|
||||
SkillRegistry.applyRegistryConfig([row('skill_doorstep_stall', true, { arrivedStalledMin: 999 })]);
|
||||
expect(SkillRegistry.getSkill('skill_doorstep_stall').effectiveThresholds.arrivedStalledMin.currentValue).toBe(20);
|
||||
});
|
||||
|
||||
it('ignores registry rows for skills this console does not implement', () => {
|
||||
SkillRegistry.applyRegistryConfig([row('stall_response', false), row('custom_night_shift_watch', true)]);
|
||||
expect(SkillRegistry.getAllSkills()).toHaveLength(8);
|
||||
expect(SkillRegistry.getSkill('stall_response')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('goes back to defaults when the registry is lost', () => {
|
||||
SkillRegistry.applyRegistryConfig([row('skill_sla_guardian', false, { slaRiskWindowMin: 15 })]);
|
||||
SkillRegistry.clearRegistryConfig();
|
||||
const sla = SkillRegistry.getSkill('skill_sla_guardian');
|
||||
expect(SkillRegistry.getSource()).toBe('defaults');
|
||||
expect(sla.enabled).toBe(true);
|
||||
expect(sla.effectiveThresholds.slaRiskWindowMin.currentValue).toBe(45);
|
||||
});
|
||||
|
||||
it('keeps nothing in browser storage', () => {
|
||||
localStorage.clear();
|
||||
SkillRegistry.applyRegistryConfig([row('skill_sla_guardian', false)]);
|
||||
expect(localStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it('notifies subscribers when the registry settings change', () => {
|
||||
const listener = jest.fn();
|
||||
const unsubscribe = SkillRegistry.subscribe(listener);
|
||||
SkillRegistry.applyRegistryConfig([row('skill_doorstep_stall', false)]);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener.mock.calls[0][0].find((s) => s.id === 'skill_doorstep_stall').enabled).toBe(false);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
125
tests/lib/SlaRemediationCard.test.jsx
Normal file
125
tests/lib/SlaRemediationCard.test.jsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import SlaRemediationCard from '@/components/doormile/SlaRemediationCard';
|
||||
|
||||
// lucide-react ships ESM Jest does not transform; every component test here stubs it.
|
||||
jest.mock('lucide-react', () =>
|
||||
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
|
||||
);
|
||||
|
||||
jest.mock('@/api/doormile/notify', () => ({
|
||||
OpenToast: jest.fn()
|
||||
}));
|
||||
|
||||
describe('SlaRemediationCard Component', () => {
|
||||
const sampleFinding = {
|
||||
title: '3 orders overdue in RS Puram',
|
||||
why: 'Parcels have exceeded their promised delivery SLA window by >45 minutes.',
|
||||
severity: 'critical',
|
||||
count: 3,
|
||||
proposal: {
|
||||
label: 'Split between Karthik & Suresh',
|
||||
blastRadius: 'Will transfer 2 orders to Karthik and 1 order to Suresh.'
|
||||
}
|
||||
};
|
||||
|
||||
it('renders title, explanation, and proposal details properly', () => {
|
||||
render(<SlaRemediationCard finding={sampleFinding} />);
|
||||
expect(screen.getByText('3 orders overdue in RS Puram')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Parcels have exceeded their promised delivery SLA window/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Needs attention now')).toBeInTheDocument();
|
||||
expect(screen.getByText('3 Orders')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Split between Karthik & Suresh').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('triggers onRemediate callback when Apply button is clicked', async () => {
|
||||
const handleRemediate = jest.fn(async () => {});
|
||||
render(<SlaRemediationCard finding={sampleFinding} onRemediate={handleRemediate} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /Split between Karthik & Suresh/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleRemediate).toHaveBeenCalledTimes(1);
|
||||
expect(handleRemediate).toHaveBeenCalledWith(sampleFinding);
|
||||
expect(screen.getByText('Action sent')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null if no finding is passed', () => {
|
||||
const { container } = render(<SlaRemediationCard finding={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SlaRemediationCard — never claims an action it did not perform', () => {
|
||||
const { OpenToast } = require('@/api/doormile/notify');
|
||||
|
||||
const finding = {
|
||||
title: '1 rider stalled at door',
|
||||
why: 'Rider stamped arrival but delivery has not completed.',
|
||||
severity: 'critical',
|
||||
count: 1,
|
||||
proposal: {
|
||||
label: 'Message waiting riders',
|
||||
blastRadius: 'Sends an assistance check-in to stalled riders.'
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
OpenToast.mockClear();
|
||||
});
|
||||
|
||||
// The bug: with no onRemediate the handler fell through to a 600ms timer and
|
||||
// then reported "Remediation executed" with a green card. No caller has ever
|
||||
// passed onRemediate, so every click on the Exceptions page took that branch
|
||||
// and told the operator a rider had been messaged when nothing was sent.
|
||||
it('does not report success when no executor is wired', async () => {
|
||||
render(<SlaRemediationCard finding={finding} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /review only/i });
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(OpenToast).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByText(/remediation executed/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels the action as review-only rather than offering a fix', () => {
|
||||
render(<SlaRemediationCard finding={finding} />);
|
||||
expect(screen.getByRole('button', { name: /review only/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /message waiting riders/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('executes and reports success only when an executor IS supplied', async () => {
|
||||
const onRemediate = jest.fn().mockResolvedValue({ ok: true, message: 'Notified 1 rider.' });
|
||||
render(<SlaRemediationCard finding={finding} onRemediate={onRemediate} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /message waiting riders/i });
|
||||
expect(button).toBeEnabled();
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRemediate).toHaveBeenCalledWith(finding);
|
||||
// (message, variant) — the branch passed ('success', msg), which rendered a RED error toast reading "success".
|
||||
// The executor's own message is shown, so a partial success stays visible.
|
||||
expect(OpenToast).toHaveBeenCalledWith('Notified 1 rider.', 'success');
|
||||
});
|
||||
});
|
||||
|
||||
it('reports failure honestly when the executor throws', async () => {
|
||||
const onRemediate = jest.fn().mockRejectedValue(new Error('network'));
|
||||
render(<SlaRemediationCard finding={finding} onRemediate={onRemediate} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /message waiting riders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(OpenToast).toHaveBeenCalledWith('network', 'error');
|
||||
});
|
||||
expect(OpenToast).not.toHaveBeenCalledWith(expect.anything(), 'success');
|
||||
});
|
||||
});
|
||||
200
tests/lib/agentActions.test.js
Normal file
200
tests/lib/agentActions.test.js
Normal file
@@ -0,0 +1,200 @@
|
||||
jest.mock('@/api/doormile', () => ({
|
||||
__esModule: true,
|
||||
getMilers: jest.fn(),
|
||||
buildMilerLookup: jest.fn(),
|
||||
notifyRider: jest.fn(),
|
||||
batchAssignBookings: jest.fn()
|
||||
}));
|
||||
|
||||
const api = require('@/api/doormile');
|
||||
const {
|
||||
executeProposal,
|
||||
canExecuteProposal,
|
||||
executableTools
|
||||
} = require('@/lib/assistant/agent/actions');
|
||||
|
||||
// Rows as findings carry them: mileruserid, never milerprofileid.
|
||||
const row = (over = {}) => ({ bookingid: 1, orderid: 'DM-BK-001', userid: 7, ridername: 'Suresh', ...over });
|
||||
|
||||
const finding = (tool, scope, severity = 'critical') => ({
|
||||
id: 'stalled_at_door',
|
||||
severity,
|
||||
proposal: { label: 'Message waiting riders', tool, scope, blastRadius: 'Sends a check-in.' }
|
||||
});
|
||||
|
||||
// The real bridge's shape: Maps keyed by string id.
|
||||
const lookupOf = (milers) => ({
|
||||
byUserId: new Map(milers.map((m) => [String(m.userid), m])),
|
||||
byProfileId: new Map(milers.map((m) => [String(m.milerprofileid), m])),
|
||||
byName: new Map()
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
api.getMilers.mockResolvedValue([{ userid: 7, milerprofileid: 99, displayname: 'Suresh' }]);
|
||||
api.buildMilerLookup.mockImplementation(lookupOf);
|
||||
api.notifyRider.mockResolvedValue({ success: true });
|
||||
api.batchAssignBookings.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
describe('which proposals can be executed', () => {
|
||||
it('only verbs with a real endpoint behind them', () => {
|
||||
expect(executableTools().sort()).toEqual(['notifyRider']);
|
||||
});
|
||||
|
||||
it('canExecuteProposal is false for the skills tools with no endpoint', () => {
|
||||
['trigger_auto_dispatch', 'enforce_cash_handoff', 'enforce_otp_verification', 'alert_low_battery_rider']
|
||||
.forEach((tool) => {
|
||||
expect(canExecuteProposal(finding(tool, [row()]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Resolving quietly would let a caller mistake "nothing happened" for success.
|
||||
it('throws rather than silently doing nothing', async () => {
|
||||
await expect(executeProposal(finding('enforce_cash_handoff', [row()]))).rejects.toThrow(/review-only/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyRider', () => {
|
||||
// The trap: /admin/milers/:id/notify keys off milerprofileid, findings carry
|
||||
// mileruserid. Both are small integers on the same record, and using the
|
||||
// wrong one pages a different rider or 404s — silently, either way.
|
||||
it('translates mileruserid to milerprofileid before pushing', async () => {
|
||||
await executeProposal(finding('notifyRider', [row({ userid: 7 })]));
|
||||
expect(api.notifyRider).toHaveBeenCalledTimes(1);
|
||||
expect(api.notifyRider.mock.calls[0][0]).toBe(99); // profile id, NOT 7
|
||||
});
|
||||
|
||||
it('fetches the miler list once for the whole batch', async () => {
|
||||
await executeProposal(finding('notifyRider', [row({ bookingid: 1 }), row({ bookingid: 2 }), row({ bookingid: 3 })]));
|
||||
expect(api.getMilers).toHaveBeenCalledTimes(1);
|
||||
expect(api.notifyRider).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
// A rider with no registered device cannot be pushed to. Saying nothing would
|
||||
// leave the operator assuming a phone buzzed.
|
||||
it('names riders it could not reach instead of skipping them', async () => {
|
||||
api.getMilers.mockResolvedValue([{ userid: 7, milerprofileid: undefined, displayname: 'Suresh' }]);
|
||||
const res = await executeProposal(finding('notifyRider', [row({ userid: 7 })]));
|
||||
|
||||
expect(api.notifyRider).not.toHaveBeenCalled();
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.unreachable[0]).toMatch(/no device registered/i);
|
||||
});
|
||||
|
||||
it('reports a partial success as partial, naming the failures', async () => {
|
||||
api.getMilers.mockResolvedValue([
|
||||
{ userid: 7, milerprofileid: 99 },
|
||||
{ userid: 8, milerprofileid: 100 }
|
||||
]);
|
||||
api.notifyRider.mockImplementation((id) => (id === 100 ? Promise.reject(new Error('device offline')) : Promise.resolve()));
|
||||
|
||||
const res = await executeProposal(
|
||||
finding('notifyRider', [row({ bookingid: 1, userid: 7 }), row({ bookingid: 2, orderid: 'DM-BK-002', userid: 8 })])
|
||||
);
|
||||
|
||||
expect(res.ok).toBe(true); // one did land
|
||||
expect(res.notified).toEqual(['DM-BK-001']);
|
||||
expect(res.message).toMatch(/Notified 1 rider/);
|
||||
expect(res.message).toMatch(/Could not reach 1/);
|
||||
expect(res.message).toMatch(/DM-BK-002/);
|
||||
});
|
||||
|
||||
it('reports failure when nothing landed at all', async () => {
|
||||
api.notifyRider.mockRejectedValue(new Error('network'));
|
||||
const res = await executeProposal(finding('notifyRider', [row()]));
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.notified).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses when no row carries a rider', async () => {
|
||||
const res = await executeProposal(finding('notifyRider', [row({ userid: undefined })]));
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.message).toMatch(/assigned rider to notify/i);
|
||||
expect(api.notifyRider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('escalates the wording for critical findings', async () => {
|
||||
await executeProposal(finding('notifyRider', [row()], 'critical'));
|
||||
expect(api.notifyRider.mock.calls[0][2]).toMatch(/urgent/i);
|
||||
|
||||
api.notifyRider.mockClear();
|
||||
await executeProposal(finding('notifyRider', [row()], 'warning'));
|
||||
expect(api.notifyRider.mock.calls[0][2]).not.toMatch(/urgent/i);
|
||||
});
|
||||
|
||||
it('records a source call per push so the operator can verify', async () => {
|
||||
const res = await executeProposal(finding('notifyRider', [row()]));
|
||||
expect(res.sourceCalls[0].target).toMatch(/\/admin\/milers\/99\/notify/);
|
||||
expect(res.sourceCalls[0].status).toBe('complete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignMiler', () => {
|
||||
// Deliberately review-only in the console. The branch executed it through
|
||||
// POST /hub/bookings/batch-assign, which is behind HubStaffAuth (roleid 6
|
||||
// only) — every admin/manager/executive click would 403. Until an admin
|
||||
// batch-assign route exists, the card must say "Review only", not fail.
|
||||
it('is not executable, so its card renders "Review only"', () => {
|
||||
expect(canExecuteProposal(finding('assignMiler', [row({ bookingid: 1 })]))).toBe(false);
|
||||
});
|
||||
|
||||
it('throws instead of pretending, and never calls the hub-only endpoint', async () => {
|
||||
await expect(executeProposal(finding('assignMiler', [row()]))).rejects.toThrow(/review-only/);
|
||||
expect(api.batchAssignBookings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// The gap the tests above have: they mock buildMilerLookup, so the id
|
||||
// translation is only ever checked against a stand-in. This uses the REAL one.
|
||||
//
|
||||
// It matters because the whole notify path hinges on it. mileruserid and
|
||||
// milerprofileid are both small integers on the same record, so a translation
|
||||
// that quietly returned the input would look identical in every mocked test
|
||||
// and would page the wrong rider in production.
|
||||
describe('notifyRider against the real buildMilerLookup', () => {
|
||||
const realLookup = jest.requireActual('@/api/doormile/queries').buildMilerLookup;
|
||||
|
||||
beforeEach(() => {
|
||||
api.buildMilerLookup.mockImplementation(realLookup);
|
||||
api.getMilers.mockResolvedValue([
|
||||
{ userid: 8, milerprofileid: 501, displayname: 'Kumar', phone: '900' },
|
||||
{ userid: 7, milerprofileid: 499, displayname: 'Suresh', phone: '901' }
|
||||
]);
|
||||
api.notifyRider.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
it('resolves mileruserid 8 to milerprofileid 501, not to 8', async () => {
|
||||
await executeProposal(finding('notifyRider', [row({ userid: 8, ridername: 'Kumar' })]));
|
||||
|
||||
expect(api.notifyRider).toHaveBeenCalledTimes(1);
|
||||
const pushedTo = api.notifyRider.mock.calls[0][0];
|
||||
expect(pushedTo).toBe(501);
|
||||
expect(pushedTo).not.toBe(8); // the failure mode, stated explicitly
|
||||
});
|
||||
|
||||
it('pushes each rider to their own profile id', async () => {
|
||||
await executeProposal(
|
||||
finding('notifyRider', [
|
||||
row({ bookingid: 1, userid: 8, ridername: 'Kumar' }),
|
||||
row({ bookingid: 2, orderid: 'DM-BK-002', userid: 7, ridername: 'Suresh' })
|
||||
])
|
||||
);
|
||||
expect(api.notifyRider.mock.calls.map((c) => c[0]).sort()).toEqual([499, 501]);
|
||||
});
|
||||
|
||||
// A rider who has left the fleet is not in the list any more. That must be
|
||||
// reported, not crash the whole batch and not silently push to undefined.
|
||||
it('reports a rider missing from the fleet list instead of throwing', async () => {
|
||||
const res = await executeProposal(
|
||||
finding('notifyRider', [
|
||||
row({ bookingid: 1, userid: 8, ridername: 'Kumar' }),
|
||||
row({ bookingid: 2, orderid: 'DM-BK-002', userid: 404, ridername: 'Gone' })
|
||||
])
|
||||
);
|
||||
|
||||
expect(api.notifyRider).toHaveBeenCalledTimes(1);
|
||||
expect(api.notifyRider.mock.calls[0][0]).toBe(501);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.unreachable.join(' ')).toMatch(/DM-BK-002/);
|
||||
});
|
||||
});
|
||||
141
tests/lib/agentBriefing.test.js
Normal file
141
tests/lib/agentBriefing.test.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { buildBriefing } from '@/lib/assistant/agent/briefing';
|
||||
import { toAgentRow } from '@/lib/assistant/agent/normalise';
|
||||
|
||||
const NOW = '2026-08-29 14:00:00';
|
||||
|
||||
// Raw rows as `/admin/bookings` actually returns them — backend status enums,
|
||||
// createdat, assignedmileruserid, and the promise buried in serviceoptions.
|
||||
// The agent's rules read none of these field names directly, which is the whole
|
||||
// reason the adapter exists.
|
||||
const rawBooking = (over = {}) => ({
|
||||
bookingid: 1,
|
||||
bookingno: 'DM-BK-001',
|
||||
status: 'Pending_Pickup',
|
||||
createdat: '2026-08-29 13:50:00',
|
||||
serviceoptions: [{ estimateddeliveryat: undefined }],
|
||||
...over
|
||||
});
|
||||
|
||||
describe('toAgentRow', () => {
|
||||
it('derives the normalised status from the backend enum', () => {
|
||||
expect(toAgentRow(rawBooking({ status: 'Pending_Pickup' })).orderstatus).toBe('pending');
|
||||
});
|
||||
|
||||
it('reads the promised time out of the first service option', () => {
|
||||
const row = toAgentRow(rawBooking({ serviceoptions: [{ estimateddeliveryat: '2026-08-29 12:00:00' }] }));
|
||||
expect(row.expecteddeliverytime).toBe('2026-08-29 12:00:00');
|
||||
});
|
||||
|
||||
it('leaves the promise undefined when there is no service option', () => {
|
||||
expect(toAgentRow(rawBooking({ serviceoptions: [] })).expecteddeliverytime).toBeUndefined();
|
||||
expect(toAgentRow(rawBooking({ serviceoptions: undefined })).expecteddeliverytime).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps the rider id off assignedmileruserid', () => {
|
||||
expect(toAgentRow(rawBooking({ assignedmileruserid: 7 })).userid).toBe(7);
|
||||
});
|
||||
|
||||
it('uses createdat as the order date', () => {
|
||||
expect(toAgentRow(rawBooking()).orderdate).toBe('2026-08-29 13:50:00');
|
||||
});
|
||||
|
||||
it('falls back to a hashed id when the booking has no number', () => {
|
||||
expect(toAgentRow(rawBooking({ bookingno: undefined, bookingid: 42 })).orderid).toBe('#42');
|
||||
});
|
||||
|
||||
it('does not throw on an empty row', () => {
|
||||
expect(() => toAgentRow({})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBriefing over raw scan rows', () => {
|
||||
// The regression this file exists for. The rules were written against the
|
||||
// normalised Deliveries shape while the scan returns raw rows, so every rule
|
||||
// read undefined and the agent reported an all-clear board no matter what was
|
||||
// actually wrong. It failed silently and linted clean.
|
||||
it('finds a breach in a RAW row, not just a normalised one', () => {
|
||||
const scan = {
|
||||
truncated: false,
|
||||
rows: [
|
||||
rawBooking({
|
||||
status: 'Pickup_Scheduled',
|
||||
serviceoptions: [{ estimateddeliveryat: '2026-08-29 12:00:00' }]
|
||||
})
|
||||
]
|
||||
};
|
||||
const briefing = buildBriefing(scan, NOW);
|
||||
expect(briefing.findings.map((f) => f.id)).toContain('sla_breached');
|
||||
expect(briefing.headline).not.toMatch(/Nothing needs attention/i);
|
||||
});
|
||||
|
||||
it('finds aging unassigned work in a RAW row', () => {
|
||||
const scan = { truncated: false, rows: [rawBooking({ createdat: '2026-08-29 11:00:00' })] };
|
||||
expect(buildBriefing(scan, NOW).findings.map((f) => f.id)).toContain('unassigned_aging');
|
||||
});
|
||||
|
||||
it('reports an all-clear only when the board really is clear', () => {
|
||||
const scan = {
|
||||
truncated: false,
|
||||
rows: [rawBooking({ status: 'Delivered', createdat: '2026-08-29 13:55:00' })]
|
||||
};
|
||||
const briefing = buildBriefing(scan, NOW);
|
||||
expect(briefing.findings).toEqual([]);
|
||||
expect(briefing.headline).toMatch(/Nothing needs attention/i);
|
||||
});
|
||||
|
||||
// A truncated scan is a floor, not a total — the assistant's standing
|
||||
// contract. Counts must say so rather than implying completeness.
|
||||
it('marks counts as a floor when the scan was truncated', () => {
|
||||
const scan = {
|
||||
truncated: true,
|
||||
rows: [
|
||||
rawBooking({ status: 'Pickup_Scheduled', serviceoptions: [{ estimateddeliveryat: '2026-08-29 12:00:00' }] })
|
||||
]
|
||||
};
|
||||
const briefing = buildBriefing(scan, NOW);
|
||||
expect(briefing.headline).toMatch(/at least/);
|
||||
expect(briefing.detail).toMatch(/floor/i);
|
||||
});
|
||||
|
||||
it('survives a scan with no rows at all', () => {
|
||||
expect(buildBriefing({ rows: [] }, NOW).findings).toEqual([]);
|
||||
expect(buildBriefing(undefined, NOW).findings).toEqual([]);
|
||||
});
|
||||
|
||||
// The bug this pins: buildBriefing used to default `now` to
|
||||
// new Date().toISOString(), which is UTC. parseDoormileTimestamp strips the
|
||||
// zone and reads the digits as IST wall-clock, so the rules received a clock
|
||||
// running 5h30m slow and nothing under five and a half hours overdue was ever
|
||||
// flagged. It failed silently — an all-clear board through most of a shift.
|
||||
//
|
||||
// Deliberately exercised with NO explicit `now`, because passing one is what
|
||||
// hid the bug from every other test in this file.
|
||||
it('uses IST wall-clock for "now", not a UTC ISO string', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
jest.setSystemTime(new Date('2026-08-29T14:00:00'));
|
||||
const scan = {
|
||||
truncated: false,
|
||||
rows: [
|
||||
rawBooking({
|
||||
status: 'Pickup_Scheduled',
|
||||
// 90 minutes ago in local wall-clock. Under a UTC-shifted clock
|
||||
// this reads as still in the future and yields no finding.
|
||||
serviceoptions: [{ estimateddeliveryat: '2026-08-29 12:30:00' }]
|
||||
})
|
||||
]
|
||||
};
|
||||
const briefing = buildBriefing(scan);
|
||||
expect(briefing.findings.map((f) => f.id)).toContain('sla_breached');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('carries a metric the panel can render', () => {
|
||||
const scan = { truncated: false, rows: [rawBooking({ createdat: '2026-08-29 11:00:00' })] };
|
||||
const { metric } = buildBriefing(scan, NOW);
|
||||
expect(metric.value).toBeGreaterThan(0);
|
||||
expect(metric.label).toEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
172
tests/lib/agentClickThrough.test.jsx
Normal file
172
tests/lib/agentClickThrough.test.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
// The wiring nothing else covers.
|
||||
//
|
||||
// The executor is tested in isolation, and SlaRemediationCard is tested in
|
||||
// isolation. What was never tested is the seam between them: does the banner
|
||||
// actually hand the card an executor, does a click reach the real
|
||||
// executeProposal, does the API get called, and does the board re-evaluate
|
||||
// afterwards.
|
||||
//
|
||||
// That seam is where this feature would fail silently — a card rendered with no
|
||||
// onRemediate looks identical to one wired correctly until somebody clicks it.
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import AgentOperationsBanner from '@/components/doormile/AgentOperationsBanner';
|
||||
import * as scanModule from '@/lib/assistant/scan';
|
||||
|
||||
jest.mock('@/lib/assistant/scan');
|
||||
jest.mock('lucide-react', () =>
|
||||
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
|
||||
);
|
||||
|
||||
// Only the network is faked. AgentFactory, SkillRegistry, the skills, the
|
||||
// adapter, briefing and the executor are all the real implementations.
|
||||
jest.mock('@/api/doormile', () => {
|
||||
const actual = jest.requireActual('@/api/doormile/queries');
|
||||
return {
|
||||
__esModule: true,
|
||||
getMilers: jest.fn(),
|
||||
notifyRider: jest.fn(),
|
||||
batchAssignBookings: jest.fn(),
|
||||
buildMilerLookup: actual.buildMilerLookup,
|
||||
mapBookingStatusToDeliveryStatus: actual.mapBookingStatusToDeliveryStatus
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@/api/doormile/notify', () => ({ OpenToast: jest.fn() }));
|
||||
|
||||
const api = require('@/api/doormile');
|
||||
const { OpenToast } = require('@/api/doormile/notify');
|
||||
|
||||
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
// A rider stamped arrival well over the 20-minute stall threshold.
|
||||
const stalledScan = () => ({
|
||||
truncated: false,
|
||||
rows: [
|
||||
{
|
||||
bookingid: 2,
|
||||
bookingno: 'DM-BK-002',
|
||||
status: 'Pickup_Scheduled',
|
||||
createdat: '2026-09-01 10:00:00',
|
||||
reachedat: '2026-09-01 13:00:00',
|
||||
assignedmileruserid: 8,
|
||||
milername: 'Kumar',
|
||||
serviceoptions: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers({ doNotFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'queueMicrotask'] });
|
||||
jest.setSystemTime(new Date('2026-09-01T14:00:00'));
|
||||
|
||||
scanModule.scanBookings.mockResolvedValue(stalledScan());
|
||||
api.getMilers.mockResolvedValue([{ userid: 8, milerprofileid: 501, displayname: 'Kumar' }]);
|
||||
api.notifyRider.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const renderBanner = () =>
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<MemoryRouter>
|
||||
<AgentOperationsBanner />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe('click-through: banner to executor to API', () => {
|
||||
it('surfaces a real stall finding from raw bookings', async () => {
|
||||
renderBanner();
|
||||
expect(await screen.findByText(/stalled at door/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The whole point. A card with no executor renders disabled and says
|
||||
// "Review only"; if the banner failed to pass onRemediate this would fail.
|
||||
it('renders the stall proposal as clickable, not review-only', async () => {
|
||||
renderBanner();
|
||||
await screen.findByText(/stalled at door/i);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /review only/i })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: /message waiting riders/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('a click reaches the real executor and pushes to the right rider', async () => {
|
||||
renderBanner();
|
||||
await screen.findByText(/stalled at door/i);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /message waiting riders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.notifyRider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Through the REAL buildMilerLookup: mileruserid 8 becomes profile 501.
|
||||
expect(api.notifyRider.mock.calls[0][0]).toBe(501);
|
||||
expect(api.notifyRider.mock.calls[0][0]).not.toBe(8);
|
||||
});
|
||||
|
||||
it('re-scans after acting, which is what makes it a loop', async () => {
|
||||
renderBanner();
|
||||
await screen.findByText(/stalled at door/i);
|
||||
const scansBefore = scanModule.scanBookings.mock.calls.length;
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /message waiting riders/i }));
|
||||
|
||||
// The verify pass: the board is re-evaluated rather than assumed fixed.
|
||||
await waitFor(() => {
|
||||
expect(scanModule.scanBookings.mock.calls.length).toBeGreaterThan(scansBefore);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports success only after the push actually landed', async () => {
|
||||
renderBanner();
|
||||
await screen.findByText(/stalled at door/i);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /message waiting riders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(OpenToast).toHaveBeenCalledWith(expect.stringMatching(/Notified 1 rider/), 'success');
|
||||
});
|
||||
});
|
||||
|
||||
// The failure that matters: the push is refused, and the operator must not be
|
||||
// told a rider was messaged.
|
||||
it('reports failure, not success, when the push is refused', async () => {
|
||||
api.notifyRider.mockRejectedValue(new Error('device offline'));
|
||||
|
||||
renderBanner();
|
||||
await screen.findByText(/stalled at door/i);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /message waiting riders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(OpenToast).toHaveBeenCalledWith(expect.any(String), 'error');
|
||||
});
|
||||
expect(OpenToast).not.toHaveBeenCalledWith(expect.anything(), 'success');
|
||||
});
|
||||
|
||||
// A rider who has left the fleet cannot be pushed to. Nothing landed, so
|
||||
// nothing may be reported as landed.
|
||||
it('does not claim success when no rider could be reached', async () => {
|
||||
api.getMilers.mockResolvedValue([]); // rider 8 is gone
|
||||
|
||||
renderBanner();
|
||||
await screen.findByText(/stalled at door/i);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /message waiting riders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(OpenToast).toHaveBeenCalledWith(expect.any(String), 'error');
|
||||
});
|
||||
expect(api.notifyRider).not.toHaveBeenCalled();
|
||||
expect(OpenToast).not.toHaveBeenCalledWith(expect.anything(), 'success');
|
||||
});
|
||||
});
|
||||
167
tests/lib/agentRouting.test.js
Normal file
167
tests/lib/agentRouting.test.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// Does asking for a status sweep actually reach the agent?
|
||||
//
|
||||
// The regex was verified in isolation, which proves nothing about routing:
|
||||
// answerQuestion walks 26 intents in order and returns the FIRST whose match()
|
||||
// recognises the text and whose run() resolves. Several later intents match on
|
||||
// bare substrings ("orders", "riders", "status") that appear in almost every
|
||||
// phrasing of "what needs attention", so ordering is the whole ballgame — and
|
||||
// ordering is exactly what a unit test of the regex cannot see.
|
||||
|
||||
jest.mock('pages/api/doormileApi', () => ({
|
||||
__esModule: true,
|
||||
getBookingsPage: jest.fn(),
|
||||
getHubs: jest.fn(async () => []),
|
||||
getVehicles: jest.fn(async () => []),
|
||||
getTripsheets: jest.fn(async () => []),
|
||||
getExceptions: jest.fn(async () => []),
|
||||
getAppUsers: jest.fn(async () => []),
|
||||
getAdminCustomers: jest.fn(async () => []),
|
||||
getAdminPricing: jest.fn(async () => []),
|
||||
getMilers: jest.fn(async () => []),
|
||||
getConsignments: jest.fn(async () => []),
|
||||
getPartners: jest.fn(async () => []),
|
||||
getCompetitorBranches: jest.fn(async () => []),
|
||||
getCarrierPricing: jest.fn(async () => []),
|
||||
getMilerSummary: jest.fn(async () => []),
|
||||
getMilerActivity: jest.fn(async () => []),
|
||||
trackConsignment: jest.fn(async () => null),
|
||||
getConsignmentLogs: jest.fn(async () => []),
|
||||
getAdminTenant: jest.fn(async () => null),
|
||||
getTenantLocations: jest.fn(async () => [])
|
||||
}));
|
||||
|
||||
jest.mock('pages/api/api', () => ({
|
||||
__esModule: true,
|
||||
getalltenants: jest.fn(async () => []),
|
||||
getallridersummary: jest.fn(async () => [])
|
||||
}));
|
||||
|
||||
let getBookingsPage;
|
||||
|
||||
// intents.js keeps a module-local page cache with a 20s TTL, so a second test
|
||||
// would otherwise be answered from the first test's fixture. Re-requiring the
|
||||
// module per test gives each one a clean cache.
|
||||
//
|
||||
// Time is pinned too: the agent's rules are all relative to `now`, and the
|
||||
// intent reads the real clock. Without a fixed instant these assertions would
|
||||
// pass or fail depending on the hour the suite happened to run.
|
||||
const NOW = new Date('2026-08-29T14:00:00');
|
||||
let answerQuestion;
|
||||
|
||||
// One page of raw bookings holding a breach, a stalled rider and aging work.
|
||||
const page = () => ({
|
||||
total: 3,
|
||||
rows: [
|
||||
{
|
||||
bookingid: 1,
|
||||
bookingno: 'DM-BK-001',
|
||||
status: 'Pickup_Scheduled',
|
||||
createdat: '2026-08-29 09:00:00',
|
||||
assignedmileruserid: 7,
|
||||
milername: 'Suresh',
|
||||
serviceoptions: [{ estimateddeliveryat: '2026-08-29 12:30:00' }]
|
||||
},
|
||||
{
|
||||
bookingid: 2,
|
||||
bookingno: 'DM-BK-002',
|
||||
status: 'Pickup_Scheduled',
|
||||
createdat: '2026-08-29 10:00:00',
|
||||
reachedat: '2026-08-29 13:15:00',
|
||||
assignedmileruserid: 8,
|
||||
milername: 'Kumar',
|
||||
serviceoptions: []
|
||||
},
|
||||
{ bookingid: 3, bookingno: 'DM-BK-003', status: 'Pending_Pickup', createdat: '2026-08-29 11:30:00', serviceoptions: [] }
|
||||
]
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ doNotFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
|
||||
jest.setSystemTime(NOW);
|
||||
jest.resetModules();
|
||||
// resetModules rebuilds intents.js AND its dependencies, so the mock has to
|
||||
// be re-required and re-configured here. Configuring the previous instance
|
||||
// would leave the copy intents.js actually holds returning undefined.
|
||||
|
||||
({ getBookingsPage } = require('pages/api/doormileApi'));
|
||||
getBookingsPage.mockResolvedValue(page());
|
||||
({ answerQuestion } = require('@/lib/assistant/intents'));
|
||||
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('routing to the ops agent', () => {
|
||||
const sweepPhrasings = [
|
||||
'what needs attention',
|
||||
'anything going wrong',
|
||||
'what needs my attention right now',
|
||||
'ops check',
|
||||
'daily briefing',
|
||||
'what should i do'
|
||||
];
|
||||
|
||||
it.each(sweepPhrasings)('"%s" reaches the agent', async (question) => {
|
||||
const result = await answerQuestion(question);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.intentId).toBe('opsBriefing');
|
||||
});
|
||||
|
||||
// The other side of the contract. These must keep going to the intents that
|
||||
// answer them properly — over-claiming here would silently change answers
|
||||
// across the whole catalog.
|
||||
const mustNotClaim = [
|
||||
'how many orders today',
|
||||
'how many riders are active',
|
||||
'total revenue today',
|
||||
'how many tenants',
|
||||
// Narrowed on port: the branch claimed these, which would have replaced the
|
||||
// delayed-orders count (Orders taxonomy) and the operations summary chip
|
||||
// with a briefing. They keep going to the intents that answer them.
|
||||
'late orders',
|
||||
'which orders are delayed?',
|
||||
"give me today's operations summary"
|
||||
];
|
||||
|
||||
it.each(mustNotClaim)('"%s" is NOT swallowed by the agent', async (question) => {
|
||||
const result = await answerQuestion(question);
|
||||
expect(result?.intentId).not.toBe('opsBriefing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the agent answer itself', () => {
|
||||
it('reports the real findings from the scanned rows', async () => {
|
||||
const result = await answerQuestion('what needs attention');
|
||||
expect(result.headline).toMatch(/need.*attention now/i);
|
||||
|
||||
// Assert on the evidence, not on a skill's prose. The wording belongs to
|
||||
// the skill definition and an ops lead may reasonably reword it; the
|
||||
// booking reference and the overdue figure are the substance.
|
||||
expect(result.detail).toMatch(/DM-BK-001/);
|
||||
expect(result.detail).toMatch(/90 min overdue/);
|
||||
});
|
||||
|
||||
// The verifiability contract: every answer shows which endpoint produced it.
|
||||
it('carries a source call naming the endpoint it queried', async () => {
|
||||
const result = await answerQuestion('what needs attention');
|
||||
expect(Array.isArray(result.sourceCalls)).toBe(true);
|
||||
expect(result.sourceCalls.length).toBeGreaterThan(0);
|
||||
expect(result.sourceCalls[0].target).toMatch(/\/admin\/bookings/);
|
||||
});
|
||||
|
||||
it('carries a metric the panel can render', async () => {
|
||||
const result = await answerQuestion('what needs attention');
|
||||
expect(result.metric.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('says the board is clear when it is', async () => {
|
||||
getBookingsPage.mockResolvedValue({ total: 1, rows: [
|
||||
{ bookingid: 9, bookingno: 'DM-BK-009', status: 'Delivered', createdat: '2026-08-29 13:59:00', serviceoptions: [] }
|
||||
] });
|
||||
const result = await answerQuestion('what needs attention');
|
||||
expect(result.intentId).toBe('opsBriefing');
|
||||
expect(result.headline).toMatch(/Nothing needs attention/i);
|
||||
});
|
||||
});
|
||||
164
tests/lib/agentSingleEngine.test.js
Normal file
164
tests/lib/agentSingleEngine.test.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// One engine, one set of thresholds.
|
||||
//
|
||||
// The chat panel and the Exceptions banner used to run different code for the
|
||||
// same five rules, with the thresholds hardcoded in one and tunable in the
|
||||
// other. They disagreed out of the box — a doorstep stall was 25 minutes in the
|
||||
// chat panel and 20 on the banner — and moving a slider in Agent Studio changed
|
||||
// one surface and not the other.
|
||||
//
|
||||
// These tests exist to stop that coming back. They are less about any single
|
||||
// rule than about the property that both surfaces are the same engine.
|
||||
|
||||
import { buildBriefing } from '@/lib/assistant/agent/briefing';
|
||||
import { AgentFactory } from '@/lib/assistant/agent/AgentFactory';
|
||||
import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
|
||||
import { toAgentRows } from '@/lib/assistant/agent/normalise';
|
||||
import * as signals from '@/lib/assistant/agent/signals';
|
||||
|
||||
const NOW = '2026-08-29 14:00:00';
|
||||
|
||||
const rawBooking = (over = {}) => ({
|
||||
bookingid: 1,
|
||||
bookingno: 'DM-BK-001',
|
||||
status: 'Pickup_Scheduled',
|
||||
createdat: '2026-08-29 13:50:00',
|
||||
serviceoptions: [],
|
||||
...over
|
||||
});
|
||||
|
||||
// A board with something wrong on it: a breach, a doorstep stall and aging work.
|
||||
const busyScan = () => ({
|
||||
truncated: false,
|
||||
rows: [
|
||||
rawBooking({
|
||||
bookingid: 1,
|
||||
bookingno: 'DM-BK-001',
|
||||
serviceoptions: [{ estimateddeliveryat: '2026-08-29 12:30:00' }]
|
||||
}),
|
||||
rawBooking({
|
||||
bookingid: 2,
|
||||
bookingno: 'DM-BK-002',
|
||||
createdat: '2026-08-29 10:00:00',
|
||||
reachedat: '2026-08-29 13:15:00',
|
||||
assignedmileruserid: 8,
|
||||
milername: 'Kumar'
|
||||
}),
|
||||
rawBooking({ bookingid: 3, bookingno: 'DM-BK-003', status: 'Pending_Pickup', createdat: '2026-08-29 11:30:00' })
|
||||
]
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Thresholds persist to localStorage, so one test's override would otherwise
|
||||
// leak into the next.
|
||||
SkillRegistry.clearRegistryConfig();
|
||||
});
|
||||
|
||||
describe('the chat panel and the banner are the same engine', () => {
|
||||
it('produces identical findings for identical rows', () => {
|
||||
const scan = busyScan();
|
||||
|
||||
// What the chat panel shows.
|
||||
const fromBriefing = buildBriefing(scan, NOW).findings;
|
||||
|
||||
// What the Exceptions banner shows — its exact call.
|
||||
const fromBanner = AgentFactory.synthesizeDefaultAgent().evaluateTelemetry(toAgentRows(scan.rows), NOW);
|
||||
|
||||
expect(fromBriefing.map((f) => f.id)).toEqual(fromBanner.map((f) => f.id));
|
||||
expect(fromBriefing.map((f) => f.count)).toEqual(fromBanner.map((f) => f.count));
|
||||
});
|
||||
|
||||
// The regression that started this: the chat panel knew five rules while the
|
||||
// registry defined twelve findings across eight skills, so asking "what needs
|
||||
// attention" could never surface cash exposure, hub congestion or a rider on
|
||||
// a dying battery.
|
||||
it('the chat panel sees every enabled skill, not a private subset', () => {
|
||||
// Enable all eight through the registry, as an admin could. By default three
|
||||
// are off (no data source yet — see their `dataGap`), so "more than five"
|
||||
// no longer holds out of the box; the point is that whatever the registry
|
||||
// enables, the chat panel runs.
|
||||
SkillRegistry.applyRegistryConfig(
|
||||
SkillRegistry.getAllSkills().map((s) => ({ skillid: s.id, enabled: true, thresholds: {} }))
|
||||
);
|
||||
const enabled = SkillRegistry.getActiveSkills();
|
||||
expect(enabled).toHaveLength(8);
|
||||
|
||||
const agent = AgentFactory.synthesizeDefaultAgent();
|
||||
expect(agent.activeSkillCount).toBe(8);
|
||||
});
|
||||
|
||||
it('ships the three skills with no data source switched off', () => {
|
||||
SkillRegistry.clearRegistryConfig();
|
||||
const off = SkillRegistry.getAllSkills().filter((s) => !s.enabled).map((s) => s.id).sort();
|
||||
expect(off).toEqual(['skill_cash_exposure', 'skill_high_value_cod', 'skill_rider_battery_safety']);
|
||||
off.forEach((id) => expect(SkillRegistry.getSkill(id).dataGap).toMatch(/\S/));
|
||||
});
|
||||
});
|
||||
|
||||
describe('no hardcoded thresholds survive', () => {
|
||||
// signals.js is now a shared-primitives module. If a THRESHOLDS object or a
|
||||
// rule reappears here, the two-engine bug is back.
|
||||
it('signals.js exports no rules and no threshold table', () => {
|
||||
expect(signals.THRESHOLDS).toBeUndefined();
|
||||
expect(signals.diagnose).toBeUndefined();
|
||||
expect(signals.breachedSla).toBeUndefined();
|
||||
expect(signals.atRiskSla).toBeUndefined();
|
||||
expect(signals.unassignedAging).toBeUndefined();
|
||||
expect(signals.stalledAtDoor).toBeUndefined();
|
||||
expect(signals.saturatedRiders).toBeUndefined();
|
||||
});
|
||||
|
||||
it('signals.js still provides the shared primitives', () => {
|
||||
expect(typeof signals.wallClockNow).toBe('function');
|
||||
expect(typeof signals.ALL_CLEAR).toBe('string');
|
||||
});
|
||||
|
||||
// Every threshold an operator can see must be declared by a skill, with the
|
||||
// metadata Agent Studio needs to render a slider for it.
|
||||
it('every skill threshold is declared with tunable bounds', () => {
|
||||
SkillRegistry.getAllSkills().forEach((skill) => {
|
||||
Object.entries(skill.thresholds || {}).forEach(([key, cfg]) => {
|
||||
expect(typeof cfg.value).toBe('number');
|
||||
expect(typeof cfg.min).toBe('number');
|
||||
expect(typeof cfg.max).toBe('number');
|
||||
expect(cfg.min).toBeLessThanOrEqual(cfg.value);
|
||||
expect(cfg.value).toBeLessThanOrEqual(cfg.max);
|
||||
// A threshold with no label cannot be rendered as a slider, so it would
|
||||
// silently become un-tunable — hardcoded by omission.
|
||||
if (!cfg.label) {
|
||||
throw new Error(`${skill.id}.${key} has no label; Agent Studio cannot render a slider for it`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tuning a threshold moves both surfaces', () => {
|
||||
// The property that was broken: an override applied in Agent Studio reached
|
||||
// the banner and never reached the chat panel.
|
||||
it('an override changes what the chat panel reports', () => {
|
||||
const scan = {
|
||||
truncated: false,
|
||||
rows: [
|
||||
rawBooking({
|
||||
bookingid: 2,
|
||||
createdat: '2026-08-29 10:00:00',
|
||||
// Stalled for 22 minutes: inside the old hardcoded 25, outside the
|
||||
// skill's default 20. Exactly the parcel the two engines disagreed on.
|
||||
reachedat: '2026-08-29 13:38:00',
|
||||
assignedmileruserid: 8,
|
||||
milername: 'Kumar'
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
const before = buildBriefing(scan, NOW).findings.map((f) => f.id);
|
||||
expect(before).toContain('stalled_at_door');
|
||||
|
||||
// Raise the stall timeout past 22 minutes; the finding must disappear from
|
||||
// the chat panel too, not just the banner.
|
||||
SkillRegistry.applyRegistryConfig([{ skillid: 'skill_doorstep_stall', enabled: true, thresholds: { arrivedStalledMin: 45 } }]);
|
||||
|
||||
const after = buildBriefing(scan, NOW).findings.map((f) => f.id);
|
||||
expect(after).not.toContain('stalled_at_door');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,11 @@
|
||||
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
|
||||
import {
|
||||
calculateDrivingDistance,
|
||||
calculateDrivingRoute,
|
||||
calculateTotalCharge,
|
||||
formatRouteDistance,
|
||||
formatRouteDuration,
|
||||
getLastRouteDurationMin
|
||||
} from '@/lib/distance';
|
||||
|
||||
/**
|
||||
* Distance and charge for a delivery leg.
|
||||
@@ -183,6 +190,60 @@ describe('calculateDrivingDistance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDrivingRoute — sub-kilometre legs', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = jest.fn();
|
||||
});
|
||||
|
||||
it('should keep the unrounded road length in metres alongside whole-km distance', async () => {
|
||||
// A same-address order: OSRM snaps both pins to the road and returns ~120 m.
|
||||
// `distance` (billed) rounds to 0 km; `meters` is what the operator sees.
|
||||
global.fetch.mockResolvedValue(osrmOk(123.4, 20));
|
||||
const res = await calculateDrivingRoute(BLR, { latitude: 12.9717, longitude: 77.5947 });
|
||||
expect(res.distance).toBe(0);
|
||||
expect(res.meters).toBe(123);
|
||||
expect(res.minutes).toBe(0);
|
||||
});
|
||||
|
||||
it('should report metres on the Haversine fallback too', async () => {
|
||||
global.fetch.mockResolvedValue({ ok: false, json: async () => ({}) });
|
||||
const res = await calculateDrivingRoute(BLR, { ...BLR });
|
||||
expect(res.distance).toBe(0);
|
||||
expect(res.meters).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRouteDistance', () => {
|
||||
it.each([
|
||||
[0, '0 m'],
|
||||
[123, '120 m'],
|
||||
[996, '1 km'],
|
||||
[1000, '1 km'],
|
||||
[1449, '1.4 km'],
|
||||
[8200, '8.2 km']
|
||||
])('should format %d m as %s', (meters, expected) => {
|
||||
expect(formatRouteDistance(meters)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([[null], [undefined], [NaN], [-5]])('should show a dash for %p', (value) => {
|
||||
expect(formatRouteDistance(value)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRouteDuration', () => {
|
||||
it('should show "< 1 min" for a sub-minute drive rather than a default', () => {
|
||||
expect(formatRouteDuration(0)).toBe('< 1 min');
|
||||
});
|
||||
|
||||
it('should show whole minutes', () => {
|
||||
expect(formatRouteDuration(15)).toBe('15 mins');
|
||||
});
|
||||
|
||||
it.each([[null], [undefined]])('should show a dash when there is no ETA (%p)', (value) => {
|
||||
expect(formatRouteDuration(value)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTotalCharge', () => {
|
||||
it('should charge only the base price below the minimum distance', () => {
|
||||
expect(calculateTotalCharge(3, 30, 6, 8)).toBe(30);
|
||||
|
||||
67
tests/lib/skills/CashExposureSkill.test.js
Normal file
67
tests/lib/skills/CashExposureSkill.test.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { CashExposureSkill } from '@/lib/assistant/skills/definitions/CashExposureSkill';
|
||||
|
||||
describe('CashExposureSkill', () => {
|
||||
const NOW = '2026-08-31 14:00:00';
|
||||
|
||||
it('flags riders carrying total COD cash above max threshold', () => {
|
||||
const rows = [
|
||||
// Rider 1: 12000 total (Critical)
|
||||
{ bookingid: 1, userid: 101, ridername: 'Rider A', orderstatus: 'picked', paymenttype: 'cod', cod_amount: '5000' },
|
||||
{ bookingid: 2, userid: 101, ridername: 'Rider A', orderstatus: 'accepted', paymenttype: 'cod', cod_amount: '7000' },
|
||||
|
||||
// Rider 2: 8000 total (Warning)
|
||||
{ bookingid: 3, userid: 102, ridername: 'Rider B', orderstatus: 'picked', paymenttype: 'cod', cod_amount: '8000' },
|
||||
|
||||
// Rider 3: 4000 total (Safe)
|
||||
{ bookingid: 4, userid: 103, ridername: 'Rider C', orderstatus: 'picked', paymenttype: 'cod', cod_amount: '4000' },
|
||||
|
||||
// Non-COD order for Rider 1 (Ignored)
|
||||
{ bookingid: 5, userid: 101, ridername: 'Rider A', orderstatus: 'picked', paymenttype: 'prepaid', cod_amount: '0' }
|
||||
];
|
||||
|
||||
const findings = CashExposureSkill.evaluate(rows, NOW, { maxCashPerRider: 10000, warningCashPercent: 75 });
|
||||
|
||||
const critical = findings.find((f) => f.id === 'cash_exposure_critical');
|
||||
const warning = findings.find((f) => f.id === 'cash_exposure_warning');
|
||||
|
||||
expect(critical).toBeDefined();
|
||||
expect(critical.count).toBe(1); // Only Rider 1
|
||||
expect(critical.rows.length).toBe(2); // Rider 1 has 2 COD orders
|
||||
expect(critical.detail[0]).toContain('12,000');
|
||||
|
||||
expect(warning).toBeDefined();
|
||||
expect(warning.count).toBe(1); // Only Rider 2
|
||||
expect(warning.detail[0]).toContain('8,000');
|
||||
});
|
||||
|
||||
it('returns no findings when all riders are under warning threshold', () => {
|
||||
const rows = [
|
||||
{ bookingid: 1, userid: 101, orderstatus: 'picked', paymenttype: 'cod', cod_amount: '5000' }
|
||||
];
|
||||
const findings = CashExposureSkill.evaluate(rows, NOW, { maxCashPerRider: 10000, warningCashPercent: 75 });
|
||||
expect(findings.length).toBe(0);
|
||||
});
|
||||
|
||||
it('executes enforce_cash_handoff tool handler', async () => {
|
||||
const tool = CashExposureSkill.tools.find((t) => t.name === 'enforce_cash_handoff');
|
||||
expect(tool).toBeDefined();
|
||||
const result = await tool.handler({ milerId: 101, totalCash: 12000, hubId: 'HUB-1' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.proposal.action).toBe('cash_handoff');
|
||||
expect(result.proposal.milerId).toBe(101);
|
||||
expect(result.proposal.totalCash).toBe(12000);
|
||||
expect(result.proposal.hubId).toBe('HUB-1');
|
||||
});
|
||||
|
||||
it('handles orders with non-numeric or missing amounts gracefully', () => {
|
||||
const rows = [
|
||||
{ bookingid: 1, userid: 101, orderstatus: 'picked', paymenttype: 'cod' }, // missing amount
|
||||
{ bookingid: 2, userid: 101, orderstatus: 'picked', paymenttype: 'cod', cod_amount: 'invalid' }, // invalid amount
|
||||
{ bookingid: 3, userid: 101, orderstatus: 'picked', paymenttype: 'cod', cod_amount: '12000' } // valid amount
|
||||
];
|
||||
const findings = CashExposureSkill.evaluate(rows, NOW, { maxCashPerRider: 10000, warningCashPercent: 75 });
|
||||
const critical = findings.find((f) => f.id === 'cash_exposure_critical');
|
||||
expect(critical).toBeDefined();
|
||||
expect(critical.detail[0]).toContain('12,000');
|
||||
});
|
||||
});
|
||||
42
tests/lib/skills/DoorstepStallSkill.test.js
Normal file
42
tests/lib/skills/DoorstepStallSkill.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { DoorstepStallSkill } from '@/lib/assistant/skills/definitions/DoorstepStallSkill';
|
||||
|
||||
describe('DoorstepStallSkill', () => {
|
||||
const fixedNow = '2026-08-31 12:00:00';
|
||||
|
||||
it('detects riders stalled at door past threshold', () => {
|
||||
const rows = [
|
||||
{
|
||||
bookingid: 1,
|
||||
bookingno: 'BK-1',
|
||||
orderstatus: 'arrived',
|
||||
reachedat: '2026-08-31 11:35:00', // 25 min waiting
|
||||
ridername: 'Karthik Raja',
|
||||
userid: 44,
|
||||
},
|
||||
{
|
||||
bookingid: 2,
|
||||
bookingno: 'BK-2',
|
||||
orderstatus: 'arrived',
|
||||
reachedat: '2026-08-31 11:55:00', // 5 min waiting
|
||||
ridername: 'Praveen',
|
||||
userid: 45,
|
||||
},
|
||||
];
|
||||
|
||||
const findings = DoorstepStallSkill.evaluate(rows, fixedNow, { arrivedStalledMin: 20 });
|
||||
expect(findings.length).toBe(1);
|
||||
expect(findings[0].id).toBe('stalled_at_door');
|
||||
expect(findings[0].count).toBe(1);
|
||||
expect(findings[0].rows[0].bookingid).toBe(1);
|
||||
});
|
||||
|
||||
it('executes ping_stalled_rider tool handler', async () => {
|
||||
const tool = DoorstepStallSkill.tools.find((t) => t.name === 'ping_stalled_rider');
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const result = await tool.handler({ milerId: 44 });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.proposal.action).toBe('notify_miler');
|
||||
expect(result.proposal.milerId).toBe(44);
|
||||
});
|
||||
});
|
||||
39
tests/lib/skills/FleetBalancerSkill.test.js
Normal file
39
tests/lib/skills/FleetBalancerSkill.test.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { FleetBalancerSkill } from '@/lib/assistant/skills/definitions/FleetBalancerSkill';
|
||||
|
||||
describe('FleetBalancerSkill', () => {
|
||||
it('detects saturated riders when pending orders are waiting in queue', () => {
|
||||
const rows = [
|
||||
// 3 active jobs for rider 10
|
||||
{ bookingid: 1, userid: 10, ridername: 'Rider A', orderstatus: 'picked' },
|
||||
{ bookingid: 2, userid: 10, ridername: 'Rider A', orderstatus: 'active' },
|
||||
{ bookingid: 3, userid: 10, ridername: 'Rider A', orderstatus: 'accepted' },
|
||||
// 1 pending order in queue
|
||||
{ bookingid: 4, userid: null, orderstatus: 'pending' },
|
||||
];
|
||||
|
||||
const findings = FleetBalancerSkill.evaluate(rows, '2026-08-31 12:00:00', { riderActiveCap: 3 });
|
||||
expect(findings.length).toBe(1);
|
||||
expect(findings[0].id).toBe('rider_saturation');
|
||||
expect(findings[0].count).toBe(1);
|
||||
});
|
||||
|
||||
it('does not flag saturation if no unassigned orders are waiting in queue', () => {
|
||||
const rows = [
|
||||
{ bookingid: 1, userid: 10, ridername: 'Rider A', orderstatus: 'picked' },
|
||||
{ bookingid: 2, userid: 10, ridername: 'Rider A', orderstatus: 'active' },
|
||||
{ bookingid: 3, userid: 10, ridername: 'Rider A', orderstatus: 'accepted' },
|
||||
];
|
||||
|
||||
const findings = FleetBalancerSkill.evaluate(rows, '2026-08-31 12:00:00', { riderActiveCap: 3 });
|
||||
expect(findings.length).toBe(0);
|
||||
});
|
||||
|
||||
it('executes balance_rider_load tool handler', async () => {
|
||||
const tool = FleetBalancerSkill.tools.find((t) => t.name === 'balance_rider_load');
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const result = await tool.handler({ fromMilerId: 10, toMilerId: 20 });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.proposal.action).toBe('rebalance_workload');
|
||||
});
|
||||
});
|
||||
37
tests/lib/skills/HighValueCodSkill.test.js
Normal file
37
tests/lib/skills/HighValueCodSkill.test.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { HighValueCodSkill } from '@/lib/assistant/skills/definitions/HighValueCodSkill';
|
||||
|
||||
describe('HighValueCodSkill', () => {
|
||||
it('detects high value COD consignments at or above threshold', () => {
|
||||
const rows = [
|
||||
{
|
||||
bookingid: 1,
|
||||
bookingno: 'BK-COD-1',
|
||||
orderstatus: 'active',
|
||||
paymenttype: 'COD',
|
||||
orderamount: 4500,
|
||||
},
|
||||
{
|
||||
bookingid: 2,
|
||||
bookingno: 'BK-COD-2',
|
||||
orderstatus: 'active',
|
||||
paymenttype: 'COD',
|
||||
orderamount: 1500,
|
||||
},
|
||||
];
|
||||
|
||||
const findings = HighValueCodSkill.evaluate(rows, '2026-08-31 12:00:00', { codRiskThresholdAmount: 3000 });
|
||||
expect(findings.length).toBe(1);
|
||||
expect(findings[0].id).toBe('high_value_cod_alert');
|
||||
expect(findings[0].count).toBe(1);
|
||||
expect(findings[0].rows[0].bookingid).toBe(1);
|
||||
});
|
||||
|
||||
it('executes enforce_otp_verification tool handler', async () => {
|
||||
const tool = HighValueCodSkill.tools.find((t) => t.name === 'enforce_otp_verification');
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const result = await tool.handler({ bookingId: 1 });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.proposal.action).toBe('require_otp');
|
||||
});
|
||||
});
|
||||
50
tests/lib/skills/HubCongestionSkill.test.js
Normal file
50
tests/lib/skills/HubCongestionSkill.test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { HubCongestionSkill } from '@/lib/assistant/skills/definitions/HubCongestionSkill';
|
||||
|
||||
describe('HubCongestionSkill', () => {
|
||||
const NOW = '2026-08-31 14:00:00';
|
||||
|
||||
it('detects parcels stalled at hub beyond dwell threshold', () => {
|
||||
const rows = [
|
||||
{ bookingid: 1, bookingno: 'BK-1', orderstatus: 'pending', orderdate: '2026-08-31 13:00:00' }, // 60 min
|
||||
{ bookingid: 2, bookingno: 'BK-2', orderstatus: 'accepted', orderdate: '2026-08-31 13:10:00' }, // 50 min
|
||||
{ bookingid: 3, bookingno: 'BK-3', orderstatus: 'pending', orderdate: '2026-08-31 13:50:00' }, // 10 min — not stalled
|
||||
{ bookingid: 4, bookingno: 'BK-4', orderstatus: 'picked', orderdate: '2026-08-31 12:00:00' }, // in transit — excluded
|
||||
];
|
||||
|
||||
const findings = HubCongestionSkill.evaluate(rows, NOW, { hubDwellMinutes: 45 });
|
||||
expect(findings.length).toBe(1);
|
||||
expect(findings[0].id).toBe('hub_parcel_stalled');
|
||||
expect(findings[0].count).toBe(2);
|
||||
expect(findings[0].rows.map((r) => r.bookingid)).toContain(1);
|
||||
expect(findings[0].rows.map((r) => r.bookingid)).toContain(2);
|
||||
expect(findings[0].rows.map((r) => r.bookingid)).not.toContain(3);
|
||||
});
|
||||
|
||||
it('returns no findings when all parcels are within dwell threshold', () => {
|
||||
const rows = [
|
||||
{ bookingid: 5, orderstatus: 'pending', orderdate: '2026-08-31 13:50:00' }, // 10 min
|
||||
];
|
||||
const findings = HubCongestionSkill.evaluate(rows, NOW, { hubDwellMinutes: 45 });
|
||||
expect(findings.length).toBe(0);
|
||||
});
|
||||
|
||||
it('executes dispatch_hub_idle_parcels tool handler', async () => {
|
||||
const tool = HubCongestionSkill.tools.find((t) => t.name === 'dispatch_hub_idle_parcels');
|
||||
expect(tool).toBeDefined();
|
||||
const result = await tool.handler({ bookingIds: [1, 2], milerId: 99 });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.proposal.action).toBe('dispatch_hub_parcels');
|
||||
expect(result.proposal.bookingIds).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('respects custom dwell threshold', () => {
|
||||
const rows = [
|
||||
{ bookingid: 10, orderstatus: 'pending', orderdate: '2026-08-31 13:45:00' }, // 15 min
|
||||
];
|
||||
const findingsStrict = HubCongestionSkill.evaluate(rows, NOW, { hubDwellMinutes: 10 });
|
||||
expect(findingsStrict.length).toBe(1);
|
||||
|
||||
const findingsLenient = HubCongestionSkill.evaluate(rows, NOW, { hubDwellMinutes: 30 });
|
||||
expect(findingsLenient.length).toBe(0);
|
||||
});
|
||||
});
|
||||
53
tests/lib/skills/LateDispatchSkill.test.js
Normal file
53
tests/lib/skills/LateDispatchSkill.test.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { LateDispatchSkill } from '@/lib/assistant/skills/definitions/LateDispatchSkill';
|
||||
|
||||
describe('LateDispatchSkill', () => {
|
||||
const NOW = '2026-08-31 14:00:00';
|
||||
|
||||
it('detects critically late dispatch orders', () => {
|
||||
const rows = [
|
||||
{ bookingid: 1, bookingno: 'BK-1', orderstatus: 'pending', orderdate: '2026-08-31 11:30:00' }, // 150 min
|
||||
{ bookingid: 2, bookingno: 'BK-2', orderstatus: 'pending', orderdate: '2026-08-31 12:30:00' }, // 90 min
|
||||
{ bookingid: 3, bookingno: 'BK-3', orderstatus: 'pending', orderdate: '2026-08-31 13:20:00' }, // 40 min — warning only
|
||||
{ bookingid: 4, bookingno: 'BK-4', orderstatus: 'pending', orderdate: '2026-08-31 13:50:00' }, // 10 min — no issue
|
||||
{ bookingid: 5, bookingno: 'BK-5', orderstatus: 'picked', orderdate: '2026-08-31 11:00:00' }, // dispatched — excluded
|
||||
];
|
||||
|
||||
const findings = LateDispatchSkill.evaluate(rows, NOW, { lateDispatchMinutes: 30, criticalDispatchMinutes: 60 });
|
||||
|
||||
const critical = findings.find((f) => f.id === 'late_dispatch_critical');
|
||||
const warning = findings.find((f) => f.id === 'late_dispatch_warning');
|
||||
|
||||
expect(critical).toBeDefined();
|
||||
expect(critical.count).toBe(2); // BK-1 and BK-2
|
||||
|
||||
expect(warning).toBeDefined();
|
||||
expect(warning.count).toBe(1); // BK-3
|
||||
});
|
||||
|
||||
it('returns no findings when all orders are dispatched quickly', () => {
|
||||
const rows = [
|
||||
{ bookingid: 1, orderstatus: 'pending', orderdate: '2026-08-31 13:55:00' }, // 5 min
|
||||
];
|
||||
const findings = LateDispatchSkill.evaluate(rows, NOW, { lateDispatchMinutes: 30, criticalDispatchMinutes: 60 });
|
||||
expect(findings.length).toBe(0);
|
||||
});
|
||||
|
||||
it('executes trigger_auto_dispatch tool handler', async () => {
|
||||
const tool = LateDispatchSkill.tools.find((t) => t.name === 'trigger_auto_dispatch');
|
||||
expect(tool).toBeDefined();
|
||||
const result = await tool.handler({ bookingIds: [1, 2], reason: 'Test late dispatch' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.proposal.action).toBe('auto_dispatch');
|
||||
expect(result.proposal.bookingIds).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('excludes non-pending status orders', () => {
|
||||
const rows = [
|
||||
{ bookingid: 1, orderstatus: 'accepted', orderdate: '2026-08-31 11:00:00' },
|
||||
{ bookingid: 2, orderstatus: 'picked', orderdate: '2026-08-31 11:00:00' },
|
||||
{ bookingid: 3, orderstatus: 'delivered', orderdate: '2026-08-31 11:00:00' },
|
||||
];
|
||||
const findings = LateDispatchSkill.evaluate(rows, NOW, { lateDispatchMinutes: 30, criticalDispatchMinutes: 60 });
|
||||
expect(findings.length).toBe(0);
|
||||
});
|
||||
});
|
||||
39
tests/lib/skills/RiderBatterySafetySkill.test.js
Normal file
39
tests/lib/skills/RiderBatterySafetySkill.test.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { RiderBatterySafetySkill } from '@/lib/assistant/skills/definitions/RiderBatterySafetySkill';
|
||||
|
||||
describe('RiderBatterySafetySkill', () => {
|
||||
it('detects active deliveries where rider device battery is critical', () => {
|
||||
const rows = [
|
||||
{
|
||||
bookingid: 1,
|
||||
bookingno: 'BK-1',
|
||||
orderstatus: 'picked',
|
||||
userid: 50,
|
||||
ridername: 'Karthik',
|
||||
battery_percentage: 12,
|
||||
},
|
||||
{
|
||||
bookingid: 2,
|
||||
bookingno: 'BK-2',
|
||||
orderstatus: 'picked',
|
||||
userid: 51,
|
||||
ridername: 'Praveen',
|
||||
battery_percentage: 85,
|
||||
},
|
||||
];
|
||||
|
||||
const findings = RiderBatterySafetySkill.evaluate(rows, '2026-08-31 12:00:00', { criticalBatteryPercent: 15 });
|
||||
expect(findings.length).toBe(1);
|
||||
expect(findings[0].id).toBe('rider_critical_battery');
|
||||
expect(findings[0].count).toBe(1);
|
||||
expect(findings[0].rows[0].bookingid).toBe(1);
|
||||
});
|
||||
|
||||
it('executes alert_low_battery_rider tool handler', async () => {
|
||||
const tool = RiderBatterySafetySkill.tools.find((t) => t.name === 'alert_low_battery_rider');
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const result = await tool.handler({ milerId: 50 });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.proposal.action).toBe('battery_alert');
|
||||
});
|
||||
});
|
||||
78
tests/lib/skills/SlaGuardianSkill.test.js
Normal file
78
tests/lib/skills/SlaGuardianSkill.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { SlaGuardianSkill } from '@/lib/assistant/skills/definitions/SlaGuardianSkill';
|
||||
|
||||
describe('SlaGuardianSkill', () => {
|
||||
const fixedNow = '2026-08-31 12:00:00';
|
||||
|
||||
it('detects breached SLA orders', () => {
|
||||
const rows = [
|
||||
{
|
||||
bookingid: 1,
|
||||
bookingno: 'BK-1',
|
||||
orderstatus: 'pending',
|
||||
expecteddeliverytime: '2026-08-31 11:30:00', // 30 min overdue
|
||||
},
|
||||
{
|
||||
bookingid: 2,
|
||||
bookingno: 'BK-2',
|
||||
orderstatus: 'delivered', // terminal, should be ignored
|
||||
expecteddeliverytime: '2026-08-31 11:30:00',
|
||||
},
|
||||
];
|
||||
|
||||
const findings = SlaGuardianSkill.evaluate(rows, fixedNow);
|
||||
const breach = findings.find((f) => f.id === 'sla_breached');
|
||||
|
||||
expect(breach).toBeDefined();
|
||||
expect(breach.count).toBe(1);
|
||||
expect(breach.rows[0].bookingid).toBe(1);
|
||||
});
|
||||
|
||||
it('detects at-risk SLA orders within custom threshold window', () => {
|
||||
const rows = [
|
||||
{
|
||||
bookingid: 10,
|
||||
bookingno: 'BK-10',
|
||||
orderstatus: 'accepted',
|
||||
expecteddeliverytime: '2026-08-31 12:20:00', // due in 20 min
|
||||
},
|
||||
];
|
||||
|
||||
// Default window is 45 min -> should match
|
||||
const findings = SlaGuardianSkill.evaluate(rows, fixedNow, { slaRiskWindowMin: 45 });
|
||||
expect(findings.some((f) => f.id === 'sla_at_risk')).toBe(true);
|
||||
|
||||
// If threshold overridden to 15 min -> should not match 20 min
|
||||
const restricted = SlaGuardianSkill.evaluate(rows, fixedNow, { slaRiskWindowMin: 15 });
|
||||
expect(restricted.some((f) => f.id === 'sla_at_risk')).toBe(false);
|
||||
});
|
||||
|
||||
it('detects unassigned aging orders', () => {
|
||||
const rows = [
|
||||
{
|
||||
bookingid: 20,
|
||||
bookingno: 'BK-20',
|
||||
orderstatus: 'pending',
|
||||
orderdate: '2026-08-31 10:30:00', // 90 min old
|
||||
},
|
||||
];
|
||||
|
||||
const findings = SlaGuardianSkill.evaluate(rows, fixedNow, { unassignedAgingMin: 60 });
|
||||
expect(findings.some((f) => f.id === 'unassigned_aging')).toBe(true);
|
||||
});
|
||||
|
||||
it('executes reassign_sla_critical_order tool handler', async () => {
|
||||
const tool = SlaGuardianSkill.tools.find((t) => t.name === 'reassign_sla_critical_order');
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const result = await tool.handler({
|
||||
bookingId: 101,
|
||||
targetMilerId: 55,
|
||||
reason: 'Urgent priority reassignment',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.proposal.action).toBe('reassign_miler');
|
||||
expect(result.proposal.bookingId).toBe(101);
|
||||
expect(result.proposal.milerId).toBe(55);
|
||||
});
|
||||
});
|
||||
48
tests/lib/useSkillRegistrySync.test.jsx
Normal file
48
tests/lib/useSkillRegistrySync.test.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
/**
|
||||
* The one bridge from the backend agent registry into the in-memory
|
||||
* SkillRegistry the banner and the chat briefing both read.
|
||||
*/
|
||||
|
||||
let mockQuery = { data: undefined, isError: false };
|
||||
const mockUseAiSkills = jest.fn(() => mockQuery);
|
||||
jest.mock('@/lib/doormileHooks', () => ({ useAiSkills: (...args) => mockUseAiSkills(...args) }));
|
||||
|
||||
import { useSkillRegistrySync } from '@/lib/assistant/skills/useSkillRegistrySync';
|
||||
import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
|
||||
|
||||
function Probe({ enabled }) {
|
||||
useSkillRegistrySync(enabled);
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
SkillRegistry.clearRegistryConfig();
|
||||
mockQuery = { data: undefined, isError: false };
|
||||
mockUseAiSkills.mockClear();
|
||||
});
|
||||
|
||||
describe('useSkillRegistrySync', () => {
|
||||
it('applies the registry rows once they arrive', () => {
|
||||
mockQuery = { data: [{ skillid: 'skill_doorstep_stall', enabled: true, thresholds: { arrivedStalledMin: 30 } }], isError: false };
|
||||
render(<Probe enabled />);
|
||||
expect(SkillRegistry.getSource()).toBe('registry');
|
||||
expect(SkillRegistry.getSkill('skill_doorstep_stall').effectiveThresholds.arrivedStalledMin.currentValue).toBe(30);
|
||||
});
|
||||
|
||||
it('falls back to code defaults when the registry cannot be read', () => {
|
||||
SkillRegistry.applyRegistryConfig([{ skillid: 'skill_doorstep_stall', enabled: false, thresholds: {} }]);
|
||||
mockQuery = { data: undefined, isError: true };
|
||||
render(<Probe enabled />);
|
||||
expect(SkillRegistry.getSource()).toBe('defaults');
|
||||
expect(SkillRegistry.getSkill('skill_doorstep_stall').enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('does not query the registry for a partner login, and runs on defaults', () => {
|
||||
render(<Probe enabled={false} />);
|
||||
expect(mockUseAiSkills).toHaveBeenCalledWith({ enabled: false, retry: false });
|
||||
expect(SkillRegistry.getSource()).toBe('defaults');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user