From 08a5c8c460823960c199a98ad23a698d39d9e177 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Fri, 11 Sep 2026 23:47:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(lotw):=20order-independent=20ADIF=20parsing?= =?UTF-8?q?=20=E2=80=94=20roamed/work=20grids=20dropped=20by=20sequential?= =?UTF-8?q?=20PROP=5FMODE=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseConfirmedGrids and parseRoamedGrids gated grid fields on propMode == SAT at the moment the field line was read. LoTW emits ADIF fields alphabetically (GRIDSQUARE/MY_GRIDSQUARE before PROP_MODE), so the gate was never satisfied on real reports: the worked set AND the roamed set silently came back empty. Both parsers now buffer the record and decide at , matching the proven parseConfirmedGridQsos pattern. Regression tests cover the alphabetical order. --- .../core/data/repository/LoTWRepository.kt | 58 ++++++++++++------- .../data/repository/LoTWRepositoryTest.kt | 37 ++++++++++++ 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt index 2a1adc5d..31d6e98e 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt @@ -264,9 +264,6 @@ class LoTWRepository : ILoTWRepository { } internal fun parseConfirmedGrids(body: String): Set? { - // LoTW answers with ADIF text; on bad credentials it returns a short error page - // containing "password=?" or an -less block. Treat anything without a header - // marker as failure so the caller can show a sensible message. // LoTW answers with ADIF text; on bad credentials it returns a short error // page without an header terminator. Real reports always carry // (LoTW writes it lowercase). Match case-insensitively to be safe. @@ -277,25 +274,34 @@ class LoTWRepository : ILoTWRepository { // QSOs omit it. Grid fields (GRIDSQUARE / VUCC_GRIDS) must only be // collected for records whose PROP_MODE is SAT, otherwise the map mixes // in terrestrial contacts. + // + // Buffered per-record pattern (same as parseConfirmedGridQsos): field order + // is NOT reliable — ADIF producers (incl. LoTW) emit fields alphabetically, + // so (G) arrives BEFORE (P) within a record. A + // sequential gate ("only add while propMode == SAT") silently drops every + // grid on real reports. Buffer the record and decide at instead. var propMode: String? = null + var pendingGrids = mutableListOf() for (raw in body.lineSequence()) { val line = raw.trim() when { - line.equals("", ignoreCase = true) -> propMode = null + line.equals("", ignoreCase = true) -> { + if (propMode == "SAT") grids.addAll(pendingGrids) + propMode = null + pendingGrids = mutableListOf() + } line.startsWith(" { - propMode = line.substringAfter('>').substringBefore("E<").trim().uppercase() + propMode = adifValue(line).uppercase() } line.startsWith(" { - if (propMode == "SAT") { - // VUCC_GRIDS holds a comma-separated PAIR of grids - // ("EN52en,EN53fa") for contacts spanning two squares — - // split on ',' and take the 4-char field of each, or the - // second grid is silently dropped. - val value = line.substringAfter('>').substringBefore("E<") - value.split(',').forEach { grid -> - val field = grid.trim().uppercase() - if (field.length >= 4) grids.add(field.take(4)) - } + // VUCC_GRIDS holds a comma-separated PAIR of grids + // ("EN52en,EN53fa") for contacts spanning two squares — + // split on ',' and take the 4-char field of each, or the + // second grid is silently dropped. + val value = adifValue(line) + value.split(',').forEach { grid -> + val field = grid.trim().uppercase() + if (field.length >= 4) pendingGrids.add(field.take(4)) } } } @@ -314,22 +320,30 @@ class LoTWRepository : ILoTWRepository { internal fun parseRoamedGrids(body: String): Set? { if (!body.contains("", ignoreCase = true)) return null val grids = mutableSetOf() + // Buffered per-record pattern (same as parseConfirmedGridQsos): field + // order is NOT reliable — ADIF producers (incl. LoTW) emit fields + // alphabetically, so (M) arrives BEFORE (P) + // within a record. A sequential gate ("only add while propMode == SAT") + // would silently drop every own grid on real reports; buffer the record + // and decide at instead. var propMode: String? = null + var myGrid: String? = null for (raw in body.lineSequence()) { val line = raw.trim() when { - line.equals("", ignoreCase = true) -> propMode = null + line.equals("", ignoreCase = true) -> { + if (propMode == "SAT" && myGrid != null) grids.add(myGrid) + propMode = null + myGrid = null + } line.startsWith(" { - propMode = line.substringAfter('>').substringBefore("E<").trim().uppercase() + propMode = adifValue(line).uppercase() } line.startsWith(" { // MY_GRIDSQUARE must not be mistaken for GRIDSQUARE (the // opposite station's grid) — only own-station grids count. - if (propMode == "SAT") { - val value = line.substringAfter('>').substringBefore("E<") - val field = value.trim().uppercase() - if (field.length >= 4) grids.add(field.take(4)) - } + val value = adifValue(line) + if (value.length >= 4) myGrid = value.take(4) } } } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt index 1d9f8d88..7e59a335 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt @@ -61,6 +61,28 @@ class LoTWRepositoryTest { assertEquals(setOf("OL62"), result) } + @Test + fun parseHandlesAlphabeticalFieldOrder() { + // Real lotwreport.adi emits fields in ALPHABETICAL order: GRIDSQUARE (G) + // and VUCC_GRIDS come BEFORE PROP_MODE (P) inside each record. The old + // sequential gate (add only while propMode == SAT) dropped every grid on + // real reports — the buffered parser must collect them regardless of + // field order and filter at . + val satQso = "BA7OPF\n" + + "NL47\n" + + "FM\n" + + "SAT\n" + + "FO-29\n" + + "EN52en,EN53fa\n" + + "\n" + val groundQso = "BA7OPF\n" + + "PM95\n" + + "20260821\n" + + "\n" + val result = repo.parseConfirmedGrids(report(satQso, groundQso)) + assertEquals(setOf("NL47", "EN52", "EN53"), result) + } + @Test fun parseReturnsEmptySetForReportWithoutGrids() { val satQso = "SAT\nAO-07\n\n" @@ -163,6 +185,21 @@ class LoTWRepositoryTest { assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso))) } + @Test + fun parseRoamedGridsHandlesAlphabeticalFieldOrder() { + // Real lotwreport.adi emits fields alphabetically, so + // (M) arrives BEFORE (P). The old sequential gate (add only + // while propMode == SAT) silently returned an EMPTY set on real reports — + // this is the exact bug that made the blue roamed stripes never appear. + val qso = "BA7OPF\n" + + "PM95\n" + + "OL72XX\n" + + "SAT\n" + + "IO-86\n" + + "\n" + assertEquals(setOf("OL72"), repo.parseRoamedGrids(report(qso))) + } + @Test fun parseRoamedGridsRejectsBodyWithoutEoh() { assertNull(repo.parseRoamedGrids("Username/password incorrect"))