From 7589f3f37df44266fb0e7b0a1b4cd9ef20ba0fc9 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Mon, 5 Oct 2026 02:02:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(map):=20scope=20grid=20QSO=20detail=20to=20?= =?UTF-8?q?the=20selected=20operated=20grid=20(=E5=8F=B0=E5=9D=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../look4sat/core/domain/model/GridQso.kt | 27 ++++++ .../domain/model/GridQsoStationScopeTest.kt | 97 +++++++++++++++++++ .../look4sat/feature/map/MapScreen.kt | 15 ++- 3 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 core/domain/src/test/java/com/rtbishop/look4sat/core/domain/model/GridQsoStationScopeTest.kt 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 a57d3eeb..cdd83275 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 @@ -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 = + 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.scopedToStation(stationId: String?): List = + if (stationId == null) this else filter { it.stationGridSetKey() == stationId } 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 new file mode 100644 index 00000000..fe372938 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/model/GridQsoStationScopeTest.kt @@ -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 . + */ +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 = 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(), list.scopedToStation("PM01")) + } +} 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 d893a721..8ec681c7 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 @@ -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, MutableSet>>() 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,