import pytest from bandsaunter.bandplan import PRESETS, by_key, fmt_hz from bandsaunter.ranges import (RangeError, ScanRange, build_plan, parse_frequency, parse_range, parse_range_list) @pytest.mark.parametrize("text,expected", [ ("146.52M", 146_520_000), ("146520000", 146_520_000), ("433.92 MHz", 433_920_000), ("14074k", 14_074_000), ("1.09G", 1_090_000_000), ("162.4", 162_400_000), ("500Hz", 500), ]) def test_parse_frequency(text, expected): assert parse_frequency(text) == pytest.approx(expected) def test_parse_range_forms(): assert parse_range("144M-148M").start == 144e6 # the unit on the right end carries over to the left r = parse_range("144-148M") assert (r.start, r.stop) == (144e6, 148e6) r = parse_range("144M-148M/25k@nfm") assert r.step == 25_000 and r.mode == "nfm" assert parse_range("gmrs").preset_key == "gmrs" def test_single_frequency_gets_width(): r = parse_range("146.52M") assert r.stop > r.start def test_parse_range_list_unlimited(): rs = parse_range_list("144M-148M, 420-450M, gmrs, 162.4-162.55M, 88-108M") assert len(rs) == 5 def test_bad_input_raises(): with pytest.raises(RangeError): parse_range("banana") def test_plan_parks_lo_clear_of_the_covered_span(): """The DC spike must never land inside the frequencies being searched.""" steps = build_plan(parse_range_list("144.05M-144.15M"), 2_048_000, dc_guard=8000.0) assert len(steps) == 1 s = steps[0] assert s.center < s.low assert s.low - s.center == pytest.approx(8000.0) def test_plan_covers_whole_range_contiguously(): ranges = parse_range_list("144M-148M") steps = build_plan(ranges, 2_048_000) assert steps[0].low == pytest.approx(144e6) assert steps[-1].high == pytest.approx(148e6) for a, b in zip(steps, steps[1:]): assert a.high == pytest.approx(b.low) def test_band_plan_is_well_formed(): keys = [p.key for p in PRESETS] assert len(keys) == len(set(keys)), "duplicate preset keys" for p in PRESETS: assert p.stop > p.start, f"{p.key} has an empty span" assert p.mode in ("nfm", "wfm", "am", "usb", "lsb", "cw", "raw", "auto") # "auto" defers to the segments beneath it, so it must not also # declare a bandwidth that would be used in preference to theirs. if p.mode == "auto": assert p.bandwidth == 0, f"{p.key} both defers and dictates" assert by_key(p.key) is p def test_fmt_hz(): assert fmt_hz(146_520_000) == "146.52 MHz" assert fmt_hz(1_090_000_000) == "1.09 GHz" assert fmt_hz(500) == "500 Hz" # --------------------------------------------------------------------------- # Presets that stand for a set of others # --------------------------------------------------------------------------- def test_all_cw_expands_to_every_cw_segment(): from bandsaunter.bandplan import PRESETS, by_key group = by_key("all-cw") assert group is not None and group.is_group members = group.expand() # every CW preset in the plan should be in it, and nothing else cw_keys = {p.key for p in PRESETS if p.mode == "cw" and not p.is_group} assert {p.key for p in members} == cw_keys, "the group and the plan disagree" assert all(p.mode == "cw" for p in members) assert len(members) >= 10 def test_a_group_becomes_one_range_per_member(): ranges = parse_range_list("all-cw") assert len(ranges) >= 10 assert all(r.mode == "cw" for r in ranges) # separate ranges, not one span from the lowest to the highest assert all(r.span < 1e6 for r in ranges), [r.span for r in ranges] assert sum(r.span for r in ranges) < 5e6 def test_a_group_declares_the_extent_of_its_members(): """The displayed span must not drift from what the group actually covers.""" from bandsaunter.bandplan import PRESETS for group in [p for p in PRESETS if p.is_group]: members = group.expand() assert group.start == pytest.approx(min(m.start for m in members)) assert group.stop == pytest.approx(max(m.stop for m in members)) def test_group_members_all_exist(): from bandsaunter.bandplan import PRESETS, by_key for group in [p for p in PRESETS if p.is_group]: for key in group.members: assert by_key(key) is not None, f"{group.key} names missing {key}" assert len(group.expand()) == len(group.members) def test_a_group_is_refused_where_only_one_range_fits(): with pytest.raises(RangeError, match="separate ranges"): parse_range("all-cw") def test_groups_do_not_label_detections(): """A group spans a huge range; it must not be used to name a signal.""" from bandsaunter.bandplan import presets_covering assert all(not p.is_group for p in presets_covering(14_050_000)) def test_a_group_mixes_with_ordinary_ranges(): ranges = parse_range_list("all-cw, 144M-148M, gmrs") labels = [r.label for r in ranges] assert "2 m CW" in labels assert "GMRS / FRS" in labels assert any("144 MHz-148 MHz" in x for x in labels) def test_the_cw_segments_are_inside_their_bands(): """A CW segment that strays outside its amateur band would be wrong.""" from bandsaunter.bandplan import by_key bands = {"160m-cw": "160m", "80m-cw": "80m", "40m-cw": "40m", "20m-cw": "20m", "17m-cw": "17m", "15m-cw": "15m", "12m-cw": "12m", "10m-cw": "10m", "6m-cw": "6m", "2m-cw": "2m"} for cw_key, band_key in bands.items(): cw, band = by_key(cw_key), by_key(band_key) assert cw.start >= band.start, cw_key assert cw.stop <= band.stop, cw_key # --------------------------------------------------------------------------- # Whole amateur bands # --------------------------------------------------------------------------- COMPLETE_BANDS = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "1.25m", "70cm", "33cm"] @pytest.mark.parametrize("band", COMPLETE_BANDS) def test_every_amateur_band_has_a_complete_entry(band): from bandsaunter.bandplan import by_key whole = by_key(f"{band}-complete") assert whole is not None, f"no {band}-complete" assert whole.category == "Amateur Radio" assert whole.mode == "auto" # It must actually cover the band it names. plain = by_key(band) assert whole.start <= plain.start and whole.stop >= plain.stop @pytest.mark.parametrize("band", COMPLETE_BANDS) def test_a_complete_band_resolves_a_real_mode_everywhere(band): """"auto" must never survive to the demodulator.""" ranges = parse_range_list(f"{band}-complete") assert len(ranges) == 1 r = ranges[0] step = r.span / 200 freq = r.start while freq < r.stop: mode = r.resolved_mode(freq) assert mode in ("nfm", "wfm", "am", "usb", "lsb", "cw", "raw"), \ f"{band} at {freq}: {mode}" assert r.resolved_bandwidth(freq) > 0, f"{band} at {freq}" freq += step def test_2m_complete_covers_cw_then_ssb_then_fm(): r = parse_range_list("2m-complete")[0] assert r.resolved_mode(144_050_000) == "cw" assert r.resolved_mode(144_200_000) == "usb" assert r.resolved_mode(146_520_000) == "nfm" def test_70cm_complete_is_not_hijacked_by_the_ism_band(): """433 MHz is shared; inside an amateur sweep the amateur reading wins.""" r = parse_range_list("70cm-complete")[0] assert r.resolved_mode(433_920_000) == "nfm" assert r.resolved_mode(432_050_000) == "cw" assert r.resolved_mode(432_200_000) == "usb" def test_a_plain_range_still_gets_the_ism_reading(): assert ScanRange(433.9e6, 433.95e6, mode="auto").resolved_mode(433.92e6) \ == "raw" def test_complete_bands_are_offered_where_they_would_be_looked_for(): from bandsaunter.bandplan import in_category keys = {p.key for p in in_category("Amateur Radio")} assert {f"{b}-complete" for b in COMPLETE_BANDS} <= keys