feat: implement rolling half-hour report windows anchored to service startup time
This commit is contained in:
@@ -19,6 +19,7 @@ def test_session_pauses_without_adding_absence_time():
|
||||
|
||||
|
||||
def test_engine_emits_half_hour_report_with_queue_classification():
|
||||
start = datetime(2026, 4, 15, 11, 7, tzinfo=TZ)
|
||||
engine = DwellEngine(
|
||||
camera_id="store_cam_01",
|
||||
queue_time_threshold_seconds=300,
|
||||
@@ -26,8 +27,8 @@ def test_engine_emits_half_hour_report_with_queue_classification():
|
||||
normal_count_threshold=2,
|
||||
pause_timeout_seconds=300,
|
||||
alert_cooldown_seconds=600,
|
||||
report_window_start=start,
|
||||
)
|
||||
start = datetime(2026, 4, 15, 11, 0, tzinfo=TZ)
|
||||
crowded_group = [
|
||||
{"person_id": f"cust_{idx}", "role": "customer"} for idx in range(6)
|
||||
]
|
||||
@@ -35,16 +36,18 @@ def test_engine_emits_half_hour_report_with_queue_classification():
|
||||
{"person_id": f"short_{idx}", "role": "customer"} for idx in range(2)
|
||||
]
|
||||
|
||||
engine.process_observations(crowded_group, start.replace(minute=0, second=0))
|
||||
engine.process_observations(crowded_group, start.replace(minute=6, second=0))
|
||||
engine.process_observations(crowded_group, start.replace(minute=7, second=0))
|
||||
engine.process_observations(crowded_group, start.replace(minute=13, second=0))
|
||||
engine.process_observations(
|
||||
crowded_group + short_wait_group, start.replace(minute=27, second=0)
|
||||
crowded_group + short_wait_group, start.replace(minute=34, second=0)
|
||||
)
|
||||
events = engine.process_observations(
|
||||
short_wait_group, start.replace(minute=30, second=0)
|
||||
short_wait_group, start.replace(minute=37, second=0)
|
||||
)
|
||||
|
||||
report = next(event for event in events if event["event"] == "half_hour_report")
|
||||
assert report["window_start"] == "2026-04-15T11:07:00+08:00"
|
||||
assert report["window_end"] == "2026-04-15T11:37:00+08:00"
|
||||
assert report["queue_metrics"]["over_threshold_count"] == 6
|
||||
assert report["queue_metrics"]["under_threshold_count"] == 2
|
||||
assert report["queue_metrics"]["queue_level"] == "crowded"
|
||||
@@ -52,6 +55,7 @@ def test_engine_emits_half_hour_report_with_queue_classification():
|
||||
|
||||
|
||||
def test_engine_tracks_queue_status_change_between_windows():
|
||||
start = datetime(2026, 4, 15, 11, 7, tzinfo=TZ)
|
||||
engine = DwellEngine(
|
||||
camera_id="store_cam_01",
|
||||
queue_time_threshold_seconds=300,
|
||||
@@ -59,28 +63,31 @@ def test_engine_tracks_queue_status_change_between_windows():
|
||||
normal_count_threshold=2,
|
||||
pause_timeout_seconds=300,
|
||||
alert_cooldown_seconds=600,
|
||||
report_window_start=start,
|
||||
)
|
||||
start = datetime(2026, 4, 15, 11, 0, tzinfo=TZ)
|
||||
engine.process_observations(
|
||||
[{"person_id": f"crowded_{idx}", "role": "customer"} for idx in range(6)],
|
||||
start,
|
||||
)
|
||||
engine.process_observations([], start.replace(minute=30))
|
||||
engine.process_observations([], start.replace(minute=37))
|
||||
engine.process_observations(
|
||||
[{"person_id": f"normal_{idx}", "role": "customer"} for idx in range(3)],
|
||||
start.replace(minute=31),
|
||||
start.replace(minute=38),
|
||||
)
|
||||
report_events = engine.process_observations([], start.replace(hour=12, minute=0))
|
||||
report_events = engine.process_observations([], start.replace(hour=12, minute=7))
|
||||
|
||||
report = next(
|
||||
event for event in report_events if event["event"] == "half_hour_report"
|
||||
)
|
||||
assert report["window_start"] == "2026-04-15T11:37:00+08:00"
|
||||
assert report["window_end"] == "2026-04-15T12:07:00+08:00"
|
||||
assert report["queue_metrics"]["queue_level"] == "normal"
|
||||
assert report["queue_metrics"]["status_change"] == "queue_normalized"
|
||||
assert report["queue_metrics"]["previous_queue_level"] == "crowded"
|
||||
|
||||
|
||||
def test_engine_emits_half_hour_report_with_closed_customers():
|
||||
report_window_start = datetime(2026, 4, 15, 11, 10, tzinfo=TZ)
|
||||
engine = DwellEngine(
|
||||
camera_id="store_cam_01",
|
||||
queue_time_threshold_seconds=300,
|
||||
@@ -88,16 +95,18 @@ def test_engine_emits_half_hour_report_with_closed_customers():
|
||||
normal_count_threshold=2,
|
||||
pause_timeout_seconds=300,
|
||||
alert_cooldown_seconds=600,
|
||||
report_window_start=report_window_start,
|
||||
)
|
||||
seen_at = datetime(2026, 4, 15, 11, 10, tzinfo=TZ)
|
||||
engine.process_observations([{"person_id": "cust_1", "role": "customer"}], seen_at)
|
||||
engine.process_observations([], datetime(2026, 4, 15, 11, 12, tzinfo=TZ))
|
||||
engine.process_observations([], datetime(2026, 4, 15, 11, 18, tzinfo=TZ))
|
||||
|
||||
events = engine.process_observations([], datetime(2026, 4, 15, 11, 30, tzinfo=TZ))
|
||||
events = engine.process_observations([], datetime(2026, 4, 15, 11, 40, tzinfo=TZ))
|
||||
|
||||
report = next(event for event in events if event["event"] == "half_hour_report")
|
||||
assert report["window_end"] == "2026-04-15T11:30:00+08:00"
|
||||
assert report["window_start"] == "2026-04-15T11:10:00+08:00"
|
||||
assert report["window_end"] == "2026-04-15T11:40:00+08:00"
|
||||
assert report["closed_customers"][0]["person_id"] == "cust_1"
|
||||
assert report["queue_metrics"]["over_threshold_count"] == 0
|
||||
assert report["queue_metrics"]["under_threshold_count"] == 1
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.modules.reporter import floor_half_hour, should_emit_half_hour_report
|
||||
from app.modules.reporter import initial_half_hour_window, should_emit_half_hour_report
|
||||
|
||||
|
||||
def test_half_hour_report_emits_on_half_hour_boundaries():
|
||||
assert should_emit_half_hour_report("2026-04-15T11:00:00+08:00") is True
|
||||
assert should_emit_half_hour_report("2026-04-15T11:30:00+08:00") is True
|
||||
assert should_emit_half_hour_report("2026-04-15T11:17:00+08:00") is False
|
||||
def test_half_hour_report_emits_after_rolling_1800_seconds():
|
||||
started_at = datetime(2026, 4, 15, 11, 17, 35, tzinfo=ZoneInfo("Asia/Shanghai"))
|
||||
_, window_end = initial_half_hour_window(started_at)
|
||||
|
||||
assert (
|
||||
should_emit_half_hour_report(window_end, started_at + timedelta(seconds=1799))
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
should_emit_half_hour_report(window_end, started_at + timedelta(seconds=1800))
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_floor_half_hour_rounds_down():
|
||||
dt = datetime(2026, 4, 15, 11, 47, 13, tzinfo=ZoneInfo("Asia/Shanghai"))
|
||||
assert floor_half_hour(dt).isoformat() == "2026-04-15T11:30:00+08:00"
|
||||
def test_initial_half_hour_window_preserves_startup_offset():
|
||||
started_at = datetime(2026, 4, 15, 11, 47, 13, tzinfo=ZoneInfo("Asia/Shanghai"))
|
||||
window_start, window_end = initial_half_hour_window(started_at)
|
||||
|
||||
assert window_start.isoformat() == "2026-04-15T11:47:13+08:00"
|
||||
assert window_end.isoformat() == "2026-04-15T12:17:13+08:00"
|
||||
|
||||
Reference in New Issue
Block a user