mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-09 13:57:24 +00:00
merge: 合入 fork 9.2 发布线(AMSAT 冲突修复并入;版本保持 9.2/567)
# Conflicts: # gradle/libs.versions.toml
This commit is contained in:
commit
1fa7dd2d88
36 files changed
+1586
-32
No files matched your search
@@ -18,7 +18,7 @@ import java.util.TimeZone
|
||||
object AdifCodec {
|
||||
private val utc = TimeZone.getTimeZone("UTC")
|
||||
|
||||
fun encode(records: List<QsoRecord>): String = buildString {
|
||||
fun encode(records: List<QsoRecord>, includeStationCallsign: Boolean = true): String = buildString {
|
||||
append(field("ADIF_VER", "3.1.7"))
|
||||
append(field("PROGRAMID", "Look4Sat"))
|
||||
append("<EOH>\r\n")
|
||||
@@ -32,7 +32,7 @@ object AdifCodec {
|
||||
append(field("TIME_OFF", format(it, "HHmmss")))
|
||||
}
|
||||
append(field("CALL", record.theirCallsign))
|
||||
append(field("STATION_CALLSIGN", record.myCallsign))
|
||||
if (includeStationCallsign) append(field("STATION_CALLSIGN", record.myCallsign))
|
||||
appendOptional("MODE", record.mode)
|
||||
appendOptional("SUBMODE", record.submode)
|
||||
appendOptional("GRIDSQUARE", record.theirGrid)
|
||||
|
||||
@@ -23,6 +23,10 @@ interface IQsoRepository {
|
||||
* logbook row can show which grids the QSO went out under.
|
||||
*/
|
||||
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList())
|
||||
|
||||
/** Marks the batch as uploaded to Wavelog (independent of the LoTW upload state). */
|
||||
suspend fun markWavelogUploaded(ids: List<Long>)
|
||||
|
||||
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
||||
suspend fun importAdi(content: String): AdifImportResult
|
||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||
|
||||
@@ -40,6 +40,8 @@ data class QsoRecord(
|
||||
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
|
||||
val lotwConfirmed: Boolean = false,
|
||||
val lotwUploaded: Boolean = false,
|
||||
/** Internal: set after a successful Wavelog upload; keeps the pending filter small. Not shown in any list. */
|
||||
val wavelogUploaded: Boolean = false,
|
||||
val lotwReceived: Boolean = false,
|
||||
val lotwQslDate: String = "",
|
||||
val vuccGrids: List<String> = emptyList(),
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
|
||||
/**
|
||||
* The records a Wavelog upload may submit: locally recorded contacts only.
|
||||
*
|
||||
* Pure LoTW-imported confirmation rows never carry [QsoRecord.lotwUploaded] (the ADIF decoder
|
||||
* does not set it), while a locally recorded row keeps its value through a confirmation merge —
|
||||
* [withConfirmation] leaves `lotwUploaded` untouched. Confirmed local rows uploaded to LoTW
|
||||
* from this app therefore stay eligible; imported history stays out of the upload.
|
||||
*/
|
||||
fun wavelogUploadCandidates(records: List<QsoRecord>): List<QsoRecord> = records.filter {
|
||||
it.status == QsoStatus.COMPLETE && !it.wavelogUploaded && (!it.lotwConfirmed || it.lotwUploaded)
|
||||
}
|
||||
|
||||
/** Operator-facing wording for why a record cannot enter a Wavelog upload. */
|
||||
fun WavelogProblem.label(): String = when (this) {
|
||||
WavelogProblem.NOT_CONFIGURED -> "Wavelog upload not configured"
|
||||
WavelogProblem.NO_STATION -> "no station profile selected"
|
||||
WavelogProblem.GRID_MISMATCH -> "logged grid outside the Wavelog station grid"
|
||||
}
|
||||
|
||||
/** One-line summary of the records excluded before a Wavelog upload (empty when none). */
|
||||
fun wavelogSkipSummary(preview: WavelogUploadPreview): String {
|
||||
if (preview.skipped <= 0) return ""
|
||||
val detail = preview.reasons.entries.sortedByDescending { it.value }
|
||||
.joinToString(", ") { (problem, count) -> "$count× ${problem.label()}" }
|
||||
return "${preview.skipped} QSO(s) skipped" + if (detail.isNotBlank()) " — $detail" else ""
|
||||
}
|
||||
|
||||
/** One-line result wording for a finished Wavelog upload. */
|
||||
fun wavelogResultMessage(result: WavelogUploadOutcome): String = when (result) {
|
||||
is WavelogUploadOutcome.Imported -> buildString {
|
||||
if (result.imported == 0 && result.duplicates > 0) {
|
||||
append("All ${result.markIds.size} record(s) already in Wavelog")
|
||||
} else {
|
||||
append("Uploaded ${result.imported} QSO(s) to Wavelog")
|
||||
if (result.duplicates > 0) append(" — ${result.duplicates} already there")
|
||||
}
|
||||
if (result.skipped > 0) append(" — ${result.skipped} skipped by Wavelog")
|
||||
}
|
||||
is WavelogUploadOutcome.Rejected -> result.message
|
||||
is WavelogUploadOutcome.Failed -> result.message
|
||||
WavelogUploadOutcome.Expired -> "Preview expired — tap upload again"
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for the piggybacked Wavelog side when nothing can be submitted —
|
||||
* null when there is nothing to say (fully caught up, no held-back records).
|
||||
*/
|
||||
fun wavelogIdleSegment(preview: WavelogUploadPreview): String? = when {
|
||||
preview.blockedBy != null -> "Wavelog: ${preview.blockedBy.label()}"
|
||||
preview.skipped > 0 -> "Wavelog: ${wavelogSkipSummary(preview)}"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for a finished piggybacked Wavelog upload: the server outcome plus
|
||||
* the records held back before the POST (e.g. grids outside the station profile).
|
||||
*/
|
||||
fun wavelogConfirmedSegment(outcome: WavelogUploadOutcome, preview: WavelogUploadPreview): String {
|
||||
val base = wavelogResultMessage(outcome)
|
||||
val head = if (outcome is WavelogUploadOutcome.Imported) base else "Wavelog: $base"
|
||||
val skip = wavelogSkipSummary(preview)
|
||||
return if (skip.isNotBlank()) "$head\n$skip" else head
|
||||
}
|
||||
|
||||
/** Message when the piggybacked upload threw outright — records stay unmarked for retry. */
|
||||
fun wavelogTransportFailureSegment(): String = "Wavelog: upload failed — it will retry with your next upload"
|
||||
@@ -104,3 +104,40 @@ 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 }
|
||||
|
||||
/**
|
||||
* For every grid in the store, the callsign of the earliest QSO — scoped to the
|
||||
* operated-grid selector's choice first, so a specific 台址 labels each worked
|
||||
* cell with ITS first contact; null ("All") keeps the global first call.
|
||||
*/
|
||||
fun Map<String, List<GridQso>>.firstCallsByGrid(stationId: String?): Map<String, String> =
|
||||
mapNotNull { (grid, qsos) ->
|
||||
qsos.scopedToStation(stationId).minByOrNull { it.epochMs }?.let { grid to it.call }
|
||||
}.toMap()
|
||||
@@ -109,6 +109,19 @@ data class WavelogSettings(
|
||||
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
|
||||
}
|
||||
|
||||
/** Wavelog QSO-upload configuration (a separate block from the worked-grids sync). */
|
||||
data class WavelogUploadSettings(
|
||||
val url: String = "",
|
||||
val apiKey: String = "",
|
||||
val stationId: String = "",
|
||||
val stationName: String = "",
|
||||
val stationCallsign: String = "",
|
||||
val stationGrid: String = ""
|
||||
) {
|
||||
val isConfigured: Boolean get() = url.isNotBlank() && apiKey.isNotBlank()
|
||||
val isReady: Boolean get() = isConfigured && stationId.isNotBlank()
|
||||
}
|
||||
|
||||
data class LoTWSettings(
|
||||
val callsign: String = "",
|
||||
val password: String = ""
|
||||
|
||||
+1
@@ -34,6 +34,7 @@ interface IMainContainer {
|
||||
val amSatRepo: IAmSatRepository
|
||||
val updateRepo: IUpdateRepository
|
||||
val wavelogRepo: IWavelogRepository
|
||||
val wavelogUploadRepository: IWavelogUploadRepository
|
||||
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
val qsoRepository: IQsoRepository
|
||||
val lotwUploadRepository: ILoTWUploadRepository
|
||||
|
||||
+6
@@ -24,6 +24,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@@ -124,6 +125,11 @@ interface ISettingsRepo {
|
||||
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
|
||||
//endregion
|
||||
|
||||
//region # Wavelog upload settings
|
||||
val wavelogUploadSettings: StateFlow<WavelogUploadSettings>
|
||||
fun updateWavelogUploadSettings(settings: WavelogUploadSettings)
|
||||
//endregion
|
||||
|
||||
//region # LoTW confirmed-grids settings
|
||||
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
||||
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
|
||||
/** One station profile as returned by the v1 station_info endpoint. */
|
||||
data class WavelogStationInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val grid: String,
|
||||
val active: Boolean
|
||||
)
|
||||
|
||||
/** Result of probing a Wavelog server (check_auth + station_info). */
|
||||
data class WavelogProbe(val rights: String, val stations: List<WavelogStationInfo>)
|
||||
|
||||
/** Why a record (or the whole upload) cannot be submitted. */
|
||||
enum class WavelogProblem { NOT_CONFIGURED, NO_STATION, GRID_MISMATCH }
|
||||
|
||||
/** Records skipped before the POST (with reasons), plus the records that will be submitted. */
|
||||
data class WavelogUploadPreview(
|
||||
val count: Int,
|
||||
val skipped: Int,
|
||||
val reasons: Map<WavelogProblem, Int>,
|
||||
val stationLabel: String,
|
||||
val firstUtc: String,
|
||||
val lastUtc: String,
|
||||
val contacts: List<String>,
|
||||
val submittedIds: List<Long>,
|
||||
val blockedBy: WavelogProblem? = null
|
||||
)
|
||||
|
||||
sealed interface WavelogUploadOutcome {
|
||||
/** [markIds] can be smaller than the submission when Wavelog reported skipped records. */
|
||||
data class Imported(
|
||||
val imported: Int,
|
||||
val duplicates: Int,
|
||||
val skipped: Int,
|
||||
val markIds: List<Long>
|
||||
) : WavelogUploadOutcome
|
||||
|
||||
data class Rejected(val message: String) : WavelogUploadOutcome
|
||||
data class Failed(val message: String) : WavelogUploadOutcome
|
||||
data object Expired : WavelogUploadOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads local QSO records to a self-hosted Wavelog via the v1 API
|
||||
* (`POST {base}/index.php/api/qso` with a batch ADIF string).
|
||||
*/
|
||||
interface IWavelogUploadRepository {
|
||||
/** check_auth + station_info; null on any transport/parse failure. */
|
||||
suspend fun probe(url: String, apiKey: String): WavelogProbe?
|
||||
|
||||
/** Filters + encodes the batch and keeps a one-shot pending payload for [upload]. */
|
||||
suspend fun prepare(records: List<QsoRecord>, settings: WavelogUploadSettings): WavelogUploadPreview
|
||||
|
||||
/** POSTs the prepared batch and classifies Wavelog's response. */
|
||||
suspend fun upload(preview: WavelogUploadPreview, settings: WavelogUploadSettings): WavelogUploadOutcome
|
||||
}
|
||||
@@ -83,4 +83,20 @@ class AdifCodecTest {
|
||||
assertTrue(record.lotwConfirmed)
|
||||
assertTrue(record.isSatellite)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeOmitsStationCallsignForWavelog() {
|
||||
val record = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "N0CALL",
|
||||
myCallsign = "BA7OPF",
|
||||
txFrequencyHz = 145_850_000L,
|
||||
band = "2M",
|
||||
satelliteName = "SO-50"
|
||||
)
|
||||
val adif = AdifCodec.encode(listOf(record), includeStationCallsign = false)
|
||||
assertTrue(!adif.contains("<STATION_CALLSIGN"))
|
||||
assertTrue(adif.contains("<CALL:6>N0CALL"))
|
||||
assertTrue(adif.contains("<SAT_NAME:5>SO-50"))
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
|
||||
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WavelogUploadMessagesTest {
|
||||
|
||||
private fun record(
|
||||
complete: Boolean = true,
|
||||
wavelogUploaded: Boolean = false,
|
||||
lotwConfirmed: Boolean = false,
|
||||
lotwUploaded: Boolean = false
|
||||
) = QsoRecord(
|
||||
startUtcMillis = 1_700_000_000_000L,
|
||||
theirCallsign = "BG5JVM",
|
||||
myCallsign = "BA7OPF",
|
||||
status = if (complete) QsoStatus.COMPLETE else QsoStatus.DRAFT,
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
wavelogUploaded = wavelogUploaded
|
||||
)
|
||||
|
||||
private fun preview(
|
||||
count: Int = 0,
|
||||
skipped: Int = 0,
|
||||
reasons: Map<WavelogProblem, Int> = emptyMap(),
|
||||
blockedBy: WavelogProblem? = null
|
||||
) = WavelogUploadPreview(
|
||||
count = count,
|
||||
skipped = skipped,
|
||||
reasons = reasons,
|
||||
stationLabel = "OL62TI · BA7OPF",
|
||||
firstUtc = "",
|
||||
lastUtc = "",
|
||||
contacts = emptyList(),
|
||||
submittedIds = List(count) { it.toLong() },
|
||||
blockedBy = blockedBy
|
||||
)
|
||||
|
||||
@Test
|
||||
fun freshLocalRecordIsEligible() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record())).isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun locallyUploadedToLotwStaysEligible() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(lotwUploaded = true))).isNotEmpty())
|
||||
assertTrue(
|
||||
wavelogUploadCandidates(listOf(record(lotwConfirmed = true, lotwUploaded = true))).isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importedConfirmationRowIsExcluded() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(lotwConfirmed = true))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun draftAndAlreadyUploadedRecordsAreExcluded() {
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(complete = false))).isEmpty())
|
||||
assertTrue(wavelogUploadCandidates(listOf(record(wavelogUploaded = true))).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun problemLabelsAreOperatorFacing() {
|
||||
assertEquals("Wavelog upload not configured", WavelogProblem.NOT_CONFIGURED.label())
|
||||
assertEquals("no station profile selected", WavelogProblem.NO_STATION.label())
|
||||
assertEquals("logged grid outside the Wavelog station grid", WavelogProblem.GRID_MISMATCH.label())
|
||||
}
|
||||
|
||||
// --- Piggyback segments (Wavelog rides the LoTW upload flow) ---
|
||||
|
||||
@Test
|
||||
fun `idle segment stays silent when nothing was held back`() {
|
||||
assertNull(wavelogIdleSegment(preview()))
|
||||
assertNull(wavelogIdleSegment(preview(count = 0, skipped = 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle segment explains a blocked profile`() {
|
||||
assertEquals(
|
||||
"Wavelog: no station profile selected",
|
||||
wavelogIdleSegment(preview(blockedBy = WavelogProblem.NO_STATION))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle segment lists held-back records with reasons`() {
|
||||
assertEquals(
|
||||
"Wavelog: 3 QSO(s) skipped — 3× logged grid outside the Wavelog station grid",
|
||||
wavelogIdleSegment(preview(skipped = 3, reasons = mapOf(WavelogProblem.GRID_MISMATCH to 3)))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed segment reports the outcome and pre-POST skips`() {
|
||||
val outcome = WavelogUploadOutcome.Imported(imported = 2, duplicates = 0, skipped = 0, markIds = listOf(1, 2))
|
||||
assertEquals("Uploaded 2 QSO(s) to Wavelog", wavelogConfirmedSegment(outcome, preview()))
|
||||
assertEquals(
|
||||
"Uploaded 2 QSO(s) to Wavelog\n1 QSO(s) skipped — 1× logged grid outside the Wavelog station grid",
|
||||
wavelogConfirmedSegment(
|
||||
outcome,
|
||||
preview(skipped = 1, reasons = mapOf(WavelogProblem.GRID_MISMATCH to 1))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed segment keeps the all-duplicates wording unprefixed`() {
|
||||
val outcome = WavelogUploadOutcome.Imported(imported = 0, duplicates = 2, skipped = 0, markIds = listOf(1, 2))
|
||||
assertEquals("All 2 record(s) already in Wavelog", wavelogConfirmedSegment(outcome, preview()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed segment prefixes failures with Wavelog`() {
|
||||
assertEquals("Wavelog: boom", wavelogConfirmedSegment(WavelogUploadOutcome.Failed("boom"), preview()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transport failure segment asks for a retry on the next upload`() {
|
||||
assertEquals(
|
||||
"Wavelog: upload failed — it will retry with your next upload",
|
||||
wavelogTransportFailureSegment()
|
||||
)
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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"))
|
||||
}
|
||||
|
||||
// --- firstCallsByGrid ---
|
||||
|
||||
@Test
|
||||
fun `first call per grid picks the earliest of the scoped records`() {
|
||||
val store = mapOf(
|
||||
"OL62" to listOf(
|
||||
qso("OTHER", myGrid = "PM01").copy(epochMs = 10),
|
||||
qso("MINE", myGrid = "OL62").copy(epochMs = 20)
|
||||
),
|
||||
"PM95" to listOf(qso("MINE2", myGrid = "OL62").copy(epochMs = 30))
|
||||
)
|
||||
assertEquals(mapOf("OL62" to "MINE", "PM95" to "MINE2"), store.firstCallsByGrid("OL62"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first call per grid with All uses every record`() {
|
||||
val store = mapOf(
|
||||
"OL62" to listOf(
|
||||
qso("OTHER", myGrid = "PM01").copy(epochMs = 10),
|
||||
qso("MINE", myGrid = "OL62").copy(epochMs = 20)
|
||||
)
|
||||
)
|
||||
assertEquals(mapOf("OL62" to "OTHER"), store.firstCallsByGrid(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grids without scoped records get no first call`() {
|
||||
val store = mapOf("OL62" to listOf(qso("A", myGrid = "PM01")))
|
||||
assertEquals(emptyMap<String, String>(), store.firstCallsByGrid("OL62"))
|
||||
}
|
||||
}
|
||||
+3
@@ -27,6 +27,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -248,6 +249,7 @@ class LoTWGridSyncTest {
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
private var workedGrids: Set<String> = emptySet()
|
||||
@@ -292,6 +294,7 @@ class LoTWGridSyncTest {
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = TODO()
|
||||
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user