Files
cold_display_guard/web/test/zone-state.test.js

688 lines
20 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
TRASH_REGION_ID,
alarmMinutesToSeconds,
buildCalibrationPayload,
buildCaseDisplayModel,
buildManualHandlePayload,
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('<img src=x onerror=alert(1)> & "zone"'),
"&lt;img src=x onerror=alert(1)&gt; &amp; &quot;zone&quot;",
);
});
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",
}]);
});
test("buildCaseDisplayModel normalizes case rows and summary metrics", () => {
const model = buildCaseDisplayModel({
summary: {
open_case_count: 1,
handled_case_count: 2,
time_alarm_case_count: 1,
pending_disposal_case_count: 1,
warning_escalated_case_count: 1,
},
cases: [
{
case_id: "case_batch_000001",
case_type: "warning_escalated",
case_status: "open",
zone_label: "区域 1",
batch_id: "batch_000001",
updated_at: "2026-06-09T09:10:00+08:00",
handled_source: "",
},
{
case_id: "case_batch_000002",
case_type: "time_alarm",
case_status: "handled",
zone_label: "区域 2",
batch_id: "batch_000002",
updated_at: "2026-06-09T09:12:00+08:00",
handled_source: "manual",
},
],
});
assert.deepEqual(model.metrics, {
openCaseCount: 1,
handledCaseCount: 2,
timeAlarmCaseCount: 1,
pendingDisposalCaseCount: 1,
warningEscalatedCaseCount: 1,
});
assert.equal(model.rows[0].caseId, "case_batch_000002");
assert.equal(model.rows[0].statusLabel, "已处理");
assert.equal(model.rows[0].tone, "good");
assert.equal(model.rows[1].typeLabel, "升级警告");
assert.equal(model.rows[1].statusLabel, "待处理");
});
test("buildManualHandlePayload trims handled_by and keeps optional note", () => {
assert.deepEqual(buildManualHandlePayload(" alice ", " checked "), {
handled_by: "alice",
note: "checked",
});
assert.deepEqual(buildManualHandlePayload("bob", ""), {
handled_by: "bob",
});
});