fix(map): scope grid QSO detail to the selected operated grid (台址)

The VUCC grid-detail dialog listed every station location's QSOs even when a specific operated grid was selected; only 'All' should combine them. The 台址 grid-set key derivation is now shared between the selector's grouping and the dialog filter (core/domain extensions + unit tests).
This commit is contained in:
atsunatsu committed 2026-10-05 02:02:55 +08:00
1 parent 6a263d8422
commit 7589f3f37d
3 files changed
+134 -5

No files matched your search

@@ -104,3 +104,30 @@ data class GridQso(
else -> band.trim().uppercase()
}
}
/**
* Every MY-side grid of this QSO: [myGrids] (MY_GRIDSQUARE + MY_VUCC_GRIDS)
* with a single-grid fallback to [myGrid] for records synced before
* multi-grid support. Empty when the record carries no MY-side grid at all.
*/
fun GridQso.myStationGrids(): Set<String> =
myGrids.ifEmpty { myGrid?.let { setOf(it) }.orEmpty() }
/**
* Station-location (台址) grouping key: the QSO's MY grid set, sorted and
* comma-joined ("OL62", "OM60,PM01"). Records covering the same grid set
* belong to the same 台址 — they are VUCC-equivalent. Null when the record
* carries no MY-side grid. Backs both the map's operated-grid selector and
* its per-台址 grid-detail filtering, so the two scope by one identity.
*/
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.
*/
fun List<GridQso>.scopedToStation(stationId: String?): List<GridQso> =
if (stationId == null) this else filter { it.stationGridSetKey() == stationId }
@@ -0,0 +1,97 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The map's VUCC view scopes QSO data to the operated-grid selector's choice
* (台址 = station location): [stationGridSetKey] identifies a record's 台址 and
* [scopedToStation] applies the choice — the grid-detail dialog relies on both.
*/
class GridQsoStationScopeTest {
private fun qso(
call: String = "BG7XYZ",
myGrid: String? = null,
myGrids: Set<String> = emptySet()
) = GridQso(
call = call, epochMs = 0L, satName = "SO-50", mode = "FM",
bandUp = "70CM", bandDown = "2M", myGrid = myGrid, myGrids = myGrids
)
@Test
fun `multi-grid record keys by its full sorted grid set`() {
assertEquals("OL62,PM01", qso(myGrids = setOf("PM01", "OL62")).stationGridSetKey())
}
@Test
fun `legacy single-grid record falls back to myGrid`() {
assertEquals("OL62", qso(myGrid = "OL62").stationGridSetKey())
}
@Test
fun `myGrids wins over myGrid when both are present`() {
val q = qso(myGrid = "OL62", myGrids = setOf("OM60", "PM01"))
assertEquals("OM60,PM01", q.stationGridSetKey())
}
@Test
fun `record without any MY grid carries no key`() {
assertNull(qso().stationGridSetKey())
}
@Test
fun `key does not depend on the grid set's iteration order`() {
assertEquals(
qso(myGrids = setOf("PM01", "OL62")).stationGridSetKey(),
qso(myGrids = setOf("OL62", "PM01")).stationGridSetKey()
)
}
@Test
fun `null scope keeps every record`() {
val a = qso("A", myGrid = "OL62")
val b = qso("B", myGrid = "PM01")
assertEquals(listOf(a, b), listOf(a, b).scopedToStation(null))
}
@Test
fun `specific scope keeps only the matching station's records`() {
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"))
}
@Test
fun `records without a MY grid belong to no station scope`() {
val orphan = qso("A")
val mine = qso("B", myGrid = "OL62")
assertEquals(listOf(mine), listOf(orphan, mine).scopedToStation("OL62"))
}
@Test
fun `scoping to a station absent from the list yields empty`() {
val list = listOf(qso("A", myGrid = "OL62"))
assertEquals(emptyList<GridQso>(), list.scopedToStation("PM01"))
}
}
@@ -86,6 +86,9 @@ import com.rtbishop.look4sat.core.domain.model.AwardCalculator
import com.rtbishop.look4sat.core.domain.model.AwardProgress
import com.rtbishop.look4sat.core.domain.model.AwardType
import com.rtbishop.look4sat.core.domain.model.MapSource
import com.rtbishop.look4sat.core.domain.model.myStationGrids
import com.rtbishop.look4sat.core.domain.model.scopedToStation
import com.rtbishop.look4sat.core.domain.model.stationGridSetKey
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
@@ -321,10 +324,8 @@ private fun MapScreen(
val byGridSet = LinkedHashMap<String, Pair<Set<String>, MutableSet<String>>>()
for ((grid, qsos) in uiState.workedGridQsos) {
for (q in qsos) {
val gs = q.myGrids.ifEmpty { q.myGrid?.let { setOf(it) }.orEmpty() }
if (gs.isEmpty()) continue
val key = gs.sorted().joinToString(",")
byGridSet.getOrPut(key) { gs to mutableSetOf() }.second.add(grid)
val key = q.stationGridSetKey() ?: continue
byGridSet.getOrPut(key) { q.myStationGrids() to mutableSetOf() }.second.add(grid)
}
}
byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) }
@@ -539,7 +540,11 @@ private fun MapScreen(
selectedGrid?.let { grid ->
WorkedGridQsoDialog(
grid = grid,
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
// Scope to the selected 台址 (null = All): a specific station location
// must not show other locations' QSOs (bug report 2026-10-05).
qsos = uiState.workedGridQsos[grid].orEmpty()
.scopedToStation(selectedStationId)
.sortedBy { it.epochMs },
marked = uiState.markedGrids[grid].orEmpty(),
isUtc = uiState.isUtc,
matchCalculating = matchCalculating,