From f35d78186c2d6a36514e6fc31f12374820f05355 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Mon, 5 Oct 2026 15:06:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(map):=20=E5=A4=9A=E6=A0=BC=E5=8F=B0?= =?UTF-8?q?=E5=9D=80=E8=A6=86=E7=9B=96=E5=8D=95=E6=A0=BC=E5=8F=B0=E5=9D=80?= =?UTF-8?q?=E6=97=B6=E9=80=9A=E8=81=94=E8=AE=A1=E5=85=A5=E5=8D=95=E6=A0=BC?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=EF=BC=88=E6=96=B9=E6=A1=88A=E5=8D=95?= =?UTF-8?q?=E5=90=91=E8=A6=86=E7=9B=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scopedToStation 由 key 精确匹配改为网格集合包含(记录 MY 网格集 ⊇ 选中台址网格集): 选中 OL62 时 OL62/63(四格点同理)的通联一并计入;反向不泄漏(选中宽台址不见单格记录) - stationGroups 每组 worked 改经 scopedToStation 推导——地图填绿/VUCC 计数/选择器计数/ 首通标签/点格子弹窗详情五出口单源;分组条目仍只列实际拥有的台址 - GridQsoStationScopeTest 改写精确匹配断言 + 新增覆盖/不泄漏/集合包含 3 例(共 14), 全量 test 通过 --- .../look4sat/core/domain/model/GridQso.kt | 20 ++++++++++++---- .../domain/model/GridQsoStationScopeTest.kt | 24 +++++++++++++++++-- .../look4sat/feature/map/MapScreen.kt | 18 ++++++++++---- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt index 4f5bc310..64ed3de3 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt @@ -124,13 +124,23 @@ fun GridQso.stationGridSetKey(): String? = myStationGrids().takeIf { it.isNotEmpty() }?.sorted()?.joinToString(",") /** - * Scopes a per-grid QSO list to the operated-grid selector's choice: - * [stationId] == null ("All") keeps the list as-is, otherwise only records - * whose [stationGridSetKey] matches stay. Records without a MY-side grid - * belong to no 台址 and are dropped under any specific scope. + * Scopes a per-grid QSO list to the operated-grid selector's choice with + * COVERING semantics (2026-10-05): [stationId] is a grid-set key such as + * "OL62" or "OL62,OL63", and a record stays when its own [myStationGrids] + * COVER that key (record grid set ⊇ key set). So a multi-grid 台址 that + * covers a single-grid one (OL62/63 covers OL62) contributes its records to + * the single-grid scope's statistics — map fills, dialog lists and first-call + * labels all derive from this one rule. The reverse does NOT hold: records + * under a narrower 台址 never leak into a wider scope (single OL62 records + * stay out of the OL62/63 scope). Records without a MY-side grid belong to + * no 台址 and are dropped under any specific scope. */ fun List.scopedToStation(stationId: String?): List = - if (stationId == null) this else filter { it.stationGridSetKey() == stationId } + if (stationId == null) this + else { + val scope = stationId.split(',').toSet() + filter { it.myStationGrids().containsAll(scope) } + } /** * For every grid in the store, the callsign of the earliest QSO — scoped to the diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/model/GridQsoStationScopeTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/model/GridQsoStationScopeTest.kt index 433e1ec2..4326e9a0 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/model/GridQsoStationScopeTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/model/GridQsoStationScopeTest.kt @@ -74,12 +74,32 @@ class GridQsoStationScopeTest { } @Test - fun `specific scope keeps only the matching station's records`() { + fun `specific scope keeps covering station's records too`() { + // 方案 A 覆盖语义: 选中单格台址 OL62 时,覆盖它的多格台址 (OL62,PM01) + // 的通联也计入;反向不泄漏。 val multi = qso("A", myGrids = setOf("OL62", "PM01")) val single = qso("B", myGrid = "OL62") val list = listOf(multi, single) assertEquals(listOf(multi), list.scopedToStation("OL62,PM01")) - assertEquals(listOf(single), list.scopedToStation("OL62")) + assertEquals(listOf(multi, single), list.scopedToStation("OL62")) + } + + @Test + fun `narrower station never leaks into a wider scope`() { + val single = qso("B", myGrid = "OL62") + val pair = qso("A", myGrids = setOf("OL62", "PM01")) + // 宽台址 (OL62,PM01) 选中时,单格 OL62 的记录不出现 + assertEquals(emptyList(), listOf(single, pair).scopedToStation("OL62,PM01").filter { it === single }) + assertEquals(listOf(pair), listOf(single, pair).scopedToStation("OL62,PM01")) + } + + @Test + fun `covering is per-cell set containment not string equality`() { + // 多格台址的 key 是排序拼接,覆盖判定必须按网格集合而非字符串 + val quad = qso("A", myGrids = setOf("OL62", "OL63", "OL64", "OL65")) + assertEquals(listOf(quad), listOf(quad).scopedToStation("OL63")) + assertEquals(listOf(quad), listOf(quad).scopedToStation("OL62,OL65")) + assertEquals(emptyList(), listOf(quad).scopedToStation("OL62,PM01")) } @Test diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt index 5eac2468..4777a4d4 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt @@ -314,16 +314,24 @@ private fun MapScreen( // can differ between records of the same physical 台址 when LoTW omits // optional fields). Data synced before multi-grid support falls back to // one group per grid, preserving the old selector exactly. + // Each group's `worked` cells are derived through scopedToStation (the + // covering rule): a single-grid 台址 also counts the cells worked by any + // multi-grid 台址 covering it (OL62 ← OL62/63, 四格点同理) — the reverse + // never leaks (2026-10-05 用户定稿, 方案 A). val stationGroups: List = remember(uiState.workedGridQsos) { - val byGridSet = LinkedHashMap, MutableSet>>() - for ((grid, qsos) in uiState.workedGridQsos) { + val gridSets = LinkedHashMap>() + for ((_, qsos) in uiState.workedGridQsos) { for (q in qsos) { val key = q.stationGridSetKey() ?: continue - byGridSet.getOrPut(key) { q.myStationGrids() to mutableSetOf() }.second.add(grid) + gridSets.getOrPut(key) { q.myStationGrids() } } } - byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) } - .sortedByDescending { it.workedCount } + gridSets.map { (key, grids) -> + val worked = uiState.workedGridQsos.filterValues { qsos -> + qsos.scopedToStation(key).isNotEmpty() + }.keys + StationGroup(id = key, grids = grids, worked = worked) + }.sortedByDescending { it.workedCount } } // Selected 台址 for VUCC counting; defaults to the group with the most // worked grids. Null = "All" (every 台址 combined). Empty when the store