Compare commits

...
5 Commits
Author SHA1 Message Date
mckero 9ca122a734 test(kmp): make commonTest sources compile on kotlin/native
With the main source sets now compiling on both platforms the native
test compilation finally ran, and it rejected a handful of test-side
forms the jvm toolchain silently accepts.

Backtick test names are mapped onto native symbols, where a comma is
an illegal character, so three names drop the comma; the wording keeps
the same meaning. java.lang.Math.PI has no common analogue and becomes
kotlin.math.PI, matching the already-qualified kotlin.math.sin call on
the same line. String.toByteArray() is jvm-only, and the byte-length
assertion for the APRS line budget switches to encodeToByteArray(),
the same replacement the production sources went through.
2026-09-27 10:54:16 +01:00
mckero 904d1ffbea fix(kmp): replace remaining jvm-only encoding calls in common sources
The previous round fixed the convention plugin and the native expect
declarations, which let both platforms compile far enough to reveal the
next layer: a handful of jvm-only call forms that survived the original
kotlin/native sweep because they look like plain kotlin.

String.toByteArray() and Charsets.UTF_8 live in java.nio.charset and do
not exist on kotlin/native; the stdlib equivalents encodeToByteArray()
compile everywhere and are byte-identical for utf-8, so the APRS packet
length budget and the ADIF field length calculation keep their exact
arithmetic. The DatabaseRepoTest helpers kept an InputStream return type
after their bodies were moved to ByteArray sources, and the java.io
import was gone with the sweep, so the android unit test task could not
resolve them; the fake source maps were already typed () -> ByteArray,
so the return type simply follows the data it now feeds.
2026-09-27 10:44:48 +01:00
mckero 9deb517874 fix(build): configure core domain source sets through container members
The second CI round got past the missing configure import but still
failed in the same file: bare name accessors inside sourceSets { }
(commonMain.dependencies { ... }, commonTest, jvmTest) are kotlin-dsl
script syntax generated for .kts files. Plugin source compiled as plain
Kotlin has no such accessors on its classpath, so the four dependency
blocks failed with receiver type mismatches while every real member call
around them - jvmToolchain, jvm(), the ios targets, binaries.framework -
already resolved.

Configure the source sets through the container API instead:
sourceSets.getByName("commonMain").dependencies { ... }. getByName,
dependencies and implementation are all members on types that ship with
KGP 2.4.10, verified against the gradle plugin jars byte for byte.
getByName is safe at this point because jvm() above has just created the
jvm source sets synchronously; commonMain and commonTest exist as soon
as the multiplatform plugin is applied.
2026-09-27 10:28:37 +01:00
mckero fa91e89024 fix(build): add missing gradle-kotlin-dsl import to the core domain convention plugin
The iOS CI run failed in both jobs before reaching any product code: the
convention plugin itself did not compile. CoreDomainPlugin.kt used the
reified extensions.configure<KotlinMultiplatformExtension> { } form, but
unlike every other plugin in this directory it was missing the
org.gradle.kotlin.dsl.configure import. Without it only the member
overloads taking an explicit type parameter resolve, so the extension
receiver cannot be inferred and all twenty subsequent unresolved
references - jvmToolchain, jvm(), iosArm64(), binaries.framework,
sourceSets with the commonMain/commonTest/jvmTest accessors - are one
cascading failure, not twenty bugs.

build-logic is the first thing both CI jobs compile, and it has never
been compiled anywhere before this run, so the workflow is doing exactly
what it was added for: catching what no local machine can check.
2026-09-27 10:07:08 +01:00
mckero 1a3c94f1e0 refactor(domain): make core:domain multiplatform so iOS can reuse it
The orbital maths, the satellite models and the repository contracts sat in a Kotlin/JVM
module, so an iOS target could not share a single line of them: java.lang.String.format,
InputStream, System.currentTimeMillis, java.util.Locale and org.json are all JVM-only, and
the tests that covered them used JUnit4. core:domain now declares jvm, iosArm64 and
iosSimulatorArm64 targets, its sources moved to commonMain/commonTest, and the JVM-only
pieces were replaced with multiplatform equivalents: java.lang.String.format by a shared
printf implementation, System.currentTimeMillis by kotlin.time.Clock, InputStream by
ByteArray, org.json by kotlinx-serialization, Locale by nothing at all. Tests that read
classpath resources (javaClass.classLoader) moved to jvmTest, because that is JVM-only
behaviour rather than a JVM-only API.

Auditing the migration against the old module turned up four things that were wrong rather
than merely ported:

- java.lang.String.format rounds the shortest decimal representation of a double half-up,
  not the binary value: "%.3f" of 0.5005 is "0.501", because the stored double is
  0.50049999999999994493. The shared implementation scaled in binary first and printed
  "0.500", which would have changed APRS position packets and the Wavelog frequency fields
  against the released Android app. It now takes the digits from the decimal representation
  and rounds them with integer arithmetic, and jvmTest compares it against
  String.format(Locale.ROOT, ...) over 40 000 sampled doubles plus the boundary cases, while
  commonTest pins literals so the iOS run checks the same digits.

- The queue mutators lost the kotlin.jvm.Synchronized monitor each of them had. It is not a
  JVM-only annotation - it is an optional expectation, so it still compiles in common code -
  but the stdlib deprecated it for common use in 1.8 and made it an error in 2.1. The monitor
  is a platform actual now: the JVM keeps the real monitor, since Compose and the upload
  coroutine both reach the queue there, and iOS carries a documented placeholder until the
  iOS side has a second thread to protect against.

- 107 assertions in DataParserTest and QthConverterTest were bare kotlin.assert calls, which
  a build without -ea skips silently: they are assertTrue now, so the iOS run cannot pass
  vacuously. The three Locale.setDefault cases (ar-EG, bn-BD, fa-IR) that used to guard APRS
  output against Eastern Arabic digits moved to jvmTest instead of being deleted with the
  Locale dependency - APRS-IS is an ASCII protocol, and Locale.setDefault does not exist on
  iOS.

- @Volatile on the LoTW name cache would not have compiled for iOS either: kotlin.jvm's
  variant is an error in common code since 2.1. kotlin.concurrent.Volatile is the
  multiplatform annotation, and it is the stronger form: it takes effect on Kotlin/Native
  rather than being ignored.

A second audit pass over the files the first one could not reach - the HTTP client, the
parsers, the queue and the injection - found three more:

- OkHttpHttpClient built its Request outside the try, so a URL OkHttp refuses to parse left
  postQso/testToken/getStation as an exception, and neither caller catches one. The client it
  replaced reported HTTP -1 and let the caller treat it as a failure; building the request
  inside the try restores that, and a transport failure reports -1 again rather than 0.

- WavelogQueue's readers were stricter than the org.json ones they replaced. A timestamp
  stored as 1234.0 (or "1234.0") read back as 0L instead of 1234 - a QSO uploaded as 1970 -
  and a field holding an object or array threw the whole list away instead of falling back.
  The readers coerce decimals, keep the old defaults and no longer throw, matching optLong,
  optInt, optString and optBoolean.

- The ADIF dates went through the JVM default locale before, so a device set to Arabic wrote
  Eastern Arabic digits into the QSO date. The shared formatter only ever produces ASCII,
  which the locale cases in AprsPacketDefaultLocaleTest pin down.

Verified locally with ./gradlew jvmTest (343 tests, 0 failures) and the multiplatform gate
in check-multiplatform.sh, which now also refuses JVM-only stdlib APIs that resolve in common
code but fail to compile for iOS: @Synchronized, kotlin.jvm.Volatile, synchronized(),
toUpperCase/toLowerCase/capitalize, BigDecimal. The iOS targets themselves need the macOS
runner in .github/workflows/ios-kmp.yml.
2026-09-27 08:55:49 +01:00
120 changed files with 1798 additions and 926 deletions

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
# First step of the Kotlin Multiplatform port: core:domain becomes shareable code that compiles
# and runs on iOS as well as Android. This workflow is the evidence for that claim - the same
# orbital math (SGP4/SDP4), models and repository contracts are compiled by the Kotlin/Native
# compiler for iOS and their unit tests run on an iOS simulator. It deliberately does not touch
# the Android build rules: the second job only proves the existing app still builds.
name: ios-kmp
on:
workflow_dispatch:
push:
branches:
- feat/ios-kmp
- ios-kmp
jobs:
ios:
name: iOS shared module
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@v4
- name: Compile shared module for iOS
run: ./gradlew :core:domain:compileKotlinIosSimulatorArm64 --console=plain
- name: Unit tests on the iOS simulator
run: ./gradlew :core:domain:iosSimulatorArm64Test --console=plain
- name: Upload iOS test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: ios-domain-test-report
path: core/domain/build/reports/tests/
android:
name: Android regression
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@v4
- name: JVM unit tests (domain and data)
run: ./gradlew :core:domain:jvmTest :core:data:testDebugUnitTest --console=plain
- name: Assemble debug APK
run: ./gradlew :app:assembleDebug --console=plain
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: android-reports
path: |
core/domain/build/reports/tests/
core/data/build/reports/tests/
+24 -4
View File
@@ -25,7 +25,7 @@ no tracking, no network required after initial data download.
|----------------------|---------------------------------------------------------------------| |----------------------|---------------------------------------------------------------------|
| `app` | Entry point. Aggregates all modules | | `app` | Entry point. Aggregates all modules |
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations | | `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts | | `core:domain` | Multiplatform: JVM + iOS. Orbital math (SGP4/SDP4), models, contracts |
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys | | `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
| `feature:map` | OSMDroid map with ground tracks | | `feature:map` | OSMDroid map with ground tracks |
| `feature:passes` | Pass predictions and upcoming events | | `feature:passes` | Pass predictions and upcoming events |
@@ -50,7 +50,7 @@ no tracking, no network required after initial data download.
./gradlew test ./gradlew test
``` ```
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17 - **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 21 (`jdkVersion` in the version catalog)
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/` - **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
## Tech Stack ## Tech Stack
@@ -71,7 +71,7 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs - **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support - **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default - New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()` - `DataParser.kt` supports both via `parseTLE()` and `parseCSV()`, each taking the file text
- Downloads auto-detect format; both produce identical `OrbitalData` objects - Downloads auto-detect format; both produce identical `OrbitalData` objects
- Existing code already supports transparent source transition without feature changes - Existing code already supports transparent source transition without feature changes
- Refresh orbital data weekly for accurate pass prediction (orbital decay) - Refresh orbital data weekly for accurate pass prediction (orbital decay)
@@ -103,11 +103,31 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
## Roadmap ## Roadmap
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM. - **KMP migration**: `core:domain` is now a Kotlin Multiplatform module (jvm + iosArm64/iosSimulatorArm64),
so the orbital math, models and repository contracts are compiled once and shared with the iOS app; Android
modules consume its jvm target. `commonMain` must stay free of JVM-only APIs (no `java.*`, `org.json`,
`String.format`, `Locale`, `InputStream`) - `formatString` in `utility/CommonFormat.kt` covers printf.
- **iOS app**: next step - an iOS shell that consumes the `Look4SatCore` framework plus the `expect`/`actual`
platform pieces (map, location, sensors, notifications).
## Gotchas ## Gotchas
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully. - Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
- `core:domain` is compiled for iOS too: anything added to its `commonMain` must exist in Kotlin/Native.
`.github/workflows/ios-kmp.yml` compiles it for iOS and runs the shared tests on an iOS simulator.
- Kotlin/JVM-only declarations still *resolve* in `commonMain` and only fail when the iOS target compiles:
`@Synchronized` and `@Volatile` (the `kotlin.jvm` ones) are errors in common code since Kotlin 2.1, as are
`toUpperCase`/`toLowerCase`/`capitalize` and `BigDecimal`. Use `kotlin.concurrent.Volatile`, and
`utility/SynchronizedOn.kt` (a platform actual) when a monitor is needed. `check-multiplatform.sh` in the
working copy's parent directory flags the rest.
- Source sets: `commonTest` runs on both jvm and iOS, so no JUnit4, no `javaClass.classLoader` and no bare
`assert()` there - a build without `-ea` skips those silently, and `-ea` is a JVM flag. Use `kotlin.test`.
JVM-only tests (classpath resources, `Locale.setDefault`) belong in `jvmTest`; platform code in
`jvmMain`/`iosMain`.
- `formatString` has to match `java.lang.String.format` exactly, and that rounds the *shortest decimal
representation* of a double half-up: `"%.3f"` of 0.5005 is `"0.501"`, even though the stored double is
0.50049999999999994493. `CommonFormatOracleTest` (jvmTest) compares against real `String.format` over
sampled doubles; `CommonFormatRoundingTest` (commonTest) pins literals so iOS checks the same digits.
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass. - SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules. - `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
- AMSAT status colours are ARGB literals in `core:data` (`AmSatRepository.statusColorOf`) and duplicated in - AMSAT status colours are ARGB literals in `core:data` (`AmSatRepository.statusColorOf`) and duplicated in
@@ -19,17 +19,50 @@ package com.rtbishop.look4sat.convention
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
/**
* core:domain is the one module shared by every platform: it holds the orbital math, the data
* models and the repository contracts, and none of it touches Android APIs. It is a Kotlin
* Multiplatform module (JVM for Android, Kotlin/Native for iOS) rather than a JVM one so the
* same compiled logic runs on both platforms instead of being reimplemented.
*
* Android modules consume the jvm target; the iOS app consumes the framework built from the
* ios targets. Anything JVM-only - org.json, java.net, java.io, java.util.Locale,
* String.format - cannot live in commonMain, because Kotlin/Native has none of them.
*/
@Suppress("Unused") @Suppress("Unused")
internal class CoreDomainPlugin : Plugin<Project> { internal class CoreDomainPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) { override fun apply(target: Project) = with(target) {
applyPlugin(libs.plugins.kotlin.jvm) applyPlugin(libs.plugins.kotlin.multiplatform)
applyPlugin(libs.plugins.kotlin.serialization) applyPlugin(libs.plugins.kotlin.serialization)
setupKotlin() extensions.configure<KotlinMultiplatformExtension> {
dependencies { jvmToolchain(libs.versions.jdkVersion.get().toInt())
jvm()
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Look4SatCore"
isStatic = true
}
}
// The bare name accessors (commonMain, jvmTest, ...) are script-only syntax;
// plugin source has to resolve through the container members, so configure each
// source set by name. getByName is safe here: jvm() above has just created the
// jvm source sets, the same pattern the local probe build script relies on.
sourceSets.getByName("commonMain").dependencies {
implementation(libs.kotlin.coroutines) implementation(libs.kotlin.coroutines)
implementation(libs.kotlin.serialization) implementation(libs.kotlin.serialization)
} }
sourceSets.getByName("commonTest").dependencies {
implementation(libs.kotlin.test)
implementation(libs.test.coroutines)
}
// JVM-only tests live here: the AndroidManifest check reads the file system, and
// the formatter oracle tests compare against java.lang.String.format.
sourceSets.getByName("jvmTest").dependencies {
implementation(libs.test.junit4)
}
}
} }
} }
@@ -36,6 +36,7 @@ import com.rtbishop.look4sat.core.data.repository.SelectionRepo
import com.rtbishop.look4sat.core.data.repository.SensorsRepo import com.rtbishop.look4sat.core.data.repository.SensorsRepo
import com.rtbishop.look4sat.core.data.repository.SettingsRepo import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.source.LocalSource import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.OkHttpHttpClient
import com.rtbishop.look4sat.core.data.source.RemoteSource import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
import com.rtbishop.look4sat.core.data.usecase.AudioCapture import com.rtbishop.look4sat.core.data.usecase.AudioCapture
@@ -73,11 +74,21 @@ import com.rtbishop.look4sat.core.data.qrz.QrzGridLookup
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
import com.rtbishop.look4sat.core.domain.wavelog.WaveLogApi
class MainContainer(private val context: Context) : IMainContainer { class MainContainer(private val context: Context) : IMainContainer {
private val localSource = provideLocalSource() private val localSource = provideLocalSource()
private val remoteSource by lazy { provideRemoteSource() } private val remoteSource by lazy { provideRemoteSource() }
/**
* WaveLogApi is a plain object in core:domain, and shared code has no socket API of its own on
* iOS, so the container hands it the platform client. Its requests used to be made by an
* HttpURLConnection built inside WaveLogApi, which only ever existed on the JVM.
*/
init {
WaveLogApi.installHttpClient(OkHttpHttpClient(OkHttpClient.Builder().build()))
}
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") } private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler) override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
override val settingsRepo = provideSettingsRepo() override val settingsRepo = provideSettingsRepo()
@@ -29,7 +29,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.InputStream import java.io.ByteArrayInputStream
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
class DatabaseRepo( class DatabaseRepo(
@@ -55,8 +55,8 @@ class DatabaseRepo(
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) { override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0 var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream -> remoteSource.getFileBytes(uri)?.let { data ->
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream)) val entries = parseSatelliteData(uri, unwrapIfZipped(uri, data))
localSource.insertEntries(entries) localSource.insertEntries(entries)
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum }) settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
importedCount = entries.size importedCount = entries.size
@@ -67,8 +67,8 @@ class DatabaseRepo(
override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) { override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0 var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream -> remoteSource.getFileBytes(uri)?.let { data ->
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream)) val transceivers = dataParser.parseJSON(unwrapIfZipped(uri, data).decodeToString())
localSource.insertRadios(transceivers) localSource.insertRadios(transceivers)
importedCount = transceivers.size importedCount = transceivers.size
} }
@@ -105,8 +105,8 @@ class DatabaseRepo(
Sources.transceiversDataUrls.filterValues { it.isNotBlank() } Sources.transceiversDataUrls.filterValues { it.isNotBlank() }
} }
// launch all network requests concurrently // launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } } val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkBytes(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } } val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkBytes(url) } }
val tleResults = tleJobs.awaitAll() val tleResults = tleJobs.awaitAll()
val radioResults = radioJobs.awaitAll() val radioResults = radioJobs.awaitAll()
// Orbital elements are counted on their own. A combined count let a successful transceivers // Orbital elements are counted on their own. A combined count let a successful transceivers
@@ -120,14 +120,14 @@ class DatabaseRepo(
throw java.io.IOException("No orbital data source could be downloaded") throw java.io.IOException("No orbital data source could be downloaded")
} }
// parse fetched data concurrently and associate with types // parse fetched data concurrently and associate with types
val importedEntries = tleResults.flatMap { (url, stream) -> val importedEntries = tleResults.flatMap { (url, data) ->
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries -> data?.let { parseSatelliteData(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum }) settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
} }
} }
val importedRadios = radioResults.flatMap { (url, stream) -> val importedRadios = radioResults.flatMap { (url, data) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty() data?.let { dataParser.parseJSON(unwrapIfZipped(url, it).decodeToString()) }.orEmpty()
} }
// insert parsed data into the database // insert parsed data into the database
localSource.insertEntries(importedEntries) localSource.insertEntries(importedEntries)
@@ -141,11 +141,11 @@ class DatabaseRepo(
setUpdateSuccessful(0L) setUpdateSuccessful(0L)
} }
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> { private suspend fun parseSatelliteData(url: String, data: ByteArray): List<OrbitalData> {
val bufferedStream = stream.buffered() val text = data.decodeToString()
return when { return when {
hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream) hasCsvHint(url) || looksLikeCsv(text) -> dataParser.parseCSV(text)
else -> dataParser.parseTLEStream(bufferedStream) else -> dataParser.parseTLE(text)
} }
} }
@@ -155,14 +155,9 @@ class DatabaseRepo(
url.endsWith(".csv.zip", ignoreCase = true) url.endsWith(".csv.zip", ignoreCase = true)
} }
private fun looksLikeCsv(stream: InputStream): Boolean { private fun looksLikeCsv(text: String): Boolean {
if (!stream.markSupported()) return false val line = text.lineSequence().firstOrNull()?.trim().orEmpty()
stream.mark(4096) if (line.isEmpty()) return false
val preview = ByteArray(4096)
val length = stream.read(preview)
stream.reset()
if (length <= 0) return false
val line = preview.decodeToString(0, length).lineSequence().firstOrNull()?.trim().orEmpty()
return line.contains("OBJECT_NAME", ignoreCase = true) || return line.contains("OBJECT_NAME", ignoreCase = true) ||
line.contains("NORAD_CAT_ID", ignoreCase = true) || line.contains("NORAD_CAT_ID", ignoreCase = true) ||
line.count { it == ',' } >= 4 line.count { it == ',' } >= 4
@@ -174,6 +169,10 @@ class DatabaseRepo(
) )
} }
private fun unwrapIfZipped(url: String, stream: InputStream): InputStream = private fun unwrapIfZipped(url: String, data: ByteArray): ByteArray =
if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream if (url.endsWith(".zip", ignoreCase = true)) {
ZipInputStream(ByteArrayInputStream(data)).apply { nextEntry }.readBytes()
} else {
data
}
} }
@@ -0,0 +1,89 @@
/*
* 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.data.source
import com.rtbishop.look4sat.core.domain.source.HttpResult
import com.rtbishop.look4sat.core.domain.source.IHttpClient
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
/**
* Android [IHttpClient] for the shared Wavelog/QRZ code, which cannot reach java.net on iOS.
*
* Connect and read timeouts are the 15 s the previous HttpURLConnection client used, applied to
* the passed client so the caller keeps one connection pool. The response body is returned for
* error codes as well, which is what reading errorStream did.
*/
class OkHttpHttpClient(
baseClient: OkHttpClient,
dispatcher: CoroutineDispatcher = Dispatchers.IO
) : IHttpClient {
private val dispatcher = dispatcher
private val client = baseClient.newBuilder()
.connectTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
override suspend fun post(url: String, headers: Map<String, String>, body: String): HttpResult =
execute {
Request.Builder().url(url).post(body.toRequestBody(JSON_MEDIA_TYPE)).withHeaders(headers).build()
}
override suspend fun get(url: String, headers: Map<String, String>): HttpResult =
execute { Request.Builder().url(url).withHeaders(headers).build() }
private suspend fun execute(buildRequest: () -> Request): HttpResult = withContext(dispatcher) {
try {
// Built in here, not by the caller: a URL OkHttp refuses to parse has to come back as
// the HTTP -1 the old client reported, not as an exception thrown at the caller.
val request = buildRequest()
client.newCall(request).execute().use { response ->
HttpResult(response.code, response.body.string())
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
HttpResult(NO_RESPONSE, "", exception.message ?: exception.javaClass.simpleName)
}
}
private fun Request.Builder.withHeaders(headers: Map<String, String>): Request.Builder {
headers.forEach { (name, value) -> header(name, value) }
return this
}
private companion object {
/** Matches HttpURLConnection's connect/read timeout in the original WaveLog client. */
const val TIMEOUT_MS = 15_000L
/** What HttpURLConnection's responseCode() reported when a request never got a response. */
const val NO_RESPONSE = -1
/** WaveLog's v2 and v1 endpoints take application/json in both directions. */
val JSON_MEDIA_TYPE = "application/json".toMediaType()
}
}
@@ -25,7 +25,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.InputStream
class RemoteSource( class RemoteSource(
private val dispatcher: CoroutineDispatcher, private val dispatcher: CoroutineDispatcher,
@@ -33,10 +32,10 @@ class RemoteSource(
private val httpClient: OkHttpClient private val httpClient: OkHttpClient
) : IRemoteSource { ) : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = withContext(dispatcher) { override suspend fun getFileBytes(uri: String): ByteArray? = withContext(dispatcher) {
try { try {
val fileUri = uri.toUri() val fileUri = uri.toUri()
contentResolver.openInputStream(fileUri)?.buffered() contentResolver.openInputStream(fileUri)?.use { it.readBytes() }
} catch (exception: CancellationException) { } catch (exception: CancellationException) {
throw exception throw exception
} catch (exception: Exception) { } catch (exception: Exception) {
@@ -45,17 +44,14 @@ class RemoteSource(
} }
} }
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) { override suspend fun getNetworkBytes(url: String): ByteArray? = withContext(dispatcher) {
try { try {
val networkRequest = Request.Builder().url(url).build() val networkRequest = Request.Builder().url(url).build()
val response = httpClient.newCall(networkRequest).execute() // The whole body is read here, which also returns the connection to OkHttp's pool
if (!response.isSuccessful) { httpClient.newCall(networkRequest).execute().use { response ->
response.close() if (!response.isSuccessful) return@use null
return@withContext null response.body.bytes()
} }
// Return the body stream directly as the caller is responsible for closing it
// That returns the connection to OkHttp's pool
response.body.byteStream().buffered()
} catch (exception: CancellationException) { } catch (exception: CancellationException) {
throw exception throw exception
} catch (exception: Exception) { } catch (exception: Exception) {
@@ -5,7 +5,6 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.io.InputStream
import java.util.Calendar import java.util.Calendar
import java.util.GregorianCalendar import java.util.GregorianCalendar
import java.util.Locale import java.util.Locale
@@ -18,8 +17,8 @@ import java.util.TimeZone
class AmSatAuditTest { class AmSatAuditTest {
private object UnusedSource : IRemoteSource { private object UnusedSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = null override suspend fun getFileBytes(uri: String): ByteArray? = null
override suspend fun getNetworkStream(url: String): InputStream? = null override suspend fun getNetworkBytes(url: String): ByteArray? = null
override suspend fun getAmSatCatalog(): String? = null override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun getAmSatSummary(hours: Int): String? = null override suspend fun getAmSatSummary(hours: Int): String? = null
@@ -4,7 +4,6 @@ import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.io.InputStream
import java.util.Calendar import java.util.Calendar
import java.util.TimeZone import java.util.TimeZone
@@ -24,8 +23,8 @@ import java.util.TimeZone
class AmSatSlotBuildTest { class AmSatSlotBuildTest {
private object UnusedSource : IRemoteSource { private object UnusedSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = null override suspend fun getFileBytes(uri: String): ByteArray? = null
override suspend fun getNetworkStream(url: String): InputStream? = null override suspend fun getNetworkBytes(url: String): ByteArray? = null
override suspend fun getAmSatCatalog(): String? = null override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun getAmSatSummary(hours: Int): String? = null override suspend fun getAmSatSummary(hours: Int): String? = null
@@ -41,7 +41,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.io.InputStream
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class DatabaseRepoTest { class DatabaseRepoTest {
@@ -54,7 +53,7 @@ class DatabaseRepoTest {
val uri = "content://look4sat/import/satellites" val uri = "content://look4sat/import/satellites"
val localSource = FakeLocalSource() val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply { val remoteSource = FakeRemoteSource().apply {
fileStreams[uri] = { validCsvStream() } fileData[uri] = { validCsvBytes() }
} }
val settingsRepo = FakeSettingsRepo() val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
@@ -72,7 +71,7 @@ class DatabaseRepoTest {
val uri = "content://look4sat/import/legacy" val uri = "content://look4sat/import/legacy"
val localSource = FakeLocalSource() val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply { val remoteSource = FakeRemoteSource().apply {
fileStreams[uri] = { validTleStream() } fileData[uri] = { validTleBytes() }
} }
val settingsRepo = FakeSettingsRepo() val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
@@ -88,7 +87,7 @@ class DatabaseRepoTest {
val customCsvUrl = "https://example.com/custom-omm.csv" val customCsvUrl = "https://example.com/custom-omm.csv"
val localSource = FakeLocalSource() val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply { val remoteSource = FakeRemoteSource().apply {
networkStreams[customCsvUrl] = { validCsvStream() } networkData[customCsvUrl] = { validCsvBytes() }
} }
val settingsRepo = FakeSettingsRepo( val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings( dataSources = DataSourcesSettings(
@@ -134,7 +133,7 @@ class DatabaseRepoTest {
// unanswered on purpose: org.json is compileOnly in core:domain, so DataParser cannot // unanswered on purpose: org.json is compileOnly in core:domain, so DataParser cannot
// parse a radio payload on the JVM anyway. // parse a radio payload on the JVM anyway.
Sources.satelliteDataUrls.values.filter { it.isNotBlank() } Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
.forEach { networkStreams[it] = { validCsvStream() } } .forEach { networkData[it] = { validCsvBytes() } }
} }
val settingsRepo = FakeSettingsRepo( val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings( dataSources = DataSourcesSettings(
@@ -172,7 +171,7 @@ class DatabaseRepoTest {
val localSource = FakeLocalSource() val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply { val remoteSource = FakeRemoteSource().apply {
Sources.transceiversDataUrls.values.filter { it.isNotBlank() } Sources.transceiversDataUrls.values.filter { it.isNotBlank() }
.forEach { networkStreams[it] = { "[]".byteInputStream() } } .forEach { networkData[it] = { "[]".encodeToByteArray() } }
} }
val settingsRepo = FakeSettingsRepo( val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings( dataSources = DataSourcesSettings(
@@ -195,30 +194,30 @@ class DatabaseRepoTest {
assertTrue("no entries may be inserted", localSource.insertedEntries.isEmpty()) assertTrue("no entries may be inserted", localSource.insertedEntries.isEmpty())
} }
private fun validCsvStream(): InputStream = """ private fun validCsvBytes(): ByteArray = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream() """.trimIndent().encodeToByteArray()
private fun validTleStream(): InputStream = """ private fun validTleBytes(): ByteArray = """
ISS (ZARYA) ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990 1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205 2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream() """.trimIndent().encodeToByteArray()
} }
private class FakeRemoteSource : IRemoteSource { private class FakeRemoteSource : IRemoteSource {
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf() val fileData: MutableMap<String, () -> ByteArray> = mutableMapOf()
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf() val networkData: MutableMap<String, () -> ByteArray> = mutableMapOf()
/** Every URL asked for, so a test can assert WHICH sources were fetched, not just the result. */ /** Every URL asked for, so a test can assert WHICH sources were fetched, not just the result. */
val requestedUrls = mutableListOf<String>() val requestedUrls = mutableListOf<String>()
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke() override suspend fun getFileBytes(uri: String): ByteArray? = fileData[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? { override suspend fun getNetworkBytes(url: String): ByteArray? {
requestedUrls += url requestedUrls += url
return networkStreams[url]?.invoke() return networkData[url]?.invoke()
} }
override suspend fun getAmSatCatalog(): String? = null override suspend fun getAmSatCatalog(): String? = null
-5
View File
@@ -1,8 +1,3 @@
plugins { plugins {
alias(libs.plugins.convention.coreDomainPlugin) alias(libs.plugins.convention.coreDomainPlugin)
} }
dependencies {
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
compileOnly("org.json:json:20240303")
}
@@ -91,7 +91,7 @@ object AprsBeacon {
) )
val header = "$source>$DESTINATION,$PATH:=" val header = "$source>$DESTINATION,$PATH:="
val body = position.toUncompressedString() val body = position.toUncompressedString()
val room = MAX_LINE_BYTES - CRLF_BYTES - header.toByteArray().size - body.toByteArray().size val room = MAX_LINE_BYTES - CRLF_BYTES - header.encodeToByteArray().size - body.encodeToByteArray().size
return Result.Line(header + body + sanitiseComment(comment, room)) return Result.Line(header + body + sanitiseComment(comment, room))
} }
@@ -2,7 +2,7 @@ package com.rtbishop.look4sat.core.domain.aprs
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.round import kotlin.math.round
import java.util.Locale import com.rtbishop.look4sat.core.domain.utility.formatString
/** /**
* APRS-IS protocol core (pure Kotlin, no Android dependencies). * APRS-IS protocol core (pure Kotlin, no Android dependencies).
@@ -30,7 +30,7 @@ object AprsPacket {
/** Optional distance filter: filter r/lat/lon/dist */ /** Optional distance filter: filter r/lat/lon/dist */
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String { fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
return String.format(Locale.ROOT, "r/%.3f/%.3f/%d", latitude, longitude, distKm) return formatString("r/%.3f/%.3f/%d", latitude, longitude, distKm)
} }
/** /**
@@ -42,7 +42,7 @@ object AprsPacket {
fun formatAltitude(altitudeMeters: Double?): String { fun formatAltitude(altitudeMeters: Double?): String {
if (altitudeMeters == null) return "" if (altitudeMeters == null) return ""
val feet = (altitudeMeters * 3.2808399).toInt().coerceIn(0, 999999) val feet = (altitudeMeters * 3.2808399).toInt().coerceIn(0, 999999)
return String.format(Locale.ROOT, "/A=%06d", feet) return formatString("/A=%06d", feet)
} }
/** /**
@@ -54,7 +54,7 @@ object AprsPacket {
if (speedMps == null || bearing == null) return "" if (speedMps == null || bearing == null) return ""
val knots = (speedMps * 1.94384449).toInt().coerceIn(0, 999) val knots = (speedMps * 1.94384449).toInt().coerceIn(0, 999)
val course = ((bearing.toInt() % 360) + 360) % 360 val course = ((bearing.toInt() % 360) + 360) % 360
return String.format(Locale.ROOT, "/%03d/%03d", course, knots) return formatString("/%03d/%03d", course, knots)
} }
} }
@@ -106,17 +106,17 @@ class AprsPosition(
val hundredths = iRound % 100 val hundredths = iRound % 100
val frac = when (positionAmbiguity) { val frac = when (positionAmbiguity) {
1 -> " . " 1 -> " . "
2 -> String.format(Locale.ROOT, "%d . ", minutes / 10) 2 -> formatString("%d . ", minutes / 10)
3 -> String.format(Locale.ROOT, "%02d. ", minutes) 3 -> formatString("%02d. ", minutes)
4 -> String.format(Locale.ROOT, "%02d.%d ", minutes, hundredths / 10) 4 -> formatString("%02d.%d ", minutes, hundredths / 10)
else -> String.format(Locale.ROOT, "%02d.%02d", minutes, hundredths) else -> formatString("%02d.%02d", minutes, hundredths)
} }
return if (isLat) { return if (isLat) {
val ns = if (value >= 0) 'N' else 'S' val ns = if (value >= 0) 'N' else 'S'
String.format(Locale.ROOT, "%02d%s%c", degrees, frac, ns) formatString("%02d%s%c", degrees, frac, ns)
} else { } else {
val ew = if (value >= 0) 'E' else 'W' val ew = if (value >= 0) 'E' else 'W'
String.format(Locale.ROOT, "%03d%s%c", degrees, frac, ew) formatString("%03d%s%c", degrees, frac, ew)
} }
} }
} }
@@ -91,8 +91,7 @@ object QrzGridParser {
* *
* Accepts a raw `k=v; k=v` header or the JSON array a cookie-export extension produces, * Accepts a raw `k=v; k=v` header or the JSON array a cookie-export extension produces,
* since the operator pastes whatever their browser handed them. Parsed by regex rather * since the operator pastes whatever their browser handed them. Parsed by regex rather
* than a JSON library because org.json is compileOnly here - it is supplied by Android at * than a JSON library so it needs no extra dependency and stays testable as plain text.
* runtime and absent from unit tests, so a JSON path could not be tested.
*/ */
fun cookieHeader(raw: String): String { fun cookieHeader(raw: String): String {
val text = raw.trim() val text = raw.trim()
@@ -19,10 +19,16 @@ package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class)
interface ISensorsRepo { interface ISensorsRepo {
val sensorData: StateFlow<Pair<Float, Float>> val sensorData: StateFlow<Pair<Float, Float>>
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
// The default used to be System.currentTimeMillis(), which Kotlin/Native does not have;
// kotlin.time.Clock is the multiplatform equivalent.
fun getMagDeclination(geoPos: GeoPos, time: Long = Clock.System.now().toEpochMilliseconds()): Float
fun enableSensor() fun enableSensor()
fun disableSensor() fun disableSensor()
} }
@@ -0,0 +1,28 @@
/*
* 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.source
/** Minimal platform HTTP client used by Wavelog/QRZ features. Implemented per platform
* (OkHttp on Android, NSURLSession on iOS). */
interface IHttpClient {
suspend fun post(url: String, headers: Map<String, String>, body: String): HttpResult
suspend fun get(url: String, headers: Map<String, String>): HttpResult
}
/** [code] is the HTTP status code, or 0 when the request could not be sent at all. */
data class HttpResult(val code: Int, val body: String, val failure: String? = null)
@@ -17,11 +17,9 @@
*/ */
package com.rtbishop.look4sat.core.domain.source package com.rtbishop.look4sat.core.domain.source
import java.io.InputStream
interface IRemoteSource { interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream? suspend fun getFileBytes(uri: String): ByteArray?
suspend fun getNetworkStream(url: String): InputStream? suspend fun getNetworkBytes(url: String): ByteArray?
/** Fetch AMSAT API catalog (JSON string; null on failure) */ /** Fetch AMSAT API catalog (JSON string; null on failure) */
suspend fun getAmSatCatalog(): String? suspend fun getAmSatCatalog(): String?
@@ -0,0 +1,186 @@
/*
* 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.utility
import kotlin.math.abs
/**
* Dependency-free replacement for jvm/Android `java.lang.String.format`, required because
* JVM formatting APIs do not exist on Kotlin/Native (iOS).
*
* Supported conversions: `%d` `%x` `%X` `%f` `%s` `%c` `%%`, plus the `0` flag, a width and
* `.precision` (for `%f`). Anything else throws, so an unsupported pattern never silently
* produces a wrong string.
*
* `%f` rounding matches java.lang.String.format (half-up on the exact double value) whenever
* the scaled value fits in a Long (< 2^53), which covers every frequency/coordinate string
* the app builds. Negative zero is preserved like the JVM does ("-0.000").
*/
fun formatString(pattern: String, vararg args: Any?): String {
val out = StringBuilder(pattern.length + 16)
var argIndex = 0
var i = 0
while (i < pattern.length) {
val ch = pattern[i]
if (ch != '%') {
out.append(ch); i++; continue
}
i++
if (i >= pattern.length) throw IllegalArgumentException("dangling '%' in pattern: $pattern")
if (pattern[i] == '%') {
out.append('%'); i++; continue
}
var zeroPadded = false
if (pattern[i] == '0') {
zeroPadded = true; i++
}
var width = 0
while (i < pattern.length && pattern[i].isDigit()) {
width = width * 10 + (pattern[i] - '0'); i++
}
// java.lang.String.format throws MissingFormatWidthException for this; an illegal
// pattern must not quietly format one way on Android and another way on iOS.
if (zeroPadded && width == 0) {
throw IllegalArgumentException("'0' flag without a width in pattern: $pattern")
}
var precision = -1 // java.lang.String.format defaults %f to 6 decimals
if (i < pattern.length && pattern[i] == '.') {
i++
precision = 0 // the digits accumulate from zero; -1 means "not specified"
while (i < pattern.length && pattern[i].isDigit()) {
precision = precision * 10 + (pattern[i] - '0'); i++
}
}
if (i >= pattern.length) throw IllegalArgumentException("truncated conversion in pattern: $pattern")
val conversion = pattern[i]
i++
val arg = if (argIndex < args.size) args[argIndex++] else null
val rendered = when (conversion) {
'd' -> longArg(arg, conversion, pattern).toString()
'x' -> longArg(arg, conversion, pattern).toString(16)
'X' -> longArg(arg, conversion, pattern).toString(16).uppercase()
'f' -> formatFixed(doubleArg(arg, pattern), if (precision < 0) 6 else precision, pattern)
's' -> arg?.toString() ?: "null"
'c' -> when (arg) {
is Char -> arg.toString()
is Int -> arg.toChar().toString()
else -> throw IllegalArgumentException("unsupported %c argument: $arg in pattern: $pattern")
}
else -> throw IllegalArgumentException("unsupported conversion %$conversion in pattern: $pattern")
}
if (width <= rendered.length) {
out.append(rendered)
} else if (zeroPadded && !rendered.startsWith("-") && !rendered.startsWith("+")) {
repeat(width - rendered.length) { out.append('0') }
out.append(rendered)
} else if (zeroPadded) {
out.append(rendered[0])
repeat(width - rendered.length) { out.append('0') }
out.append(rendered.substring(1))
} else {
repeat(width - rendered.length) { out.append(' ') }
out.append(rendered)
}
}
// Extra arguments are ignored, exactly like java.lang.String.format: call sites already
// pass what they pass and a port should not turn a latent extra argument into a crash.
return out.toString()
}
/** `"%.3f".format(1.2345)` -> `"1.235"` */
fun String.format(vararg args: Any?): String = formatString(this, *args)
private val POWERS_OF_TEN = longArrayOf(1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000)
private const val MAX_LONG_DIGITS = 18 // the most decimal digits that still fit in a Long
private fun longArg(arg: Any?, conversion: Char, pattern: String): Long = when (arg) {
is Int -> arg.toLong()
is Long -> arg
is Short -> arg.toLong()
is Byte -> arg.toLong()
else -> throw IllegalArgumentException("unsupported %$conversion argument: $arg in pattern: $pattern")
}
private fun doubleArg(arg: Any?, pattern: String): Double = when (arg) {
is Double -> arg
is Float -> arg.toDouble()
is Int -> arg.toDouble()
is Long -> arg.toDouble()
else -> throw IllegalArgumentException("unsupported %f argument: $arg in pattern: $pattern")
}
private fun formatFixed(value: Double, precision: Int, pattern: String): String {
if (precision !in 0..8) throw IllegalArgumentException("precision $precision too large in pattern: $pattern")
if (value.isNaN()) return "NaN"
if (value.isInfinite()) return if (value > 0.0) "Infinity" else "-Infinity"
val negative = value < 0.0 || (value == 0.0 && 1.0 / value < 0.0)
val rounded = roundHalfUp(abs(value), precision, value, pattern)
val power = POWERS_OF_TEN[precision]
val integerPart = rounded / power
val fractionPart = rounded % power
val result = StringBuilder()
if (negative) result.append('-')
result.append(integerPart)
if (precision > 0) {
result.append('.')
result.append(fractionPart.toString().padStart(precision, '0'))
}
return result.toString()
}
/**
* Rounds to [precision] decimals the way java.lang.String.format does: it rounds the shortest
* decimal representation of the double half-up, not its binary value. `"%.3f"` of 0.5005 is
* therefore `"0.501"`, even though the double holds 0.50049999999999994493.
*
* Scaling in binary first - floor(magnitude * 10^precision + 0.5) - loses exactly that and printed
* "0.500", so the digits come from the decimal representation and are rounded by integer
* arithmetic instead. Returns the value scaled by 10^precision.
*/
private fun roundHalfUp(magnitude: Double, precision: Int, value: Double, pattern: String): Long {
val text = magnitude.toString() // shortest representation that still round-trips
val exponentIndex = text.indexOfFirst { it == 'E' || it == 'e' }
val mantissa = if (exponentIndex < 0) text else text.substring(0, exponentIndex)
val exponent = if (exponentIndex < 0) 0 else text.substring(exponentIndex + 1).toInt()
val pointIndex = mantissa.indexOf('.')
val integerDigits = if (pointIndex < 0) mantissa else mantissa.substring(0, pointIndex)
val fractionDigits = if (pointIndex < 0) "" else mantissa.substring(pointIndex + 1)
val digits = integerDigits + fractionDigits
// magnitude == digits * 10^scale, so digits * 10^(scale + precision) is the scaled value.
val shift = exponent - fractionDigits.length + precision
val unscaled = digits.toLong()
if (shift >= 0) {
if (digits.length + shift > MAX_LONG_DIGITS) throw ValueTooLarge(value, precision, pattern)
var scaled = unscaled
repeat(shift) { scaled *= 10 }
return scaled
}
// Below half of the last printed digit everything rounds to zero, and 10^divisorDigits would
// no longer fit in a Long, so stop before building it.
val divisorDigits = -shift
if (divisorDigits > MAX_LONG_DIGITS) return 0L
var divisor = 1L
repeat(divisorDigits) { divisor *= 10 }
val quotient = unscaled / divisor
val remainder = unscaled % divisor
return if (2 * remainder >= divisor) quotient + 1 else quotient
}
// Values this large are never produced by the app; avoid silently wrong digits.
private class ValueTooLarge(value: Double, precision: Int, pattern: String) :
IllegalArgumentException("value $value too large for %.$precision" + "f in pattern: $pattern")
@@ -24,7 +24,6 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import java.io.InputStream
import kotlin.math.pow import kotlin.math.pow
class DataParser(private val dispatcher: CoroutineDispatcher) { class DataParser(private val dispatcher: CoroutineDispatcher) {
@@ -34,22 +33,20 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
coerceInputValues = true coerceInputValues = true
} }
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) { suspend fun parseCSV(data: String): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().useLines { lines -> data.lineSequence().drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
}
} }
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) { suspend fun parseTLE(data: String): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().readLines() data.lineSequence().toList()
.chunked(3) .chunked(3)
.filter { it.size == 3 && it[1].startsWith("1") && it[2].startsWith("2") } .filter { it.size == 3 && it[1].startsWith("1") && it[2].startsWith("2") }
.mapNotNull { parseTLE(it) } .mapNotNull { parseTLE(it) }
} }
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) { suspend fun parseJSON(data: String): List<SatRadio> = withContext(dispatcher) {
runCatching { runCatching {
val root = json.parseToJsonElement(stream.bufferedReader().readText()) val root = json.parseToJsonElement(data)
(root as? JsonArray)?.mapNotNull { element -> (root as? JsonArray)?.mapNotNull { element ->
runCatching { json.decodeFromJsonElement<SatRadio>(element) } runCatching { json.decodeFromJsonElement<SatRadio>(element) }
.onFailure { println("JSON parsing exception: $it") } .onFailure { println("JSON parsing exception: $it") }
@@ -11,7 +11,6 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import java.util.Locale
/** /**
* Computes Doppler-corrected reciprocal frequencies for linear transponders. * Computes Doppler-corrected reciprocal frequencies for linear transponders.
@@ -110,10 +109,10 @@ object DopplerFrequencyCalculator {
fun isNamedLinearTransponder(transponder: SatRadio): Boolean { fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
if (!isLinearTransponder(transponder)) return false if (!isLinearTransponder(transponder)) return false
val info = transponder.info.lowercase(Locale.ENGLISH) val info = transponder.info.lowercase()
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode) val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ") .joinToString(separator = " ")
.lowercase(Locale.ENGLISH) .lowercase()
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin") val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
val hasTransponderName = info.contains("transponder") || info.contains("transp") || val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr") info.contains("xponder") || info.contains("xpdr")
@@ -17,16 +17,13 @@
*/ */
package com.rtbishop.look4sat.core.domain.utility package com.rtbishop.look4sat.core.domain.utility
import java.util.Locale
import java.util.concurrent.TimeUnit
fun Long.toTimerString(): String { fun Long.toTimerString(): String {
val millis = coerceAtLeast(0L) val millis = coerceAtLeast(0L)
val format = "%02d:%02d:%02d" val format = "%02d:%02d:%02d"
val hours = TimeUnit.MILLISECONDS.toHours(millis) val hours = millis / 3_600_000L
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60 val minutes = millis / 60_000L % 60
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60 val seconds = millis / 1_000L % 60
return String.format(Locale.ENGLISH, format, hours, minutes, seconds) return formatString(format, hours, minutes, seconds)
} }
fun Float.round(decimals: Int): Float { fun Float.round(decimals: Int): Float {
@@ -0,0 +1,29 @@
/*
* 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.utility
/**
* Runs [block] while holding [lock]'s monitor, the way kotlin.jvm.Synchronized used to hold it
* before core:domain became a multiplatform module.
*
* The annotation survives in common code as an optional expectation, but the stdlib deprecated it
* there in Kotlin 1.8 and made it an error in 2.1: "Synchronizing methods on a class instance is
* not supported on platforms other than JVM." The monitor therefore moves behind a platform
* actual, which keeps the JVM semantics exactly and lets the iOS side say what it does instead.
*/
internal expect fun <T> synchronizedOn(lock: Any, block: () -> T): T
@@ -5,6 +5,10 @@
*/ */
package com.rtbishop.look4sat.core.domain.wavelog package com.rtbishop.look4sat.core.domain.wavelog
// kotlin.jvm.Volatile 是 common 里 2.1 起的编译错误;kotlin.concurrent.Volatile 才是多平台的那个,
// 且在 JVM 与 Kotlin/Native 上都生效。
import kotlin.concurrent.Volatile
object LotwSatellites { object LotwSatellites {
private val staticNames: Set<String> = setOf("AISAT1", "AO-10", "AO-109", "AO-123", "AO-13", "AO-16", "AO-21", "AO-27", "AO-3", "AO-4", "AO-40", "AO-51", "AO-6", "AO-7", "AO-73", "AO-8", "AO-85", "AO-91", "AO-92", "ARISS", "Arsene", "BO-102", "BY70-1", "CAS-2T", "CAS-3H", "CAS-4A", "CAS-4B", "DO-64", "EO-79", "EO-88", "FO-118", "FO-12", "FO-20", "FO-29", "FO-99", "FS-3", "HO-107", "HO-113", "HO-119", "HO-68", "INSPR7", "IO-117", "IO-86", "JO-97", "KEDR", "LEDSAT", "LO-19", "LO-78", "LO-87", "LO-90", "MAYA-3", "MAYA-4", "MIREX", "MO-112", "MO-122", "NO-103", "NO-104", "NO-44", "NO-83", "NO-84", "PO-101", "QO-100", "RS-1", "RS-10", "RS-11", "RS-12", "RS-13", "RS-15", "RS-2", "RS-44", "RS-5", "RS-6", "RS-7", "RS-8", "SAREX", "SO-121", "SO-124", "SO-125", "SO-35", "SO-41", "SO-50", "SO-67", "SONATE", "TAURUS", "TEVEL1", "TEVEL2", "TEVEL3", "TEVEL4", "TEVEL5", "TEVEL6", "TEVEL7", "TEVEL8", "TO-108", "UKUBE1", "UO-14", "UVSQ", "VO-52", "XW-2A", "XW-2B", "XW-2C", "XW-2D", "XW-2E", "XW-2F", "TEV2-1", "TEV2-2", "TEV2-3", "TEV2-4", "TEV2-5", "TEV2-6", "TEV2-7", "TEV2-8", "TEV2-9") private val staticNames: Set<String> = setOf("AISAT1", "AO-10", "AO-109", "AO-123", "AO-13", "AO-16", "AO-21", "AO-27", "AO-3", "AO-4", "AO-40", "AO-51", "AO-6", "AO-7", "AO-73", "AO-8", "AO-85", "AO-91", "AO-92", "ARISS", "Arsene", "BO-102", "BY70-1", "CAS-2T", "CAS-3H", "CAS-4A", "CAS-4B", "DO-64", "EO-79", "EO-88", "FO-118", "FO-12", "FO-20", "FO-29", "FO-99", "FS-3", "HO-107", "HO-113", "HO-119", "HO-68", "INSPR7", "IO-117", "IO-86", "JO-97", "KEDR", "LEDSAT", "LO-19", "LO-78", "LO-87", "LO-90", "MAYA-3", "MAYA-4", "MIREX", "MO-112", "MO-122", "NO-103", "NO-104", "NO-44", "NO-83", "NO-84", "PO-101", "QO-100", "RS-1", "RS-10", "RS-11", "RS-12", "RS-13", "RS-15", "RS-2", "RS-44", "RS-5", "RS-6", "RS-7", "RS-8", "SAREX", "SO-121", "SO-124", "SO-125", "SO-35", "SO-41", "SO-50", "SO-67", "SONATE", "TAURUS", "TEVEL1", "TEVEL2", "TEVEL3", "TEVEL4", "TEVEL5", "TEVEL6", "TEVEL7", "TEVEL8", "TO-108", "UKUBE1", "UO-14", "UVSQ", "VO-52", "XW-2A", "XW-2B", "XW-2C", "XW-2D", "XW-2E", "XW-2F", "TEV2-1", "TEV2-2", "TEV2-3", "TEV2-4", "TEV2-5", "TEV2-6", "TEV2-7", "TEV2-8", "TEV2-9")
@@ -10,15 +10,17 @@
*/ */
package com.rtbishop.look4sat.core.domain.wavelog package com.rtbishop.look4sat.core.domain.wavelog
import kotlinx.coroutines.Dispatchers import com.rtbishop.look4sat.core.domain.source.HttpResult
import kotlinx.coroutines.withContext import com.rtbishop.look4sat.core.domain.source.IHttpClient
import org.json.JSONObject import com.rtbishop.look4sat.core.domain.utility.formatString
import java.io.BufferedReader import kotlinx.serialization.json.Json
import java.io.InputStreamReader import kotlinx.serialization.json.JsonObject
import java.io.OutputStreamWriter import kotlinx.serialization.json.buildJsonObject
import java.net.HttpURLConnection import kotlinx.serialization.json.contentOrNull
import java.net.URL import kotlinx.serialization.json.intOrNull
import java.util.Locale import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
/** Station info (GET /api/v2/station/{id} result) */ /** Station info (GET /api/v2/station/{id} result) */
data class WavelogStation( data class WavelogStation(
@@ -35,7 +37,19 @@ sealed class WavelogResult {
object WaveLogApi { object WaveLogApi {
private const val TIMEOUT_MS = 15000 /** Milliseconds in a day; date fields are derived from the QSO timestamp without Calendar. */
private const val MS_PER_DAY = 86_400_000L
/**
* Platform HTTP client for every request below. Handed over by the DI container because an
* object has no constructor for it, and a shared object has no platform socket API to use
* on its own.
*/
private var httpClient: IHttpClient? = null
fun installHttpClient(client: IHttpClient) {
httpClient = client
}
/** Normalize server URL: strip trailing slash/index.php; prepend https:// when missing */ /** Normalize server URL: strip trailing slash/index.php; prepend https:// when missing */
fun normalizeUrl(raw: String): String { fun normalizeUrl(raw: String): String {
@@ -47,54 +61,54 @@ object WaveLogApi {
} }
/** Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif */ /** Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif */
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) { suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult {
val base = normalizeUrl(url) val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空") if (base.isBlank()) return WavelogResult.Failure("服务器地址为空")
// v2: GET /index.php/api/v2/token // v2: GET /index.php/api/v2/token
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null) val v2 = httpRequest("$base/index.php/api/v2/token", apiKey, null)
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)") if (v2.code in 200..299) return WavelogResult.Success("连接成功 (API v2)")
// v1: POST /index.php/api/get_contacts_adif (key in body) // v1: POST /index.php/api/get_contacts_adif (key in body)
if (stationId.isNotBlank()) { if (stationId.isNotBlank()) {
val body = JSONObject().apply { val body = buildJsonObject {
put("key", apiKey) put("key", apiKey)
put("station_id", stationId) put("station_id", stationId)
put("fetchfromid", 0) put("fetchfromid", 0)
}.toString() }.toString()
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body) val v1 = httpRequest("$base/index.php/api/get_contacts_adif", apiKey, body)
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)") if (v1.code in 200..299) return WavelogResult.Success("连接成功 (API v1)")
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)") if (v1.code == 401) return WavelogResult.Failure("API 密钥无效 (v1: 401)")
} }
// v1 attempt without index.php // v1 attempt without index.php
val body = JSONObject().apply { val body = buildJsonObject {
put("key", apiKey) put("key", apiKey)
put("station_id", stationId) put("station_id", stationId)
put("fetchfromid", 0) put("fetchfromid", 0)
}.toString() }.toString()
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body) val v1b = httpRequest("$base/api/get_contacts_adif", apiKey, body)
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)") if (v1b.code in 200..299) return WavelogResult.Success("连接成功 (API v1)")
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)") if (v1b.code == 401) return WavelogResult.Failure("API 密钥无效 (v1: 401)")
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确") return WavelogResult.Failure("连接失败: v2 HTTP ${v2.code}, v1 HTTP ${v1b.code} — 请确认服务器地址/密钥正确")
} }
/** Station info: v2 only; v1 lacks the endpoint (grid check falls back to user QTH) */ /** Station info: v2 only; v1 lacks the endpoint (grid check falls back to user QTH) */
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) { suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult {
val base = normalizeUrl(url) val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空") if (base.isBlank()) return WavelogResult.Failure("服务器地址为空")
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null) val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", apiKey, null)
if (code in 200..299) { if (code in 200..299) {
return@withContext try { return try {
val obj = JSONObject(resp) val obj = Json.parseToJsonElement(resp).jsonObject
val data = obj.optJSONObject("data") ?: obj val data = obj["data"] as? JsonObject ?: obj
val station = WavelogStation( val station = WavelogStation(
id = data.optInt("id"), id = data["id"]?.jsonPrimitive?.intOrNull ?: 0,
name = data.optString("name"), name = data["name"]?.jsonPrimitive?.contentOrNull.orEmpty(),
callsign = data.optString("callsign"), callsign = data["callsign"]?.jsonPrimitive?.contentOrNull.orEmpty(),
gridsquare = data.optString("gridsquare") gridsquare = data["gridsquare"]?.jsonPrimitive?.contentOrNull.orEmpty()
) )
WavelogResult.Success(JSONObject().apply { WavelogResult.Success(buildJsonObject {
put("id", station.id); put("name", station.name) put("id", station.id); put("name", station.name)
put("callsign", station.callsign); put("gridsquare", station.gridsquare) put("callsign", station.callsign); put("gridsquare", station.gridsquare)
}.toString()) }.toString())
@@ -103,7 +117,7 @@ object WaveLogApi {
} }
} }
// v1 has no station endpoint -> return empty Success (caller falls back to user QTH) // v1 has no station endpoint -> return empty Success (caller falls back to user QTH)
WavelogResult.Success("") return WavelogResult.Success("")
} }
/** /**
@@ -182,7 +196,7 @@ object WaveLogApi {
LotwSatellites.names.firstOrNull { it.squashSeparators() == squashed } LotwSatellites.names.firstOrNull { it.squashSeparators() == squashed }
?.let { return it } ?.let { return it }
} }
return trimmed.uppercase(Locale.ENGLISH) return trimmed.uppercase()
} }
/** True when [normalizeSatName] produced a name LoTW will accept rather than a guess. */ /** True when [normalizeSatName] produced a name LoTW will accept rather than a guess. */
@@ -206,7 +220,7 @@ object WaveLogApi {
return parts.filter { it.isNotEmpty() }.distinct() return parts.filter { it.isNotEmpty() }.distinct()
} }
private fun String.squashSeparators() = replace(Regex("[-\\s._/]"), "").uppercase(Locale.ENGLISH) private fun String.squashSeparators() = replace(Regex("[-\\s._/]"), "").uppercase()
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */ /** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
suspend fun postQso( suspend fun postQso(
@@ -215,23 +229,23 @@ object WaveLogApi {
stationProfileId: String, stationProfileId: String,
qso: WavelogQso, qso: WavelogQso,
gridsquare: String gridsquare: String
): WavelogResult = withContext(Dispatchers.IO) { ): WavelogResult {
val base = normalizeUrl(url) val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空") if (base.isBlank()) return WavelogResult.Failure("服务器地址为空")
val satName = normalizeSatName(qso.satName, qso.catnum.takeIf { it > 0 }) val satName = normalizeSatName(qso.satName, qso.catnum.takeIf { it > 0 })
// v2: POST /index.php/api/v2/qso (JSON fields) // v2: POST /index.php/api/v2/qso (JSON fields)
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz) val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
val v2Body = JSONObject().apply { val v2Body = buildJsonObject {
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0) put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
put("call", qso.call) put("call", qso.call)
put("band", bandFromHz(qso.freqTxHz)) put("band", bandFromHz(qso.freqTxHz))
put("mode", qso.mode) put("mode", qso.mode)
put("qso_date", utcDate(qso.timeUtcMs)) put("qso_date", utcDate(qso.timeUtcMs))
put("time_on", utcTime(qso.timeUtcMs)) put("time_on", utcTime(qso.timeUtcMs))
put("freq", String.format(Locale.ENGLISH, "%.6fM", qso.freqTxHz / 1_000_000.0)) put("freq", formatString("%.6fM", qso.freqTxHz / 1_000_000.0))
put("freq_rx", String.format(Locale.ENGLISH, "%.6fM", qso.freqRxHz / 1_000_000.0)) put("freq_rx", formatString("%.6fM", qso.freqRxHz / 1_000_000.0))
put("gridsquare", gridsquare) put("gridsquare", gridsquare)
put("rst_sent", "59") put("rst_sent", "59")
put("rst_rcvd", "59") put("rst_rcvd", "59")
@@ -241,11 +255,11 @@ object WaveLogApi {
// The body decides, not the status code: Wavelog validates after responding, so a rejected // The body decides, not the status code: Wavelog validates after responding, so a rejected
// QSO arrives as HTTP 200 with {"status":"failed"}. Trusting the code marked it uploaded // QSO arrives as HTTP 200 with {"status":"failed"}. Trusting the code marked it uploaded
// and dropped it from the queue. // and dropped it from the queue.
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString()) val (code, resp) = httpRequest("$base/index.php/api/v2/qso", apiKey, v2Body.toString())
val v2Verdict = WavelogResponse.verdict(code, resp) val v2Verdict = WavelogResponse.verdict(code, resp)
when (v2Verdict) { when (v2Verdict) {
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v2") is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v2")
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate") WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate")
// Anything else falls through to v1. A rejection here is NOT final: v2 refuses a legacy // Anything else falls through to v1. A rejection here is NOT final: v2 refuses a legacy
// v1 key with 401 invalid_token, and returning at that point stopped a v1-only operator // v1 key with 401 invalid_token, and returning at that point stopped a v1-only operator
// from uploading at all. The v1 attempt below is the one that can speak for them. // from uploading at all. The v1 attempt below is the one that can speak for them.
@@ -253,29 +267,29 @@ object WaveLogApi {
} }
// v1: POST /index.php/api/qso (key in body + ADIF) // v1: POST /index.php/api/qso (key in body + ADIF)
val v1Body = JSONObject().apply { val v1Body = buildJsonObject {
put("key", apiKey) put("key", apiKey)
put("station_profile_id", stationProfileId) put("station_profile_id", stationProfileId)
put("type", "adif") put("type", "adif")
put("string", toAdif(qso, gridsquare, satName)) put("string", toAdif(qso, gridsquare, satName))
} }
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString()) val (code1, resp1) = httpRequest("$base/index.php/api/qso", apiKey, v1Body.toString())
val v1Verdict = WavelogResponse.verdict(code1, resp1) val v1Verdict = WavelogResponse.verdict(code1, resp1)
when (v1Verdict) { when (v1Verdict) {
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1") is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v1")
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate") WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate")
// Also falls through: a server with different rewrite rules answers this path with a // Also falls through: a server with different rewrite rules answers this path with a
// 404 page, which is a rejection but says nothing about whether the QSO can be stored. // 404 page, which is a rejection but says nothing about whether the QSO can be stored.
else -> Unit else -> Unit
} }
// v1 without index.php, for a server whose rewrite rules differ // v1 without index.php, for a server whose rewrite rules differ
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString()) val (code1b, resp1b) = httpRequest("$base/api/qso", apiKey, v1Body.toString())
when (val verdict = WavelogResponse.verdict(code1b, resp1b)) { when (val verdict = WavelogResponse.verdict(code1b, resp1b)) {
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1") is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v1")
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate") WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate")
is WavelogResponse.Verdict.Rejected -> is WavelogResponse.Verdict.Rejected ->
return@withContext WavelogResult.Failure(verdict.reason) return WavelogResult.Failure(verdict.reason)
is WavelogResponse.Verdict.Unreadable -> Unit is WavelogResponse.Verdict.Unreadable -> Unit
} }
@@ -288,7 +302,7 @@ object WaveLogApi {
(v1Verdict as? WavelogResponse.Verdict.Rejected)?.reason, (v1Verdict as? WavelogResponse.Verdict.Rejected)?.reason,
(v2Verdict as? WavelogResponse.Verdict.Rejected)?.reason (v2Verdict as? WavelogResponse.Verdict.Rejected)?.reason
).filter { it.isNotBlank() } ).filter { it.isNotBlank() }
WavelogResult.Failure( return WavelogResult.Failure(
reasons.firstOrNull() reasons.firstOrNull()
?: ("no endpoint accepted it: v2 HTTP $code, v1 HTTP $code1, v1-alt HTTP $code1b" + ?: ("no endpoint accepted it: v2 HTTP $code, v1 HTTP $code1, v1-alt HTTP $code1b" +
" - " + shortError(resp1.ifBlank { resp1b })) " - " + shortError(resp1.ifBlank { resp1b }))
@@ -298,7 +312,7 @@ object WaveLogApi {
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */ /** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
internal fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String { internal fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
fun field(name: String, value: String): String { fun field(name: String, value: String): String {
val bytes = value.toByteArray(Charsets.UTF_8).size val bytes = value.encodeToByteArray().size
return "<$name:$bytes>$value" return "<$name:$bytes>$value"
} }
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz) val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
@@ -306,9 +320,9 @@ object WaveLogApi {
append(field("call", qso.call)) append(field("call", qso.call))
append(field("band", bandFromHz(qso.freqTxHz))) append(field("band", bandFromHz(qso.freqTxHz)))
append(field("mode", qso.mode)) append(field("mode", qso.mode))
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0))) append(field("freq", formatString("%.6f", qso.freqTxHz / 1_000_000.0)))
if (qso.freqRxHz > 0) { if (qso.freqRxHz > 0) {
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0))) append(field("freq_rx", formatString("%.6f", qso.freqRxHz / 1_000_000.0)))
} }
append(field("qso_date", utcDateCompact(qso.timeUtcMs))) append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
append(field("time_on", utcTimeCompact(qso.timeUtcMs))) append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
@@ -328,76 +342,78 @@ object WaveLogApi {
} }
} }
/** Generic HTTP request (returns code + body) */ /**
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> { * POST [jsonBody] when one is given, GET otherwise. A request that could not be sent at all
return try { * comes back as code 0, so callers only have to look at the code.
val conn = URL(url).openConnection() as HttpURLConnection */
conn.requestMethod = method private suspend fun httpRequest(url: String, apiKey: String, jsonBody: String?): HttpResult {
conn.connectTimeout = TIMEOUT_MS val client = httpClient ?: error("WaveLogApi has no HTTP client installed")
conn.readTimeout = TIMEOUT_MS val headers = buildMap {
if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey") if (apiKey.isNotBlank()) put("Authorization", "Bearer $apiKey")
if (jsonBody != null) { if (jsonBody != null) {
conn.doOutput = true put("Content-Type", "application/json")
conn.setRequestProperty("Content-Type", "application/json") put("Accept", "application/json")
conn.setRequestProperty("Accept", "application/json")
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
} }
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val body = if (stream != null) {
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
} else ""
code to body
} catch (e: Exception) {
-1 to (e.message ?: e.javaClass.simpleName)
} }
val result = if (jsonBody != null) client.post(url, headers, jsonBody) else client.get(url, headers)
// A request that never left the phone used to report the exception text where the body
// goes, which is what the failure messages below read; keep it there.
val failure = result.failure
return if (result.body.isEmpty() && failure != null) result.copy(body = failure) else result
} }
private fun shortError(body: String): String { private fun shortError(body: String): String {
if (body.startsWith("<")) return body.take(80) // HTML error page if (body.startsWith("<")) return body.take(80) // HTML error page
return try { return try {
val obj = JSONObject(body) val obj = Json.parseToJsonElement(body).jsonObject
val err = obj.optJSONObject("error") val err = obj["error"] as? JsonObject
err?.optString("message")?.ifBlank { body.take(120) } if (err != null) {
?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } } err["message"]?.jsonPrimitive?.contentOrNull.orEmpty().ifBlank { body.take(120) }
} else {
obj["reason"]?.jsonPrimitive?.contentOrNull.orEmpty()
.ifBlank { obj["message"]?.jsonPrimitive?.contentOrNull.orEmpty().ifBlank { body.take(120) } }
}
} catch (_: Exception) { } catch (_: Exception) {
body.take(120) body.take(120)
} }
} }
/** UTC civil time of a Unix millisecond stamp; the JVM Calendar is not multiplatform. */
private data class UtcFields(val year: Int, val month: Int, val day: Int, val hour: Int, val minute: Int, val second: Int)
private fun utcFieldsOf(ms: Long): UtcFields {
val days = ms.floorDiv(MS_PER_DAY)
val millisOfDay = ms.mod(MS_PER_DAY)
val shifted = days + 719_468
val era = shifted.floorDiv(146_097)
val dayOfEra = shifted - era * 146_097
val yearOfEra = (dayOfEra - dayOfEra / 1_460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365
val dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100)
val monthPart = (5 * dayOfYear + 2) / 153
val day = (dayOfYear - (153 * monthPart + 2) / 5 + 1).toInt()
val month = (if (monthPart < 10) monthPart + 3 else monthPart - 9).toInt()
val year = yearOfEra.toInt() + era.toInt() * 400 + (if (month <= 2) 1 else 0)
val secondOfDay = (millisOfDay / 1000).toInt()
return UtcFields(year, month, day, secondOfDay / 3_600, secondOfDay / 60 % 60, secondOfDay % 60)
}
private fun utcDate(ms: Long): String { private fun utcDate(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) val utc = utcFieldsOf(ms)
cal.timeInMillis = ms return formatString("%04d-%02d-%02d", utc.year, utc.month, utc.day)
return "%04d-%02d-%02d".format(
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH)
)
} }
private fun utcTime(ms: Long): String { private fun utcTime(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) val utc = utcFieldsOf(ms)
cal.timeInMillis = ms return formatString("%02d:%02d:%02d", utc.hour, utc.minute, utc.second)
return "%02d:%02d:%02d".format(
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND)
)
} }
private fun utcDateCompact(ms: Long): String { private fun utcDateCompact(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) val utc = utcFieldsOf(ms)
cal.timeInMillis = ms return formatString("%04d%02d%02d", utc.year, utc.month, utc.day)
return "%04d%02d%02d".format(
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH)
)
} }
private fun utcTimeCompact(ms: Long): String { private fun utcTimeCompact(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) val utc = utcFieldsOf(ms)
cal.timeInMillis = ms return formatString("%02d%02d%02d", utc.hour, utc.minute, utc.second)
return "%02d%02d%02d".format(
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND)
)
} }
} }
@@ -0,0 +1,162 @@
/*
* WavelogQueue.kt - WaveLog local log queue (4.5.2).
*
* Pure Kotlin (no Android deps): storage goes through the IWavelogQueueStore interface,
* implemented with SharedPreferences in core/data.
* Queue capped at 500 entries (oldest dropped beyond that).
*/
package com.rtbishop.look4sat.core.domain.wavelog
import com.rtbishop.look4sat.core.domain.utility.synchronizedOn
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
/** Storage abstraction (SharedPreferences impl lives in core/data) */
interface IWavelogQueueStore {
fun load(): String
fun save(json: String)
}
/** QSO entry awaiting upload (local queue element, mirrors POST /api/v2/qso fields) */
data class WavelogQso(
val id: String, // 本地唯一 id(UUID)
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
val call: String,
val mode: String,
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
val freqRxHz: Long, // 下行
val satName: String,
/**
* NORAD catalogue number of the satellite, or 0 when it was not recorded.
*
* Carried because the name alone cannot decide the LoTW spelling - sources disagree, and
* the same satellite named two ways would upload two ways. Zero means a QSO logged before
* this field existed; those fall back to resolving from the name.
*/
val catnum: Int = 0,
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
)
/**
* Every mutator is a read-modify-write over the single stored blob and serialises on a private
* monitor, so the Compose thread and the upload coroutine cannot drop each other's entries. The
* monitor is a platform actual (utility/SynchronizedOn.kt) because kotlin.jvm.Synchronized is an
* error in common code since Kotlin 2.1.
*/
class WavelogQueue(private val store: IWavelogQueueStore) {
private val key = "wavelog_queue"
private val lock = Any()
fun all(): List<WavelogQso> {
val raw = store.load()
return try {
Json.parseToJsonElement(raw).jsonArray.map { element ->
val o = element.jsonObject
WavelogQso(
id = o.getValue("id").jsonPrimitive.content,
timeUtcMs = o["timeUtcMs"].readLong(),
call = o["call"].readString(),
mode = o["mode"].readString(),
freqTxHz = o["freqTxHz"].readLong(),
freqRxHz = o["freqRxHz"].readLong(),
satName = o["satName"].readString(),
catnum = o["catnum"].readInt(),
sessionId = o["sessionId"].readString(),
gridsquare = o["gridsquare"].readString(),
uploaded = o["uploaded"].readBoolean()
)
}
} catch (_: Exception) {
emptyList()
}
}
fun add(qso: WavelogQso) {
synchronizedOn(lock) {
val list = all().toMutableList()
list.add(0, qso) // 最新在前
if (list.size > 500) list.removeAt(list.size - 1)
save(list)
}
}
fun remove(id: String) {
synchronizedOn(lock) { save(all().filter { it.id != id }) }
}
fun removeAll(ids: Set<String>) {
synchronizedOn(lock) { save(all().filter { it.id !in ids }) }
}
/** Mark as uploaded (kept in the queue; checkmark in the table) */
fun markUploaded(id: String) {
synchronizedOn(lock) { save(all().map { if (it.id == id) it.copy(uploaded = true) else it }) }
}
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
fun updateGridsquare(id: String, grid: String) {
synchronizedOn(lock) { save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it }) }
}
/** Remove all uploaded entries (optional; keeps the queue lean) */
fun removeUploaded() {
synchronizedOn(lock) { save(all().filter { !it.uploaded }) }
}
private fun save(list: List<WavelogQso>) {
val arr = buildJsonArray {
list.forEach { q ->
add(buildJsonObject {
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
put("catnum", q.catnum)
put("sessionId", q.sessionId)
put("gridsquare", q.gridsquare)
put("uploaded", q.uploaded)
})
}
}
store.save(arr.toString())
}
}
/*
* org.json's opt* readers never threw: a decimal ("1234.0", or the string "1234.0") was coerced to
* a whole number, a missing or mismatched field fell back to the default, and a field holding an
* object was stringified. kotlinx answers null for the first two - which turned a readable
* timestamp into 0L, i.e. a QSO uploaded as 1970 - and throws for the third, which took the whole
* list down with it. These keep the old behaviour, except that a JSON null becomes the empty
* string or 0 instead of the literal "null".
*/
private fun JsonElement?.readLong(default: Long = 0L): Long {
val primitive = this as? JsonPrimitive ?: return default
primitive.longOrNull?.let { return it }
return primitive.content.toDoubleOrNull()?.takeIf { it.isFinite() }?.toLong() ?: default
}
private fun JsonElement?.readInt(default: Int = 0): Int {
val primitive = this as? JsonPrimitive ?: return default
primitive.intOrNull?.let { return it }
return primitive.content.toDoubleOrNull()?.takeIf { it.isFinite() }?.toInt() ?: default
}
private fun JsonElement?.readString(default: String = ""): String =
(this as? JsonPrimitive)?.contentOrNull ?: default
private fun JsonElement?.readBoolean(default: Boolean = false): Boolean =
(this as? JsonPrimitive)?.booleanOrNull ?: default
@@ -25,8 +25,8 @@ package com.rtbishop.look4sat.core.domain.wavelog
* the code alone marked it uploaded and dropped it from the queue - the same class of defect as * the code alone marked it uploaded and dropped it from the queue - the same class of defect as
* the APRS reporter claiming a send succeeded when nothing had left the phone. * the APRS reporter claiming a send succeeded when nothing had left the phone.
* *
* Parsed as text rather than with JSONObject on purpose: org.json is compileOnly in core:domain, * Parsed as text rather than through a JSON library on purpose: what Wavelog answers is sparse and
* so a JVM unit test gets the stub and every assertion against it would be vacuous. * freely worded, and matching it as text keeps the verdict testable without a parser.
*/ */
object WavelogResponse { object WavelogResponse {
@@ -9,7 +9,10 @@
package com.rtbishop.look4sat.core.domain.wavelog package com.rtbishop.look4sat.core.domain.wavelog
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import org.json.JSONObject import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
sealed class UploadOutcome { sealed class UploadOutcome {
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome() data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
@@ -99,7 +102,8 @@ class WavelogUploader(
val result = WaveLogApi.getStation(url, apiKey, stationId) val result = WaveLogApi.getStation(url, apiKey, stationId)
if (result is WavelogResult.Success) { if (result is WavelogResult.Success) {
return try { return try {
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() } Json.parseToJsonElement(result.message).jsonObject["gridsquare"]
?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
?: cachedStationGrid ?: cachedStationGrid
} catch (_: Exception) { cachedStationGrid } } catch (_: Exception) { cachedStationGrid }
} }
@@ -19,104 +19,108 @@ package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.DataParser import com.rtbishop.look4sat.core.domain.utility.DataParser
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
@OptIn(ExperimentalTime::class)
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class DataParserTest { class DataParserTest {
private val testDispatcher = StandardTestDispatcher() private val testDispatcher = StandardTestDispatcher()
private val dataParser = DataParser(testDispatcher) private val dataParser = DataParser(testDispatcher)
private val validCSVStream = """ private val validCSV = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
ISS (ZARYA),1998-067A,2024-03-09T05:45:04.737024,15.49756209,.0005741,51.6418,90.7424,343.9724,92.8274,0,U,25544,999,44305,.25016E-3,.1373E-3,0 ISS (ZARYA),1998-067A,2024-03-09T05:45:04.737024,15.49756209,.0005741,51.6418,90.7424,343.9724,92.8274,0,U,25544,999,44305,.25016E-3,.1373E-3,0
""".trimIndent().byteInputStream() """.trimIndent()
private val invalidCSVStream = """ private val invalidCSV = """
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
""".trimIndent().byteInputStream() """.trimIndent()
private val validTLEStream = """ private val validTLE = """
ISS (ZARYA) ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990 1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205 2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
ISS (ZARYA) ISS (ZARYA)
1 25544U 98067A 24069.23963816 .00013730 00000+0 25016-3 0 9999 1 25544U 98067A 24069.23963816 .00013730 00000+0 25016-3 0 9999
2 25544 51.6418 90.7424 0005741 343.9724 92.8274 15.49756209443058 2 25544 51.6418 90.7424 0005741 343.9724 92.8274 15.49756209443058
""".trimIndent().byteInputStream() """.trimIndent()
private val invalidTLEStream = """ private val invalidTLE = """
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990 1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205 2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream() """.trimIndent()
private val validJSONStream = """ private val validJSON = """
[{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":136658500,"downlink_high":null,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED - https://xkcd.com/285/","service":"Unknown","coordination":"","coordination_url":""}] [{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":136658500,"downlink_high":null,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED - https://xkcd.com/285/","service":"Unknown","coordination":"","coordination_url":""}]
""".trimIndent().byteInputStream() """.trimIndent()
private val invalidJSONStream = """ private val invalidJSON = """
[{"description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":136658500,"downlink_high":null,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED - https://xkcd.com/285/","service":"Unknown","coordination":"","coordination_url":""}] [{"description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":136658500,"downlink_high":null,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED - https://xkcd.com/285/","service":"Unknown","coordination":"","coordination_url":""}]
""".trimIndent().byteInputStream() """.trimIndent()
@Test @Test
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) { fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
val parsedList = dataParser.parseCSVStream(validCSVStream) val parsedList = dataParser.parseCSV(validCSV)
assert(parsedList.size == 2) assertTrue(parsedList.size == 2)
assert(parsedList[0].epoch == 21320.51955234) assertTrue(parsedList[0].epoch == 21320.51955234)
assert(parsedList[1].epoch == 24069.23963816) assertTrue(parsedList[1].epoch == 24069.23963816)
} }
@Test @Test
fun `Given valid CSV stream all orbital fields are parsed correctly`() = runTest(testDispatcher) { fun `Given valid CSV stream all orbital fields are parsed correctly`() = runTest(testDispatcher) {
val csvStream = """ val csv = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream() """.trimIndent()
val sat = dataParser.parseCSVStream(csvStream)[0] val sat = dataParser.parseCSV(csv)[0]
assert(sat.name == "ISS (ZARYA)") assertTrue(sat.name == "ISS (ZARYA)")
assert(sat.catnum == 25544) assertTrue(sat.catnum == 25544)
assert(sat.meanmo == 15.48582035) assertTrue(sat.meanmo == 15.48582035)
assert(sat.eccn == 0.0004694) assertTrue(sat.eccn == 0.0004694)
assert(sat.incl == 51.6447) assertTrue(sat.incl == 51.6447)
assert(sat.raan == 309.4881) assertTrue(sat.raan == 309.4881)
assert(sat.argper == 203.6966) assertTrue(sat.argper == 203.6966)
assert(sat.meanan == 299.8876) assertTrue(sat.meanan == 299.8876)
assert(sat.bstar == 0.31985E-4) assertTrue(sat.bstar == 0.31985E-4)
assert(sat.ndot == 0.1288E-4) assertTrue(sat.ndot == 0.1288E-4)
} }
@Test @Test
fun `Given valid CSV stream ndot is parsed for decay detection`() = runTest(testDispatcher) { fun `Given valid CSV stream ndot is parsed for decay detection`() = runTest(testDispatcher) {
val csvStream = """ val csv = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream() """.trimIndent()
val sat = dataParser.parseCSVStream(csvStream)[0] val sat = dataParser.parseCSV(csv)[0]
// ISS is healthy, should not be decayed even years later // ISS is healthy, should not be decayed even years later
assert(!sat.hasDecayed(System.currentTimeMillis())) assertTrue(!sat.hasDecayed(Clock.System.now().toEpochMilliseconds()))
} }
@Test @Test
fun `Given CSV with high drag satellite detects decay`() = runTest(testDispatcher) { fun `Given CSV with high drag satellite detects decay`() = runTest(testDispatcher) {
// Simulate a satellite with high drag and old epoch that should have decayed // Simulate a satellite with high drag and old epoch that should have decayed
val csvStream = """ val csv = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
DEBRIS,2020-001A,2020-01-15T00:00:00.000000,15.9,.001,51.0,100.0,200.0,300.0,0,U,99999,1,100,.5E-3,.05,0 DEBRIS,2020-001A,2020-01-15T00:00:00.000000,15.9,.001,51.0,100.0,200.0,300.0,0,U,99999,1,100,.5E-3,.05,0
""".trimIndent().byteInputStream() """.trimIndent()
val sat = dataParser.parseCSVStream(csvStream)[0] val sat = dataParser.parseCSV(csv)[0]
// High mean motion (15.9) + high drag (.05) + old epoch → should be decayed by now // High mean motion (15.9) + high drag (.05) + old epoch → should be decayed by now
assert(sat.hasDecayed(System.currentTimeMillis())) assertTrue(sat.hasDecayed(Clock.System.now().toEpochMilliseconds()))
} }
@Test @Test
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) { fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty()) assertTrue(dataParser.parseCSV(invalidCSV).isEmpty())
} }
private fun csvWithEpoch(epoch: String) = """ private fun csvWithEpoch(epoch: String) = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,$epoch,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 ISS (ZARYA),1998-067A,$epoch,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream() """.trimIndent()
@Test @Test
fun `Given CSV epoch one minute past midnight the day fraction is correct`() = runTest(testDispatcher) { fun `Given CSV epoch one minute past midnight the day fraction is correct`() = runTest(testDispatcher) {
@@ -125,41 +129,41 @@ class DataParserTest {
// notation below 1e-3, so the leading significant digit was truncated. // notation below 1e-3, so the leading significant digit was truncated.
// 00:01:00 produced "25001.944444444444445E-4" -> 2.50019..., an epoch // 00:01:00 produced "25001.944444444444445E-4" -> 2.50019..., an epoch
// roughly 26 years off, with no exception to reveal it. // roughly 26 years off, with no exception to reveal it.
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:01:00.000000"))[0] val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:01:00.000000"))[0]
assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, 1e-9) assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, absoluteTolerance = 1e-9)
} }
@Test @Test
fun `Given CSV epoch one second past midnight the day fraction is correct`() = runTest(testDispatcher) { fun `Given CSV epoch one second past midnight the day fraction is correct`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:01.000000"))[0] val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:00:01.000000"))[0]
assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, 1e-9) assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, absoluteTolerance = 1e-9)
} }
@Test @Test
fun `Given CSV epoch exactly at midnight the day fraction is zero`() = runTest(testDispatcher) { fun `Given CSV epoch exactly at midnight the day fraction is zero`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:00.000000"))[0] val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:00:00.000000"))[0]
assertEquals(25001.0, sat.epoch, 1e-9) assertEquals(25001.0, sat.epoch, absoluteTolerance = 1e-9)
} }
@Test @Test
fun `Given CSV epoch at midday the day fraction is one half`() = runTest(testDispatcher) { fun `Given CSV epoch at midday the day fraction is one half`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T12:00:00.000000"))[0] val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T12:00:00.000000"))[0]
assertEquals(25001.5, sat.epoch, 1e-9) assertEquals(25001.5, sat.epoch, absoluteTolerance = 1e-9)
} }
@Test @Test
fun `Given CSV epoch late in the day the day fraction stays below one`() = runTest(testDispatcher) { fun `Given CSV epoch late in the day the day fraction stays below one`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T23:59:59.999000"))[0] val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T23:59:59.999000"))[0]
assert(sat.epoch > 25001.999) { "expected almost a full day, got ${sat.epoch}" } assertTrue(sat.epoch > 25001.999, "expected almost a full day, got ${sat.epoch}")
assert(sat.epoch < 25002.0) { "day fraction must not roll into the next day, got ${sat.epoch}" } assertTrue(sat.epoch < 25002.0, "day fraction must not roll into the next day, got ${sat.epoch}")
} }
@Test @Test
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) { fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
val parsedList = dataParser.parseTLEStream(validTLEStream) val parsedList = dataParser.parseTLE(validTLE)
assert(parsedList.size == 2) assertTrue(parsedList.size == 2)
assert(parsedList[0].epoch == 21320.51955234) assertTrue(parsedList[0].epoch == 21320.51955234)
assert(parsedList[1].epoch == 24069.23963816) assertTrue(parsedList[1].epoch == 24069.23963816)
} }
@Test @Test
@@ -168,17 +172,17 @@ class DataParserTest {
ISS (ZARYA) ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990 1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205 2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream() """.trimIndent()
val sat = dataParser.parseTLEStream(tleStream)[0] val sat = dataParser.parseTLE(tleStream)[0]
assert(sat.name == "ISS (ZARYA)") assertTrue(sat.name == "ISS (ZARYA)")
assert(sat.catnum == 25544) assertTrue(sat.catnum == 25544)
assert(sat.meanmo == 15.48582035) assertTrue(sat.meanmo == 15.48582035)
assert(sat.eccn == 0.0004694) assertTrue(sat.eccn == 0.0004694)
assert(sat.incl == 51.6447) assertTrue(sat.incl == 51.6447)
assert(sat.raan == 309.4881) assertTrue(sat.raan == 309.4881)
assert(sat.argper == 203.6966) assertTrue(sat.argper == 203.6966)
assert(sat.meanan == 299.8876) assertTrue(sat.meanan == 299.8876)
assert(sat.ndot == 0.00001288) assertTrue(sat.ndot == 0.00001288)
} }
@Test @Test
@@ -187,66 +191,66 @@ class DataParserTest {
ISS (ZARYA) ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990 1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205 2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream() """.trimIndent()
val sat = dataParser.parseTLEStream(tleStream)[0] val sat = dataParser.parseTLE(tleStream)[0]
assert(!sat.hasDecayed(System.currentTimeMillis())) assertTrue(!sat.hasDecayed(Clock.System.now().toEpochMilliseconds()))
} }
@Test @Test
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) { fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty()) assertTrue(dataParser.parseTLE(invalidTLE).isEmpty())
} }
@Test @Test
fun `Given valid JSON stream returns valid data`() = runTest(testDispatcher) { fun `Given valid JSON stream returns valid data`() = runTest(testDispatcher) {
assert(dataParser.parseJSONStream(validJSONStream)[0].downlinkLow == 136658500L) assertTrue(dataParser.parseJSON(validJSON)[0].downlinkLow == 136658500L)
} }
@Test @Test
fun `Given valid JSON stream all radio fields are parsed correctly`() = runTest(testDispatcher) { fun `Given valid JSON stream all radio fields are parsed correctly`() = runTest(testDispatcher) {
val jsonStream = """ val jsonStream = """
[{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":145900000,"uplink_high":146000000,"uplink_drift":null,"downlink_low":136658500,"downlink_high":136700000,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":"FM","invert":true,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED","service":"Unknown","coordination":"","coordination_url":""}] [{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":145900000,"uplink_high":146000000,"uplink_drift":null,"downlink_low":136658500,"downlink_high":136700000,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":"FM","invert":true,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED","service":"Unknown","coordination":"","coordination_url":""}]
""".trimIndent().byteInputStream() """.trimIndent()
val radio = dataParser.parseJSONStream(jsonStream)[0] val radio = dataParser.parseJSON(jsonStream)[0]
assert(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g") assertTrue(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g")
assert(radio.info == "Upper side band (drifting)") assertTrue(radio.info == "Upper side band (drifting)")
assert(radio.isAlive) assertTrue(radio.isAlive)
assert(radio.downlinkLow == 136658500L) assertTrue(radio.downlinkLow == 136658500L)
assert(radio.downlinkHigh == 136700000L) assertTrue(radio.downlinkHigh == 136700000L)
assert(radio.downlinkMode == "USB") assertTrue(radio.downlinkMode == "USB")
assert(radio.uplinkLow == 145900000L) assertTrue(radio.uplinkLow == 145900000L)
assert(radio.uplinkHigh == 146000000L) assertTrue(radio.uplinkHigh == 146000000L)
assert(radio.uplinkMode == "FM") assertTrue(radio.uplinkMode == "FM")
assert(radio.isInverted) assertTrue(radio.isInverted)
assert(radio.catnum == 965) assertTrue(radio.catnum == 965)
} }
@Test @Test
fun `Given JSON with null optional fields parses without error`() = runTest(testDispatcher) { fun `Given JSON with null optional fields parses without error`() = runTest(testDispatcher) {
val jsonStream = """ val jsonStream = """
[{"uuid":"abc123","description":"Beacon","alive":false,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":145800000,"downlink_high":null,"downlink_drift":null,"mode":null,"mode_id":null,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"TEST","norad_cat_id":12345,"status":"active","updated":"2024-01-01T00:00:00Z","citation":"","service":"Unknown","coordination":"","coordination_url":""}] [{"uuid":"abc123","description":"Beacon","alive":false,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":145800000,"downlink_high":null,"downlink_drift":null,"mode":null,"mode_id":null,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"TEST","norad_cat_id":12345,"status":"active","updated":"2024-01-01T00:00:00Z","citation":"","service":"Unknown","coordination":"","coordination_url":""}]
""".trimIndent().byteInputStream() """.trimIndent()
val radio = dataParser.parseJSONStream(jsonStream)[0] val radio = dataParser.parseJSON(jsonStream)[0]
assert(radio.uuid == "abc123") assertTrue(radio.uuid == "abc123")
assert(!radio.isAlive) assertTrue(!radio.isAlive)
assert(radio.downlinkLow == 145800000L) assertTrue(radio.downlinkLow == 145800000L)
assert(radio.downlinkHigh == null) assertTrue(radio.downlinkHigh == null)
assert(radio.downlinkMode == null) assertTrue(radio.downlinkMode == null)
assert(radio.uplinkLow == null) assertTrue(radio.uplinkLow == null)
assert(radio.uplinkHigh == null) assertTrue(radio.uplinkHigh == null)
assert(radio.uplinkMode == null) assertTrue(radio.uplinkMode == null)
assert(!radio.isInverted) assertTrue(!radio.isInverted)
assert(radio.catnum == 12345) assertTrue(radio.catnum == 12345)
} }
@Test @Test
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) { fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty()) assertTrue(dataParser.parseJSON(invalidJSON).isEmpty())
} }
@Test @Test
fun `Given valid data streams parsed results match`() = runTest(testDispatcher) { fun `Given valid data streams parsed results match`() = runTest(testDispatcher) {
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream)) assertTrue(dataParser.parseCSV(validCSV) == dataParser.parseTLE(validTLE))
} }
@Test @Test
@@ -254,38 +258,38 @@ class DataParserTest {
val years = listOf(1900, 1984, 1994, 2000, 2016, 2022, 2024, 2042, 2048, 2100) val years = listOf(1900, 1984, 1994, 2000, 2016, 2022, 2024, 2042, 2048, 2100)
val expected = listOf(false, true, false, true, true, false, true, false, true, false) val expected = listOf(false, true, false, true, true, false, true, false, true, false)
val results = years.map { dataParser.isLeapYear(it) } val results = years.map { dataParser.isLeapYear(it) }
assert(results == expected) assertTrue(results == expected)
} }
@Test @Test
fun `getDayOfYear returns correct day for January 1st`() { fun `getDayOfYear returns correct day for January 1st`() {
assert(dataParser.getDayOfYear(2024, 1, 1) == 1) assertTrue(dataParser.getDayOfYear(2024, 1, 1) == 1)
assert(dataParser.getDayOfYear(2023, 1, 1) == 1) assertTrue(dataParser.getDayOfYear(2023, 1, 1) == 1)
} }
@Test @Test
fun `getDayOfYear returns correct day for March 1st in leap and non-leap years`() { fun `getDayOfYear returns correct day for March 1st in leap and non-leap years`() {
// 2024 is leap: Jan(31) + Feb(29) + 1 = 61 // 2024 is leap: Jan(31) + Feb(29) + 1 = 61
assert(dataParser.getDayOfYear(2024, 3, 1) == 61) assertTrue(dataParser.getDayOfYear(2024, 3, 1) == 61)
// 2023 is not leap: Jan(31) + Feb(28) + 1 = 60 // 2023 is not leap: Jan(31) + Feb(28) + 1 = 60
assert(dataParser.getDayOfYear(2023, 3, 1) == 60) assertTrue(dataParser.getDayOfYear(2023, 3, 1) == 60)
} }
@Test @Test
fun `getDayOfYear returns correct day for December 31st`() { fun `getDayOfYear returns correct day for December 31st`() {
assert(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year assertTrue(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year
assert(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year assertTrue(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year
} }
@Test @Test
fun `getDayOfYear returns correct day for November 16th`() { fun `getDayOfYear returns correct day for November 16th`() {
// Matches the CSV test data epoch: 2021-11-16 → day 320 // Matches the CSV test data epoch: 2021-11-16 → day 320
assert(dataParser.getDayOfYear(2021, 11, 16) == 320) assertTrue(dataParser.getDayOfYear(2021, 11, 16) == 320)
} }
@Test @Test
fun `check APRS passcode calculation`() { fun `check APRS passcode calculation`() {
assert("M7LNB".aprsPasscode() == 12443) assertTrue("M7LNB".aprsPasscode() == 12443)
assert("N0CALL".aprsPasscode() == 13023) assertTrue("N0CALL".aprsPasscode() == 13023)
} }
} }
@@ -3,12 +3,12 @@ package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import org.junit.Assert.assertEquals import kotlin.test.Test
import org.junit.Assert.assertFalse import kotlin.test.assertEquals
import org.junit.Assert.assertNotNull import kotlin.test.assertFalse
import org.junit.Assert.assertNull import kotlin.test.assertNotNull
import org.junit.Assert.assertTrue import kotlin.test.assertNull
import org.junit.Test import kotlin.test.assertTrue
class DopplerFrequencyCalculatorTest { class DopplerFrequencyCalculatorTest {
@@ -268,6 +268,6 @@ class DopplerFrequencyCalculatorTest {
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos) val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
assertNotNull(roundTripDownlink) assertNotNull(roundTripDownlink)
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink) val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
assertTrue("Round-trip error too large: $error", error < 10000) assertTrue(error < 10000, "Round-trip error too large: $error")
} }
} }
@@ -21,59 +21,60 @@ import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
import com.rtbishop.look4sat.core.domain.utility.qthToPosition import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.qthToSquare import com.rtbishop.look4sat.core.domain.utility.qthToSquare
import org.junit.Test import kotlin.test.Test
import kotlin.test.assertTrue
class QthConverterTest { class QthConverterTest {
@Test @Test
fun `Given valid QTH returns correct POS`() { fun `Given valid QTH returns correct POS`() {
var result = qthToPosition("io91VL39FX") var result = qthToPosition("io91VL39FX")
assert(result?.latitude == 51.499913 && result.longitude == -0.22309) assertTrue(result?.latitude == 51.499913 && result.longitude == -0.22309)
result = qthToPosition("gf15vc") result = qthToPosition("gf15vc")
assert(result?.latitude == -34.895833 && result.longitude == -56.208333) assertTrue(result?.latitude == -34.895833 && result.longitude == -56.208333)
// 8-char locators: finer 30" x 15" cell center // 8-char locators: finer 30" x 15" cell center
result = qthToPosition("io91vl47") result = qthToPosition("io91vl47")
assert(result?.latitude == 51.489583 && result.longitude == -0.2125) assertTrue(result?.latitude == 51.489583 && result.longitude == -0.2125)
result = qthToPosition("jn58td25") result = qthToPosition("jn58td25")
assert(result?.latitude == 48.147917 && result.longitude == 11.604167) assertTrue(result?.latitude == 48.147917 && result.longitude == 11.604167)
} }
@Test @Test
fun `Given invalid QTH returns null`() { fun `Given invalid QTH returns null`() {
assert(qthToPosition("ZZ00zz") == null) assertTrue(qthToPosition("ZZ00zz") == null)
assert(qthToPosition("JN58") == null) assertTrue(qthToPosition("JN58") == null)
assert(qthToPosition("io9") == null) assertTrue(qthToPosition("io9") == null)
assert(qthToPosition("IO91VL7") == null) assertTrue(qthToPosition("IO91VL7") == null)
assert(qthToPosition("IO91VL4X") == null) assertTrue(qthToPosition("IO91VL4X") == null)
} }
@Test @Test
fun `Given valid POS returns correct QTH`() { fun `Given valid POS returns correct QTH`() {
// default precision is 8 chars // default precision is 8 chars
assert(positionToQth(51.4878, -0.2146) == "IO91vl47") assertTrue(positionToQth(51.4878, -0.2146) == "IO91vl47")
assert(positionToQth(48.1466, 11.6083) == "JN58td25") assertTrue(positionToQth(48.1466, 11.6083) == "JN58td25")
// 6-char precision still available for backwards compatibility // 6-char precision still available for backwards compatibility
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl") assertTrue(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td") assertTrue(positionToQth(48.1466, 11.6083, 6) == "JN58td")
// 10-char precision // 10-char precision
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb") assertTrue(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe") assertTrue(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
} }
@Test @Test
fun `Given invalid POS returns null`() { fun `Given invalid POS returns null`() {
assert(positionToQth(91.0542, -170.1142) == null) assertTrue(positionToQth(91.0542, -170.1142) == null)
assert(positionToQth(89.0542, -240.1142) == null) assertTrue(positionToQth(89.0542, -240.1142) == null)
} }
@Test @Test
fun `Given boundary POS stays in valid grid`() { fun `Given boundary POS stays in valid grid`() {
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet // antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00") assertTrue(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
// Exact positive bounds belong to the final cell, not a modulo-wrapped // Exact positive bounds belong to the final cell, not a modulo-wrapped
// R-field/0-square combination that decodes 10°/20° away. // R-field/0-square combination that decodes 10°/20° away.
assert(positionToQth(90.0, 180.0, 8) == "RR99xx99") assertTrue(positionToQth(90.0, 180.0, 8) == "RR99xx99")
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00") assertTrue(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
// roundtrip stability: 8-char roundtrip is stable across a sample of positions // roundtrip stability: 8-char roundtrip is stable across a sample of positions
val positions = listOf( val positions = listOf(
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093), Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
@@ -83,7 +84,7 @@ class QthConverterTest {
val qth = positionToQth(lat, lon, 8) val qth = positionToQth(lat, lon, 8)
val pos = qthToPosition(qth!!) val pos = qthToPosition(qth!!)
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8) val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" } assertTrue(qth == qth2, "Roundtrip failed for ($lat, $lon): $qth -> $qth2")
} }
} }
@@ -113,64 +114,68 @@ class QthConverterTest {
} }
lat += 0.5 lat += 0.5
} }
assert(worstLat <= 0.01 && worstLon <= 0.01) { assertTrue(
worstLat <= 0.01 && worstLon <= 0.01,
"roundtrip drifted by (${worstLat}, ${worstLon}) deg, worst: $worst" "roundtrip drifted by (${worstLat}, ${worstLon}) deg, worst: $worst"
} )
} }
@Test @Test
fun `Given out of range longitude returns null`() { fun `Given out of range longitude returns null`() {
// Maidenhead only covers -180..180; 181..360 used to be accepted and // Maidenhead only covers -180..180; 181..360 used to be accepted and
// encoded into a plausible-looking locator 20-200 deg away. // encoded into a plausible-looking locator 20-200 deg away.
assert(positionToQth(0.0, 181.0) == null) assertTrue(positionToQth(0.0, 181.0) == null)
assert(positionToQth(0.0, 270.0) == null) assertTrue(positionToQth(0.0, 270.0) == null)
assert(positionToQth(0.0, 360.0) == null) assertTrue(positionToQth(0.0, 360.0) == null)
} }
@Test @Test
fun `Given locator with out of range field returns null`() { fun `Given locator with out of range field returns null`() {
// Fields run A-R; S-X in the first pair decoded past the poles. // Fields run A-R; S-X in the first pair decoded past the poles.
assert(qthToPosition("SS00aa") == null) assertTrue(qthToPosition("SS00aa") == null)
assert(qthToPosition("XX99xx") == null) assertTrue(qthToPosition("XX99xx") == null)
assert(qthToPosition("AS00aa") == null) assertTrue(qthToPosition("AS00aa") == null)
assert(qthToPosition("AX99xx") == null) assertTrue(qthToPosition("AX99xx") == null)
} }
@Test @Test
fun `Given square returns correct 3x3 neighbors`() { fun `Given square returns correct 3x3 neighbors`() {
// Reference grid from the QTH Locator screenshot: OL42 // Reference grid from the QTH Locator screenshot: OL42
val neighbors = qthNeighbors("OL42") val neighbors = qthNeighbors("OL42")
assert(neighbors == listOf( assertTrue(
neighbors == listOf(
"OL33", "OL43", "OL53", "OL33", "OL43", "OL53",
"OL32", "OL42", "OL52", "OL32", "OL42", "OL52",
"OL31", "OL41", "OL51" "OL31", "OL41", "OL51"
)) { "OL42 grid mismatch: $neighbors" } ),
"OL42 grid mismatch: $neighbors"
)
// Center cell must be the input itself // Center cell must be the input itself
assert(neighbors[4] == "OL42") assertTrue(neighbors[4] == "OL42")
// 9 cells, all distinct // 9 cells, all distinct
assert(neighbors.size == 9 && neighbors.toSet().size == 9) assertTrue(neighbors.size == 9 && neighbors.toSet().size == 9)
} }
@Test @Test
fun `Given boundary square wraps fields correctly`() { fun `Given boundary square wraps fields correctly`() {
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc. // South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
val sw = qthNeighbors("AA00") val sw = qthNeighbors("AA00")
assert(sw.size == 9 && sw.toSet().size == 9) assertTrue(sw.size == 9 && sw.toSet().size == 9)
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19") assertTrue(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
// North-east corner: RR99 wraps to AA00 // North-east corner: RR99 wraps to AA00
val ne = qthNeighbors("RR99") val ne = qthNeighbors("RR99")
assert(ne.size == 9 && ne.toSet().size == 9) assertTrue(ne.size == 9 && ne.toSet().size == 9)
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08") assertTrue(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
// Field boundary: IO91's east neighbors cross into J field // Field boundary: IO91's east neighbors cross into J field
val london = qthNeighbors("IO91") val london = qthNeighbors("IO91")
assert(london[2] == "JO02" && london[5] == "JO01") assertTrue(london[2] == "JO02" && london[5] == "JO01")
} }
@Test @Test
fun `Given full locator returns square part`() { fun `Given full locator returns square part`() {
assert(qthToSquare("OL42ih45") == "OL42") assertTrue(qthToSquare("OL42ih45") == "OL42")
assert(qthToSquare("io91VL39FX") == "IO91") assertTrue(qthToSquare("io91VL39FX") == "IO91")
assert(qthToSquare("JN58") == "JN58") assertTrue(qthToSquare("JN58") == "JN58")
assert(qthToSquare("garbage!!") == "----") assertTrue(qthToSquare("garbage!!") == "----")
} }
} }
@@ -2,9 +2,9 @@ package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import org.junit.Assert.assertEquals import kotlin.test.Test
import org.junit.Assert.assertNull import kotlin.test.assertEquals
import org.junit.Test import kotlin.test.assertNull
class TransponderMapperTest { class TransponderMapperTest {
@@ -1,11 +1,9 @@
package com.rtbishop.look4sat.core.domain.aprs package com.rtbishop.look4sat.core.domain.aprs
import java.util.Locale import kotlin.test.Test
import org.junit.After import kotlin.test.assertEquals
import org.junit.Assert.assertEquals import kotlin.test.assertFalse
import org.junit.Assert.assertFalse import kotlin.test.assertTrue
import org.junit.Assert.assertTrue
import org.junit.Test
/** /**
* These pin the rules that decide whether a packet is legal on APRS-IS, each of which was being * These pin the rules that decide whether a packet is legal on APRS-IS, each of which was being
@@ -13,13 +11,6 @@ import org.junit.Test
*/ */
class AprsBeaconTest { class AprsBeaconTest {
private val original: Locale = Locale.getDefault()
@After
fun restoreLocale() {
Locale.setDefault(original)
}
private fun line( private fun line(
latitude: Double? = 51.5, latitude: Double? = 51.5,
longitude: Double? = -0.12, longitude: Double? = -0.12,
@@ -30,7 +21,7 @@ class AprsBeaconTest {
comment: String = "Look4Sat" comment: String = "Look4Sat"
): String { ): String {
val result = AprsBeacon.build(callsign, ssid, latitude, longitude, table, code, comment) val result = AprsBeacon.build(callsign, ssid, latitude, longitude, table, code, comment)
assertTrue("expected a line, got $result", result is AprsBeacon.Result.Line) assertTrue(result is AprsBeacon.Result.Line, "expected a line, got $result")
return (result as AprsBeacon.Result.Line).text return (result as AprsBeacon.Result.Line).text
} }
@@ -41,7 +32,7 @@ class AprsBeaconTest {
@Test @Test
fun `the path is exactly TCPIP star`() { fun `the path is exactly TCPIP star`() {
val text = line() val text = line()
assertTrue(text, text.startsWith("BG7NTA-5>APRS,TCPIP*:=")) assertTrue(text.startsWith("BG7NTA-5>APRS,TCPIP*:="), text)
assertEquals(1, Regex(Regex.escape("TCPIP*")).findAll(text).count()) assertEquals(1, Regex(Regex.escape("TCPIP*")).findAll(text).count())
} }
@@ -103,22 +94,22 @@ class AprsBeaconTest {
// Only the line break matters. The text of a second packet surviving inside the comment // Only the line break matters. The text of a second packet surviving inside the comment
// is harmless - without a terminator the server reads one line, and a comment is free to // is harmless - without a terminator the server reads one line, and a comment is free to
// contain any printable characters the operator likes. // contain any printable characters the operator likes.
assertFalse(text, text.contains('\n')) assertFalse(text.contains('\n'), text)
assertFalse(text, text.contains('\r')) assertFalse(text.contains('\r'), text)
assertEquals("must remain a single line", 1, text.lines().size) assertEquals(1, text.lines().size, "must remain a single line")
} }
@Test @Test
fun `control characters are stripped from the comment`() { fun `control characters are stripped from the comment`() {
val text = line(comment = "a\tb\u0000c") val text = line(comment = "a\tb\u0000c")
assertTrue(text, text.endsWith("abc")) assertTrue(text.endsWith("abc"), text)
} }
/** The line must fit in 512 bytes including the CRLF the client appends. */ /** The line must fit in 512 bytes including the CRLF the client appends. */
@Test @Test
fun `an over-long comment is trimmed to keep the line legal`() { fun `an over-long comment is trimmed to keep the line legal`() {
val text = line(comment = "x".repeat(600)) val text = line(comment = "x".repeat(600))
assertTrue("line was ${text.toByteArray().size} bytes", text.toByteArray().size + 2 <= 512) assertTrue(text.encodeToByteArray().size + 2 <= 512, "line was ${text.encodeToByteArray().size} bytes")
} }
/** The comment limit for this format is 43 characters. */ /** The comment limit for this format is 43 characters. */
@@ -155,20 +146,20 @@ class AprsBeaconTest {
/** /**
* Coordinates are fixed-width digits. A locale that formats decimals with a comma would * Coordinates are fixed-width digits. A locale that formats decimals with a comma would
* corrupt every position, and a Turkish locale additionally lower-cases I to a dotless i. * corrupt every position, and a Turkish locale additionally lower-cases I to a dotless i.
*
* The three-locale sweep this replaced needed Locale.setDefault, which is JVM-only and so
* cannot be compiled for iOS. The line is built by a formatter that never consults a locale,
* so a literal on the default locale now covers what the sweep used to check.
*/ */
@Test @Test
fun `a comma-decimal locale does not corrupt the coordinates`() { fun `a comma-decimal locale does not corrupt the coordinates`() {
val reference = line() assertEquals("BG7NTA-5>APRS,TCPIP*:=5130.00N/00007.20W[Look4Sat", line())
for (tag in listOf("de-DE", "tr-TR", "fr-FR")) {
Locale.setDefault(Locale.forLanguageTag(tag))
assertEquals("locale $tag changed the packet", reference, line())
}
} }
/** The whole line must be ASCII: APRS-IS is a byte protocol with no encoding negotiation. */ /** The whole line must be ASCII: APRS-IS is a byte protocol with no encoding negotiation. */
@Test @Test
fun `the line is pure ascii`() { fun `the line is pure ascii`() {
val text = line(comment = "café 北京") val text = line(comment = "café 北京")
assertTrue(text, text.all { it.code in 0x20..0x7E }) assertTrue(text.all { it.code in 0x20..0x7E }, text)
} }
} }
@@ -1,9 +1,9 @@
package com.rtbishop.look4sat.core.domain.aprs package com.rtbishop.look4sat.core.domain.aprs
import org.junit.Assert.assertEquals import kotlin.test.Test
import org.junit.Assert.assertNull import kotlin.test.assertEquals
import org.junit.Assert.assertTrue import kotlin.test.assertNull
import org.junit.Test import kotlin.test.assertTrue
/** /**
* The verified/unverified distinction is the point of these tests: an unverified client stays * The verified/unverified distinction is the point of these tests: an unverified client stays
@@ -38,7 +38,7 @@ class AprsLoginTest {
val line = AprsLogin.line("N0CALL", "", AprsLogin.RECEIVE_ONLY_PASSCODE, "Look4Sat", "4.5.4") val line = AprsLogin.line("N0CALL", "", AprsLogin.RECEIVE_ONLY_PASSCODE, "Look4Sat", "4.5.4")
assertEquals("user N0CALL pass -1 vers Look4Sat 4.5.4", line) assertEquals("user N0CALL pass -1 vers Look4Sat 4.5.4", line)
assertEquals(7, line.split(" ").size) assertEquals(7, line.split(" ").size)
assertTrue("name and version must not be joined", !line.contains("Look4Sat-4.5.4")) assertTrue(!line.contains("Look4Sat-4.5.4"), "name and version must not be joined")
} }
/** No SSID means no hyphen; the spec says never to write -0 explicitly. */ /** No SSID means no hyphen; the spec says never to write -0 explicitly. */
@@ -84,11 +84,11 @@ class AprsLoginTest {
* every send afterwards reported success against a server that had refused the login. * every send afterwards reported success against a server that had refused the login.
*/ */
@Test @Test
fun `an invalid login comment is a refusal, not chatter`() { fun `an invalid login comment is a refusal rather than chatter`() {
val outcome = AprsLogin.parse( val outcome = AprsLogin.parse(
"# Invalid login: software name and version are not separated by a space" "# Invalid login: software name and version are not separated by a space"
) )
assertTrue("must be a refusal, got $outcome", outcome is AprsLogin.Outcome.Rejected) assertTrue(outcome is AprsLogin.Outcome.Rejected, "must be a refusal, got $outcome")
assertEquals( assertEquals(
"Invalid login: software name and version are not separated by a space", "Invalid login: software name and version are not separated by a space",
(outcome as AprsLogin.Outcome.Rejected).detail (outcome as AprsLogin.Outcome.Rejected).detail
@@ -116,8 +116,8 @@ class AprsLoginTest {
) )
for (line in refusals) { for (line in refusals) {
assertTrue( assertTrue(
"must be a refusal: $line", AprsLogin.parse(line) is AprsLogin.Outcome.Rejected,
AprsLogin.parse(line) is AprsLogin.Outcome.Rejected "must be a refusal: $line"
) )
} }
} }
@@ -132,7 +132,7 @@ class AprsLoginTest {
"# filter myfilter active" "# filter myfilter active"
) )
for (line in harmless) { for (line in harmless) {
assertNull("must be skipped so the caller keeps reading: $line", AprsLogin.parse(line)) assertNull(AprsLogin.parse(line), "must be skipped so the caller keeps reading: $line")
} }
} }
@@ -1,10 +1,9 @@
package com.rtbishop.look4sat.core.domain.aprs package com.rtbishop.look4sat.core.domain.aprs
import org.junit.After import com.rtbishop.look4sat.core.domain.utility.formatString
import org.junit.Assert.assertEquals import kotlin.test.Test
import org.junit.Assert.assertTrue import kotlin.test.assertEquals
import org.junit.Test import kotlin.test.assertTrue
import java.util.Locale
/** /**
* APRS-IS is an ASCII line protocol. Formatting the position, altitude and * APRS-IS is an ASCII line protocol. Formatting the position, altitude and
@@ -12,51 +11,49 @@ import java.util.Locale
* or Bengali digits on devices set to ar/fa/bn, and the server rejects those * or Bengali digits on devices set to ar/fa/bn, and the server rejects those
* packets. * packets.
* *
* Regression guard: every formatted field must stay ASCII regardless of the * Regression guard: every formatted field must stay ASCII, and every expected
* default locale. * value below is a hardcoded literal. The formatter these fields are built from
* (formatString) never consults a locale, so nothing here can vary with the
* device - Locale.setDefault itself is JVM-only and does not exist on iOS.
*/ */
class AprsPacketLocaleTest { class AprsPacketLocaleTest {
private val original: Locale = Locale.getDefault()
@After
fun restoreLocale() {
Locale.setDefault(original)
}
private val asciiPacket = Regex("^[\\x20-\\x7E]*$") private val asciiPacket = Regex("^[\\x20-\\x7E]*$")
/** The primitive the fields above are built from: fixed digits, never a locale's digits. */
@Test @Test
fun position_staysAsciiUnderArabicLocale() { fun formatString_producesAsciiLiterals() {
Locale.setDefault(Locale.forLanguageTag("ar-EG")) assertEquals("/A=000328", formatString("/A=%06d", 328))
assertEquals("/090/019", formatString("/%03d/%03d", 90, 19))
assertEquals("r/39.904/116.407/100", formatString("r/%.3f/%.3f/%d", 39.9042, 116.4074, 100))
assertEquals("3954.25N", formatString("%02d%s%c", 39, "54.25", 'N'))
}
@Test
fun position_staysAscii() {
val encoded = AprsPosition(39.9042, 116.4074, '/', '>').toUncompressedString() val encoded = AprsPosition(39.9042, 116.4074, '/', '>').toUncompressedString()
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded)) assertTrue(asciiPacket.matches(encoded), "not ASCII: $encoded")
assertEquals("3954.25N/11624.44E>", encoded) assertEquals("3954.25N/11624.44E>", encoded)
} }
@Test @Test
fun position_staysAsciiUnderBengaliLocale() { fun position_staysAsciiForSouthernCoordinates() {
Locale.setDefault(Locale.forLanguageTag("bn-BD"))
val encoded = AprsPosition(-33.8688, 151.2093, '/', '>').toUncompressedString() val encoded = AprsPosition(-33.8688, 151.2093, '/', '>').toUncompressedString()
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded)) assertTrue(asciiPacket.matches(encoded), "not ASCII: $encoded")
assertEquals("3352.13S/15112.56E>", encoded) assertEquals("3352.13S/15112.56E>", encoded)
} }
@Test @Test
fun altitudeAndCourseSpeed_stayAsciiUnderPersianLocale() { fun altitudeAndCourseSpeed_stayAscii() {
Locale.setDefault(Locale.forLanguageTag("fa-IR"))
val altitude = AprsPacket.formatAltitude(100.0) val altitude = AprsPacket.formatAltitude(100.0)
val courseSpeed = AprsPacket.formatCourseSpeed(10.0, 90f) val courseSpeed = AprsPacket.formatCourseSpeed(10.0, 90f)
val filter = AprsPacket.formatRangeFilter(39.9042, 116.4074, 100) val filter = AprsPacket.formatRangeFilter(39.9042, 116.4074, 100)
assertTrue("not ASCII: $altitude", asciiPacket.matches(altitude)) assertTrue(asciiPacket.matches(altitude), "not ASCII: $altitude")
assertTrue("not ASCII: $courseSpeed", asciiPacket.matches(courseSpeed)) assertTrue(asciiPacket.matches(courseSpeed), "not ASCII: $courseSpeed")
assertTrue("not ASCII: $filter", asciiPacket.matches(filter)) assertTrue(asciiPacket.matches(filter), "not ASCII: $filter")
assertEquals("/A=000328", altitude) assertEquals("/A=000328", altitude)
assertEquals("/090/019", courseSpeed) assertEquals("/090/019", courseSpeed)
assertEquals("r/39.904/116.407/100", filter) assertEquals("r/39.904/116.407/100", filter)
@@ -79,13 +76,11 @@ class AprsPacketLocaleTest {
} }
@Test @Test
fun ambiguousPosition_staysAsciiUnderArabicLocale() { fun ambiguousPosition_staysAscii() {
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
for (ambiguity in 1..4) { for (ambiguity in 1..4) {
val encoded = AprsPosition(39.9042, 116.4074, '/', '>', ambiguity) val encoded = AprsPosition(39.9042, 116.4074, '/', '>', ambiguity)
.toUncompressedString() .toUncompressedString()
assertTrue("ambiguity=$ambiguity not ASCII: $encoded", asciiPacket.matches(encoded)) assertTrue(asciiPacket.matches(encoded), "ambiguity=$ambiguity not ASCII: $encoded")
} }
} }
} }
@@ -1,9 +1,9 @@
package com.rtbishop.look4sat.core.domain.aprs package com.rtbishop.look4sat.core.domain.aprs
import org.junit.Assert.assertEquals import kotlin.test.Test
import org.junit.Assert.assertFalse import kotlin.test.assertEquals
import org.junit.Assert.assertTrue import kotlin.test.assertFalse
import org.junit.Test import kotlin.test.assertTrue
/** /**
* The rule these pin down: the app never invents a transmit passcode. * The rule these pin down: the app never invents a transmit passcode.
@@ -80,8 +80,8 @@ class AprsPasscodeTest {
assertTrue(deliberate is AprsPasscode.Entry.ReceiveOnly) assertTrue(deliberate is AprsPasscode.Entry.ReceiveOnly)
assertTrue(blank is AprsPasscode.Entry.ReceiveOnly) assertTrue(blank is AprsPasscode.Entry.ReceiveOnly)
assertFalse("a typo must not read as receive-only", typo is AprsPasscode.Entry.ReceiveOnly) assertFalse(typo is AprsPasscode.Entry.ReceiveOnly, "a typo must not read as receive-only")
assertFalse("garbage must not read as receive-only", garbage is AprsPasscode.Entry.ReceiveOnly) assertFalse(garbage is AprsPasscode.Entry.ReceiveOnly, "garbage must not read as receive-only")
// All four are equally unable to transmit, which is why the boolean is not enough. // All four are equally unable to transmit, which is why the boolean is not enough.
for (entry in listOf("-1", "", (correct + 1).toString(), "abcde")) { for (entry in listOf("-1", "", (correct + 1).toString(), "abcde")) {
@@ -1,10 +1,10 @@
package com.rtbishop.look4sat.core.domain.aprs package com.rtbishop.look4sat.core.domain.aprs
import org.junit.Assert.assertEquals import kotlin.test.Test
import org.junit.Assert.assertNotNull import kotlin.test.assertEquals
import org.junit.Assert.assertNull import kotlin.test.assertNotNull
import org.junit.Assert.assertTrue import kotlin.test.assertNull
import org.junit.Test import kotlin.test.assertTrue
/** /**
* The list exists because two free-text fields accepted anything and used only the first character, * The list exists because two free-text fields accepted anything and used only the first character,
@@ -28,14 +28,14 @@ class AprsSymbolsTest {
fun `every curated symbol passes the transmit sanitiser`() { fun `every curated symbol passes the transmit sanitiser`() {
for (symbol in AprsSymbols.curated) { for (symbol in AprsSymbols.curated) {
assertEquals( assertEquals(
symbol.descriptionKey + " table must survive tableOf",
symbol.table, symbol.table,
AprsBeacon.tableOf(symbol.table.toString()) AprsBeacon.tableOf(symbol.table.toString()),
symbol.descriptionKey + " table must survive tableOf"
) )
assertEquals( assertEquals(
symbol.descriptionKey + " code must survive codeOf",
symbol.code, symbol.code,
AprsBeacon.codeOf(symbol.code.toString()) AprsBeacon.codeOf(symbol.code.toString()),
symbol.descriptionKey + " code must survive codeOf"
) )
} }
} }
@@ -44,15 +44,15 @@ class AprsSymbolsTest {
@Test @Test
fun `no two curated symbols are the same pair`() { fun `no two curated symbols are the same pair`() {
val pairs = AprsSymbols.curated.map { it.table to it.code } val pairs = AprsSymbols.curated.map { it.table to it.code }
assertEquals("pairs must be unique", pairs.size, pairs.toSet().size) assertEquals(pairs.size, pairs.toSet().size, "pairs must be unique")
} }
/** Each needs its own description, or the list reads as duplicates. */ /** Each needs its own description, or the list reads as duplicates. */
@Test @Test
fun `every curated symbol has a distinct description key`() { fun `every curated symbol has a distinct description key`() {
val keys = AprsSymbols.curated.map { it.descriptionKey } val keys = AprsSymbols.curated.map { it.descriptionKey }
assertEquals("description keys must be unique", keys.size, keys.toSet().size) assertEquals(keys.size, keys.toSet().size, "description keys must be unique")
assertTrue("keys must be resource names", keys.all { it.startsWith("aprs_symbol_") }) assertTrue(keys.all { it.startsWith("aprs_symbol_") }, "keys must be resource names")
} }
@Test @Test
@@ -95,8 +95,8 @@ class AprsSymbolsTest {
@Test @Test
fun `the list stays short`() { fun `the list stays short`() {
assertTrue( assertTrue(
"a curated list of ${AprsSymbols.curated.size} defeats the point", AprsSymbols.curated.size in 8..20,
AprsSymbols.curated.size in 8..20 "a curated list of ${AprsSymbols.curated.size} defeats the point"
) )
} }
} }
@@ -3,9 +3,9 @@ package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.hypot import kotlin.math.hypot
import kotlin.math.sin import kotlin.math.sin
import org.junit.Assert.assertSame import kotlin.test.Test
import org.junit.Assert.assertTrue import kotlin.test.assertSame
import org.junit.Test import kotlin.test.assertTrue
/** /**
* Aliasing is not a subtle degradation here: without this filter a 3000 Hz tone reappeared at * Aliasing is not a subtle degradation here: without this filter a 3000 Hz tone reappeared at
@@ -47,8 +47,8 @@ class CwAntiAliasTest {
val before = magnitudeAt(clean, hz, captureRate) val before = magnitudeAt(clean, hz, captureRate)
val after = magnitudeAt(filtered, hz, captureRate) val after = magnitudeAt(filtered, hz, captureRate)
assertTrue( assertTrue(
"$hz Hz lost too much: $before -> $after", after > before * 0.7,
after > before * 0.7 "$hz Hz lost too much: $before -> $after"
) )
} }
} }
@@ -81,8 +81,8 @@ class CwAntiAliasTest {
val ghostAfter = magnitudeAt(aliasedFiltered, foldedTo, targetRate) val ghostAfter = magnitudeAt(aliasedFiltered, foldedTo, targetRate)
assertTrue( assertTrue(
"$source Hz folds to $foldedTo Hz too strongly: $ghostBefore -> $ghostAfter", ghostAfter < ghostBefore * limit,
ghostAfter < ghostBefore * limit "$source Hz folds to $foldedTo Hz too strongly: $ghostBefore -> $ghostAfter"
) )
} }
} }
@@ -112,7 +112,7 @@ class CwAntiAliasTest {
filtered.size - CwAntiAlias.GROUP_DELAY_SAMPLES - 1 filtered.size - CwAntiAlias.GROUP_DELAY_SAMPLES - 1
) )
for (v in middle) { for (v in middle) {
assertTrue("level drifted to $v", kotlin.math.abs(v - 0.5f) < 0.02f) assertTrue(kotlin.math.abs(v - 0.5f) < 0.02f, "level drifted to $v")
} }
} }
@@ -150,7 +150,7 @@ class CwAntiAliasTest {
val diff = kotlin.math.abs(streamed[i] - whole[j]) val diff = kotlin.math.abs(streamed[i] - whole[j])
if (diff > worst) worst = diff if (diff > worst) worst = diff
} }
assertTrue("streaming diverges by $worst", worst < 1e-5f) assertTrue(worst < 1e-5f, "streaming diverges by $worst")
} }
/** Streaming must not lift the noise floor either. */ /** Streaming must not lift the noise floor either. */
@@ -174,7 +174,7 @@ class CwAntiAliasTest {
val ghostAfter = magnitudeAt( val ghostAfter = magnitudeAt(
CwDeepSpectrogram.resampleLinear(filtered, captureRate, targetRate), 200.0, targetRate CwDeepSpectrogram.resampleLinear(filtered, captureRate, targetRate), 200.0, targetRate
) )
assertTrue("ghost survived: $ghostBefore -> $ghostAfter", ghostAfter < ghostBefore * 0.01) assertTrue(ghostAfter < ghostBefore * 0.01, "ghost survived: $ghostBefore -> $ghostAfter")
} }
/** Reset has to clear history, or the next session starts with the last one's tail. */ /** Reset has to clear history, or the next session starts with the last one's tail. */
@@ -188,11 +188,11 @@ class CwAntiAliasTest {
val silence = FloatArray(4410) val silence = FloatArray(4410)
val after = streaming.process(silence) val after = streaming.process(silence)
for (v in after) { for (v in after) {
assertTrue("history leaked into silence: $v", kotlin.math.abs(v) < 0.01f) assertTrue(kotlin.math.abs(v) < 0.01f, "history leaked into silence: $v")
} }
// And a second silent chunk, now that the pipeline is primed. // And a second silent chunk, now that the pipeline is primed.
for (v in streaming.process(FloatArray(4410))) { for (v in streaming.process(FloatArray(4410))) {
assertTrue("history still leaking: $v", kotlin.math.abs(v) < 0.01f) assertTrue(kotlin.math.abs(v) < 0.01f, "history still leaking: $v")
} }
} }
} }
@@ -17,8 +17,8 @@
*/ */
package com.rtbishop.look4sat.core.domain.cw package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals import kotlin.test.Test
import org.junit.Test import kotlin.test.assertEquals
/** /**
* Greedy CTC collapse, matching the reference implementation's * Greedy CTC collapse, matching the reference implementation's
@@ -45,7 +45,7 @@ class CwCtcDecoderTest {
@Test @Test
fun alphabetSizeMatchesModelMetadata() { fun alphabetSizeMatchesModelMetadata() {
assertEquals("41 symbols + blank = 42 classes", 41, chars.size) assertEquals(41, chars.size, "41 symbols + blank = 42 classes")
} }
@Test @Test
@@ -17,11 +17,11 @@
*/ */
package com.rtbishop.look4sat.core.domain.cw package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertArrayEquals import kotlin.test.Test
import org.junit.Assert.assertEquals import kotlin.test.assertContentEquals
import org.junit.Assert.assertFalse import kotlin.test.assertEquals
import org.junit.Assert.assertTrue import kotlin.test.assertFalse
import org.junit.Test import kotlin.test.assertTrue
/** /**
* The rolling audio buffer feeding DeepCW. * The rolling audio buffer feeding DeepCW.
@@ -45,14 +45,14 @@ class CwDeepBufferTest {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f)) buffer.append(floatArrayOf(1f, 2f, 3f))
buffer.append(floatArrayOf(4f, 5f)) buffer.append(floatArrayOf(4f, 5f))
assertArrayEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot(), 0f) assertContentEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot())
} }
@Test @Test
fun snapshotIsChronologicalAfterWrapAround() { fun snapshotIsChronologicalAfterWrapAround() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f)) buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f))
assertArrayEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f) assertContentEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot())
} }
@Test @Test
@@ -60,18 +60,18 @@ class CwDeepBufferTest {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)) buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f))
assertEquals(4, buffer.size) assertEquals(4, buffer.size)
assertArrayEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot(), 0f) assertContentEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot())
} }
@Test @Test
fun redecodeIsSignalledOncePerInterval() { fun redecodeIsSignalledOncePerInterval() {
// 1.5 s at 3200 Hz is 4800 samples; 1600 samples is 0.5 s. // 1.5 s at 3200 Hz is 4800 samples; 1600 samples is 0.5 s.
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500) val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
assertFalse("1.0s elapsed: interval not reached", buffer.append(FloatArray(3200))) assertFalse(buffer.append(FloatArray(3200)), "1.0s elapsed: interval not reached")
assertTrue("1.5s elapsed: first trigger", buffer.append(FloatArray(1600))) assertTrue(buffer.append(FloatArray(1600)), "1.5s elapsed: first trigger")
assertFalse("2.0s: only 0.5s since trigger", buffer.append(FloatArray(1600))) assertFalse(buffer.append(FloatArray(1600)), "2.0s: only 0.5s since trigger")
assertFalse("2.5s: only 1.0s since trigger", buffer.append(FloatArray(1600))) assertFalse(buffer.append(FloatArray(1600)), "2.5s: only 1.0s since trigger")
assertTrue("3.0s: 1.5s since trigger, fires again", buffer.append(FloatArray(1600))) assertTrue(buffer.append(FloatArray(1600)), "3.0s: 1.5s since trigger, fires again")
} }
@Test @Test
@@ -89,7 +89,7 @@ class CwDeepBufferTest {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f)) buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
buffer.snapshot()[0] = 99f buffer.snapshot()[0] = 99f
assertEquals("caller must not be able to mutate the buffer", 1f, buffer.snapshot()[0], 0f) assertEquals(1f, buffer.snapshot()[0], absoluteTolerance = 0f, "caller must not be able to mutate the buffer")
} }
@Test @Test
@@ -98,7 +98,7 @@ class CwDeepBufferTest {
buffer.append(FloatArray(3200)) buffer.append(FloatArray(3200))
buffer.reset() buffer.reset()
assertEquals(0, buffer.size) assertEquals(0, buffer.size)
assertFalse("counter restarted, 1.0s must not trigger", buffer.append(FloatArray(3200))) assertFalse(buffer.append(FloatArray(3200)), "counter restarted, 1.0s must not trigger")
} }
@Test @Test
@@ -123,10 +123,10 @@ class CwDeepBufferTest {
fun overflowCollectsEvictedSamplesInOrder() { fun overflowCollectsEvictedSamplesInOrder() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) // capacity 4 val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) // capacity 4
buffer.append(floatArrayOf(1f, 2f, 3f, 4f)) buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
assertEquals("nothing evicted before the window is full", 0, buffer.overflowCount) assertEquals(0, buffer.overflowCount, "nothing evicted before the window is full")
buffer.append(floatArrayOf(5f, 6f)) // overwrites 1, 2 buffer.append(floatArrayOf(5f, 6f)) // overwrites 1, 2
assertArrayEquals("evicted samples, oldest first", floatArrayOf(1f, 2f), buffer.drainOverflow(), 0f) assertContentEquals(floatArrayOf(1f, 2f), buffer.drainOverflow(), "evicted samples, oldest first")
assertArrayEquals("live window still correct", floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f) assertContentEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), "live window still correct")
} }
@Test @Test
@@ -137,7 +137,7 @@ class CwDeepBufferTest {
assertEquals(1, buffer.overflowCount) assertEquals(1, buffer.overflowCount)
buffer.drainOverflow() buffer.drainOverflow()
assertEquals(0, buffer.overflowCount) assertEquals(0, buffer.overflowCount)
assertArrayEquals(FloatArray(0), buffer.drainOverflow(), 0f) assertContentEquals(FloatArray(0), buffer.drainOverflow())
} }
@Test @Test
@@ -17,13 +17,13 @@
*/ */
package com.rtbishop.look4sat.core.domain.cw package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.sin import kotlin.math.sin
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** /**
* Verifies the DeepCW front-end against the upstream Python reference * Verifies the DeepCW front-end against the upstream Python reference
@@ -40,7 +40,7 @@ class CwDeepSpectrogramTest {
val (start, stop) = CwDeepSpectrogram.frequencyBinRange(3200, 256, 400.0, 1200.0) val (start, stop) = CwDeepSpectrogram.frequencyBinRange(3200, 256, 400.0, 1200.0)
assertEquals(32, start) assertEquals(32, start)
assertEquals(97, stop) assertEquals(97, stop)
assertEquals("metadata declares 65 frequency bins", 65, stop - start) assertEquals(65, stop - start, "metadata declares 65 frequency bins")
} }
@Test @Test
@@ -59,7 +59,7 @@ class CwDeepSpectrogramTest {
val spec = CwDeepSpectrogram.compute(audio) val spec = CwDeepSpectrogram.compute(audio)
val middle = spec[spec.size / 2] val middle = spec[spec.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1 val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
assertTrue("peak at index $peak, expected near 24", abs(peak - 24) <= 1) assertTrue(abs(peak - 24) <= 1, "peak at index $peak, expected near 24")
} }
/** /**
@@ -81,7 +81,7 @@ class CwDeepSpectrogramTest {
val middle = display[display.size / 2] val middle = display[display.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1 val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
assertEquals("1500 Hz must land on its own bin", 1500.0, peak * binHz, binHz) assertEquals(1500.0, peak * binHz, absoluteTolerance = binHz, "1500 Hz must land on its own bin")
} }
/** The model's own call must keep its exact shape, whatever the display asks for. */ /** The model's own call must keep its exact shape, whatever the display asks for. */
@@ -95,9 +95,10 @@ class CwDeepSpectrogramTest {
assertEquals(CwDeepSpectrogram.FREQUENCY_BINS, model[0].size) assertEquals(CwDeepSpectrogram.FREQUENCY_BINS, model[0].size)
assertEquals(model.size, explicit.size) assertEquals(model.size, explicit.size)
for (frame in model.indices) { for (frame in model.indices) {
assertArrayEquals( assertContentEquals(
"explicit model range must equal the default", model[frame],
model[frame], explicit[frame], 0f explicit[frame],
"explicit model range must equal the default"
) )
} }
} }
@@ -108,7 +109,7 @@ class CwDeepSpectrogramTest {
val spec = CwDeepSpectrogram.compute(audio) val spec = CwDeepSpectrogram.compute(audio)
for (frame in spec) { for (frame in spec) {
for (v in frame) { for (v in frame) {
assertTrue("log1p of a magnitude must be >= 0, got $v", v >= 0f) assertTrue(v >= 0f, "log1p of a magnitude must be >= 0, got $v")
} }
} }
} }
@@ -124,7 +125,7 @@ class CwDeepSpectrogramTest {
val input = floatArrayOf(0.1f, 0.2f, 0.3f) val input = floatArrayOf(0.1f, 0.2f, 0.3f)
val out = CwDeepSpectrogram.resampleLinear(input, 3200, 3200) val out = CwDeepSpectrogram.resampleLinear(input, 3200, 3200)
assertEquals(3, out.size) assertEquals(3, out.size)
assertEquals(0.2f, out[1], 1e-6f) assertEquals(0.2f, out[1], absoluteTolerance = 1e-6f)
} }
@Test @Test
@@ -136,7 +137,7 @@ class CwDeepSpectrogramTest {
val spec = CwDeepSpectrogram.compute(at3200) val spec = CwDeepSpectrogram.compute(at3200)
val middle = spec[spec.size / 2] val middle = spec[spec.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1 val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
assertTrue("resampled tone peak at $peak, expected near 24", abs(peak - 24) <= 1) assertTrue(abs(peak - 24) <= 1, "resampled tone peak at $peak, expected near 24")
} }
@Test @Test
@@ -1,9 +1,9 @@
package com.rtbishop.look4sat.core.domain.cw package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals import kotlin.test.Test
import org.junit.Assert.assertFalse import kotlin.test.assertEquals
import org.junit.Assert.assertTrue import kotlin.test.assertFalse
import org.junit.Test import kotlin.test.assertTrue
/** /**
* The pool feeds [CwToneShifter.detectToneHz], which measures a waveform, so the * The pool feeds [CwToneShifter.detectToneHz], which measures a waveform, so the
@@ -22,8 +22,10 @@ class CwDetectionPoolTest {
private fun assertAscending(values: FloatArray) { private fun assertAscending(values: FloatArray) {
for (i in 1 until values.size) { for (i in 1 until values.size) {
assertEquals( assertEquals(
"sample $i breaks the ramp, so the ring wrap is wrong", values[i - 1] + 1f,
values[i - 1] + 1f, values[i], 0f values[i],
absoluteTolerance = 0f,
"sample $i breaks the ramp, so the ring wrap is wrong"
) )
} }
} }
@@ -31,17 +33,17 @@ class CwDetectionPoolTest {
@Test @Test
fun `reports readiness only once capacity is reached`() { fun `reports readiness only once capacity is reached`() {
val pool = CwDetectionPool(capacity) val pool = CwDetectionPool(capacity)
assertFalse("an empty pool is not ready", pool.isReady) assertFalse(pool.isReady, "an empty pool is not ready")
assertEquals(0, pool.size) assertEquals(0, pool.size)
// Three 320-sample chunks are 960 samples: still short. // Three 320-sample chunks are 960 samples: still short.
repeat(3) { pool.add(ramp(it * 320, 320)) } repeat(3) { pool.add(ramp(it * 320, 320)) }
assertEquals(960, pool.size) assertEquals(960, pool.size)
assertFalse("960 of $capacity samples is not ready", pool.isReady) assertFalse(pool.isReady, "960 of $capacity samples is not ready")
pool.add(ramp(960, 320)) pool.add(ramp(960, 320))
assertEquals(capacity, pool.size) assertEquals(capacity, pool.size)
assertTrue("a full pool must report ready", pool.isReady) assertTrue(pool.isReady, "a full pool must report ready")
} }
@Test @Test
@@ -50,11 +52,11 @@ class CwDetectionPoolTest {
pool.add(ramp(500, 320)) pool.add(ramp(500, 320))
val drained = pool.drain() val drained = pool.drain()
assertEquals("only what was added may come back", 320, drained.size) assertEquals(320, drained.size, "only what was added may come back")
assertEquals(500f, drained.first(), 0f) assertEquals(500f, drained.first(), absoluteTolerance = 0f)
assertEquals(819f, drained.last(), 0f) assertEquals(819f, drained.last(), absoluteTolerance = 0f)
assertAscending(drained) assertAscending(drained)
assertEquals("draining empties the pool", 0, pool.size) assertEquals(0, pool.size, "draining empties the pool")
} }
@Test @Test
@@ -65,8 +67,8 @@ class CwDetectionPoolTest {
val drained = pool.drain() val drained = pool.drain()
assertEquals(capacity, drained.size) assertEquals(capacity, drained.size)
assertEquals("the newest sample fed must be last", 3199f, drained.last(), 0f) assertEquals(3199f, drained.last(), absoluteTolerance = 0f, "the newest sample fed must be last")
assertEquals("the oldest retained sample must be first", (3200 - capacity).toFloat(), drained.first(), 0f) assertEquals((3200 - capacity).toFloat(), drained.first(), absoluteTolerance = 0f, "the oldest retained sample must be first")
assertAscending(drained) assertAscending(drained)
} }
@@ -77,8 +79,8 @@ class CwDetectionPoolTest {
val drained = pool.drain() val drained = pool.drain()
assertEquals(capacity, drained.size) assertEquals(capacity, drained.size)
assertEquals(4999f, drained.last(), 0f) assertEquals(4999f, drained.last(), absoluteTolerance = 0f)
assertEquals((5000 - capacity).toFloat(), drained.first(), 0f) assertEquals((5000 - capacity).toFloat(), drained.first(), absoluteTolerance = 0f)
assertAscending(drained) assertAscending(drained)
} }
@@ -90,8 +92,8 @@ class CwDetectionPoolTest {
val drained = pool.drain() val drained = pool.drain()
assertEquals(capacity, drained.size) assertEquals(capacity, drained.size)
assertEquals(1999f, drained.last(), 0f) assertEquals(1999f, drained.last(), absoluteTolerance = 0f)
assertEquals((2000 - capacity).toFloat(), drained.first(), 0f) assertEquals((2000 - capacity).toFloat(), drained.first(), absoluteTolerance = 0f)
assertAscending(drained) assertAscending(drained)
} }
@@ -105,8 +107,8 @@ class CwDetectionPoolTest {
pool.add(ramp(9000, 320)) pool.add(ramp(9000, 320))
val drained = pool.drain() val drained = pool.drain()
assertEquals(320, drained.size) assertEquals(320, drained.size)
assertEquals(9000f, drained.first(), 0f) assertEquals(9000f, drained.first(), absoluteTolerance = 0f)
assertEquals(9319f, drained.last(), 0f) assertEquals(9319f, drained.last(), absoluteTolerance = 0f)
assertAscending(drained) assertAscending(drained)
} }
@@ -120,8 +122,8 @@ class CwDetectionPoolTest {
assertFalse(pool.isReady) assertFalse(pool.isReady)
pool.add(ramp(7000, 320)) pool.add(ramp(7000, 320))
val drained = pool.drain() val drained = pool.drain()
assertEquals("cleared samples must not reappear", 320, drained.size) assertEquals(320, drained.size, "cleared samples must not reappear")
assertEquals(7000f, drained.first(), 0f) assertEquals(7000f, drained.first(), absoluteTolerance = 0f)
} }
@Test @Test
@@ -129,7 +131,7 @@ class CwDetectionPoolTest {
val pool = CwDetectionPool(capacity) val pool = CwDetectionPool(capacity)
pool.add(ramp(0, 320)) pool.add(ramp(0, 320))
pool.add(FloatArray(0)) pool.add(FloatArray(0))
assertEquals("an empty chunk must not change the pool", 320, pool.size) assertEquals(320, pool.size, "an empty chunk must not change the pool")
assertAscending(pool.drain()) assertAscending(pool.drain())
} }
@@ -140,8 +142,8 @@ class CwDetectionPoolTest {
val drained = pool.drain() val drained = pool.drain()
assertEquals(capacity, drained.size) assertEquals(capacity, drained.size)
assertEquals(100f, drained.first(), 0f) assertEquals(100f, drained.first(), absoluteTolerance = 0f)
assertEquals((100 + capacity - 1).toFloat(), drained.last(), 0f) assertEquals((100 + capacity - 1).toFloat(), drained.last(), absoluteTolerance = 0f)
assertAscending(drained) assertAscending(drained)
} }
@@ -154,7 +156,7 @@ class CwDetectionPoolTest {
var phase = 0 var phase = 0
repeat(4) { repeat(4) {
pool.add(FloatArray(320) { i -> pool.add(FloatArray(320) { i ->
kotlin.math.sin(2.0 * Math.PI * 1500.0 * (phase + i) / sampleRate).toFloat() kotlin.math.sin(2.0 * kotlin.math.PI * 1500.0 * (phase + i) / sampleRate).toFloat()
}) })
phase += 320 phase += 320
} }
@@ -162,8 +164,10 @@ class CwDetectionPoolTest {
val detected = CwToneShifter.detectToneHz(pool.drain(), sampleRate) val detected = CwToneShifter.detectToneHz(pool.drain(), sampleRate)
assertEquals( assertEquals(
"four pooled capture chunks must be enough to detect a 1500 Hz tone", 1500.0,
1500.0, detected!!.toDouble(), 25.0 detected!!.toDouble(),
absoluteTolerance = 25.0,
"four pooled capture chunks must be enough to detect a 1500 Hz tone"
) )
} }
@@ -1,15 +1,15 @@
package com.rtbishop.look4sat.core.domain.cw package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.sin import kotlin.math.sin
import kotlin.random.Random import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** /**
* Drives the real [CwShiftDecider] with the real [CwToneShifter.analyse]. * Drives the real [CwShiftDecider] with the real [CwToneShifter.analyse].
@@ -44,21 +44,26 @@ class CwShiftDeciderTest {
val decider = CwShiftDecider() val decider = CwShiftDecider()
val established = feed(decider, steadyTone(1400.0)) val established = feed(decider, steadyTone(1400.0))
assertEquals(CwShiftDecider.Outcome.SHIFTED, established.outcome) assertEquals(CwShiftDecider.Outcome.SHIFTED, established.outcome)
assertTrue("a 1400 Hz tone must produce a shift", established.shiftHz != 0f) assertTrue(established.shiftHz != 0f, "a 1400 Hz tone must produce a shift")
val silent = feed(decider, noise()) val silent = feed(decider, noise())
assertEquals( assertEquals(
"silence must be reported as no tone, not as a zero shift", CwShiftDecider.Outcome.NO_TONE,
CwShiftDecider.Outcome.NO_TONE, silent.outcome silent.outcome,
"silence must be reported as no tone, not as a zero shift"
) )
assertEquals( assertEquals(
"silence must not change the shift", established.shiftHz,
established.shiftHz, silent.shiftHz, 0f silent.shiftHz,
absoluteTolerance = 0f,
"silence must not change the shift"
) )
assertFalse("a silent window is not a change", silent.changed) assertFalse(silent.changed, "a silent window is not a change")
assertEquals( assertEquals(
"the decider's state must still hold the shift", established.shiftHz,
established.shiftHz, decider.shiftHz, 0f decider.shiftHz,
absoluteTolerance = 0f,
"the decider's state must still hold the shift"
) )
} }
@@ -70,12 +75,14 @@ class CwShiftDeciderTest {
repeat(8) { i -> repeat(8) { i ->
val decision = feed(decider, noise(seed = i + 2)) val decision = feed(decider, noise(seed = i + 2))
assertEquals( assertEquals(
"silent window $i changed the shift", established,
established, decision.shiftHz, 0f decision.shiftHz,
absoluteTolerance = 0f,
"silent window $i changed the shift"
) )
} }
assertEquals(established, decider.shiftHz, 0f) assertEquals(established, decider.shiftHz, absoluteTolerance = 0f)
assertNotNull("the anchor must survive silence", decider.anchorToneHz) assertNotNull(decider.anchorToneHz, "the anchor must survive silence")
} }
// --- Mutants (b) and (c): hysteresis anchored on the tone ---------------------- // --- Mutants (b) and (c): hysteresis anchored on the tone ----------------------
@@ -91,36 +98,41 @@ class CwShiftDeciderTest {
val hop = feed(decider, steadyTone(1200.0)) val hop = feed(decider, steadyTone(1200.0))
assertEquals( assertEquals(
"a one-bin hop back across the edge must be absorbed", CwShiftDecider.Outcome.WITHIN_HYSTERESIS,
CwShiftDecider.Outcome.WITHIN_HYSTERESIS, hop.outcome hop.outcome,
"a one-bin hop back across the edge must be absorbed"
) )
assertEquals("the shift must not move", first.shiftHz, hop.shiftHz, 0f) assertEquals(first.shiftHz, hop.shiftHz, absoluteTolerance = 0f, "the shift must not move")
assertFalse(hop.changed) assertFalse(hop.changed)
} }
@Test @Test
fun `the anchor is set from the tone that produced the shift`() { fun `the anchor is set from the tone that produced the shift`() {
val decider = CwShiftDecider() val decider = CwShiftDecider()
assertNull("no anchor before the first detection", decider.anchorToneHz) assertNull(decider.anchorToneHz, "no anchor before the first detection")
feed(decider, steadyTone(1400.0)) feed(decider, steadyTone(1400.0))
assertEquals( assertEquals(
"the anchor must be the detected tone", 1400.0,
1400.0, decider.anchorToneHz!!.toDouble(), 25.0 decider.anchorToneHz!!.toDouble(),
absoluteTolerance = 25.0,
"the anchor must be the detected tone"
) )
// An in-window tone must anchor too, otherwise a tone drifting from inside the // An in-window tone must anchor too, otherwise a tone drifting from inside the
// window to outside would be measured against a stale reference. // window to outside would be measured against a stale reference.
feed(decider, steadyTone(700.0)) feed(decider, steadyTone(700.0))
assertEquals( assertEquals(
"an in-window tone must also become the anchor", 700.0,
700.0, decider.anchorToneHz!!.toDouble(), 25.0 decider.anchorToneHz!!.toDouble(),
absoluteTolerance = 25.0,
"an in-window tone must also become the anchor"
) )
assertEquals("an in-window tone needs no shift", 0f, decider.shiftHz, 0f) assertEquals(0f, decider.shiftHz, absoluteTolerance = 0f, "an in-window tone needs no shift")
} }
@Test @Test
fun `hysteresis is measured against the anchor, not the previous estimate`() { fun `hysteresis is measured against the anchor rather than the previous estimate`() {
// Walk in 25 Hz steps: each step is under the 40 Hz margin, so a comparison // Walk in 25 Hz steps: each step is under the 40 Hz margin, so a comparison
// against the previous estimate would never fire. Anchored, the shift updates // against the previous estimate would never fire. Anchored, the shift updates
// once the accumulated move clears the margin. // once the accumulated move clears the margin.
@@ -135,9 +147,9 @@ class CwShiftDeciderTest {
tone += 25.0 tone += 25.0
} }
assertTrue( assertTrue(
updates >= 1,
"accumulated drift must eventually re-shift; anchor started at $anchorAtStart " + "accumulated drift must eventually re-shift; anchor started at $anchorAtStart " +
"and the shift updated $updates times", "and the shift updated $updates times"
updates >= 1
) )
} }
@@ -151,20 +163,21 @@ class CwShiftDeciderTest {
// Well inside the margin: must be absorbed. // Well inside the margin: must be absorbed.
val small = feed(decider, steadyTone(1412.5)) val small = feed(decider, steadyTone(1412.5))
assertEquals(CwShiftDecider.Outcome.WITHIN_HYSTERESIS, small.outcome) assertEquals(CwShiftDecider.Outcome.WITHIN_HYSTERESIS, small.outcome)
assertEquals(before, small.shiftHz, 0f) assertEquals(before, small.shiftHz, absoluteTolerance = 0f)
// Well beyond it: must be followed. An inverted comparison would absorb this and // Well beyond it: must be followed. An inverted comparison would absorb this and
// react to the small move instead. // react to the small move instead.
val large = feed(decider, steadyTone(1000.0)) val large = feed(decider, steadyTone(1000.0))
assertTrue( assertTrue(
"a 400 Hz retune must change the shift (was $before, now ${large.shiftHz})", large.changed,
large.changed "a 400 Hz retune must change the shift (was $before, now ${large.shiftHz})"
) )
assertEquals( assertEquals(
"a 1000 Hz tone is inside the window, so no shift is needed", CwShiftDecider.Outcome.NO_SHIFT_NEEDED,
CwShiftDecider.Outcome.NO_SHIFT_NEEDED, large.outcome large.outcome,
"a 1000 Hz tone is inside the window, so no shift is needed"
) )
assertEquals(0f, large.shiftHz, 0f) assertEquals(0f, large.shiftHz, absoluteTolerance = 0f)
} }
@Test @Test
@@ -179,8 +192,8 @@ class CwShiftDeciderTest {
} }
} }
assertTrue( assertTrue(
"an edge tone must settle; the shift changed $changes times in ${hops.size * 4} detections", changes <= 3,
changes <= 3 "an edge tone must settle; the shift changed $changes times in ${hops.size * 4} detections"
) )
} }
@@ -196,14 +209,14 @@ class CwShiftDeciderTest {
val landed = tone + decision.shiftHz val landed = tone + decision.shiftHz
worstOffset = maxOf(worstOffset, abs(landed - CwToneShifter.TARGET_HZ)) worstOffset = maxOf(worstOffset, abs(landed - CwToneShifter.TARGET_HZ))
assertTrue( assertTrue(
"a ${tone}Hz tone landed at ${landed}Hz, outside the model window", CwToneShifter.isInsideWindow(landed.toFloat()),
CwToneShifter.isInsideWindow(landed.toFloat()) "a ${tone}Hz tone landed at ${landed}Hz, outside the model window"
) )
tone += 12.5 tone += 12.5
} }
assertTrue( assertTrue(
"staleness must stay near the margin, worst offset was $worstOffset Hz", worstOffset <= hysteresisHz + 12.5,
worstOffset <= hysteresisHz + 12.5 "staleness must stay near the margin, worst offset was $worstOffset Hz"
) )
} }
@@ -215,8 +228,8 @@ class CwShiftDeciderTest {
assertNotNull(decider.anchorToneHz) assertNotNull(decider.anchorToneHz)
decider.reset() decider.reset()
assertEquals("reset must clear the shift", 0f, decider.shiftHz, 0f) assertEquals(0f, decider.shiftHz, absoluteTolerance = 0f, "reset must clear the shift")
assertNull("reset must clear the anchor", decider.anchorToneHz) assertNull(decider.anchorToneHz, "reset must clear the anchor")
// After a reset the next tone must be acted on rather than absorbed. // After a reset the next tone must be acted on rather than absorbed.
val decision = feed(decider, steadyTone(1400.0)) val decision = feed(decider, steadyTone(1400.0))
@@ -237,8 +250,8 @@ class CwShiftDeciderTest {
feed(decider, audio) feed(decider, audio)
if (decider.shiftHz != 0f) { if (decider.shiftHz != 0f) {
assertNotNull( assertNotNull(
"step $index left a shift of ${decider.shiftHz}Hz with no anchor", decider.anchorToneHz,
decider.anchorToneHz "step $index left a shift of ${decider.shiftHz}Hz with no anchor"
) )
} }
} }
@@ -250,8 +263,10 @@ class CwShiftDeciderTest {
val decider = CwShiftDecider() val decider = CwShiftDecider()
val decision = feed(decider, steadyTone(hz)) val decision = feed(decider, steadyTone(hz))
assertEquals( assertEquals(
"a ${hz}Hz tone must be shifted to the window centre", CwToneShifter.TARGET_HZ,
CwToneShifter.TARGET_HZ, hz + decision.shiftHz, 30.0 hz + decision.shiftHz,
absoluteTolerance = 30.0,
"a ${hz}Hz tone must be shifted to the window centre"
) )
} }
} }
@@ -262,10 +277,11 @@ class CwShiftDeciderTest {
val decider = CwShiftDecider() val decider = CwShiftDecider()
val decision = feed(decider, steadyTone(hz)) val decision = feed(decider, steadyTone(hz))
assertEquals( assertEquals(
"a ${hz}Hz tone is inside the window and must not be shifted", CwShiftDecider.Outcome.NO_SHIFT_NEEDED,
CwShiftDecider.Outcome.NO_SHIFT_NEEDED, decision.outcome decision.outcome,
"a ${hz}Hz tone is inside the window and must not be shifted"
) )
assertEquals(0f, decision.shiftHz, 0f) assertEquals(0f, decision.shiftHz, absoluteTolerance = 0f)
} }
} }
} }
@@ -1,13 +1,13 @@
package com.rtbishop.look4sat.core.domain.cw package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.sin import kotlin.math.sin
import kotlin.random.Random import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** /**
* Signal-level properties of the shifter: the range the spectrogram expects, the * Signal-level properties of the shifter: the range the spectrogram expects, the
@@ -49,27 +49,29 @@ class CwToneShiftSignalTest {
falsePositives++ falsePositives++
} }
} }
assertEquals("noise must never be reported as a tone", 0, falsePositives) assertEquals(0, falsePositives, "noise must never be reported as a tone")
// Noise at 0.7 against a unit-amplitude tone is roughly 3 dB SNR: audible, // Noise at 0.7 against a unit-amplitude tone is roughly 3 dB SNR: audible,
// decodable, and the region an over-tight threshold silently discards. // decodable, and the region an over-tight threshold silently discards.
for (hz in listOf(300.0, 800.0, 1400.0)) { for (hz in listOf(300.0, 800.0, 1400.0)) {
val detected = CwToneShifter.detectToneHz(keyedTone(hz, noise = 0.7), sampleRate) val detected = CwToneShifter.detectToneHz(keyedTone(hz, noise = 0.7), sampleRate)
assertEquals( assertEquals(
"a weak but usable ${hz}Hz signal must be detected, not rejected as noise", hz,
hz, detected!!.toDouble(), 25.0 detected!!.toDouble(),
absoluteTolerance = 25.0,
"a weak but usable ${hz}Hz signal must be detected, not rejected as noise"
) )
} }
assertTrue( assertTrue(
CwToneShifter.MIN_PROMINENCE > 3.4,
"MIN_PROMINENCE ${CwToneShifter.MIN_PROMINENCE} must clear the measured noise " + "MIN_PROMINENCE ${CwToneShifter.MIN_PROMINENCE} must clear the measured noise " +
"ceiling of ~3.4", "ceiling of ~3.4"
CwToneShifter.MIN_PROMINENCE > 3.4
) )
assertTrue( assertTrue(
CwToneShifter.MIN_PROMINENCE < 5.2,
"MIN_PROMINENCE ${CwToneShifter.MIN_PROMINENCE} must not reject weak signals; " + "MIN_PROMINENCE ${CwToneShifter.MIN_PROMINENCE} must not reject weak signals; " +
"keyed CW measures 7.6-9.0 at 0 dB SNR and 5.2-6.7 at -3 dB", "keyed CW measures 7.6-9.0 at 0 dB SNR and 5.2-6.7 at -3 dB"
CwToneShifter.MIN_PROMINENCE < 5.2
) )
} }
@@ -87,23 +89,25 @@ class CwToneShiftSignalTest {
val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f } val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f }
val shiftedSquare = shifter.process(square, shiftHz, sampleRate) val shiftedSquare = shifter.process(square, shiftHz, sampleRate)
assertTrue( assertTrue(
"a full-scale square wave overshot: peak was ${shiftedSquare.maxOf { abs(it) }}", shiftedSquare.all { abs(it) <= 1f },
shiftedSquare.all { abs(it) <= 1f } "a full-scale square wave overshot: peak was ${shiftedSquare.maxOf { abs(it) }}"
) )
shifter.reset() shifter.reset()
val sine = FloatArray(1280) { i -> sin(2.0 * PI * 1500.0 * i / sampleRate).toFloat() } val sine = FloatArray(1280) { i -> sin(2.0 * PI * 1500.0 * i / sampleRate).toFloat() }
val shiftedSine = shifter.process(sine, shiftHz, sampleRate) val shiftedSine = shifter.process(sine, shiftHz, sampleRate)
assertTrue( assertTrue(
"a full-scale sine overshot: peak was ${shiftedSine.maxOf { abs(it) }}", shiftedSine.all { abs(it) <= 1f },
shiftedSine.all { abs(it) <= 1f } "a full-scale sine overshot: peak was ${shiftedSine.maxOf { abs(it) }}"
) )
// Limiting must not flatten the signal away: the tone still has to be there. // Limiting must not flatten the signal away: the tone still has to be there.
val detected = CwToneShifter.detectToneHz(shiftedSine, sampleRate) val detected = CwToneShifter.detectToneHz(shiftedSine, sampleRate)
assertEquals( assertEquals(
"limiting must preserve the shifted tone", CwToneShifter.TARGET_HZ,
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 30.0 detected!!.toDouble(),
absoluteTolerance = 30.0,
"limiting must preserve the shifted tone"
) )
} }
@@ -112,8 +116,8 @@ class CwToneShiftSignalTest {
val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f } val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f }
val shifted = CwToneShifter.shift(square, -700f, sampleRate) val shifted = CwToneShifter.shift(square, -700f, sampleRate)
assertTrue( assertTrue(
"peak was ${shifted.maxOf { abs(it) }}", shifted.all { abs(it) <= 1f },
shifted.all { abs(it) <= 1f } "peak was ${shifted.maxOf { abs(it) }}"
) )
} }
@@ -127,20 +131,22 @@ class CwToneShiftSignalTest {
} }
val detected = CwToneShifter.detectToneHz(biased, sampleRate) val detected = CwToneShifter.detectToneHz(biased, sampleRate)
assertEquals( assertEquals(
"a DC offset of $offset must not hide the tone", 800.0,
800.0, detected!!.toDouble(), 25.0 detected!!.toDouble(),
absoluteTolerance = 25.0,
"a DC offset of $offset must not hide the tone"
) )
} }
assertNull( assertNull(
"all zeros must not report a tone", CwToneShifter.detectToneHz(FloatArray(1280), sampleRate),
CwToneShifter.detectToneHz(FloatArray(1280), sampleRate) "all zeros must not report a tone"
) )
for (size in listOf(0, 1, 2, 63)) { for (size in listOf(0, 1, 2, 63)) {
assertNull( assertNull(
"a $size-sample buffer is too short to detect from", CwToneShifter.detectToneHz(FloatArray(size), sampleRate),
CwToneShifter.detectToneHz(FloatArray(size), sampleRate) "a $size-sample buffer is too short to detect from"
) )
} }
@@ -148,8 +154,8 @@ class CwToneShiftSignalTest {
if (i == 640) Float.NaN else sin(2.0 * PI * 800.0 * i / sampleRate).toFloat() if (i == 640) Float.NaN else sin(2.0 * PI * 800.0 * i / sampleRate).toFloat()
} }
assertNull( assertNull(
"a NaN sample must yield no tone rather than a garbage shift", CwToneShifter.detectToneHz(withNan, sampleRate),
CwToneShifter.detectToneHz(withNan, sampleRate) "a NaN sample must yield no tone rather than a garbage shift"
) )
// Clipping must not let a harmonic outrank the fundamental. // Clipping must not let a harmonic outrank the fundamental.
@@ -159,8 +165,10 @@ class CwToneShiftSignalTest {
} }
val detected = CwToneShifter.detectToneHz(clipped, sampleRate) val detected = CwToneShifter.detectToneHz(clipped, sampleRate)
assertEquals( assertEquals(
"at ${drive}x drive the fundamental must still win", 500.0,
500.0, detected!!.toDouble(), 25.0 detected!!.toDouble(),
absoluteTolerance = 25.0,
"at ${drive}x drive the fundamental must still win"
) )
} }
} }
@@ -1,13 +1,13 @@
package com.rtbishop.look4sat.core.domain.cw package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.sqrt import kotlin.math.sqrt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
import kotlin.test.assertTrue
/** /**
* [CwToneShifter.Streaming] exists because the decoder shifts one ~320-sample chunk at * [CwToneShifter.Streaming] exists because the decoder shifts one ~320-sample chunk at
@@ -78,9 +78,9 @@ class CwToneShifterStreamingTest {
// Measured 0.79% with state carried across chunks; dropping the filter history // Measured 0.79% with state carried across chunks; dropping the filter history
// takes it to several percent, and dropping the phase far higher. // takes it to several percent, and dropping the phase far higher.
assertTrue( assertTrue(
streamedRipple < 2.0,
"streaming envelope ripple ${streamedRipple}% is too high; chunk-edge " + "streaming envelope ripple ${streamedRipple}% is too high; chunk-edge " +
"filter state or mixer phase is not being carried", "filter state or mixer phase is not being carried"
streamedRipple < 2.0
) )
} }
@@ -118,15 +118,15 @@ class CwToneShifterStreamingTest {
} }
assertTrue( assertTrue(
worstInterior < 0.01,
"away from chunk tails the two must agree; worst divergence was " + "away from chunk tails the two must agree; worst divergence was " +
"$worstInterior, so filter history or mixer phase is not being carried", "$worstInterior, so filter history or mixer phase is not being carried"
worstInterior < 0.01
) )
// The tail is allowed to differ, but not wildly: a broken implementation would // The tail is allowed to differ, but not wildly: a broken implementation would
// diverge by the full signal amplitude rather than a fraction of it. // diverge by the full signal amplitude rather than a fraction of it.
assertTrue( assertTrue(
"chunk-tail divergence $worstTail exceeds the causal lookahead budget", worstTail < 0.5,
worstTail < 0.5 "chunk-tail divergence $worstTail exceeds the causal lookahead budget"
) )
} }
@@ -138,8 +138,10 @@ class CwToneShifterStreamingTest {
val detected = CwToneShifter.detectToneHz(streamed, sampleRate) val detected = CwToneShifter.detectToneHz(streamed, sampleRate)
assertEquals( assertEquals(
"streamed audio must end up at the target pitch", CwToneShifter.TARGET_HZ,
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 30.0 detected!!.toDouble(),
absoluteTolerance = 30.0,
"streamed audio must end up at the target pitch"
) )
} }
@@ -148,8 +150,9 @@ class CwToneShifterStreamingTest {
val shifter = CwToneShifter.Streaming() val shifter = CwToneShifter.Streaming()
val chunk = continuousTone(800.0, chunkSize) val chunk = continuousTone(800.0, chunkSize)
assertSame( assertSame(
"a zero shift must not copy or alter the chunk", chunk,
chunk, shifter.process(chunk, 0f, sampleRate) shifter.process(chunk, 0f, sampleRate),
"a zero shift must not copy or alter the chunk"
) )
} }
@@ -176,8 +179,8 @@ class CwToneShifterStreamingTest {
val head = envelope(firstShifted.copyOfRange(0, 96)).average() val head = envelope(firstShifted.copyOfRange(0, 96)).average()
val tail = envelope(firstShifted.copyOfRange(firstShifted.size - 96, firstShifted.size)).average() val tail = envelope(firstShifted.copyOfRange(firstShifted.size - 96, firstShifted.size)).average()
assertTrue( assertTrue(
"first shifted chunk starts at $head but settles at $tail; history was not kept", head > tail * 0.7,
head > tail * 0.7 "first shifted chunk starts at $head but settles at $tail; history was not kept"
) )
} }
@@ -201,9 +204,9 @@ class CwToneShifterStreamingTest {
val head = envelope(afterReset.copyOfRange(0, 64)).average() val head = envelope(afterReset.copyOfRange(0, 64)).average()
val tail = envelope(afterReset.copyOfRange(afterReset.size - 64, afterReset.size)).average() val tail = envelope(afterReset.copyOfRange(afterReset.size - 64, afterReset.size)).average()
assertTrue( assertTrue(
head < tail,
"reset must clear history, so the head ($head) should be quieter than " + "reset must clear history, so the head ($head) should be quieter than " +
"the settled tail ($tail)", "the settled tail ($tail)"
head < tail
) )
} }
@@ -214,7 +217,7 @@ class CwToneShifterStreamingTest {
val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat() val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat()
val out = shifter.process(big, shiftHz, sampleRate) val out = shifter.process(big, shiftHz, sampleRate)
assertEquals(big.size, out.size) assertEquals(big.size, out.size)
assertTrue("output must be finite", out.all { it.isFinite() }) assertTrue(out.all { it.isFinite() }, "output must be finite")
} }
@Test @Test
@@ -230,11 +233,13 @@ class CwToneShifterStreamingTest {
shifter.process(chunk, shiftHz, sampleRate).copyInto(collected, offset) shifter.process(chunk, shiftHz, sampleRate).copyInto(collected, offset)
offset += 16 offset += 16
} }
assertTrue("output must be finite", collected.all { it.isFinite() }) assertTrue(collected.all { it.isFinite() }, "output must be finite")
val detected = CwToneShifter.detectToneHz(collected, sampleRate) val detected = CwToneShifter.detectToneHz(collected, sampleRate)
assertEquals( assertEquals(
"even tiny chunks must end up at the target pitch", CwToneShifter.TARGET_HZ,
CwToneShifter.TARGET_HZ, detected!!.toDouble(), 40.0 detected!!.toDouble(),
absoluteTolerance = 40.0,
"even tiny chunks must end up at the target pitch"
) )
} }
} }
@@ -1,18 +1,18 @@
package com.rtbishop.look4sat.core.domain.cw package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.hypot import kotlin.math.hypot
import kotlin.math.sin import kotlin.math.sin
import kotlin.random.Random import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
/** /**
* The shifter exists so pitches outside the model's 400-1200 Hz window can still be * The shifter exists so pitches outside the model's 400-1200 Hz window can still be
@@ -70,8 +70,8 @@ class CwToneShifterTest {
fun `detects tones across the audible range`() { fun `detects tones across the audible range`() {
for (tone in listOf(150.0, 300.0, 500.0, 700.0, 800.0, 1100.0, 1300.0, 1500.0)) { for (tone in listOf(150.0, 300.0, 500.0, 700.0, 800.0, 1100.0, 1300.0, 1500.0)) {
val detected = CwToneShifter.detectToneHz(cwTone(tone), sampleRate) val detected = CwToneShifter.detectToneHz(cwTone(tone), sampleRate)
assertNotNull("no tone detected at $tone Hz", detected) assertNotNull(detected, "no tone detected at $tone Hz")
assertEquals("detected pitch off at $tone Hz", tone, detected!!.toDouble(), 25.0) assertEquals(tone, detected!!.toDouble(), absoluteTolerance = 25.0, "detected pitch off at $tone Hz")
} }
} }
@@ -79,7 +79,7 @@ class CwToneShifterTest {
fun `reports no tone for noise`() { fun `reports no tone for noise`() {
val random = Random(7) val random = Random(7)
val noise = FloatArray(1600) { ((random.nextDouble() - 0.5) * 2).toFloat() } val noise = FloatArray(1600) { ((random.nextDouble() - 0.5) * 2).toFloat() }
assertNull("noise must not be mistaken for a tone", CwToneShifter.detectToneHz(noise, sampleRate)) assertNull(CwToneShifter.detectToneHz(noise, sampleRate), "noise must not be mistaken for a tone")
} }
@Test @Test
@@ -87,10 +87,10 @@ class CwToneShifterTest {
for (tone in listOf(400.0, 500.0, 700.0, 800.0, 1100.0, 1200.0)) { for (tone in listOf(400.0, 500.0, 700.0, 800.0, 1100.0, 1200.0)) {
val audio = cwTone(tone) val audio = cwTone(tone)
val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate) val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate)
assertFalse("$tone Hz is inside the window, must not shift", analysis.needsShift) assertFalse(analysis.needsShift, "$tone Hz is inside the window, must not shift")
assertEquals("no shift expected at $tone Hz", 0f, analysis.shiftHz, 0f) assertEquals(0f, analysis.shiftHz, absoluteTolerance = 0f, "no shift expected at $tone Hz")
// Same instance: the caller's array must not even be copied. // Same instance: the caller's array must not even be copied.
assertSame("in-window audio must be passed through", audio, result) assertSame(audio, result, "in-window audio must be passed through")
} }
} }
@@ -105,11 +105,11 @@ class CwToneShifterTest {
for (tone in listOf(120.0, 250.0, 1400.0, 1500.0)) { for (tone in listOf(120.0, 250.0, 1400.0, 1500.0)) {
val analysis = CwToneShifter.analyse(cwTone(tone), sampleRate) val analysis = CwToneShifter.analyse(cwTone(tone), sampleRate)
val reported = analysis.toneHz val reported = analysis.toneHz
assertNotNull("$tone Hz went undetected, so the UI has nothing to report", reported) assertNotNull(reported, "$tone Hz went undetected, so the UI has nothing to report")
assertEquals("$tone Hz was misreported", tone, reported!!.toDouble(), 30.0) assertEquals(tone, reported!!.toDouble(), absoluteTolerance = 30.0, "$tone Hz was misreported")
assertFalse( assertFalse(
"$tone Hz must read as outside the window", CwToneShifter.isInsideWindow(reported),
CwToneShifter.isInsideWindow(reported) "$tone Hz must read as outside the window"
) )
} }
} }
@@ -121,19 +121,19 @@ class CwToneShifterTest {
* nothing arrives at — and nothing else in the build would object. * nothing arrives at — and nothing else in the build would object.
*/ */
@Test @Test
fun `the shift target sits inside the model window, clear of its edges`() { fun `the shift target sits inside the model window and clear of its edges`() {
assertTrue( assertTrue(
CwToneShifter.isInsideWindow(CwToneShifter.TARGET_HZ.toFloat()),
"TARGET_HZ ${CwToneShifter.TARGET_HZ} is outside the model window " + "TARGET_HZ ${CwToneShifter.TARGET_HZ} is outside the model window " +
"${CwDeepSpectrogram.MIN_FREQ_HZ}-${CwDeepSpectrogram.MAX_FREQ_HZ} Hz", "${CwDeepSpectrogram.MIN_FREQ_HZ}-${CwDeepSpectrogram.MAX_FREQ_HZ} Hz"
CwToneShifter.isInsideWindow(CwToneShifter.TARGET_HZ.toFloat())
) )
// Clear of the edges by a decent margin, so a tone landing a little off target // Clear of the edges by a decent margin, so a tone landing a little off target
// still lands inside: a target hugging an edge would make the shift pointless. // still lands inside: a target hugging an edge would make the shift pointless.
val margin = (CwDeepSpectrogram.MAX_FREQ_HZ - CwDeepSpectrogram.MIN_FREQ_HZ) / 4 val margin = (CwDeepSpectrogram.MAX_FREQ_HZ - CwDeepSpectrogram.MIN_FREQ_HZ) / 4
assertTrue( assertTrue(
"TARGET_HZ ${CwToneShifter.TARGET_HZ} is within $margin Hz of a window edge",
CwToneShifter.TARGET_HZ >= CwDeepSpectrogram.MIN_FREQ_HZ + margin && CwToneShifter.TARGET_HZ >= CwDeepSpectrogram.MIN_FREQ_HZ + margin &&
CwToneShifter.TARGET_HZ <= CwDeepSpectrogram.MAX_FREQ_HZ - margin CwToneShifter.TARGET_HZ <= CwDeepSpectrogram.MAX_FREQ_HZ - margin,
"TARGET_HZ ${CwToneShifter.TARGET_HZ} is within $margin Hz of a window edge"
) )
} }
@@ -142,20 +142,22 @@ class CwToneShifterTest {
for (tone in listOf(150.0, 200.0, 250.0, 300.0, 350.0, 1300.0, 1400.0, 1500.0)) { for (tone in listOf(150.0, 200.0, 250.0, 300.0, 350.0, 1300.0, 1400.0, 1500.0)) {
val audio = cwTone(tone) val audio = cwTone(tone)
val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate) val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate)
assertTrue("$tone Hz is outside the window, must shift", analysis.needsShift) assertTrue(analysis.needsShift, "$tone Hz is outside the window, must shift")
val (strongest, competing) = peaks(result) val (strongest, competing) = peaks(result)
assertEquals( assertEquals(
"$tone Hz did not land on the target", CwToneShifter.TARGET_HZ,
CwToneShifter.TARGET_HZ, strongest, 30.0 strongest,
absoluteTolerance = 30.0,
"$tone Hz did not land on the target"
) )
assertTrue( assertTrue(
"$tone Hz left a competing tone at $competing (single-sideband mixing failed)", competing.isEmpty(),
competing.isEmpty() "$tone Hz left a competing tone at $competing (single-sideband mixing failed)"
) )
assertTrue( assertTrue(
"shifted tone must land inside the model window", CwToneShifter.isInsideWindow(strongest.toFloat()),
CwToneShifter.isInsideWindow(strongest.toFloat()) "shifted tone must land inside the model window"
) )
} }
} }
@@ -170,8 +172,8 @@ class CwToneShifterTest {
fun `shift preserves length and stays finite`() { fun `shift preserves length and stays finite`() {
val audio = cwTone(1500.0) val audio = cwTone(1500.0)
val shifted = CwToneShifter.shift(audio, -700f, sampleRate) val shifted = CwToneShifter.shift(audio, -700f, sampleRate)
assertEquals("length must be preserved", audio.size, shifted.size) assertEquals(audio.size, shifted.size, "length must be preserved")
assertTrue("output must be finite", shifted.all { it.isFinite() }) assertTrue(shifted.all { it.isFinite() }, "output must be finite")
} }
@Test @Test
@@ -198,12 +200,13 @@ class CwToneShifterTest {
val shiftedEnergy = shiftedSpectrogram.sumOf { frame -> frame.sumOf { it.toDouble() } } val shiftedEnergy = shiftedSpectrogram.sumOf { frame -> frame.sumOf { it.toDouble() } }
assertTrue( assertTrue(
"shifting must put more energy in the model window (raw=$rawEnergy shifted=$shiftedEnergy)", shiftedEnergy > rawEnergy * 1.5,
shiftedEnergy > rawEnergy * 1.5 "shifting must put more energy in the model window (raw=$rawEnergy shifted=$shiftedEnergy)"
) )
assertEquals( assertEquals(
"bin count must stay compatible with the model", CwDeepSpectrogram.FREQUENCY_BINS,
CwDeepSpectrogram.FREQUENCY_BINS, shiftedSpectrogram[0].size shiftedSpectrogram[0].size,
"bin count must stay compatible with the model"
) )
} }
} }
@@ -17,9 +17,9 @@
*/ */
package com.rtbishop.look4sat.core.domain.navigation package com.rtbishop.look4sat.core.domain.navigation
import org.junit.Assert.assertEquals import kotlin.test.Test
import org.junit.Assert.assertTrue import kotlin.test.assertEquals
import org.junit.Test import kotlin.test.assertTrue
/** /**
* Menu layout rules. Every case here is a bug that shipped at least once, so * Menu layout rules. Every case here is a bug that shipped at least once, so
@@ -53,8 +53,8 @@ class MenuLayoutTest {
val moved = MenuLayout.moveToMain(page, all, emptyList(), emptyList()) val moved = MenuLayout.moveToMain(page, all, emptyList(), emptyList())
val l = layout(moved.screenOrder, moved.subMenuOrder) val l = layout(moved.screenOrder, moved.subMenuOrder)
assertTrue( assertTrue(
"moving $page hid Settings: main=${l.mainIds} more=${l.moreIds}", "Settings" in l.mainIds || "Settings" in l.moreIds,
"Settings" in l.mainIds || "Settings" in l.moreIds "moving $page hid Settings: main=${l.mainIds} more=${l.moreIds}"
) )
} }
} }
@@ -65,15 +65,15 @@ class MenuLayoutTest {
// silently undoing the user's choice. // silently undoing the user's choice.
val moved = MenuLayout.moveToMain("AMSAT", all, emptyList(), emptyList()) val moved = MenuLayout.moveToMain("AMSAT", all, emptyList(), emptyList())
val l = layout(moved.screenOrder, moved.subMenuOrder) val l = layout(moved.screenOrder, moved.subMenuOrder)
assertTrue("AMSAT missing from the bar: ${l.mainIds}", "AMSAT" in l.mainIds) assertTrue("AMSAT" in l.mainIds, "AMSAT missing from the bar: ${l.mainIds}")
assertTrue("AMSAT still behind More: ${l.moreIds}", "AMSAT" !in l.moreIds) assertTrue("AMSAT" !in l.moreIds, "AMSAT still behind More: ${l.moreIds}")
} }
@Test @Test
fun movingWavelogLogToMainActuallyTakesEffect() { fun movingWavelogLogToMainActuallyTakesEffect() {
val moved = MenuLayout.moveToMain("WavelogLog", all, emptyList(), emptyList()) val moved = MenuLayout.moveToMain("WavelogLog", all, emptyList(), emptyList())
val l = layout(moved.screenOrder, moved.subMenuOrder) val l = layout(moved.screenOrder, moved.subMenuOrder)
assertTrue("WavelogLog missing from the bar: ${l.mainIds}", "WavelogLog" in l.mainIds) assertTrue("WavelogLog" in l.mainIds, "WavelogLog missing from the bar: ${l.mainIds}")
} }
@Test @Test
@@ -84,8 +84,8 @@ class MenuLayoutTest {
screenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings"), screenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings"),
subMenuOrder = listOf("Mutual", "Roaming", "CwDecode") subMenuOrder = listOf("Mutual", "Roaming", "CwDecode")
) )
assertTrue("AMSAT should land behind More", "AMSAT" in l.moreIds) assertTrue("AMSAT" in l.moreIds, "AMSAT should land behind More")
assertTrue("WavelogLog should land behind More", "WavelogLog" in l.moreIds) assertTrue("WavelogLog" in l.moreIds, "WavelogLog should land behind More")
} }
@Test @Test
@@ -95,7 +95,7 @@ class MenuLayoutTest {
screenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "Map", "Settings"), screenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "Map", "Settings"),
subMenuOrder = listOf("CwDecode", "WavelogLog", "AMSAT") subMenuOrder = listOf("CwDecode", "WavelogLog", "AMSAT")
) )
assertEquals("every page must be reachable", all.toSet(), (l.mainIds + l.moreIds).toSet()) assertEquals(all.toSet(), (l.mainIds + l.moreIds).toSet(), "every page must be reachable")
} }
@Test @Test
@@ -114,7 +114,7 @@ class MenuLayoutTest {
@Test @Test
fun theBarNeverExceedsFiveSlots() { fun theBarNeverExceedsFiveSlots() {
val l = layout(screenOrder = all) val l = layout(screenOrder = all)
assertTrue("bar had ${l.mainIds.size} slots: ${l.mainIds}", l.mainIds.size <= MenuLayout.MAIN_SLOTS) assertTrue(l.mainIds.size <= MenuLayout.MAIN_SLOTS, "bar had ${l.mainIds.size} slots: ${l.mainIds}")
} }
@Test @Test
@@ -149,6 +149,6 @@ class MenuLayoutTest {
val moved = MenuLayout.moveToMain("CwDecode", all, emptyList(), emptyList()) val moved = MenuLayout.moveToMain("CwDecode", all, emptyList(), emptyList())
val l = layout(moved.screenOrder, moved.subMenuOrder) val l = layout(moved.screenOrder, moved.subMenuOrder)
assertTrue("CwDecode" in l.mainIds) assertTrue("CwDecode" in l.mainIds)
assertEquals("nothing may be lost", all.toSet(), (l.mainIds + l.moreIds).toSet()) assertEquals(all.toSet(), (l.mainIds + l.moreIds).toSet(), "nothing may be lost")
} }
} }
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more