631 lines
18 KiB
JavaScript
631 lines
18 KiB
JavaScript
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('<img src=x onerror=alert(1)> & "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",
|
|
}]);
|
|
});
|