import unittest from collections import Counter from ccu_emulator.controller import CcuConfig, CcuEmulator from ccu_emulator.frames import ACTIVE_SEED_COMMAND0, NEUTRAL_ACK_FRAME, build_frame, frame_checksum_ok from ccu_emulator.iris_mblack_link import IrisMblackLinkModule from ccu_emulator.policy import AckPolicy from ccu_emulator.refresh import PeriodicRefresh from ccu_emulator.serial_link import RxFrame class CcuEmulatorFrameTests(unittest.TestCase): def test_build_frame_adds_checksum(self): self.assertEqual(build_frame(0x00, 0x0000, 0x8080), ACTIVE_SEED_COMMAND0) self.assertTrue(frame_checksum_ok(build_frame(0x06, 0x0015, 0x0001))) def test_ack_policy_acknowledges_reports_with_neutral_ack(self): decision = AckPolicy().decide(bytes.fromhex("02000200005A"), "connect_ok_path_response_candidate") self.assertTrue(decision.should_ack) self.assertEqual(decision.frame, NEUTRAL_ACK_FRAME) def test_ack_policy_skips_table_readback(self): decision = AckPolicy().decide(bytes.fromhex("04000080805E"), "table_readback_candidate") self.assertFalse(decision.should_ack) class PeriodicRefreshTests(unittest.TestCase): def test_periodic_refresh_returns_due_frames(self): refresh = PeriodicRefresh(frames=[ACTIVE_SEED_COMMAND0], interval=0.5) refresh.start(now=10.0) self.assertEqual(refresh.due_frames(now=10.4), []) self.assertEqual(refresh.due_frames(now=10.5), [ACTIVE_SEED_COMMAND0]) self.assertEqual(refresh.due_frames(now=10.6), []) class IrisMblackLinkModuleTests(unittest.TestCase): def test_active_report_gets_selector_ack_and_mirror(self): module = IrisMblackLinkModule(mirror_delay=0.012) decision = module.on_rx(bytes.fromhex("000013400009"), "known_iris_mblack_link_active_report_candidate") self.assertIsNotNone(decision) assert decision is not None self.assertTrue(decision.suppress_default_ack) self.assertEqual( [tx.frame for tx in decision.tx], [bytes.fromhex("05001300004C"), bytes.fromhex("000013400009")], ) self.assertEqual(decision.tx[1].delay, 0.012) self.assertIn("active", decision.reason) def test_clear_report_gets_selector_ack_and_mirror(self): module = IrisMblackLinkModule() decision = module.on_rx(bytes.fromhex("02001300004B"), "queued_iris_mblack_link_clear_report_candidate") self.assertIsNotNone(decision) assert decision is not None self.assertEqual( [tx.frame for tx in decision.tx], [bytes.fromhex("05001300004C"), bytes.fromhex("000013000049")], ) self.assertIn("clear", decision.reason) def test_non_0013_report_is_ignored(self): module = IrisMblackLinkModule() self.assertIsNone(module.on_rx(bytes.fromhex("0000158000CF"), "known_call_button_active_report")) class CcuEmulatorModuleIntegrationTests(unittest.TestCase): def test_module_response_suppresses_generic_neutral_ack(self): link = FakeLink(RxFrame(bytes.fromhex("000013400009"), "known_iris_mblack_link_active_report_candidate")) logger = FakeLogger() emulator = CcuEmulator( link, logger, config=CcuConfig(seed_frames=(), ready_heartbeats=0), modules=(IrisMblackLinkModule(mirror_delay=0.0),), ) emulator._service_rx() self.assertEqual( [frame for frame, _label in link.sent], [bytes.fromhex("05001300004C"), bytes.fromhex("000013400009")], ) self.assertEqual(emulator.stats.ack_frames, 0) self.assertEqual(emulator.stats.module_frames, 2) self.assertEqual(emulator.stats.tx_frames, 2) class FakeDetector: def __init__(self) -> None: self.labels = Counter() self.resync_events = 0 self.dropped_bytes = 0 class FakeLink: def __init__(self, *items: RxFrame) -> None: self.items = list(items) self.sent: list[tuple[bytes, str]] = [] self.detector = FakeDetector() def read_available(self) -> list[RxFrame]: if not self.items: return [] return [self.items.pop(0)] def send(self, frame: bytes, label: str) -> None: self.sent.append((frame, label)) class FakeLogger: def __init__(self) -> None: self.lines: list[str] = [] def event(self, text: str) -> None: self.lines.append(text) def emit(self, line: str = "") -> None: self.lines.append(line) if __name__ == "__main__": unittest.main()