diff --git a/ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift b/ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift index 60cc4a4d..ca166800 100644 --- a/ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift +++ b/ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift @@ -286,7 +286,15 @@ func clampReport(_ snr: Int) -> Int { min(30, max(-30, snr)) } func fmtReport(_ n: Int) -> String { String(format: "%+03d", Int32(n)) } private func classify(_ msg: DecodedMessage) -> Content { - if !msg.grid.isEmpty { return .grid(msg.grid) } + // Recognize the QSO sign-offs before treating the message as a grid report. + // "RR73" is a valid 4-char Maidenhead-shaped token (R,R in A..R + two + // digits), so the decoder copies it into `grid` (looksLikeGrid("RR73") is + // true). Checking grid first would classify a partner's standard RR73 + // sign-off as Content.grid, stranding the auto-sequencer at the RReport + // stage — it would retransmit our R-report forever and never complete/log + // the QSO. Matching the sign-offs first fixes that without touching + // looksLikeGrid (the map/distance code relies on its shape test). Mirrors + // the desktop (qso.rs) and Android (GeneralVariables) reference ports. let extra = msg.extra.trimmingCharacters(in: .whitespacesAndNewlines) switch extra { case "RR73": return .rr73 @@ -294,6 +302,7 @@ private func classify(_ msg: DecodedMessage) -> Content { case "73": return .bye73 default: break } + if !msg.grid.isEmpty { return .grid(msg.grid) } if extra.hasPrefix("R") { let rest = String(extra.dropFirst()) if let n = Int(rest) { return .rReport(n) } diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift index a3ddbada..16c7398e 100644 --- a/ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift +++ b/ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift @@ -53,6 +53,42 @@ final class QsoEngineTests: XCTestCase { XCTAssertEqual(e.txMessage, "CQ K0XYZ EN37") } + func testRr73WithDecoderGridStillCompletesQso() { + // Regression for the sign-off-vs-grid classification bug (ported from + // desktop qso.rs `rr73_with_decoder_grid_still_completes_qso`). Live, the + // DX's RR73 arrives with grid == "RR73" because looksLikeGrid("RR73") is + // true and the decoder copies it into the grid field. If classify checked + // grid first it returned Content.grid, the RReport arm hit `default: + // break`, and the QSO hung retransmitting our R-report forever instead of + // logging. + let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37") + e.answer(msg("CQ", "K1ABC", "FN42", "FN42", -5)) + _ = e.processRx([msg("K0XYZ", "K1ABC", "-12", "", -8)]) + XCTAssertEqual(e.txMessage, "K1ABC K0XYZ R-08") + XCTAssertEqual(e.status().stage, .rReport) + + // DX signs off with RR73 exactly as the decoder emits it live (grid set). + let out = e.processRx([msg("K0XYZ", "K1ABC", "RR73", "RR73", -8)]) + guard case .completed(let rec)? = out else { + return XCTFail("RR73 sign-off must complete the QSO") + } + XCTAssertEqual(rec.call, "K1ABC") + XCTAssertEqual(rec.gridsquare, "FN42") + XCTAssertEqual(rec.rstSent, "-08") + XCTAssertEqual(rec.rstRcvd, "-12") + XCTAssertEqual(e.txMessage, "K1ABC K0XYZ 73") + XCTAssertEqual(e.status().stage, .bye73) + + // A genuine 4-char grid reply is still classified as a grid, not a + // sign-off — the fix reorders the checks without weakening grid handling. + // As the CQ initiator, an answerer's grid must advance us to send a report. + let e2 = QsoEngine(myCall: "K0XYZ", myGrid: "EN37") + e2.startCq() + _ = e2.processRx([msg("K0XYZ", "K1ABC", "FN31", "FN31", -10)]) + XCTAssertEqual(e2.txMessage, "K1ABC K0XYZ -10") + XCTAssertEqual(e2.status().stage, .report) + } + func testInitiatorCqSequenceLogsQso() { let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37") e.startCq()