Files
doormilxpress_astryx/tests/lib/agentSingleEngine.test.js

165 lines
6.6 KiB
JavaScript

// 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');
});
});