"""현장 경광등·부저 (GPIO). 폐쇄망에서 유일하게 네트워크 없이 동작하는 통보 경로다. 그래서 여기서 지켜야 할 성질은 "울린다"보다 "감지를 방해하지 않는다"에 가깝다: 배선이 없거나 고장 나도 파이프라인은 계속 돌아야 하고, 그 사실은 조용히 묻히면 안 된다. 실제로 핀 전압이 떨어지는지는 단위 테스트로 증명할 수 없다 — tests/hardware/ 의 OFF-HW 항목이다 (docs/hardware-bringup.md). """ from edge.beacon import GpioBeacon, NullBeacon, build_beacons, fire_all from edge.events import Event, EventType, Severity class _FakePin: def __init__(self): self.states = [] self.closed = False def set(self, high: bool): self.states.append(high) def close(self): self.closed = True @property def level(self): return self.states[-1] if self.states else None def _beacon(**over) -> tuple[GpioBeacon, _FakePin]: pin = _FakePin() kwargs = dict(mode="latch", hold_s=10.0, active_high=True, min_severity="high", backend=pin) kwargs.update(over) return GpioBeacon(7, **kwargs), pin def _event(sev: Severity) -> Event: return Event(EventType.FALL, sev, "cam-1", 1, None, 0.0, 0.0, {}, None) def test_fires_on_or_above_the_configured_severity(): b, pin = _beacon(min_severity="high") assert b.fire("critical", 0.0) is True assert b.fire("high", 0.0) is True assert b.fire("warning", 0.0) is False assert b.fire("info", 0.0) is False def test_active_low_wiring_is_inverted(): # 릴레이 모듈은 보통 LOW 에서 도통한다. 설정으로 뒤집지 못하면 그런 현장에서는 # 경광등이 평상시 켜져 있고 사고 때 꺼진다. low, low_pin = _beacon(active_high=False) low.fire("critical", 0.0) assert low_pin.level is False, "active_low: 켜짐 = LOW" high, high_pin = _beacon(active_high=True) high.fire("critical", 0.0) assert high_pin.level is True def test_hold_time_releases_the_latch(): b, pin = _beacon(hold_s=10.0) b.fire("critical", 0.0) b.tick(5.0) assert pin.level is True, "유지 시간 안에는 계속 울린다" b.tick(10.0) assert pin.level is False def test_a_new_event_extends_the_hold_instead_of_restarting_it(): # 사고가 이어지는 동안 경광등이 중간에 꺼졌다 켜지면 사람이 "끝났다"로 읽는다. b, pin = _beacon(hold_s=10.0) b.fire("critical", 0.0) b.fire("critical", 8.0) b.tick(11.0) assert pin.level is True, "연장되었으므로 아직 울린다" b.tick(18.0) assert pin.level is False def test_release_turns_it_off_and_closes_the_pin(): b, pin = _beacon() b.fire("critical", 0.0) b.release() assert pin.level is False assert pin.closed is True def test_fire_all_uses_the_worst_severity_present(): b, pin = _beacon(min_severity="critical") fired = fire_all([b], [_event(Severity.WARNING), _event(Severity.CRITICAL)], now=0.0) assert fired == 1, "가장 높은 심각도로 판단한다 — 목록의 첫 항목이 아니라" b2, _ = _beacon(min_severity="critical") assert fire_all([b2], [_event(Severity.WARNING), _event(Severity.HIGH)], now=0.0) == 0 def test_fire_all_is_a_no_op_without_beacons_or_events(): b, _ = _beacon() assert fire_all([], [_event(Severity.CRITICAL)], now=0.0) == 0 assert fire_all([b], [], now=0.0) == 0 assert fire_all(None, None, now=0.0) == 0 def test_null_beacon_satisfies_the_interface(): n = NullBeacon() assert n.fire("critical", 0.0) is False n.tick(1.0) n.release() assert n.available is False def test_unwired_pin_is_skipped_without_touching_hardware(): # gpio_pin=0 은 "관리 화면에서 핀을 아직 안 정했다"는 뜻이다. 열려고 시도하면 안 된다. logged = [] built = build_beacons({"beacons": [{"gpio_pin": 0, "mode": "latch"}]}, log=logged.append) assert built == [] assert logged == [], "미배선은 오류가 아니다" def test_failed_pin_is_logged_not_raised(): # 이 환경에는 GPIO 가 없다. 감지가 멈추면 안 되고, 동시에 조용히 묻혀서도 안 된다. logged = [] built = build_beacons({"beacons": [{"gpio_pin": 17, "mode": "latch"}]}, log=logged.append) assert built == [] assert any("17" in m for m in logged), "경광등이 죽은 사실은 반드시 로그에 남는다" def test_missing_beacon_config_is_a_no_op(): assert build_beacons({}) == [] assert build_beacons(None) == [] assert build_beacons({"beacons": None}) == [] assert build_beacons({"beacons": ["not-a-dict"]}) == []