import assert from "node:assert/strict"; import test from "node:test"; import { TRASH_REGION_ID, alarmMinutesToSeconds, buildCalibrationPayload, buildPolygonMap, buildRuntimeDisplayModel, classifyEvent, deriveFoodZones, escapeHtml, secondsToAlarmMinutes, } from "../src/zone-state.js"; test("deriveFoodZones creates numeric zones from legacy grid config", () => { const zones = deriveFoodZones({ layout: {zone_ids: ["r1c1", "r1c2"]}, zones: [ {id: "r1c1", label: "1排1列", polygon: [[0, 0], [0.4, 0], [0.4, 0.4]]}, {id: "r1c2", polygon: [[0.4, 0], [0.8, 0], [0.8, 0.4]]}, ], }); assert.deepEqual(zones.map((zone) => zone.id), ["1", "2"]); assert.deepEqual(zones.map((zone) => zone.label), ["区域 1", "区域 2"]); assert.deepEqual(zones[1].polygon, [ {x: 0.4, y: 0}, {x: 0.8, y: 0}, {x: 0.8, y: 0.4}, ]); }); test("deriveFoodZones maps legacy rows and columns without explicit zone ids", () => { const zones = deriveFoodZones({ layout: {rows: 1, cols: 2}, zones: [ {id: "r1c1", polygon: [[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]}, {id: "r1c2", polygon: [[0.4, 0.1], [0.6, 0.1], [0.6, 0.3]]}, ], }); assert.deepEqual(zones.map((zone) => zone.id), ["1", "2"]); assert.deepEqual(zones.map((zone) => zone.label), ["区域 1", "区域 2"]); assert.deepEqual(zones[0].polygon, [ {x: 0.1, y: 0.1}, {x: 0.3, y: 0.1}, {x: 0.3, y: 0.3}, ]); assert.deepEqual(zones[1].polygon, [ {x: 0.4, y: 0.1}, {x: 0.6, y: 0.1}, {x: 0.6, y: 0.3}, ]); }); test("deriveFoodZones honors numeric zone count and clamps to ten", () => { const zones = deriveFoodZones({ layout: { zone_count: 11, zone_ids: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"], }, }); assert.equal(zones.length, 10); assert.equal(zones.at(-1).id, "10"); }); test("buildCalibrationPayload keeps trash roi separate from food zones", () => { const payload = buildCalibrationPayload( [ {id: "1", label: "1排1列"}, {id: "2", label: "区域 2"}, ], { 1: [{x: 0, y: 0}, {x: 0.2, y: 0}, {x: 0.2, y: 0.2}], 2: [{x: 0.2, y: 0}, {x: 0.4, y: 0}], [TRASH_REGION_ID]: [{x: 0.8, y: 0.8}, {x: 1, y: 0.8}, {x: 1, y: 1}], }, ); assert.deepEqual(payload.zones, [ { id: "1", label: "区域 1", polygon: [[0, 0], [0.2, 0], [0.2, 0.2]], }, ]); assert.deepEqual(payload.layout, {zone_count: 2, zone_ids: ["1", "2"]}); assert.deepEqual(payload.trash.roi, [[0.8, 0.8], [1, 0.8], [1, 1]]); assert.equal(payload.zones.some((zone) => zone.id === TRASH_REGION_ID), false); }); test("buildPolygonMap keeps saved config polygons when draft entries are empty", () => { const foodZones = deriveFoodZones({ layout: {zone_count: 1, zone_ids: ["1"]}, zones: [{id: "1", polygon: [[0, 0], [0.5, 0], [0.5, 0.5]]}], trash: {roi: [[0.8, 0.8], [1, 0.8], [1, 1]]}, }); const polygons = buildPolygonMap(foodZones, {1: [], [TRASH_REGION_ID]: []}, [[0.8, 0.8], [1, 0.8], [1, 1]]); assert.deepEqual(polygons["1"], [ {x: 0, y: 0}, {x: 0.5, y: 0}, {x: 0.5, y: 0.5}, ]); assert.deepEqual(polygons[TRASH_REGION_ID], [ {x: 0.8, y: 0.8}, {x: 1, y: 0.8}, {x: 1, y: 1}, ]); }); test("classifyEvent exposes alarm and warning event display data", () => { assert.deepEqual(classifyEvent({event: "time_alarm", zone_id: "2"}), { severity: "alarm", tone: "alarm", zoneIndex: 2, zoneLabel: "区域 2", isAlert: true, isWarning: false, isViolation: false, }); assert.deepEqual(classifyEvent({event: "warning_escalated", severity: "warning", zone_index: 3}), { severity: "warning", tone: "warning", zoneIndex: 3, zoneLabel: "区域 3", isAlert: false, isWarning: true, isViolation: true, }); }); test("alarm minute helpers round trip to backend seconds", () => { assert.equal(secondsToAlarmMinutes(1200), 20); assert.equal(secondsToAlarmMinutes(10800), 180); assert.equal(alarmMinutesToSeconds(20), 1200); }); test("escapeHtml neutralizes dynamic HTML before innerHTML rendering", () => { assert.equal( escapeHtml(' & "zone"'), "<img src=x onerror=alert(1)> & "zone"", ); }); test("buildRuntimeDisplayModel does not synthesize demo runtime data", () => { const model = buildRuntimeDisplayModel({ summary: null, events: [], config: {thresholds: {max_dwell_seconds: 1200}}, foodZones: deriveFoodZones({layout: {zone_count: 8}}), demoReason: "接口不可用", }); assert.equal(model.isDemo, false); assert.equal(model.summaryIsDemo, false); assert.equal(model.eventsAreDemo, false); assert.equal(model.progressIsDemo, false); assert.equal(model.demoReason, "接口不可用"); assert.equal(model.summary.metrics.event_count, 0); assert.deepEqual(model.events, []); assert.deepEqual(model.progressRows, []); }); test("buildRuntimeDisplayModel tolerates null config before backend config loads", () => { const model = buildRuntimeDisplayModel({ summary: null, events: [], config: null, foodZones: deriveFoodZones({layout: {zone_count: 2}}), }); assert.equal(model.isDemo, false); assert.deepEqual(model.events, []); assert.deepEqual(model.progressRows, []); assert.equal(model.summary.metrics.max_dwell_seconds, 1200); }); test("buildRuntimeDisplayModel keeps diagnostics-only runtime data without demo fallback", () => { const summary = { metrics: { event_count: 0, alert_count: 0, warning_count: 0, violation_count: 0, diagnostics_count: 8, latest_zone_counts: {}, }, }; const model = buildRuntimeDisplayModel({ summary, events: [], config: {thresholds: {max_dwell_seconds: 1200}}, foodZones: deriveFoodZones({layout: {zone_count: 3}}), }); assert.equal(model.summaryIsDemo, false); assert.equal(model.eventsAreDemo, false); assert.equal(model.progressIsDemo, false); assert.equal(model.summary, summary); assert.deepEqual(model.events, []); assert.deepEqual(model.progressRows, []); }); test("buildRuntimeDisplayModel filters legacy demo events and summaries", () => { const model = buildRuntimeDisplayModel({ summary: { result_type: "cold_display_guard_demo", metrics: { event_count: 4, alert_count: 1, warning_count: 2, violation_count: 1, }, }, events: [ { demo: true, event: "time_alarm", zone_id: "1", zone_index: 1, zone_label: "区域 1", dwell_seconds: 1200, }, { event: "batch_started", zone_id: "2", zone_index: 2, zone_label: "区域 2", dwell_seconds: 0, }, ], config: {thresholds: {max_dwell_seconds: 1200}}, foodZones: deriveFoodZones({layout: {zone_count: 3}}), }); assert.equal(model.hasSummary, false); assert.equal(model.summary.metrics.event_count, 0); assert.deepEqual(model.events.map((event) => event.zone_id), ["2"]); assert.deepEqual(model.progressRows.map((row) => row.zoneIndex), [2]); }); test("buildRuntimeDisplayModel keeps real summary and events ahead of demo data", () => { const realSummary = { metrics: { event_count: 1, alert_count: 1, warning_count: 0, violation_count: 0, diagnostics_count: 2, baseline_ready: true, latest_alert_time: "2026-05-26T14:40:00+08:00", }, }; const realEvents = [{ event: "time_alarm", severity: "alarm", ts: "2026-05-26T14:40:00+08:00", zone_id: "2", zone_index: 2, zone_label: "区域 2", batch_id: "batch_real", dwell_seconds: 1300, max_dwell_seconds: 1200, }]; const model = buildRuntimeDisplayModel({ summary: realSummary, events: realEvents, config: {thresholds: {max_dwell_seconds: 1200}}, foodZones: deriveFoodZones({layout: {zone_count: 4}}), }); assert.equal(model.isDemo, false); assert.equal(model.summaryIsDemo, false); assert.equal(model.eventsAreDemo, false); assert.equal(model.progressIsDemo, false); assert.equal(model.summary, realSummary); assert.deepEqual(model.events, realEvents); assert.deepEqual(model.progressRows, [{ zoneIndex: 2, zoneLabel: "区域 2", dwellSeconds: 1300, thresholdSeconds: 1200, progressPct: 100, status: "alarm", source: "real", }]); }); test("buildRuntimeDisplayModel uses latest real event for zone progress", () => { const model = buildRuntimeDisplayModel({ summary: { metrics: { event_count: 2, alert_count: 1, warning_count: 0, violation_count: 0, }, }, events: [ { event: "time_alarm", severity: "alarm", ts: "2026-05-26T14:40:00+08:00", zone_id: "2", zone_index: 2, zone_label: "区域 2", batch_id: "old_batch", dwell_seconds: 1300, max_dwell_seconds: 1200, }, { event: "batch_started", severity: "info", ts: "2026-05-26T14:50:00+08:00", zone_id: "2", zone_index: 2, zone_label: "区域 2", batch_id: "new_batch", current_count: 2, dwell_seconds: 0, max_dwell_seconds: 1200, }, ], config: {thresholds: {max_dwell_seconds: 1200}}, foodZones: deriveFoodZones({layout: {zone_count: 4}}), }); assert.deepEqual(model.progressRows, [{ zoneIndex: 2, zoneLabel: "区域 2", dwellSeconds: 0, thresholdSeconds: 1200, progressPct: 0, status: "normal", source: "real", }]); }); test("buildRuntimeDisplayModel keeps active dwell timer moving from started_at", () => { const model = buildRuntimeDisplayModel({ summary: {metrics: {event_count: 1, alert_count: 1}}, events: [ { event: "time_alarm", severity: "alarm", ts: "2026-05-27T09:43:48+08:00", zone_id: "1", zone_index: 1, zone_label: "区域 1", batch_id: "batch_active", state: "alerted", started_at: "2026-05-27T09:23:43+08:00", alerted_at: "2026-05-27T09:43:48+08:00", dwell_seconds: 1205, max_dwell_seconds: 1200, }, ], config: {thresholds: {max_dwell_seconds: 1200}}, foodZones: deriveFoodZones({layout: {zone_count: 2}}), now: "2026-05-27T09:50:00+08:00", }); assert.equal(model.progressRows[0].dwellSeconds, 1577); assert.equal(model.progressRows[0].progressPct, 100); assert.equal(model.progressRows[0].status, "alarm"); }); test("buildRuntimeDisplayModel exposes live dwell seconds for event table rows", () => { const model = buildRuntimeDisplayModel({ summary: {metrics: {event_count: 1, alert_count: 1}}, events: [ { event: "time_alarm", severity: "alarm", ts: "2026-05-27T09:43:48+08:00", zone_id: "6", zone_index: 6, zone_label: "区域 6", batch_id: "batch_active", state: "alerted", started_at: "2026-05-27T09:23:49+08:00", alerted_at: "2026-05-27T09:43:54+08:00", dwell_seconds: 1204, max_dwell_seconds: 1200, }, ], config: {thresholds: {max_dwell_seconds: 1200}}, foodZones: deriveFoodZones({layout: {zone_count: 8}}), now: "2026-05-27T11:03:49+08:00", }); assert.equal(model.events[0].dwell_seconds, 1204); assert.equal(model.displayEvents[0].displayDwellSeconds, 6000); }); test("buildRuntimeDisplayModel does not keep batch_started row ticking after removal", () => { const model = buildRuntimeDisplayModel({ summary: {metrics: {event_count: 2, latest_zone_counts: {"1": 0}}}, events: [ { event: "batch_started", severity: "info", ts: "2026-05-29T09:59:49+08:00", zone_id: "1", zone_index: 1, zone_label: "区域 1", batch_id: "batch_done", state: "active", started_at: "2026-05-29T09:59:49+08:00", dwell_seconds: 0, max_dwell_seconds: 300, }, { event: "batch_consumed", severity: "info", ts: "2026-05-29T10:00:53+08:00", zone_id: "1", zone_index: 1, zone_label: "区域 1", batch_id: "batch_done", state: "consumed", started_at: "2026-05-29T09:59:49+08:00", ended_at: "2026-05-29T10:00:53+08:00", dwell_seconds: 64, max_dwell_seconds: 300, }, ], config: {thresholds: {max_dwell_seconds: 300}}, foodZones: deriveFoodZones({layout: {zone_count: 8}}), now: "2026-05-29T10:05:00+08:00", }); assert.equal(model.displayEvents[0].displayDwellSeconds, 0); assert.equal(model.displayEvents[1].displayDwellSeconds, 64); }); test("buildRuntimeDisplayModel hides live progress for zones currently empty in diagnostics", () => { const model = buildRuntimeDisplayModel({ summary: {metrics: {event_count: 2, alert_count: 2, latest_zone_counts: {"1": 1, "3": 0}}}, events: [ { event: "time_alarm", severity: "alarm", ts: "2026-05-27T09:43:48+08:00", zone_id: "1", zone_index: 1, zone_label: "区域 1", batch_id: "batch_real", state: "alerted", started_at: "2026-05-27T09:23:43+08:00", dwell_seconds: 1204, max_dwell_seconds: 1200, }, { event: "time_alarm", severity: "alarm", ts: "2026-05-27T10:13:55+08:00", zone_id: "3", zone_index: 3, zone_label: "区域 3", batch_id: "batch_reflection", state: "alerted", started_at: "2026-05-27T09:53:51+08:00", dwell_seconds: 1204, max_dwell_seconds: 1200, }, ], config: {thresholds: {max_dwell_seconds: 1200}}, foodZones: deriveFoodZones({layout: {zone_count: 4}}), now: "2026-05-27T11:03:51+08:00", }); assert.deepEqual(model.progressRows.map((row) => row.zoneIndex), [1]); }); test("buildRuntimeDisplayModel hides historical zones outside current configuration", () => { const model = buildRuntimeDisplayModel({ summary: {metrics: {event_count: 3, latest_zone_counts: {"4": 1}}}, events: [ { event: "time_alarm", severity: "alarm", ts: "2026-05-29T09:50:00+08:00", zone_id: "4", zone_index: 4, zone_label: "区域 4", batch_id: "batch_current", started_at: "2026-05-29T09:45:00+08:00", dwell_seconds: 300, max_dwell_seconds: 300, }, { event: "batch_consumed", severity: "info", ts: "2026-05-28T08:31:53+08:00", zone_id: "9", zone_index: 9, zone_label: "区域 9", batch_id: "batch_old_9", started_at: "2026-05-28T08:13:48+08:00", ended_at: "2026-05-28T08:31:53+08:00", dwell_seconds: 1085, max_dwell_seconds: 1200, }, { event: "batch_consumed", severity: "info", ts: "2026-05-28T08:31:53+08:00", zone_id: "10", zone_index: 10, zone_label: "区域 10", batch_id: "batch_old_10", started_at: "2026-05-28T08:13:48+08:00", ended_at: "2026-05-28T08:31:53+08:00", dwell_seconds: 1085, max_dwell_seconds: 1200, }, ], config: {layout: {zone_count: 8, zone_ids: ["1", "2", "3", "4", "5", "6", "7", "8"]}}, foodZones: deriveFoodZones({layout: {zone_count: 8, zone_ids: ["1", "2", "3", "4", "5", "6", "7", "8"]}}), }); assert.deepEqual(model.progressRows.map((row) => row.zoneIndex), [4]); }); test("buildRuntimeDisplayModel does not advance ended batch dwell timer", () => { const model = buildRuntimeDisplayModel({ summary: {metrics: {event_count: 1}}, events: [ { event: "batch_consumed", severity: "info", ts: "2026-05-27T09:25:00+08:00", zone_id: "1", zone_index: 1, zone_label: "区域 1", batch_id: "batch_done", state: "consumed", started_at: "2026-05-27T09:23:43+08:00", ended_at: "2026-05-27T09:25:00+08:00", dwell_seconds: 77, max_dwell_seconds: 1200, }, ], config: {thresholds: {max_dwell_seconds: 1200}}, foodZones: deriveFoodZones({layout: {zone_count: 2}}), now: "2026-05-27T09:50:00+08:00", }); assert.equal(model.progressRows[0].dwellSeconds, 77); assert.equal(model.progressRows[0].progressPct, 6); assert.equal(model.progressRows[0].status, "normal"); }); test("buildRuntimeDisplayModel falls back to event order when latest event has no timestamp", () => { const model = buildRuntimeDisplayModel({ summary: {metrics: {event_count: 2, alert_count: 1}}, events: [ { event: "time_alarm", severity: "alarm", ts: "2026-05-26T14:40:00+08:00", zone_id: "2", zone_index: 2, zone_label: "区域 2", batch_id: "old_batch", dwell_seconds: 1300, max_dwell_seconds: 1200, }, { event: "batch_started", severity: "info", zone_id: "2", zone_index: 2, zone_label: "区域 2", batch_id: "new_batch", current_count: 2, dwell_seconds: 0, }, ], config: {thresholds: {max_dwell_seconds: 1200}}, foodZones: deriveFoodZones({layout: {zone_count: 4}}), }); assert.deepEqual(model.progressRows, [{ zoneIndex: 2, zoneLabel: "区域 2", dwellSeconds: 0, thresholdSeconds: 1200, progressPct: 0, status: "normal", source: "real", }]); }); test("buildRuntimeDisplayModel uses config threshold when event omits threshold", () => { const model = buildRuntimeDisplayModel({ summary: {metrics: {event_count: 1}}, events: [ { event: "batch_count_changed", severity: "info", ts: "2026-05-26T14:40:00+08:00", zone_id: "1", zone_index: 1, zone_label: "区域 1", batch_id: "batch_1", dwell_seconds: 700, }, ], config: {thresholds: {max_dwell_seconds: 600}}, foodZones: deriveFoodZones({layout: {zone_count: 4}}), }); assert.deepEqual(model.progressRows, [{ zoneIndex: 1, zoneLabel: "区域 1", dwellSeconds: 700, thresholdSeconds: 600, progressPct: 100, status: "alarm", source: "real", }]); });