from edge.detectors.zone import Zone from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig _POLY = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] def _zone(zone_id="z1", **rules): return Zone(zone_id, "restricted", _POLY, rules or None) def _watch(**cfg): return OccupancyWatchdog(OccupancyConfig(**cfg)) def test_no_rules_emits_nothing(): w = _watch() zones = [Zone("z1", "restricted", _POLY)] assert w.update(zones, {"z1": {1}}, now=0.0) == [] assert w.update(zones, {"z1": {1}}, now=999.0) == [] def test_lone_worker_fires_after_sustained_solo(): w = _watch(lone_confirm_s=30.0) zones = [_zone(min_workers=2)] assert w.update(zones, {"z1": {7}}, now=0.0) == [] # 방금 혼자가 됨 assert w.update(zones, {"z1": {7}}, now=29.0) == [] # 아직 유예 out = w.update(zones, {"z1": {7}}, now=31.0) assert out == [{"kind": "lone_worker", "zone_id": "z1", "track_id": 7, "metrics": {"workers": 1, "required": 2, "alone_s": 31.0}}] # 조건이 계속돼도 재발보 없음 (해소 후 재충족으로만) assert w.update(zones, {"z1": {7}}, now=60.0) == [] def test_colleague_return_resets_lone_timer(): w = _watch(lone_confirm_s=30.0) zones = [_zone(min_workers=2)] w.update(zones, {"z1": {7}}, now=0.0) w.update(zones, {"z1": {7, 8}}, now=20.0) # 동료 복귀 → 리셋 assert w.update(zones, {"z1": {7}}, now=40.0) == [] # 새로 30초 필요 assert len(w.update(zones, {"z1": {7}}, now=71.0)) == 1 def test_empty_zone_is_not_a_lone_violation(): w = _watch(lone_confirm_s=0.0) zones = [_zone(min_workers=2)] assert w.update(zones, {}, now=0.0) == [] assert w.update(zones, {}, now=100.0) == [] def test_overcapacity_fires_after_sustained_excess(): w = _watch(over_confirm_s=10.0) zones = [_zone(max_workers=2)] assert w.update(zones, {"z1": {1, 2, 3}}, now=0.0) == [] out = w.update(zones, {"z1": {1, 2, 3}}, now=11.0) assert out == [{"kind": "overcapacity", "zone_id": "z1", "track_id": None, "metrics": {"workers": 3, "limit": 2}}] # 해소 후 재충족이면 다시 발보 w.update(zones, {"z1": {1, 2}}, now=20.0) w.update(zones, {"z1": {1, 2, 3}}, now=30.0) assert len(w.update(zones, {"z1": {1, 2, 3}}, now=41.0)) == 1 def test_overstay_fires_per_track(): w = _watch() zones = [_zone(max_dwell_s=60)] w.update(zones, {"z1": {1}}, now=0.0) w.update(zones, {"z1": {1, 2}}, now=30.0) # 2번은 30초에 진입 out = w.update(zones, {"z1": {1, 2}}, now=61.0) # 1번만 60초 초과 assert [o["track_id"] for o in out] == [1] assert out[0]["kind"] == "overstay" out = w.update(zones, {"z1": {1, 2}}, now=91.0) # 이제 2번도 초과 assert [o["track_id"] for o in out] == [2] def test_dwell_survives_short_detection_dropout(): # run_once의 stale 필터로 1~2틱 미검출이 흔하다. grace 안의 부재는 체류 연속으로 # 간주해야 10분 규칙이 실사용에서 무력화되지 않는다. w = _watch(dwell_absence_grace_s=5.0) zones = [_zone(max_dwell_s=60)] w.update(zones, {"z1": {1}}, now=0.0) w.update(zones, {}, now=30.0) # 드롭아웃 (grace보다 김? 아님: 아래 재검출로 판단) out = w.update(zones, {"z1": {1}}, now=61.0) # 30.0의 부재는 last_seen(0.0)에서 5초 grace를 초과 → 새 체류로 리셋되었다. assert out == [] # 연속 체류가 실제로 60초를 넘기면 발보 w.update(zones, {"z1": {1}}, now=90.0) w.update(zones, {"z1": {1}}, now=120.0) assert len(w.update(zones, {"z1": {1}}, now=122.0)) == 1 def test_dwell_short_gap_within_grace_keeps_timer(): w = _watch(dwell_absence_grace_s=5.0) zones = [_zone(max_dwell_s=60)] w.update(zones, {"z1": {1}}, now=0.0) w.update(zones, {}, now=2.0) # 2초 드롭아웃 — grace 이내 out = w.update(zones, {"z1": {1}}, now=61.0) assert len(out) == 1 and out[0]["metrics"]["dwell_s"] == 61.0 def test_forget_clears_dwell_state(): w = _watch() zones = [_zone(max_dwell_s=60)] w.update(zones, {"z1": {1}}, now=0.0) w.forget(1) assert w.update(zones, {"z1": {1}}, now=61.0) == [] # 새 체류로 시작 def test_deleted_zone_state_is_pruned(): w = _watch(lone_confirm_s=0.0, over_confirm_s=0.0) zones = [_zone(min_workers=2, max_workers=1)] w.update(zones, {"z1": {1}}, now=0.0) w.update([], {}, now=1.0) # 존 삭제 (config 동기) assert w._lone_since == {} and w._over_since == {} assert w._lone_fired == set() and w._over_fired == set()