#!/usr/bin/env python3 """Unit tests for the pure CI-V codec (civ.py). Stdlib only: `python3 -m unittest`.""" import unittest import civ class TestFreqCodec(unittest.TestCase): # Real capture from the radio: VFO was 147.300000 MHz. REAL_BCD = bytes.fromhex("0000304701") REAL_HZ = 147_300_000 def test_bcd_to_freq_decodes_real_capture(self): self.assertEqual(civ.bcd_to_freq(self.REAL_BCD), self.REAL_HZ) def test_freq_to_bcd_matches_real_capture(self): self.assertEqual(civ.freq_to_bcd(self.REAL_HZ), self.REAL_BCD) def test_freq_roundtrip(self): for hz in (146_520_000, 446_000_000, 147_300_000, 1_240_000_000): self.assertEqual(civ.bcd_to_freq(civ.freq_to_bcd(hz)), hz) class TestLevelCodec(unittest.TestCase): # ICOM levels are 0..255 sent as 2-byte BCD "0000".."0255". def test_level_to_bcd_known(self): self.assertEqual(civ.level_to_bcd(0), b"\x00\x00") self.assertEqual(civ.level_to_bcd(128), b"\x01\x28") self.assertEqual(civ.level_to_bcd(255), b"\x02\x55") def test_bcd_to_level_known(self): self.assertEqual(civ.bcd_to_level(b"\x00\x00"), 0) self.assertEqual(civ.bcd_to_level(b"\x01\x28"), 128) self.assertEqual(civ.bcd_to_level(b"\x02\x55"), 255) def test_level_rejects_out_of_range(self): with self.assertRaises(ValueError): civ.level_to_bcd(256) with self.assertRaises(ValueError): civ.level_to_bcd(-1) class TestBuildFrame(unittest.TestCase): def test_read_freq_frame(self): # FE FE 03 FD self.assertEqual(civ.build_frame(0x03), bytes.fromhex("fefe8ce003fd")) def test_frame_with_data(self): # set freq 147.3 MHz: FE FE 8C E0 05 <5 BCD> FD frame = civ.build_frame(0x05, civ.freq_to_bcd(147_300_000)) self.assertEqual(frame, bytes.fromhex("fefe8ce005" + "0000304701" + "fd")) class TestParseFrames(unittest.TestCase): # Exact bytes the radio returned during the probe (echo + reply). REAL_RX = bytes.fromhex("fefe8ce003fd" + "fefee08c030000304701fd") def test_splits_echo_and_reply(self): frames = civ.parse_frames(self.REAL_RX) self.assertEqual(len(frames), 2) echo, reply = frames self.assertEqual((echo.to, echo.frm, echo.cn), (0x8C, 0xE0, 0x03)) self.assertEqual(echo.data, b"") self.assertEqual((reply.to, reply.frm, reply.cn), (0xE0, 0x8C, 0x03)) self.assertEqual(reply.data, bytes.fromhex("0000304701")) def test_ignores_leading_noise_and_partial_tail(self): noisy = b"\x00\xff" + self.REAL_RX + b"\xfe\xfe\x99" # partial frame at end frames = civ.parse_frames(noisy) self.assertEqual(len(frames), 2) def test_empty_buffer(self): self.assertEqual(civ.parse_frames(b""), []) class TestOffsetCodec(unittest.TestCase): # PROVISIONAL: 3-byte little-endian BCD in units of 100 Hz. # Round-trip validated until pinned to a real capture (plan Task 6). def test_offset_roundtrip(self): for hz in (600_000, 5_000_000, 1_600_000, 0): self.assertEqual(civ.bcd_to_offset(civ.offset_to_bcd(hz)), hz) def test_offset_is_three_bytes(self): self.assertEqual(len(civ.offset_to_bcd(600_000)), 3) def test_offset_rejects_negative(self): with self.assertRaises(ValueError): civ.offset_to_bcd(-1) class TestToneCodec(unittest.TestCase): # ICOM convention: CTCSS tone as 2-byte BCD of tone*10. 131.8 -> "13 18". def test_tone_to_bcd_known(self): self.assertEqual(civ.tone_to_bcd(88.5), b"\x08\x85") self.assertEqual(civ.tone_to_bcd(131.8), b"\x13\x18") self.assertEqual(civ.tone_to_bcd(100.0), b"\x10\x00") def test_bcd_to_tone_known(self): self.assertAlmostEqual(civ.bcd_to_tone(b"\x13\x18"), 131.8) self.assertAlmostEqual(civ.bcd_to_tone(b"\x08\x85"), 88.5) def test_tone_roundtrip(self): for hz in (67.0, 88.5, 131.8, 146.2, 203.5): self.assertAlmostEqual(civ.bcd_to_tone(civ.tone_to_bcd(hz)), hz) if __name__ == "__main__": unittest.main()