feat: add cold display alarm flow and labeled snapshots
This commit is contained in:
@@ -2,10 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from cold_display_guard import alarm_snapshots
|
||||
from cold_display_guard.alarm_snapshots import (
|
||||
capture_alert_snapshot,
|
||||
fallback_label_text,
|
||||
load_alarm_snapshot_settings,
|
||||
upload_snapshot_bytes,
|
||||
)
|
||||
@@ -153,6 +155,40 @@ class AlarmSnapshotTests(unittest.TestCase):
|
||||
self.assertNotEqual(annotated.pixel(2, 2), (0, 0, 0))
|
||||
self.assertNotEqual(annotated.pixel(0, 0), (0, 0, 0))
|
||||
|
||||
def test_calibration_overlay_uses_distinct_zone_colors_and_draws_labels(self) -> None:
|
||||
frame = Frame(width=40, height=20, rgb=b"\x00\x00\x00" * 800)
|
||||
|
||||
annotated = alarm_snapshots.apply_calibration_overlay(
|
||||
frame,
|
||||
{
|
||||
"zones": [
|
||||
{
|
||||
"id": "1",
|
||||
"label": "区域 1",
|
||||
"polygon": [[0.05, 0.10], [0.40, 0.10], [0.40, 0.90], [0.05, 0.90]],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"label": "区域 2",
|
||||
"polygon": [[0.55, 0.10], [0.90, 0.10], [0.90, 0.90], [0.55, 0.90]],
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
self.assertNotEqual(annotated.pixel(10, 15), annotated.pixel(30, 15))
|
||||
label_pixels = [annotated.pixel(x, y) for y in range(2, 10) for x in range(2, 18)]
|
||||
self.assertTrue(any(max(pixel) >= 220 for pixel in label_pixels), "expected bright label text pixels")
|
||||
|
||||
def test_chinese_label_fallback_uses_readable_ascii_when_font_renderer_is_unavailable(self) -> None:
|
||||
self.assertEqual(fallback_label_text("区域 1"), "R1")
|
||||
self.assertEqual(fallback_label_text("区域 12"), "R12")
|
||||
self.assertEqual(fallback_label_text("垃圾区"), "TRASH")
|
||||
|
||||
def test_docker_image_installs_cjk_fonts_for_alarm_snapshot_labels(self) -> None:
|
||||
dockerfile = (Path(__file__).resolve().parents[1] / "Dockerfile").read_text(encoding="utf-8")
|
||||
self.assertIn("fonts-noto-cjk", dockerfile)
|
||||
|
||||
def test_capture_alert_snapshot_encodes_frame_with_configured_calibration_overlay(self) -> None:
|
||||
encoded_frames: list[Frame] = []
|
||||
|
||||
|
||||
@@ -48,6 +48,55 @@ class CaseStoreTests(unittest.TestCase):
|
||||
self.assertEqual(snapshots[0]["case_status"], "open")
|
||||
self.assertEqual(snapshots[0]["source_event"], "time_alarm")
|
||||
|
||||
def test_time_pre_warning_creates_open_pre_warning_case(self) -> None:
|
||||
store = CaseStore()
|
||||
|
||||
snapshots = store.apply_batch_events(
|
||||
[event("time_pre_warning", self.t0, severity="warning", state="pre_warning")]
|
||||
)
|
||||
|
||||
self.assertEqual(len(snapshots), 1)
|
||||
self.assertEqual(snapshots[0]["case_type"], "pre_warning")
|
||||
self.assertEqual(snapshots[0]["case_status"], "open")
|
||||
self.assertEqual(snapshots[0]["source_event"], "time_pre_warning")
|
||||
|
||||
def test_pre_warning_handled_auto_closes_open_case(self) -> None:
|
||||
store = CaseStore()
|
||||
store.apply_batch_events([event("time_pre_warning", self.t0, severity="warning", state="pre_warning")])
|
||||
|
||||
snapshots = store.apply_batch_events(
|
||||
[event("pre_warning_handled", self.t0.replace(minute=1), severity="info", state="handled")]
|
||||
)
|
||||
|
||||
self.assertEqual(len(snapshots), 1)
|
||||
self.assertEqual(snapshots[0]["case_status"], "handled")
|
||||
self.assertEqual(snapshots[0]["handled_source"], "auto_removed_before_alarm")
|
||||
|
||||
def test_time_alarm_upgrades_pre_warning_case(self) -> None:
|
||||
store = CaseStore()
|
||||
store.apply_batch_events([event("time_pre_warning", self.t0, severity="warning", state="pre_warning")])
|
||||
|
||||
snapshots = store.apply_batch_events([event("time_alarm", self.t0.replace(minute=2), severity="alarm", state="alerted")])
|
||||
|
||||
self.assertEqual(len(snapshots), 1)
|
||||
self.assertEqual(snapshots[0]["case_type"], "time_alarm")
|
||||
self.assertEqual(snapshots[0]["case_status"], "open")
|
||||
self.assertEqual(snapshots[0]["source_event"], "time_alarm")
|
||||
|
||||
def test_alarm_removal_timeout_upgrades_same_case(self) -> None:
|
||||
store = CaseStore()
|
||||
store.apply_batch_events([event("time_pre_warning", self.t0, severity="warning", state="pre_warning")])
|
||||
store.apply_batch_events([event("time_alarm", self.t0.replace(minute=2), severity="alarm", state="alerted")])
|
||||
|
||||
snapshots = store.apply_batch_events(
|
||||
[event("alarm_removal_timeout", self.t0.replace(minute=3), severity="alarm", state="alarm_removal_timeout")]
|
||||
)
|
||||
|
||||
self.assertEqual(len(snapshots), 1)
|
||||
self.assertEqual(snapshots[0]["case_type"], "alarm_removal_timeout")
|
||||
self.assertEqual(snapshots[0]["case_status"], "open")
|
||||
self.assertEqual(snapshots[0]["source_event"], "alarm_removal_timeout")
|
||||
|
||||
def test_pending_disposal_upgrades_existing_case(self) -> None:
|
||||
store = CaseStore()
|
||||
store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])
|
||||
|
||||
@@ -16,7 +16,9 @@ class ConfigTests(unittest.TestCase):
|
||||
camera_id = "cam_a"
|
||||
|
||||
[thresholds]
|
||||
pre_warning_seconds = 20
|
||||
max_dwell_seconds = 30
|
||||
alarm_removal_seconds = 2
|
||||
trash_confirmation_seconds = 4
|
||||
|
||||
[layout]
|
||||
@@ -29,7 +31,9 @@ cols = 2
|
||||
settings = load_settings(path)
|
||||
|
||||
self.assertEqual(settings.camera_id, "cam_a")
|
||||
self.assertEqual(settings.pre_warning_seconds, 20)
|
||||
self.assertEqual(settings.max_dwell_seconds, 30)
|
||||
self.assertEqual(settings.alarm_removal_seconds, 2)
|
||||
self.assertEqual(settings.trash_confirmation_seconds, 4)
|
||||
self.assertEqual(settings.zone_ids, ("r1c1", "r1c2"))
|
||||
|
||||
@@ -118,6 +122,8 @@ zone_ids = ["1", "2", "3"]
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("zone_count = 2", text)
|
||||
self.assertIn("pre_warning_seconds = 0", text)
|
||||
self.assertIn("alarm_removal_seconds = 0", text)
|
||||
self.assertIn('label = "区域 1"', text)
|
||||
self.assertIn("[trash]", text)
|
||||
self.assertNotIn('"trash"', text.split("[layout]", maxsplit=1)[1].split("[[zones]]", maxsplit=1)[0])
|
||||
|
||||
@@ -140,6 +140,96 @@ class BatchEngineTests(unittest.TestCase):
|
||||
self.assertEqual(alarm_events[0]["current_count"], 1)
|
||||
self.assertIn("alerted_at", alarm_events[0])
|
||||
|
||||
def test_pre_warning_emits_once_before_alarm_threshold(self) -> None:
|
||||
settings = EngineSettings(
|
||||
camera_id="test_cam",
|
||||
pre_warning_seconds=60,
|
||||
max_dwell_seconds=120,
|
||||
alarm_removal_seconds=30,
|
||||
trash_confirmation_seconds=30,
|
||||
zone_ids=("1",),
|
||||
)
|
||||
engine = BatchEngine(settings)
|
||||
engine.process(obs(self.t0, {"1": 1}))
|
||||
|
||||
pre_warning_events = engine.process(obs(self.t0 + timedelta(seconds=60), {"1": 1}))
|
||||
repeated_events = engine.process(obs(self.t0 + timedelta(seconds=90), {"1": 1}))
|
||||
|
||||
self.assertEqual([event["event"] for event in pre_warning_events], ["time_pre_warning"])
|
||||
self.assertEqual(pre_warning_events[0]["severity"], "warning")
|
||||
self.assertEqual(pre_warning_events[0]["state"], "pre_warning")
|
||||
self.assertEqual(pre_warning_events[0]["pre_warning_seconds"], 60)
|
||||
self.assertEqual(pre_warning_events[0]["pre_warned_at"], (self.t0 + timedelta(seconds=60)).isoformat())
|
||||
self.assertEqual(pre_warning_events[0]["current_count"], 1)
|
||||
self.assertEqual(repeated_events, [])
|
||||
|
||||
def test_pre_warning_removed_before_alarm_is_auto_handled(self) -> None:
|
||||
settings = EngineSettings(
|
||||
camera_id="test_cam",
|
||||
pre_warning_seconds=60,
|
||||
max_dwell_seconds=120,
|
||||
alarm_removal_seconds=30,
|
||||
trash_confirmation_seconds=30,
|
||||
zone_ids=("1",),
|
||||
)
|
||||
engine = BatchEngine(settings)
|
||||
engine.process(obs(self.t0, {"1": 1}))
|
||||
engine.process(obs(self.t0 + timedelta(seconds=60), {"1": 1}))
|
||||
|
||||
events = engine.process(obs(self.t0 + timedelta(seconds=90), {"1": 0}))
|
||||
|
||||
self.assertEqual([event["event"] for event in events], ["pre_warning_handled"])
|
||||
self.assertEqual(events[0]["severity"], "info")
|
||||
self.assertEqual(events[0]["state"], "handled")
|
||||
self.assertEqual(events[0]["handled_source"], "auto_removed_before_alarm")
|
||||
self.assertEqual(events[0]["ended_at"], (self.t0 + timedelta(seconds=90)).isoformat())
|
||||
|
||||
def test_alarm_removal_timeout_emits_once_before_late_removal(self) -> None:
|
||||
settings = EngineSettings(
|
||||
camera_id="test_cam",
|
||||
pre_warning_seconds=60,
|
||||
max_dwell_seconds=120,
|
||||
alarm_removal_seconds=30,
|
||||
trash_confirmation_seconds=30,
|
||||
zone_ids=("1",),
|
||||
)
|
||||
engine = BatchEngine(settings)
|
||||
engine.process(obs(self.t0, {"1": 1}))
|
||||
engine.process(obs(self.t0 + timedelta(seconds=60), {"1": 1}))
|
||||
alarm_events = engine.process(obs(self.t0 + timedelta(seconds=120), {"1": 1}))
|
||||
|
||||
timeout_events = engine.process(obs(self.t0 + timedelta(seconds=151), {"1": 1}))
|
||||
repeated_events = engine.process(obs(self.t0 + timedelta(seconds=160), {"1": 1}))
|
||||
removal_events = engine.process(obs(self.t0 + timedelta(seconds=170), {"1": 0}, trash=True))
|
||||
|
||||
self.assertEqual([event["event"] for event in alarm_events], ["time_alarm"])
|
||||
self.assertEqual(alarm_events[0]["alarm_removal_deadline"], (self.t0 + timedelta(seconds=150)).isoformat())
|
||||
self.assertEqual([event["event"] for event in timeout_events], ["alarm_removal_timeout"])
|
||||
self.assertEqual(timeout_events[0]["severity"], "alarm")
|
||||
self.assertEqual(timeout_events[0]["state"], "alarm_removal_timeout")
|
||||
self.assertEqual(timeout_events[0]["reason"], "alarmed_batch_not_removed_after_alarm_window")
|
||||
self.assertEqual(repeated_events, [])
|
||||
self.assertEqual([event["event"] for event in removal_events], ["batch_pending_disposal", "batch_discarded"])
|
||||
|
||||
def test_alarmed_batch_removed_within_alarm_window_does_not_emit_removal_timeout(self) -> None:
|
||||
settings = EngineSettings(
|
||||
camera_id="test_cam",
|
||||
pre_warning_seconds=60,
|
||||
max_dwell_seconds=120,
|
||||
alarm_removal_seconds=30,
|
||||
trash_confirmation_seconds=30,
|
||||
zone_ids=("1",),
|
||||
)
|
||||
engine = BatchEngine(settings)
|
||||
engine.process(obs(self.t0, {"1": 1}))
|
||||
engine.process(obs(self.t0 + timedelta(seconds=60), {"1": 1}))
|
||||
engine.process(obs(self.t0 + timedelta(seconds=120), {"1": 1}))
|
||||
|
||||
events = engine.process(obs(self.t0 + timedelta(seconds=150), {"1": 0}, trash=True))
|
||||
|
||||
self.assertEqual([event["event"] for event in events], ["batch_pending_disposal", "batch_discarded"])
|
||||
self.assertTrue(all(event["event"] != "alarm_removal_timeout" for event in events))
|
||||
|
||||
def test_alarmed_batch_removed_without_trash_deposit_escalates_warning(self) -> None:
|
||||
settings = EngineSettings(
|
||||
camera_id="test_cam",
|
||||
@@ -260,6 +350,37 @@ class BatchEngineTests(unittest.TestCase):
|
||||
self.assertEqual(removal_events[0]["batch_id"], "batch_000124")
|
||||
self.assertEqual(removal_events[0]["dwell_seconds"], 1400)
|
||||
|
||||
def test_restore_keeps_alarm_removal_timeout_deadline_after_runtime_restart(self) -> None:
|
||||
settings = EngineSettings(
|
||||
camera_id="test_cam",
|
||||
pre_warning_seconds=60,
|
||||
max_dwell_seconds=120,
|
||||
alarm_removal_seconds=30,
|
||||
trash_confirmation_seconds=30,
|
||||
zone_ids=("1",),
|
||||
)
|
||||
engine = BatchEngine(settings)
|
||||
engine.restore_from_events(
|
||||
[
|
||||
{
|
||||
"event": "time_alarm",
|
||||
"zone_id": "1",
|
||||
"batch_id": "batch_000124",
|
||||
"started_at": self.t0.isoformat(),
|
||||
"pre_warned_at": (self.t0 + timedelta(seconds=60)).isoformat(),
|
||||
"alerted_at": (self.t0 + timedelta(seconds=120)).isoformat(),
|
||||
"current_count": 1,
|
||||
"state": "alerted",
|
||||
},
|
||||
],
|
||||
active_zone_counts={"1": 1},
|
||||
)
|
||||
|
||||
timeout_events = engine.process(obs(self.t0 + timedelta(seconds=151), {"1": 1}))
|
||||
|
||||
self.assertEqual([event["event"] for event in timeout_events], ["alarm_removal_timeout"])
|
||||
self.assertEqual(timeout_events[0]["batch_id"], "batch_000124")
|
||||
|
||||
def test_restore_skips_active_false_positive_when_latest_zone_count_is_empty(self) -> None:
|
||||
settings = EngineSettings(
|
||||
camera_id="test_cam",
|
||||
|
||||
@@ -80,6 +80,46 @@ class WebhookTests(unittest.TestCase):
|
||||
self.assertFalse(payload["is_discarded"])
|
||||
self.assertEqual(payload["alerted_at"], datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat())
|
||||
|
||||
def test_build_batch_event_payload_preserves_pre_warning_and_alarm_times(self) -> None:
|
||||
pre_warned_at = datetime(2026, 6, 9, 8, 59, tzinfo=UTC).isoformat()
|
||||
alarm_at = datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat()
|
||||
|
||||
pre_warning_payload = build_batch_event_payload(
|
||||
{
|
||||
"event": "time_pre_warning",
|
||||
"ts": pre_warned_at,
|
||||
"batch_id": "batch_000001",
|
||||
"camera_id": "cam_01",
|
||||
"zone_id": "1",
|
||||
"zone_label": "区域 1",
|
||||
"severity": "warning",
|
||||
"state": "pre_warning",
|
||||
"started_at": datetime(2026, 6, 9, 8, 58, tzinfo=UTC).isoformat(),
|
||||
"pre_warned_at": pre_warned_at,
|
||||
}
|
||||
)
|
||||
alarm_payload = build_batch_event_payload(
|
||||
{
|
||||
"event": "time_alarm",
|
||||
"ts": alarm_at,
|
||||
"batch_id": "batch_000001",
|
||||
"camera_id": "cam_01",
|
||||
"zone_id": "1",
|
||||
"zone_label": "区域 1",
|
||||
"severity": "alarm",
|
||||
"state": "alerted",
|
||||
"started_at": datetime(2026, 6, 9, 8, 58, tzinfo=UTC).isoformat(),
|
||||
"pre_warned_at": pre_warned_at,
|
||||
"alerted_at": alarm_at,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(pre_warning_payload["pre_warned_at"], pre_warned_at)
|
||||
self.assertEqual(pre_warning_payload["created_at"], pre_warned_at)
|
||||
self.assertEqual(pre_warning_payload["alarm_at"], "")
|
||||
self.assertEqual(alarm_payload["pre_warned_at"], pre_warned_at)
|
||||
self.assertEqual(alarm_payload["alarm_at"], alarm_at)
|
||||
|
||||
def test_build_batch_event_payload_includes_uploaded_snapshot_path(self) -> None:
|
||||
payload = build_batch_event_payload(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user