#!/usr/bin/env python3 """ test_qso_engine.py — Zestaw testow maszyny stanow QSO (HAMCTRL). Uruchomienie: python3 test_qso_engine.py Cel: zlapac REGRESJE w logice QSO PRZED wgraniem na produkcje. Kazda naprawa z sesji ma tu swoj test — jesli ktos przypadkiem cofnie poprawke, test to wychwyci od razu, zamiast lapac blad na pasmie. Pokrycie: - parse_message: wszystkie formaty (CQ, grid, raport, R-raport, RRR/RR73/73) - Pelne QSO oba kierunki (my wolamy / my CQ) - Przypadki brzegowe naprawione w sesji: * przedluzanie po DONE (echo 73/RR73/RRR -> cisza) * powtorka wolania partnera (grid w REPORT_SENT -> powtorz raport) * zamrozenie raportu SNR (staly przez cale QSO) - Brak kolejki Call 1st: obca stacja w trakcie zajetego QSO jest ignorowana - Reset stanu miedzy QSO Zwraca exit code 0 gdy wszystko OK, 1 gdy sa bledy (do CI/skryptow). """ import sys import os import time import importlib.util # ── Zaladuj qso_engine.py jako modul ──────────────────────────────────────── # Szukamy qso_engine.py OBOK tego pliku (nie w katalogu biezacym), zeby test # dzialal niezaleznie skad go uruchomisz (np. z pulpitu, nie tylko z D:\HAMCTRL). _here = os.path.dirname(os.path.abspath(__file__)) _engine_path = os.path.join(_here, "qso_engine.py") if not os.path.exists(_engine_path): print(f"BLAD: nie znaleziono qso_engine.py obok testu ({_engine_path}).") print("Umiesc test_qso_engine.py w tym samym katalogu co qso_engine.py.") sys.exit(2) _spec = importlib.util.spec_from_file_location("qso_engine", _engine_path) _qe = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_qe) QsoEngine = _qe.QsoEngine pick_best_caller = _qe.pick_best_caller parse_message = _qe.parse_message is_cq_modifier = _qe.is_cq_modifier ST_IDLE = _qe.ST_IDLE ST_CALLING = _qe.ST_CALLING ST_REPORT_SENT = _qe.ST_REPORT_SENT ST_RRR_SENT = _qe.ST_RRR_SENT ST_DONE = _qe.ST_DONE # ── Infrastruktura testowa ────────────────────────────────────────────────── _passed = 0 _failed = 0 _fail_details = [] def check(cond, name): global _passed, _failed if cond: _passed += 1 else: _failed += 1 _fail_details.append(name) print(f" FAIL: {name}") def section(title): print(f"\n=== {title} ===") def _fmt_report(snr): """Odtwarza _format_report z webapp.py (dla testow zamrozenia).""" return f"{max(-30, min(49, int(round(snr)))):+03d}" def _dispatch(eng, msg, snr=0): """ Symuluje pelna sciezke webapp._maybe_measure_and_send Z ZAMROZENIEM: on_decode + podstawienie/zamrozenie raportu. Zwraca (akcja, tekst_raportu). """ parsed = parse_message(msg) if parsed is None: return None, None result = eng.on_decode(parsed) if result is None: return None, None if result.get("needs_measured_report"): result = dict(result) frozen = eng.partner_report_sent if frozen: result["report_or_grid"] = frozen else: result["report_or_grid"] = _fmt_report(snr) eng.record_sent_report(result["report_or_grid"]) return result, result.get("report_or_grid") # ════════════════════════════════════════════════════════════════════════════ # 1. PARSOWANIE WIADOMOSCI # ════════════════════════════════════════════════════════════════════════════ def test_parsing(): section("Parsowanie wiadomosci") p = parse_message("CQ SP9XYZ JO90") check(p is not None and p["is_cq"], "CQ: rozpoznane jako CQ") check(p["call_de"] == "SP9XYZ", "CQ: call_de = SP9XYZ") check(p["extra"] == "JO90", "CQ: grid = JO90") p = parse_message("SQ3MZM SP9XYZ JO90") check(p is not None and not p["is_cq"], "Grid: nie-CQ") check(p["call_to"] == "SQ3MZM", "Grid: call_to = SQ3MZM") check(p["call_de"] == "SP9XYZ", "Grid: call_de = SP9XYZ") check(p["extra"] == "JO90", "Grid: extra = JO90") p = parse_message("SQ3MZM SP9XYZ -12") check(p is not None and p["report"] == "-12", "Raport: -12 rozpoznany") check(not p["is_rrr"] and not p["is_73"], "Raport: nie RRR/73") p = parse_message("SQ3MZM SP9XYZ R-08") check(p is not None and p["report"] == "R-08", "R-raport: R-08 rozpoznany") p = parse_message("SQ3MZM SP9XYZ RRR") check(p is not None and p["is_rrr"], "RRR rozpoznane") p = parse_message("SQ3MZM SP9XYZ RR73") check(p is not None and p["is_rr73"], "RR73 rozpoznane") p = parse_message("SQ3MZM SP9XYZ 73") check(p is not None and p["is_73"], "73 rozpoznane") check(parse_message("") is None, "Pusta wiadomosc -> None") check(parse_message("XYZ") is None, "Pojedynczy token -> None") # CQ z modifierem programu aktywacyjnego (2026-08-15: front i backend # traktowaly modifier jako call_de, bo "SOTA"/"POTA" (4 znaki) nie # laczyly sie z heurystyka len<=3 - naprawiono do len<=6, tu blokujemy # regresje na kilku z calej rodziny "*OTA", nie tylko na tych dwoch co # akurat byly na sztywnej whiteliscie. p = parse_message("CQ SOTA 5B4AMX KM65") check(p is not None and p["call_de"] == "5B4AMX", "CQ SOTA: call_de = 5B4AMX (nie 'SOTA')") check(p["cq_modifier"] == "SOTA", "CQ SOTA: modifier rozpoznany") p = parse_message("CQ POTA W1XYZ FN42") check(p is not None and p["call_de"] == "W1XYZ", "CQ POTA: call_de = W1XYZ (nie 'POTA')") p = parse_message("CQ BOTA DL1ABC JO62") check(p is not None and p["call_de"] == "DL1ABC", "CQ BOTA (nie na whiteliscie): call_de = DL1ABC (nie 'BOTA') - fallback len<=6") check(p["cq_modifier"] == "BOTA", "CQ BOTA: modifier rozpoznany przez fallback") check(is_cq_modifier("SOTA"), "is_cq_modifier: SOTA (whitelist)") check(is_cq_modifier("BOTA"), "is_cq_modifier: BOTA (fallback <=6 liter)") check(is_cq_modifier("GOTA"), "is_cq_modifier: GOTA (fallback <=6 liter)") check(not is_cq_modifier("SP3GSK"), "is_cq_modifier: SP3GSK (ma cyfre) -> NIE modifier") check(not is_cq_modifier("K7RA"), "is_cq_modifier: K7RA (ma cyfre) -> NIE modifier") # ════════════════════════════════════════════════════════════════════════════ # 2. PELNE QSO — MY ODPOWIADAMY NA CQ # ════════════════════════════════════════════════════════════════════════════ def test_full_qso_we_answer(): section("Pelne QSO: my odpowiadamy na CQ SP9XYZ") eng = QsoEngine("SQ3MZM", "JO82") eng.start_qso("SP9XYZ", parse_message("CQ SP9XYZ JO90")) check(eng.state == ST_CALLING, "Po start_qso: CALLING") # Partner daje nam raport -12; slyszymy go jako -08 -> nasz raport zamrozi na -08 act, rpt = _dispatch(eng, "SQ3MZM SP9XYZ -12", snr=-8) check(act is not None, "Raport partnera: jest akcja (nie cisza)") check(rpt == "-08", "Nasz raport zamrozony na -08 (nasz pomiar)") check(eng.state == ST_REPORT_SENT, "Po raporcie: REPORT_SENT") # Partner RR73 -> my 73, DONE act, rpt = _dispatch(eng, "SQ3MZM SP9XYZ RR73", snr=-8) check(act is not None and rpt == "73", "RR73 partnera -> nasze 73") check(act.get("qso_complete"), "QSO oznaczone jako complete") check(eng.state == ST_DONE, "Po RR73: DONE") # ════════════════════════════════════════════════════════════════════════════ # 3. PELNE QSO — MY NADAJEMY CQ, PARTNER ODPOWIADA # ════════════════════════════════════════════════════════════════════════════ def test_full_qso_we_cq(): section("Pelne QSO: my CQ, SP9XYZ odpowiada") eng = QsoEngine("SQ3MZM", "JO82") # Partner odpowiada na nasze CQ swoim gridem -> start_qso zwraca akcje raport act = eng.start_qso("SP9XYZ", parse_message("SQ3MZM SP9XYZ JO90")) check(act is not None and act.get("needs_measured_report"), "start_qso z gridem -> akcja raport (needs_measured)") check(eng.state == ST_REPORT_SENT, "Po start z gridem: REPORT_SENT") # Partner R-08 -> od razu nasze RR73, QSO zakonczone (skracamy o jeden # cykl - nie czekamy juz na osobne 73 partnera, patrz test nizej) act, rpt = _dispatch(eng, "SQ3MZM SP9XYZ R-08", snr=-8) check(act is not None and rpt == "RR73", "R-08 partnera -> nasze RR73 (bez osobnego RRR)") check(act.get("qso_complete"), "QSO oznaczone jako complete od razu po RR73") check(eng.state == ST_DONE, "Po R-raporcie: od razu DONE (nie RRR_SENT)") # ════════════════════════════════════════════════════════════════════════════ # 4. REGRESJA: przedluzanie po DONE (naprawa sesji) # ════════════════════════════════════════════════════════════════════════════ def test_no_extending_after_done(): section("Regresja: brak przedluzania QSO po DONE") eng = QsoEngine("SQ3MZM", "JO82") eng.start_qso("SP9XYZ", parse_message("CQ SP9XYZ JO90")) _dispatch(eng, "SQ3MZM SP9XYZ -12", snr=-8) _dispatch(eng, "SQ3MZM SP9XYZ RR73", snr=-8) # -> DONE check(eng.state == ST_DONE, "Osiagnieto DONE") # Echo 73 po DONE -> CISZA (nie nadawaj 73 w kolko) act, _ = _dispatch(eng, "SQ3MZM SP9XYZ 73", snr=-8) check(act is None, "Echo 73 po DONE -> cisza") # Echo RR73 po DONE -> CISZA act, _ = _dispatch(eng, "SQ3MZM SP9XYZ RR73", snr=-8) check(act is None, "Echo RR73 po DONE -> cisza") # Echo RRR po DONE -> CISZA act, _ = _dispatch(eng, "SQ3MZM SP9XYZ RRR", snr=-8) check(act is None, "Echo RRR po DONE -> cisza") def test_no_loop_after_abort_on_partner_echo(): section("Regresja: brak petli 73<->73 po abort_qso() (zgloszenie live 2026-08-26)") # Odtwarza DOKLADNIE sciezke webapp.py: po qso_complete=True webapp.py # woła engine.abort_qso() natychmiast (zeby zwolnic partner_call/queue # dla nastepnej stacji), ZANIM ewentualne echo 73 od korespondenta # (typowe na FT8 - stacja grzecznie powtarza 73) zdazy dotrzec. Bez # poprawki: skoro partner_call=None po abort_qso(), silnik traktowal # to echo jak NOWA stacja wolajaca -> start_qso -> odpowiadal WLASNYM # 73 -> qso_complete=True ZNOWU -> webapp.py loguje duplikat i znow # abort_qso() -> petla bez konca na kazdym kolejnym "73" korespondenta. eng = QsoEngine("SQ3MZM", "JO82") eng.start_qso("SP9XYZ", parse_message("CQ SP9XYZ JO90")) _dispatch(eng, "SQ3MZM SP9XYZ -12", snr=-8) act, _ = _dispatch(eng, "SQ3MZM SP9XYZ RR73", snr=-8) # -> DONE, my 73 check(act is not None and act.get("qso_complete"), "RR73 -> nasze 73, qso_complete") # webapp.py: QSO zalogowane, natychmiastowy abort_qso() (jak w _process_auto_qso) eng.abort_qso() check(eng.state == ST_IDLE, "Po abort_qso(): IDLE") check(eng.partner_call is None, "Po abort_qso(): brak partnera") # Korespondent grzecznie powtarza 73 (echo, nie nowe wolanie) - dociera # PO abort_qso(), zanim jakakolwiek nowa stacja zdazyla sie zglosic. result = eng.on_decode(parse_message("SQ3MZM SP9XYZ 73")) check(result is None, "Echo 73 po abort_qso() -> cisza (NIE nowe QSO)") check(eng.state == ST_IDLE, "Stan pozostaje IDLE (nie CALLING/DONE)") check(eng.partner_call is None, "Partner nadal None (nie wystartowalo nowe QSO)") # To samo dla RR73 i RRR jako "echo po abort" - zaden nie powinien # startowac nowego QSO. result = eng.on_decode(parse_message("SQ3MZM SP9XYZ RR73")) check(result is None, "Echo RR73 po abort_qso() -> cisza") result = eng.on_decode(parse_message("SQ3MZM SP9XYZ RRR")) check(result is None, "Echo RRR po abort_qso() -> cisza") # ════════════════════════════════════════════════════════════════════════════ # 5. REGRESJA: powtorka wolania partnera (naprawa sesji) # ════════════════════════════════════════════════════════════════════════════ def test_repeated_call(): section("Regresja: powtorka wolania partnera (automat nie moze milczec)") eng = QsoEngine("SQ3MZM", "JO82") # Partner wola nas gridem (Call 1st) act = eng.start_qso("SP9XYZ", parse_message("SQ3MZM SP9XYZ JO90")) check(act is not None, "1. wolanie -> akcja raport") # zamroz raport if act.get("needs_measured_report"): eng.record_sent_report(_fmt_report(3)) check(eng.state == ST_REPORT_SENT, "Po 1. wolaniu: REPORT_SENT") # Partner POWTARZA grid (nie uslyszal) -> musimy POWTORZYC raport (nie cisza) act, rpt = _dispatch(eng, "SQ3MZM SP9XYZ JO90", snr=3) check(act is not None, "Powtorka wolania -> akcja (NIE cisza)") check(rpt == "+03", "Powtorka -> ten sam zamrozony raport +03") # I jeszcze raz act, rpt = _dispatch(eng, "SQ3MZM SP9XYZ JO90", snr=3) check(act is not None and rpt == "+03", "Druga powtorka -> nadal +03") # Partner w koncu uslyszal -> R-raport -> od razu nasze RR73, koniec QSO act, rpt = _dispatch(eng, "SQ3MZM SP9XYZ R+03", snr=3) check(act is not None and rpt == "RR73", "Po R-raporcie -> RR73 (QSO zakonczone)") check(eng.state == ST_DONE, "Po R-raporcie: DONE") # ════════════════════════════════════════════════════════════════════════════ # REGRESJA: partner powtarza w kolko (grid LUB surowy raport), nigdy nie # potwierdza -> automat poddaje sie po MAX_REPORT_REPEATS, nie powtarza # raportu w nieskonczonosc (2026-09-03). Live report: "automat nie # powtarzal w kolko raportu do klienta jesli powtorzylismy 3 razy i nam # nie potwierdzil powinien nastapic stop automatycznie". Przed poprawka # retry_count (uzywany przez CISZE - brak jakiejkolwiek odpowiedzi) resetowal # sie na KAZDA wiadomosc od partnera, WLACZNIE z bezuzytecznymi powtorkami # - wiec automat mogl powtarzac swoj raport w nieskonczonosc, dopoki partner # cokolwiek nadawal, nawet jesli nigdy nie potwierdzal. # ════════════════════════════════════════════════════════════════════════════ def test_give_up_after_stuck_report_repeats_grid(): section("Regresja: partner w kolko powtarza GRID, nigdy nie potwierdza -> stop po 3 probach") eng = QsoEngine("SQ3MZM", "JO82") act = eng.start_qso("SP9XYZ", parse_message("SQ3MZM SP9XYZ JO90")) check(act is not None, "1. wolanie -> akcja raport") if act.get("needs_measured_report"): eng.record_sent_report(_fmt_report(3)) check(eng.state == ST_REPORT_SENT, "Po 1. wolaniu: REPORT_SENT") # 3 powtorki grida partnera, nigdy R-raportu -> 1., 2., 3. powtorka nadal # odpowiadamy raportem (repeat_count 1,2,3 wciaz < 3 w momencie sprawdzenia) for i in range(3): act, rpt = _dispatch(eng, "SQ3MZM SP9XYZ JO90", snr=3) check(act is not None and act.get("action") == "reply" and rpt == "+03", f"Powtorka {i+1}/3 grida -> nadal odpowiadamy +03") check(eng.state == ST_REPORT_SENT, f"Po powtorce {i+1}: nadal REPORT_SENT") # 4. powtorka (3 realne powtorki juz wykorzystane) -> STOP, nie kolejny raport act = eng.on_decode(parse_message("SQ3MZM SP9XYZ JO90")) check(act is not None and act.get("action") == "give_up", "4. powtorka grida (po 3 nieudanych probach) -> give_up, nie kolejny raport") check(act.get("call_de") == "SP9XYZ", "give_up zawiera porzucanego partnera") check(eng.state == ST_IDLE, "Po give_up: IDLE (wolni dla nastepnej stacji)") check(eng.partner_call is None, "Po give_up: brak partnera (abort_qso wykonany)") def test_give_up_after_stuck_report_repeats_raw_report(): section("Regresja: partner w kolko powtarza SUROWY raport (nie R+), nigdy nie potwierdza -> stop po 3 probach") eng = QsoEngine("SQ3MZM", "JO82") eng.start_qso("SP9XYZ", parse_message("CQ SP9XYZ JO90")) # Pierwszy surowy raport partnera -> nasz raport z r_flag=True (R+raport # w eterze - "R" to osobny bit kodowany przez r_flag, nie prefiks w # samym tekscie report_or_grid, patrz test_full_qso_we_answer wyzej) act, rpt = _dispatch(eng, "SQ3MZM SP9XYZ -12", snr=-8) check(act is not None and act.get("r_flag") is True and rpt == "-08", "1. surowy raport partnera -> nasz raport -08 z r_flag=True") check(eng.state == ST_REPORT_SENT, "Po 1. raporcie: REPORT_SENT") # Partner nie uslyszal naszego R+raportu i powtarza SWOJ surowy raport # 3 razy -> wciaz odpowiadamy (repeat_count 1,2,3) for i in range(3): act, rpt = _dispatch(eng, "SQ3MZM SP9XYZ -12", snr=-8) check(act is not None and act.get("action") == "reply", f"Powtorka {i+1}/3 surowego raportu -> nadal odpowiadamy") check(eng.state == ST_REPORT_SENT, f"Po powtorce {i+1}: nadal REPORT_SENT") # 4. powtorka -> STOP act = eng.on_decode(parse_message("SQ3MZM SP9XYZ -12")) check(act is not None and act.get("action") == "give_up", "4. powtorka surowego raportu (po 3 probach) -> give_up") check(eng.state == ST_IDLE, "Po give_up: IDLE") check(eng.partner_call is None, "Po give_up: brak partnera") # ════════════════════════════════════════════════════════════════════════════ # 6. REGRESJA: zamrozenie raportu SNR (naprawa sesji) # ════════════════════════════════════════════════════════════════════════════ def test_frozen_report(): section("Regresja: raport SNR zamrozony przez cale QSO") eng = QsoEngine("SQ3MZM", "JO82") eng.start_qso("SP9XYZ", parse_message("CQ SP9XYZ JO90")) # 1. raport partnera, slyszymy -10 -> zamrozi na -10 _, r1 = _dispatch(eng, "SQ3MZM SP9XYZ -12", snr=-10) check(r1 == "-10", "1. raport zamrozony na -10") # SNR partnera SKACZE (-3), ale raport ma zostac -10 _, r2 = _dispatch(eng, "SQ3MZM SP9XYZ -12", snr=-3) check(r2 == "-10", "SNR skoczyl na -3, raport WCIAZ -10 (zamrozony)") # SNR znowu inny (-18), raport nadal -10 _, r3 = _dispatch(eng, "SQ3MZM SP9XYZ -12", snr=-18) check(r3 == "-10", "SNR -18, raport WCIAZ -10") # partner_report_sent (uzywany przez log) = ta sama wartosc check(eng.partner_report_sent == "-10", "Log (partner_report_sent) = -10, spojne") # Nowe QSO liczy SWIEZY raport (reset) eng.abort_qso() eng.start_qso("DL1ABC", parse_message("CQ DL1ABC JN40")) _, rn = _dispatch(eng, "SQ3MZM DL1ABC -05", snr=-7) check(rn == "-07", "Nowe QSO -> swiezy raport -07 (nie -10)") # ════════════════════════════════════════════════════════════════════════════ # 7. RAPORT PARTNERA (RST_RCVD) — ostatnia wartosc # ════════════════════════════════════════════════════════════════════════════ def test_partner_report_last_value(): section("Raport partnera (RST_RCVD) = ostatnia jego wartosc") eng = QsoEngine("SQ3MZM", "JO82") eng.start_qso("SP9XYZ", parse_message("CQ SP9XYZ JO90")) _dispatch(eng, "SQ3MZM SP9XYZ -12", snr=-8) check(eng.partner_report_recv == "-12", "Partner dal -12 -> zapisane") # Partner koryguje na -05 _dispatch(eng, "SQ3MZM SP9XYZ -05", snr=-8) check(eng.partner_report_recv == "-05", "Partner skorygowal -> -05 (ostatnia)") # ════════════════════════════════════════════════════════════════════════════ # 8. BRAK KOLEJKI Call 1st (usunieta 2026-08-26, live feedback: "kolejka nie # ma sensu przy 4 stacjach w kolejce nikt nie bedzie czekal 4 minut") # ════════════════════════════════════════════════════════════════════════════ def test_no_queue_busy_caller_ignored(): section("Brak kolejki: stacja wolajaca gdy jestesmy zajeci -> ignorowana") eng = QsoEngine("SQ3MZM", "JO82") eng.start_qso("SP9XYZ", parse_message("CQ SP9XYZ JO90")) check(eng.state == ST_CALLING, "Zajeci: QSO z SP9XYZ w toku") # Inna stacja odpowiada na nasze CQ, podczas gdy jestesmy zajeci SP9XYZ. result = eng.on_decode(parse_message("SQ3MZM SP1AAA JO70")) check(result is None, "Obca stacja w trakcie zajetego QSO -> cisza (brak kolejki)") check(eng.partner_call == "SP9XYZ", "Partner sie nie zmienil") # Konczymy QSO z SP9XYZ. _dispatch(eng, "SQ3MZM SP9XYZ -12", snr=-8) _dispatch(eng, "SQ3MZM SP9XYZ RR73", snr=-8) eng.abort_qso() check(eng.state == ST_IDLE, "Wolni po zakonczeniu QSO") # SP1AAA (ktora wolala nas w trakcie zajetosci) NIE zostala zapamietana # - dopiero SWIEZY dekod od niej (a nie ten sprzed chwili) dostaje odpowiedz. result = eng.on_decode(parse_message("SQ3MZM SP1AAA JO70")) check(result is not None and result.get("action") == "new_caller", "SP1AAA wola PONOWNIE gdy jestesmy wolni -> natychmiastowa odpowiedz") check(result.get("call_de") == "SP1AAA", "new_caller: poprawny call_de") # ════════════════════════════════════════════════════════════════════════════ # 8b. first_contact_at: logowany czas QSO to PIERWSZA odpowiedz partnera, # nie moment kiedy MY zaczelismy go wolac (naprawa live 2026-08-26: # "wolalem RI1FJL cale QSO trwa 5min? tyle ile wolalem") # ════════════════════════════════════════════════════════════════════════════ def test_first_contact_at_not_calling_start(): section("first_contact_at = pierwsza odpowiedz partnera, nie poczatek wolania") import time as _time # Scenariusz A: MY wolamy (klik na CQ), partner odpowiada DOPIERO PO # KILKU probach (retransmisjach) - typowe przy slabym sygnale/pileupie. eng = QsoEngine("SQ3MZM", "JO82") eng.start_qso("RI1FJL") # bez initial_decode -> stan CALLING, Tx1 do wyslania check(eng.state == ST_CALLING, "Po start_qso bez initial_decode: CALLING") check(eng.first_contact_at is None, "Zanim partner odpowie: first_contact_at = None (jeszcze nie wiadomo)") check(eng.started_at is not None, "started_at ustawiony od razu (kiedy MY zaczelismy wolac)") _time.sleep(0.05) # symuluje kilka okresow retransmisji bez odpowiedzi _calling_duration_before_reply = _time.time() - eng.started_at # Partner W KONCU odpowiada (dopiero teraz, po "kilku minutach" wolania) act, _ = _dispatch(eng, "SQ3MZM RI1FJL JO40", snr=-10) check(act is not None, "Partner odpowiedzial gridem -> akcja") check(eng.first_contact_at is not None, "Po odpowiedzi: first_contact_at ustawiony") check(eng.first_contact_at > eng.started_at, "first_contact_at PO started_at (odpowiedz przyszla PO rozpoczeciu wolania) - " "to jest CEL tej poprawki, nie powinny byc rowne przy realnym opoznieniu") check((eng.first_contact_at - eng.started_at) >= _calling_duration_before_reply * 0.9, "Odstep first_contact_at - started_at odzwierciedla realny czas wolania bez odpowiedzi") # first_contact_at NIE zmienia sie na kolejnych wiadomosciach od partnera _first = eng.first_contact_at _dispatch(eng, "SQ3MZM RI1FJL R-05", snr=-10) check(eng.first_contact_at == _first, "first_contact_at zamrozony na PIERWSZYM kontakcie, kolejne wiadomosci go nie zmieniaja") # Scenariusz B: partner JUZ odpowiedzial (initial_decode) - first_contact_at # i started_at powinny byc (prawie) rownoczesne, bo nie bylo fazy "samego wolania". eng2 = QsoEngine("SQ3MZM", "JO82") eng2.start_qso("RI1FJL", initial_decode=parse_message("SQ3MZM RI1FJL JO40")) check(eng2.first_contact_at is not None, "start_qso z initial_decode -> first_contact_at ustawiony od razu") check(abs(eng2.first_contact_at - eng2.started_at) < 0.01, "Bez fazy wolania: first_contact_at ~= started_at (ta sama chwila)") # Reset miedzy QSO: nowe QSO nie dziedziczy first_contact_at z poprzedniego eng2.abort_qso() check(eng2.first_contact_at is None, "Po abort_qso: first_contact_at wyzerowany") eng2.start_qso("DL1ABC") check(eng2.first_contact_at is None, "Nowe QSO (bez initial_decode): first_contact_at znowu None, nie zostaje ze starego") # ════════════════════════════════════════════════════════════════════════════ # 9. RESET STANU miedzy QSO # ════════════════════════════════════════════════════════════════════════════ def test_reset_between_qso(): section("Reset stanu miedzy QSO") eng = QsoEngine("SQ3MZM", "JO82") eng.start_qso("SP9XYZ", parse_message("CQ SP9XYZ JO90")) _dispatch(eng, "SQ3MZM SP9XYZ -12", snr=-8) eng.abort_qso() check(eng.state == ST_IDLE, "Po abort: IDLE") check(eng.partner_call is None, "Po abort: partner_call = None") check(eng.partner_report_sent is None, "Po abort: raport zresetowany") # ════════════════════════════════════════════════════════════════════════════ # 10. RETRANSMISJA / GIVE-UP — partner przestaje odpowiadac w trakcie QSO # ════════════════════════════════════════════════════════════════════════════ def test_retransmit_and_giveup(): section("Retransmisja ostatniej wiadomosci i porzucenie po limicie prob") eng = QsoEngine("SQ3MZM", "JO82") check(eng.should_retransmit(30.0) is False, "IDLE: nigdy nie trzeba retransmitowac") eng.start_qso("HB9CNU", parse_message("SQ3MZM HB9CNU JN37")) check(eng.state == ST_REPORT_SENT, "Po starcie z gridem partnera: REPORT_SENT") check(eng.should_retransmit(30.0) is False, "Przed pierwsza transmisja: nic do powtorzenia") eng.record_tx_sent() check(eng.should_retransmit(30.0) is False, "Zaraz po wyslaniu: za wczesnie na powtorke") eng.last_tx_at -= 31.0 # symuluj 31s bez odpowiedzi partnera check(eng.should_retransmit(30.0) is True, "Po pelnym okresie ciszy: pora powtorzyc") check(eng.should_give_up(4) is False, "retry_count=0: jeszcze nie poddajemy sie") for expected in (1, 2, 3, 4): eng.note_retry() check(eng.retry_count == expected, f"note_retry: licznik = {expected}") check(eng.should_give_up(4) is True, "Po 4 probach (limit=4): poddajemy sie") # Odpowiedz partnera zeruje licznik prob (dostal sygnal, warto sprobowac dalej) _dispatch(eng, "SQ3MZM HB9CNU -12", snr=-8) check(eng.retry_count == 0, "Odpowiedz partnera zeruje retry_count") eng.abort_qso() check(eng.should_retransmit(30.0) is False, "Po abort: znow IDLE, nic do powtorzenia") check(eng.retry_count == 0, "Po abort: licznik prob zresetowany") # ════════════════════════════════════════════════════════════════════════════ # 11. CALL 1ST: stacja odpowiada od razu raportem (pomija Tx1/grid) # ════════════════════════════════════════════════════════════════════════════ def test_call1st_start_with_raw_report(): section("Call 1st: wolajacy pomija grid, od razu raport (np. 'SQ3MZM XX0XXX -10')") eng = QsoEngine("SQ3MZM", "JO82") parsed = parse_message("SQ3MZM XX0XXX -10") check(parsed is not None and parsed.get("report") == "-10", "Parsowanie: wiadomosc z surowym raportem (bez R-prefix)") # Krok 1 (webapp.py _process_auto_qso): silnik w IDLE, wiadomosc od NOWEJ # stacji adresowana do nas -> sygnalizuje 'new_caller' (webapp.py # natychmiast wywoluje start_qso z tym samym dekodem). result = eng.on_decode(parsed) check(result is not None and result.get("action") == "new_caller", "IDLE + obca stacja z raportem -> 'new_caller' (tak jak przy CQ-odpowiedzi)") check(result.get("call_de") == "XX0XXX", "new_caller: poprawny call_de") # Krok 2 (webapp.py) -> natychmiast start_qso z TYM SAMYM dekodem jako # initial_decode, zeby nie wysylac zbednego Tx1/grid (partner juz # przeslal cos wiecej niz CQ). start_result = eng.start_qso("XX0XXX", initial_decode=parsed) check(start_result is not None and start_result.get("action") == "reply", "start_qso(initial_decode=raport) -> od razu akcja 'reply' (pomija Tx1)") check(start_result.get("needs_measured_report") is True, "reply wymaga podstawienia ZMIERZONEGO SNR (nasz pomiar, nie ich)") check(start_result.get("r_flag") is True, "r_flag=True -> odpowiadamy z prefixem R (potwierdzenie + nasz raport)") check(eng.state == ST_REPORT_SENT, "Po starcie z samym raportem: REPORT_SENT") check(eng.partner_call == "XX0XXX", "partner_call ustawiony poprawnie") # Automat powinien pociagnac QSO dalej normalnie az do konca. act, rpt = _dispatch(eng, "SQ3MZM XX0XXX RR73", snr=5) check(act is not None and rpt == "73", "RR73 partnera -> nasze 73") check(eng.state == ST_DONE, "QSO zakonczone poprawnie mimo pominietego Tx1/grid") # ════════════════════════════════════════════════════════════════════════════ # REGRESJA: odebrane RRR -> od razu 73, bez wlasnego echo RRR (naprawa sesji # 2026-08-15). Wczesniej odebranie literalnego "RRR" od partnera (w # odroznieniu od "R+raport") wysylalo WLASNE RRR z powrotem (stan RRR_SENT) # i dopiero echo TEGO RRR od partnera konczylo QSO wyslaniem 73 - zbedny # dodatkowy cykl. Zgloszone na zywo: "automat niepotrzebnie przedluza QSO, # jesli dostalem RRR to wysylam 73". # ════════════════════════════════════════════════════════════════════════════ def test_rrr_goes_straight_to_73(): section("Regresja: odebrane RRR -> od razu 73 (bez wlasnego echo RRR)") eng = QsoEngine("SQ3MZM", "JO82") eng.start_qso("SP9XYZ", parse_message("CQ SP9XYZ JO90")) _dispatch(eng, "SQ3MZM SP9XYZ -12", snr=-8) check(eng.state == ST_REPORT_SENT, "Po naszym R-raporcie: REPORT_SENT") act, rpt = _dispatch(eng, "SQ3MZM SP9XYZ RRR", snr=-8) check(act is not None and rpt == "73", "RRR partnera -> nasze 73 BEZPOSREDNIO (nie wlasne RRR)") check(act.get("qso_complete"), "QSO oznaczone jako complete po odebranym RRR") check(eng.state == ST_DONE, "Po odebranym RRR: od razu DONE (nie RRR_SENT)") # ════════════════════════════════════════════════════════════════════════════ # REGRESJA: R-raport partnera -> od razu nasze RR73, bez wlasnego RRR (2026- # 09-03). Symetria do testu wyzej, ale w drugim kierunku: to MY dostajemy # R+raport od partnera (potwierdzenie naszego raportu + jego wlasny) i to MY # decydujemy jak odpowiedziec. Wczesniej wysylalismy wlasne RRR (stan # RRR_SENT) i czekalismy na 73/RR73/RRR partnera, zanim w koncu koriczylismy # wlasnym 73 - dodatkowy cykl. Zadanie na zywo: "musimy pominac rrr zamienic # je od razu na rr73 skrocimy czas lacznosci". # ════════════════════════════════════════════════════════════════════════════ def test_r_report_goes_straight_to_rr73(): section("Regresja: R-raport partnera -> od razu nasze RR73 (bez wlasnego RRR)") eng = QsoEngine("SQ3MZM", "JO82") eng.start_qso("SP9XYZ", parse_message("SQ3MZM SP9XYZ JO90")) check(eng.state == ST_REPORT_SENT, "Po starcie z gridem: REPORT_SENT") act, rpt = _dispatch(eng, "SQ3MZM SP9XYZ R-08", snr=-8) check(act is not None and rpt == "RR73", "R-raport partnera -> nasze RR73 BEZPOSREDNIO (nie wlasne RRR)") check(act.get("qso_complete"), "QSO oznaczone jako complete od razu po naszym RR73") check(eng.state == ST_DONE, "Po R-raporcie partnera: od razu DONE (nie RRR_SENT)") # Ewentualne echo tego R-raportu (partner nie uslyszal naszego RR73 i # powtarza) po DONE -> cisza, nie wysylamy RR73 w kolko. act, _ = _dispatch(eng, "SQ3MZM SP9XYZ R-08", snr=-8) check(act is None, "Echo R-raportu po DONE -> cisza") # ════════════════════════════════════════════════════════════════════════════ # REGRESJA: partner nadaje juz do kogos innego -> porzucamy wolanie zamiast # slepo probowac dalej (naprawa sesji 2026-08-15). Zglaszane na zywo: # odpowiedzielismy na CQ, ale zanim partner nas uslyszal, zaczal QSO z inna # stacja - retry mechanizm i tak dalej go wolal, mimo widocznego dowodu w # dekodach ze jest juz zajety. # ════════════════════════════════════════════════════════════════════════════ def test_partner_busy_with_someone_else(): section("Regresja: partner nadaje do kogos innego -> porzucamy wolanie") eng = QsoEngine("SQ3MZM", "JO72") eng.start_qso("YO1BRANCUSI", parse_message("CQ YO1BRANCUSI KN34")) check(eng.state == ST_CALLING, "Po start_qso: CALLING") # YO1BRANCUSI nadaje do UR5WCS, nie do nas result = eng.on_decode(parse_message("UR5WCS YO1BRANCUSI -09")) check(result is not None and result.get("action") == "partner_busy", "Partner zajety inna stacja -> akcja partner_busy") check(result.get("call_de") == "YO1BRANCUSI", "partner_busy wskazuje wlasciwa (nasza) stacje") # Kontrola: wiadomosc miedzy DWIEMA OBCYMI stacjami (nie nasz partner) # nie powinna wywolywac partner_busy - to normalna aktywnosc pasma. eng2 = QsoEngine("SQ3MZM", "JO72") eng2.start_qso("YO1BRANCUSI", parse_message("CQ YO1BRANCUSI KN34")) result2 = eng2.on_decode(parse_message("UR5WCS DL1ABC -09")) check(result2 is None, "Wymiana miedzy obcymi stacjami -> cisza (nie partner_busy)") # ════════════════════════════════════════════════════════════════════════════ # Systematyczny przeglad scenariuszy (2026-08-24): partner ZNOWU wola CQ w # trakcie naszej z nim wymiany - silniejszy dowod ze "poszedl dalej" niz # partner_busy (odpowiada komus innemu), a wczesniej byl calkowicie # ignorowany (is_cq zawsze zwracalo None, bez sprawdzenia czy to nasz # partner). Routowane przez ten sam partner_busy (bounded retry, nie # natychmiastowy abort). # ════════════════════════════════════════════════════════════════════════════ def test_partner_calls_cq_again(): section("Partner wola CQ ponownie w trakcie QSO -> partner_busy") eng = QsoEngine("SQ3MZM", "JO72") eng.start_qso("YO1BRANCUSI", parse_message("CQ YO1BRANCUSI KN34")) check(eng.state == ST_CALLING, "Po start_qso: CALLING") result = eng.on_decode(parse_message("CQ YO1BRANCUSI KN34")) check(result is not None and result.get("action") == "partner_busy", "Partner znow wola CQ -> akcja partner_busy") check(result.get("call_de") == "YO1BRANCUSI", "partner_busy wskazuje wlasciwa (nasza) stacje") # Kontrola: CQ od OBCEJ stacji (nie naszego partnera) to normalna # aktywnosc pasma - nie powinno nic wywolywac. eng2 = QsoEngine("SQ3MZM", "JO72") eng2.start_qso("YO1BRANCUSI", parse_message("CQ YO1BRANCUSI KN34")) result2 = eng2.on_decode(parse_message("CQ DL1ABC JO40")) check(result2 is None, "CQ od obcej stacji -> cisza (nie partner_busy)") # Kontrola: CQ gdy jestesmy IDLE (bez aktywnego QSO) - bez zmian, # UI/webapp decyduje czy odpowiedziec. eng3 = QsoEngine("SQ3MZM", "JO72") result3 = eng3.on_decode(parse_message("CQ YO1BRANCUSI KN34")) check(result3 is None, "CQ gdy IDLE -> bez akcji silnika") # ════════════════════════════════════════════════════════════════════════════ # NOWA FUNKCJA: parse_dxpedition_message (typ 0.1, i3=0/n3=1) - wiadomosc # ktorej Fox uzywa zeby JEDNOCZESNIE zamknac QSO (RR73) i zaprosic kolejna # stacje z raportem, w jednej transmisji. Ten sam format wiadomosci uzywaja # stacje na MSHV w trybie "Multi Answering" nawet w zwyklych, codziennych # QSO (nie tylko prawdziwe DXpedycje) - stad silnik glownego automatu, nie # tylko Hound mode, musi to poprawnie rozumiec. # ════════════════════════════════════════════════════════════════════════════ def test_dxpedition_message_not_for_us(): section("parse_dxpedition_message: wiadomosc dotyczy dwoch INNYCH stacji") parse_dxpedition_message = _qe.parse_dxpedition_message result = parse_dxpedition_message("K1ABC", "W9XYZ", "KH1/KH7Z", "-08", "SQ3MZM") check(result is None, "Ani call_to ani call_de to nie my -> None") def test_dxpedition_message_we_get_report(): section("parse_dxpedition_message: jestesmy zapraszani z nowym raportem") parse_dxpedition_message = _qe.parse_dxpedition_message # call_to=K1ABC (inny Hound, dostaje RR73), call_de=SQ3MZM (my, dostajemy raport) parsed = parse_dxpedition_message("K1ABC", "SQ3MZM", "KH1/KH7Z", "-08", "SQ3MZM") check(parsed is not None, "Wiadomosc do nas -> sparsowana") check(parsed["call_to"] == "SQ3MZM", "call_to = my znak") check(parsed["call_de"] == "KH1/KH7Z", "call_de = prawdziwy nadawca (sender_call)") check(parsed["report"] == "-08", "Surowy raport bez R-prefix") check(not parsed["is_rr73"] and not parsed["is_73"] and not parsed["is_rrr"], "To NIE jest RR73/73/RRR - zwykly nowy raport") # Pelna integracja: IDLE -> powinno dac 'new_caller' eng = QsoEngine("SQ3MZM", "JO82") result = eng.on_decode(parsed) check(result is not None and result.get("action") == "new_caller", "Stacja MSHV zaprasza nas z raportem gdy IDLE -> new_caller") def test_dxpedition_message_we_get_rr73(): section("parse_dxpedition_message: dostajemy RR73 (konczy nasze QSO)") parse_dxpedition_message = _qe.parse_dxpedition_message # call_to=SQ3MZM (my, dostajemy RR73), call_de=W9XYZ (inny Hound, dostaje raport) parsed = parse_dxpedition_message("SQ3MZM", "W9XYZ", "DL1XYZ", "-13", "SQ3MZM") check(parsed is not None, "Wiadomosc do nas -> sparsowana") check(parsed["is_rr73"] is True, "To jest RR73") check(parsed["call_de"] == "DL1XYZ", "call_de = prawdziwy nadawca (sender_call)") # Pelna integracja: jestesmy W TRAKCIE QSO z DL1XYZ (np. stacja na MSHV # ktora dopiero co wyslala nam surowy raport zwykla wiadomoscia, my # odpowiedzielismy R+rpt, i TERAZ ona konczy QSO polaczona wiadomoscia # zamiast zwyklego "SQ3MZM DL1XYZ RR73"). eng = QsoEngine("SQ3MZM", "JO82") eng.start_qso("DL1XYZ", parse_message("CQ DL1XYZ JO60")) _dispatch(eng, "SQ3MZM DL1XYZ -12", snr=-8) check(eng.state == ST_REPORT_SENT, "Przed polaczona wiadomoscia: REPORT_SENT") result = eng.on_decode(parsed) check(result is not None and result.get("qso_complete"), "Polaczona wiadomosc RR73 od aktywnego partnera -> QSO complete") check(result.get("report_or_grid") == "73", "Odpowiadamy naszym 73") check(eng.state == ST_DONE, "Po polaczonym RR73: DONE") def test_dxpedition_message_unresolved_sender_falls_back(): section("parse_dxpedition_message: nierozpoznany hash Foxa (\"...\") -> fallback") parse_dxpedition_message = _qe.parse_dxpedition_message parsed = parse_dxpedition_message("SQ3MZM", "W9XYZ", "...", "-13", "SQ3MZM") check(parsed is not None, "Wiadomosc do nas -> sparsowana mimo nierozpoznanego hasha") check(parsed["call_de"] == "W9XYZ", "Fallback na call_de gdy sender_call nierozpoznany") def test_should_retransmit_slot(): section("should_retransmit_slot: retry wg siatki slotow (handoff item F)") window_s = 15.0 eng = QsoEngine("SQ3MZM", "JO82") check(eng.should_retransmit_slot(window_s) is False, "IDLE: nigdy nie trzeba retransmitowac") eng.start_qso("HB9CNU", parse_message("SQ3MZM HB9CNU JN37")) check(eng.should_retransmit_slot(window_s) is False, "Przed pierwsza transmisja: nic do powtorzenia") eng.record_tx_sent() # Zakotwicz last_tx_at do GRANICY aktualnego slotu (nie dowolnego # przesuniecia od "teraz") - test deterministyczny niezaleznie od tego, # w ktorym momencie realnego slotu akurat sie wykonuje. now_slot = int(time.time() // window_s) eng.last_tx_at = now_slot * window_s check(eng.should_retransmit_slot(window_s) is False, "Ten sam slot co 'teraz': za wczesnie na powtorke") eng.last_tx_at = (now_slot - 1) * window_s check(eng.should_retransmit_slot(window_s) is False, "1 slot wstecz (polowa wlasnego okresu): jeszcze za wczesnie") eng.last_tx_at = (now_slot - 2) * window_s check(eng.should_retransmit_slot(window_s) is True, "2 sloty wstecz (pelny wlasny okres minal): pora powtorzyc") eng.last_tx_at = (now_slot - 5) * window_s check(eng.should_retransmit_slot(window_s) is True, "Dawno minelo wiecej niz jeden okres: dalej True") eng.abort_qso() eng.last_tx_at = (now_slot - 5) * window_s check(eng.should_retransmit_slot(window_s) is False, "Po abort: znow IDLE, nic do powtorzenia niezaleznie od last_tx_at") def test_pick_best_caller(): section("pick_best_caller: wybor stacji z wielu wolajacych (handoff item E)") check(pick_best_caller([]) is None, "Pusta lista -> None") # Sama roznica SNR, obie nie w logu -> wygrywa wyzszy SNR cands = [ {"call_de": "SP1AAA", "snr": -10, "worked_before": False}, {"call_de": "SP2BBB", "snr": -3, "worked_before": False}, {"call_de": "SP3CCC", "snr": -20, "worked_before": False}, ] best = pick_best_caller(cands) check(best["call_de"] == "SP2BBB", "Najwyzszy SNR wygrywa, gdy nikt nie jest w logu") # Stacja NIE w logu wygrywa z duzo lepszym SNR stacji JUZ w logu cands = [ {"call_de": "WORKED", "snr": 10, "worked_before": True}, {"call_de": "NEW", "snr": -15, "worked_before": False}, ] best = pick_best_caller(cands) check(best["call_de"] == "NEW", "Stacja NIE w logu wygrywa mimo gorszego SNR") # Obie juz w logu -> SNR rozstrzyga cands = [ {"call_de": "A", "snr": -5, "worked_before": True}, {"call_de": "B", "snr": 2, "worked_before": True}, ] best = pick_best_caller(cands) check(best["call_de"] == "B", "Gdy obie w logu, SNR rozstrzyga") # Brakujace klucze (snr/worked_before) nie wywalaja wyjatku cands = [{"call_de": "X"}, {"call_de": "Y", "snr": 1}] best = pick_best_caller(cands) check(best is not None, "Brakujace klucze nie wywalaja wyjatku (domyslne wartosci)") def main(): print("╔══════════════════════════════════════════════════════╗") print("║ TEST SUITE — Maszyna stanow QSO (HAMCTRL) ║") print("╚══════════════════════════════════════════════════════╝") test_parsing() test_full_qso_we_answer() test_full_qso_we_cq() test_no_extending_after_done() test_no_loop_after_abort_on_partner_echo() test_repeated_call() test_give_up_after_stuck_report_repeats_grid() test_give_up_after_stuck_report_repeats_raw_report() test_frozen_report() test_partner_report_last_value() test_no_queue_busy_caller_ignored() test_first_contact_at_not_calling_start() test_reset_between_qso() test_retransmit_and_giveup() test_call1st_start_with_raw_report() test_rrr_goes_straight_to_73() test_r_report_goes_straight_to_rr73() test_partner_busy_with_someone_else() test_partner_calls_cq_again() test_dxpedition_message_not_for_us() test_dxpedition_message_we_get_report() test_dxpedition_message_we_get_rr73() test_dxpedition_message_unresolved_sender_falls_back() test_should_retransmit_slot() test_pick_best_caller() print("\n" + "═" * 56) total = _passed + _failed if _failed == 0: print(f" WYNIK: {_passed}/{total} — WSZYSTKO OK ✓") print(" Maszyna stanow QSO dziala poprawnie. Mozna wgrywac.") else: print(f" WYNIK: {_passed}/{total} — {_failed} BLEDOW ✗") print(" NIE WGRYWAJ — napraw bledy najpierw:") for d in _fail_details: print(f" - {d}") print("═" * 56) return 0 if _failed == 0 else 1 if __name__ == "__main__": sys.exit(main())