fix(map): 多格台址覆盖单格台址时通联计入单格统计(方案A单向覆盖)

- scopedToStation 由 key 精确匹配改为网格集合包含(记录 MY 网格集 ⊇ 选中台址网格集):
  选中 OL62 时 OL62/63(四格点同理)的通联一并计入;反向不泄漏(选中宽台址不见单格记录)
- stationGroups 每组 worked 改经 scopedToStation 推导——地图填绿/VUCC 计数/选择器计数/
  首通标签/点格子弹窗详情五出口单源;分组条目仍只列实际拥有的台址
- GridQsoStationScopeTest 改写精确匹配断言 + 新增覆盖/不泄漏/集合包含 3 例(共 14),
  全量 test 通过
This commit is contained in:
atsunatsu committed 2026-10-05 15:06:19 +08:00
1 parent 09539ff13a
commit f35d78186c
3 files changed
+50 -12

No files matched your search

@@ -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<GridQso>.scopedToStation(stationId: String?): List<GridQso> =
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
@@ -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<GridQso>(), 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<GridQso>(), listOf(quad).scopedToStation("OL62,PM01"))
}
@Test
@@ -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<StationGroup> = remember(uiState.workedGridQsos) {
val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>()
for ((grid, qsos) in uiState.workedGridQsos) {
val gridSets = LinkedHashMap<String, Set<String>>()
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