From af51b2657dda60abd71b3411a66e6bca5d95e494 Mon Sep 17 00:00:00 2001 From: The Dust Council Date: Fri, 21 Aug 2026 22:29:29 -0700 Subject: [PATCH] Make the simulator reproducible and widen the burst tests A transmitter seeded itself from the builtin hash() of its label, and that is salted per interpreter, so every run produced different synthetic speech and keying. A test that failed could not be made to fail again -- the one thing needed to fix it. crc32 gives the same content every run. The hang test then compounded it by listening for a 1 s burst in a 6 s cycle over three sweeps: it is the release after the transmission that is under test, but a missed burst failed it just the same, with "nothing recorded". Now 2 s in 5 s over six sweeps, so only the ending can fail it. The neighbouring test had the opposite fault: asserting only that nothing was truncated, it passed whether or not the burst was ever heard. It now requires the recording it is drawing a conclusion from. Co-Authored-By: Claude Opus 5 --- bandsaunter/simulator.py | 7 ++++++- tests/test_scanner.py | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/bandsaunter/simulator.py b/bandsaunter/simulator.py index 9513501..cd371e9 100755 --- a/bandsaunter/simulator.py +++ b/bandsaunter/simulator.py @@ -10,6 +10,7 @@ from __future__ import annotations import math import time +import zlib from dataclasses import dataclass, field import numpy as np @@ -115,7 +116,11 @@ class VirtualTransmitter: _rng: np.random.Generator = field(default=None, init=False, repr=False) def __post_init__(self): - self._seed = abs(hash(self.label or self.frequency)) % 2**31 + # crc32 rather than hash(): the builtin is salted per interpreter, so + # a test's synthetic speech and keying differed on every run and a + # failure could not be reproduced by running it again. + key = str(self.label or self.frequency).encode() + self._seed = zlib.crc32(key) self._rng = np.random.default_rng(self._seed) if not self.label: self.label = f"{self.frequency/1e6:.4f} MHz {self.mode}" diff --git a/tests/test_scanner.py b/tests/test_scanner.py index d5a9945..fe97d6b 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -52,11 +52,16 @@ def test_record_seconds_is_honoured_exactly(tmp_path): def test_hang_seconds_ends_a_finished_transmission(tmp_path): - """A transmitter that stops must release the scanner after the hang time.""" + """A transmitter that stops must release the scanner after the hang time. + + What is under test is the ending, not the catching, so the burst is a + generous 40% of the cycle over six sweeps: landing on it is meant to be + easy, and only the release afterwards should be able to fail this. + """ tx = [V(146_520_000, "nfm", 0.4, 12_500, "burst", - period_seconds=6.0, on_seconds=1.0)] + period_seconds=5.0, on_seconds=2.0)] s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", - record_seconds=20.0, hang_seconds=0.8, max_cycles=3) + record_seconds=20.0, hang_seconds=0.8, max_cycles=6) assert hits, "nothing recorded" ended_on_silence = [h for h in hits if "quiet" in h.stop_reason] assert ended_on_silence, [h.stop_reason for h in hits] @@ -369,9 +374,12 @@ def test_being_cut_off_mid_transmission_is_reported(tmp_path): def test_no_warning_when_the_signal_really_ended(tmp_path): tx = [V(146_520_000, "nfm", 0.4, 12_500, "burst", - period_seconds=12.0, on_seconds=2.0)] + period_seconds=5.0, on_seconds=2.0)] s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=20.0, - hang_seconds=1.0, max_cycles=3, revisit_seconds=0.1) + hang_seconds=1.0, max_cycles=6, revisit_seconds=0.1) + # Without a recording there is nothing to warn about either way, so the + # count alone would pass whether or not the burst was ever heard. + assert hits, "nothing recorded" assert s.stats.truncated == 0