diff --git a/.github/workflows/ios-kmp.yml b/.github/workflows/ios-kmp.yml new file mode 100644 index 00000000..ad9f0424 --- /dev/null +++ b/.github/workflows/ios-kmp.yml @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index 263617dc..f48c7e36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ no tracking, no network required after initial data download. |----------------------|---------------------------------------------------------------------| | `app` | Entry point. Aggregates all modules | | `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 | | `feature:map` | OSMDroid map with ground tracks | | `feature:passes` | Pass predictions and upcoming events | @@ -50,7 +50,7 @@ no tracking, no network required after initial data download. ./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/` ## 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 - **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 -- `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 - Existing code already supports transparent source transition without feature changes - 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 -- **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 - 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. - `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 diff --git a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/CoreDomainPlugin.kt b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/CoreDomainPlugin.kt index 19531a5d..f7990300 100644 --- a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/CoreDomainPlugin.kt +++ b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/CoreDomainPlugin.kt @@ -19,17 +19,47 @@ package com.rtbishop.look4sat.convention import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.kotlin.dsl.dependencies +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") internal class CoreDomainPlugin : Plugin { override fun apply(target: Project) = with(target) { - applyPlugin(libs.plugins.kotlin.jvm) + applyPlugin(libs.plugins.kotlin.multiplatform) applyPlugin(libs.plugins.kotlin.serialization) - setupKotlin() - dependencies { - implementation(libs.kotlin.coroutines) - implementation(libs.kotlin.serialization) + extensions.configure { + jvmToolchain(libs.versions.jdkVersion.get().toInt()) + jvm() + listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "Look4SatCore" + isStatic = true + } + } + sourceSets { + commonMain.dependencies { + implementation(libs.kotlin.coroutines) + implementation(libs.kotlin.serialization) + } + 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. + jvmTest.dependencies { + implementation(libs.test.junit4) + } + } } } } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 16864446..b0a6ec58 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -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.SettingsRepo 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.usecase.AddToCalendar 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 okhttp3.OkHttpClient import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo +import com.rtbishop.look4sat.core.domain.wavelog.WaveLogApi class MainContainer(private val context: Context) : IMainContainer { private val localSource = provideLocalSource() 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") } override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler) override val settingsRepo = provideSettingsRepo() diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt index 99dd5b10..b8ba3032 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepo.kt @@ -29,7 +29,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext -import java.io.InputStream +import java.io.ByteArrayInputStream import java.util.zip.ZipInputStream class DatabaseRepo( @@ -55,8 +55,8 @@ class DatabaseRepo( override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) { var importedCount = 0 - remoteSource.getFileStream(uri)?.let { stream -> - val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream)) + remoteSource.getFileBytes(uri)?.let { data -> + val entries = parseSatelliteData(uri, unwrapIfZipped(uri, data)) localSource.insertEntries(entries) settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum }) importedCount = entries.size @@ -67,8 +67,8 @@ class DatabaseRepo( override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) { var importedCount = 0 - remoteSource.getFileStream(uri)?.let { stream -> - val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream)) + remoteSource.getFileBytes(uri)?.let { data -> + val transceivers = dataParser.parseJSON(unwrapIfZipped(uri, data).decodeToString()) localSource.insertRadios(transceivers) importedCount = transceivers.size } @@ -105,8 +105,8 @@ class DatabaseRepo( Sources.transceiversDataUrls.filterValues { it.isNotBlank() } } // launch all network requests concurrently - val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } } - val radioJobs = radioUrls.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.getNetworkBytes(url) } } val tleResults = tleJobs.awaitAll() val radioResults = radioJobs.awaitAll() // 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") } // 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 - 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 }) } } - val importedRadios = radioResults.flatMap { (url, stream) -> - stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty() + val importedRadios = radioResults.flatMap { (url, data) -> + data?.let { dataParser.parseJSON(unwrapIfZipped(url, it).decodeToString()) }.orEmpty() } // insert parsed data into the database localSource.insertEntries(importedEntries) @@ -141,11 +141,11 @@ class DatabaseRepo( setUpdateSuccessful(0L) } - private suspend fun parseSatelliteStream(url: String, stream: InputStream): List { - val bufferedStream = stream.buffered() + private suspend fun parseSatelliteData(url: String, data: ByteArray): List { + val text = data.decodeToString() return when { - hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream) - else -> dataParser.parseTLEStream(bufferedStream) + hasCsvHint(url) || looksLikeCsv(text) -> dataParser.parseCSV(text) + else -> dataParser.parseTLE(text) } } @@ -155,14 +155,9 @@ class DatabaseRepo( url.endsWith(".csv.zip", ignoreCase = true) } - private fun looksLikeCsv(stream: InputStream): Boolean { - if (!stream.markSupported()) return false - stream.mark(4096) - 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() + private fun looksLikeCsv(text: String): Boolean { + val line = text.lineSequence().firstOrNull()?.trim().orEmpty() + if (line.isEmpty()) return false return line.contains("OBJECT_NAME", ignoreCase = true) || line.contains("NORAD_CAT_ID", ignoreCase = true) || line.count { it == ',' } >= 4 @@ -174,6 +169,10 @@ class DatabaseRepo( ) } - private fun unwrapIfZipped(url: String, stream: InputStream): InputStream = - if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream + private fun unwrapIfZipped(url: String, data: ByteArray): ByteArray = + if (url.endsWith(".zip", ignoreCase = true)) { + ZipInputStream(ByteArrayInputStream(data)).apply { nextEntry }.readBytes() + } else { + data + } } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/OkHttpHttpClient.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/OkHttpHttpClient.kt new file mode 100644 index 00000000..86c2dd86 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/OkHttpHttpClient.kt @@ -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 . + */ +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, 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): 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): 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() + } +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt index 46485541..0a818732 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import java.io.InputStream class RemoteSource( private val dispatcher: CoroutineDispatcher, @@ -33,10 +32,10 @@ class RemoteSource( private val httpClient: OkHttpClient ) : IRemoteSource { - override suspend fun getFileStream(uri: String): InputStream? = withContext(dispatcher) { + override suspend fun getFileBytes(uri: String): ByteArray? = withContext(dispatcher) { try { val fileUri = uri.toUri() - contentResolver.openInputStream(fileUri)?.buffered() + contentResolver.openInputStream(fileUri)?.use { it.readBytes() } } catch (exception: CancellationException) { throw 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 { val networkRequest = Request.Builder().url(url).build() - val response = httpClient.newCall(networkRequest).execute() - if (!response.isSuccessful) { - response.close() - return@withContext null + // The whole body is read here, which also returns the connection to OkHttp's pool + httpClient.newCall(networkRequest).execute().use { response -> + if (!response.isSuccessful) return@use 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) { throw exception } catch (exception: Exception) { diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatAuditTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatAuditTest.kt index 4f6fbe91..a732a3f5 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatAuditTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatAuditTest.kt @@ -5,7 +5,6 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test -import java.io.InputStream import java.util.Calendar import java.util.GregorianCalendar import java.util.Locale @@ -18,8 +17,8 @@ import java.util.TimeZone class AmSatAuditTest { private object UnusedSource : IRemoteSource { - override suspend fun getFileStream(uri: String): InputStream? = null - override suspend fun getNetworkStream(url: String): InputStream? = null + override suspend fun getFileBytes(uri: String): ByteArray? = null + override suspend fun getNetworkBytes(url: String): ByteArray? = null override suspend fun getAmSatCatalog(): String? = null override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null override suspend fun getAmSatSummary(hours: Int): String? = null diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt index 4431fde4..54bb766b 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt @@ -4,7 +4,6 @@ import com.rtbishop.look4sat.core.domain.source.IRemoteSource import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import java.io.InputStream import java.util.Calendar import java.util.TimeZone @@ -24,8 +23,8 @@ import java.util.TimeZone class AmSatSlotBuildTest { private object UnusedSource : IRemoteSource { - override suspend fun getFileStream(uri: String): InputStream? = null - override suspend fun getNetworkStream(url: String): InputStream? = null + override suspend fun getFileBytes(uri: String): ByteArray? = null + override suspend fun getNetworkBytes(url: String): ByteArray? = null override suspend fun getAmSatCatalog(): String? = null override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null override suspend fun getAmSatSummary(hours: Int): String? = null diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index 03c193be..7c37b989 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -41,7 +41,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import java.io.InputStream @OptIn(ExperimentalCoroutinesApi::class) class DatabaseRepoTest { @@ -54,7 +53,7 @@ class DatabaseRepoTest { val uri = "content://look4sat/import/satellites" val localSource = FakeLocalSource() val remoteSource = FakeRemoteSource().apply { - fileStreams[uri] = { validCsvStream() } + fileData[uri] = { validCsvBytes() } } val settingsRepo = FakeSettingsRepo() val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) @@ -72,7 +71,7 @@ class DatabaseRepoTest { val uri = "content://look4sat/import/legacy" val localSource = FakeLocalSource() val remoteSource = FakeRemoteSource().apply { - fileStreams[uri] = { validTleStream() } + fileData[uri] = { validTleBytes() } } val settingsRepo = FakeSettingsRepo() val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo) @@ -88,7 +87,7 @@ class DatabaseRepoTest { val customCsvUrl = "https://example.com/custom-omm.csv" val localSource = FakeLocalSource() val remoteSource = FakeRemoteSource().apply { - networkStreams[customCsvUrl] = { validCsvStream() } + networkData[customCsvUrl] = { validCsvBytes() } } val settingsRepo = FakeSettingsRepo( dataSources = DataSourcesSettings( @@ -134,7 +133,7 @@ class DatabaseRepoTest { // unanswered on purpose: org.json is compileOnly in core:domain, so DataParser cannot // parse a radio payload on the JVM anyway. Sources.satelliteDataUrls.values.filter { it.isNotBlank() } - .forEach { networkStreams[it] = { validCsvStream() } } + .forEach { networkData[it] = { validCsvBytes() } } } val settingsRepo = FakeSettingsRepo( dataSources = DataSourcesSettings( @@ -172,7 +171,7 @@ class DatabaseRepoTest { val localSource = FakeLocalSource() val remoteSource = FakeRemoteSource().apply { Sources.transceiversDataUrls.values.filter { it.isNotBlank() } - .forEach { networkStreams[it] = { "[]".byteInputStream() } } + .forEach { networkData[it] = { "[]".encodeToByteArray() } } } val settingsRepo = FakeSettingsRepo( dataSources = DataSourcesSettings( @@ -195,30 +194,30 @@ class DatabaseRepoTest { assertTrue("no entries may be inserted", localSource.insertedEntries.isEmpty()) } - private fun validCsvStream(): InputStream = """ + private fun validCsvBytes(): InputStream = """ 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 - """.trimIndent().byteInputStream() + """.trimIndent().encodeToByteArray() - private fun validTleStream(): InputStream = """ + private fun validTleBytes(): InputStream = """ ISS (ZARYA) 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 - """.trimIndent().byteInputStream() + """.trimIndent().encodeToByteArray() } private class FakeRemoteSource : IRemoteSource { - val fileStreams: MutableMap InputStream> = mutableMapOf() - val networkStreams: MutableMap InputStream> = mutableMapOf() + val fileData: MutableMap ByteArray> = mutableMapOf() + val networkData: MutableMap ByteArray> = mutableMapOf() /** Every URL asked for, so a test can assert WHICH sources were fetched, not just the result. */ val requestedUrls = mutableListOf() - 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 - return networkStreams[url]?.invoke() + return networkData[url]?.invoke() } override suspend fun getAmSatCatalog(): String? = null diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index dbc922ee..841c9857 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -1,8 +1,3 @@ plugins { alias(libs.plugins.convention.coreDomainPlugin) } - -dependencies { - // 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json - compileOnly("org.json:json:20240303") -} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeacon.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsBeacon.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeacon.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsBeacon.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsLogin.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsLogin.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsLogin.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsLogin.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt similarity index 85% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt index 63ed3497..5736476f 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt @@ -2,7 +2,7 @@ package com.rtbishop.look4sat.core.domain.aprs import kotlin.math.abs 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). @@ -30,7 +30,7 @@ object AprsPacket { /** Optional distance filter: filter r/lat/lon/dist */ 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 { if (altitudeMeters == null) return "" 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 "" val knots = (speedMps * 1.94384449).toInt().coerceIn(0, 999) 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 frac = when (positionAmbiguity) { 1 -> " . " - 2 -> String.format(Locale.ROOT, "%d . ", minutes / 10) - 3 -> String.format(Locale.ROOT, "%02d. ", minutes) - 4 -> String.format(Locale.ROOT, "%02d.%d ", minutes, hundredths / 10) - else -> String.format(Locale.ROOT, "%02d.%02d", minutes, hundredths) + 2 -> formatString("%d . ", minutes / 10) + 3 -> formatString("%02d. ", minutes) + 4 -> formatString("%02d.%d ", minutes, hundredths / 10) + else -> formatString("%02d.%02d", minutes, hundredths) } return if (isLat) { 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 { 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) } } } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsPasscode.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPasscode.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsPasscode.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPasscode.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsSymbols.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsSymbols.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsSymbols.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsSymbols.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwAntiAlias.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwAntiAlias.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwAntiAlias.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwAntiAlias.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoder.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoder.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoder.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoder.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBuffer.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDeepBuffer.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBuffer.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDeepBuffer.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogram.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogram.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogram.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogram.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDetectionPool.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDetectionPool.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwDetectionPool.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDetectionPool.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwShiftDecider.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwShiftDecider.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwShiftDecider.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwShiftDecider.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifter.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwToneShifter.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifter.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/CwToneShifter.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/ICwDecoder.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/ICwDecoder.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/cw/ICwDecoder.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/cw/ICwDecoder.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Constants.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/model/Constants.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Constants.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/model/Constants.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatItem.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/model/SatItem.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatItem.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/model/SatItem.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatRadio.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/model/SatRadio.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatRadio.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/model/SatRadio.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/model/SatStatus.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/model/SatStatus.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/model/Settings.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/model/Settings.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/navigation/MenuLayout.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/navigation/MenuLayout.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/navigation/MenuLayout.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/navigation/MenuLayout.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/Constants.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/Constants.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/Constants.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/Constants.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/DeepSpaceObject.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/DeepSpaceObject.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/DeepSpaceObject.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/DeepSpaceObject.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/GeoPos.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/GeoPos.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/GeoPos.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/GeoPos.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/NearEarthObject.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/NearEarthObject.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/NearEarthObject.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/NearEarthObject.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalData.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/OrbitalData.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalData.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/OrbitalData.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalMath.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/OrbitalMath.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalMath.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/OrbitalMath.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalObject.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/OrbitalObject.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalObject.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/OrbitalObject.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPass.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/OrbitalPass.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPass.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/OrbitalPass.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPos.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/OrbitalPos.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPos.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/predict/OrbitalPos.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/IQrzGridLookup.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/qrz/IQrzGridLookup.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/IQrzGridLookup.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/qrz/IQrzGridLookup.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/QrzGrid.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/qrz/QrzGrid.kt similarity index 96% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/QrzGrid.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/qrz/QrzGrid.kt index 90c58fe7..b0f75f1a 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/QrzGrid.kt +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/qrz/QrzGrid.kt @@ -91,8 +91,7 @@ object QrzGridParser { * * 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 - * than a JSON library because org.json is compileOnly here - it is supplied by Android at - * runtime and absent from unit tests, so a JSON path could not be tested. + * than a JSON library so it needs no extra dependency and stays testable as plain text. */ fun cookieHeader(raw: String): String { val text = raw.trim() diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IDatabaseRepo.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/IDatabaseRepo.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IDatabaseRepo.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/IDatabaseRepo.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioTrackingService.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/IRadioTrackingService.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioTrackingService.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/IRadioTrackingService.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IReporterRepo.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/IReporterRepo.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IReporterRepo.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/IReporterRepo.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISelectionRepo.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/ISelectionRepo.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISelectionRepo.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/ISelectionRepo.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISensorsRepo.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/ISensorsRepo.kt similarity index 75% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISensorsRepo.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/ISensorsRepo.kt index f376c336..75f23ad7 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISensorsRepo.kt +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/ISensorsRepo.kt @@ -19,10 +19,16 @@ package com.rtbishop.look4sat.core.domain.repository import com.rtbishop.look4sat.core.domain.predict.GeoPos import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) interface ISensorsRepo { val sensorData: StateFlow> - 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 disableSensor() } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt diff --git a/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/source/IHttpClient.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/source/IHttpClient.kt new file mode 100644 index 00000000..a302f775 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/source/IHttpClient.kt @@ -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 . + */ +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, body: String): HttpResult + suspend fun get(url: String, headers: Map): 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) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt similarity index 90% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt index ee522b5b..5f8da11f 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt @@ -17,11 +17,9 @@ */ package com.rtbishop.look4sat.core.domain.source -import java.io.InputStream - interface IRemoteSource { - suspend fun getFileStream(uri: String): InputStream? - suspend fun getNetworkStream(url: String): InputStream? + suspend fun getFileBytes(uri: String): ByteArray? + suspend fun getNetworkBytes(url: String): ByteArray? /** Fetch AMSAT API catalog (JSON string; null on failure) */ suspend fun getAmSatCatalog(): String? diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/source/Sources.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/source/Sources.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IAddToCalendar.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/usecase/IAddToCalendar.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IAddToCalendar.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/usecase/IAddToCalendar.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IAudioCapture.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/usecase/IAudioCapture.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IAudioCapture.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/usecase/IAudioCapture.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/ISaveImage.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/usecase/ISaveImage.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/ISaveImage.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/usecase/ISaveImage.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IShowToast.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/usecase/IShowToast.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IShowToast.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/usecase/IShowToast.kt diff --git a/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/CommonFormat.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/CommonFormat.kt new file mode 100644 index 00000000..31eae069 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/CommonFormat.kt @@ -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 . + */ +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") diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/DataParser.kt similarity index 89% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/DataParser.kt index bda2126b..eeaa089e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/DataParser.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.decodeFromJsonElement -import java.io.InputStream import kotlin.math.pow class DataParser(private val dispatcher: CoroutineDispatcher) { @@ -34,22 +33,20 @@ class DataParser(private val dispatcher: CoroutineDispatcher) { coerceInputValues = true } - suspend fun parseCSVStream(stream: InputStream): List = withContext(dispatcher) { - stream.bufferedReader().useLines { lines -> - lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList() - } + suspend fun parseCSV(data: String): List = withContext(dispatcher) { + data.lineSequence().drop(1).mapNotNull { parseCSV(it.split(",")) }.toList() } - suspend fun parseTLEStream(stream: InputStream): List = withContext(dispatcher) { - stream.bufferedReader().readLines() + suspend fun parseTLE(data: String): List = withContext(dispatcher) { + data.lineSequence().toList() .chunked(3) .filter { it.size == 3 && it[1].startsWith("1") && it[2].startsWith("2") } .mapNotNull { parseTLE(it) } } - suspend fun parseJSONStream(stream: InputStream): List = withContext(dispatcher) { + suspend fun parseJSON(data: String): List = withContext(dispatcher) { runCatching { - val root = json.parseToJsonElement(stream.bufferedReader().readText()) + val root = json.parseToJsonElement(data) (root as? JsonArray)?.mapNotNull { element -> runCatching { json.decodeFromJsonElement(element) } .onFailure { println("JSON parsing exception: $it") } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt similarity index 97% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt index e05b3d19..fb9bdb8c 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt @@ -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.predict.OrbitalPos -import java.util.Locale /** * Computes Doppler-corrected reciprocal frequencies for linear transponders. @@ -110,10 +109,10 @@ object DopplerFrequencyCalculator { fun isNamedLinearTransponder(transponder: SatRadio): Boolean { if (!isLinearTransponder(transponder)) return false - val info = transponder.info.lowercase(Locale.ENGLISH) + val info = transponder.info.lowercase() val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode) .joinToString(separator = " ") - .lowercase(Locale.ENGLISH) + .lowercase() val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin") val hasTransponderName = info.contains("transponder") || info.contains("transp") || info.contains("xponder") || info.contains("xpdr") diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/Extensions.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/Extensions.kt similarity index 89% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/Extensions.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/Extensions.kt index 30619675..c79424ca 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/Extensions.kt +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/Extensions.kt @@ -17,16 +17,13 @@ */ package com.rtbishop.look4sat.core.domain.utility -import java.util.Locale -import java.util.concurrent.TimeUnit - fun Long.toTimerString(): String { val millis = coerceAtLeast(0L) val format = "%02d:%02d:%02d" - val hours = TimeUnit.MILLISECONDS.toHours(millis) - val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60 - val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60 - return String.format(Locale.ENGLISH, format, hours, minutes, seconds) + val hours = millis / 3_600_000L + val minutes = millis / 60_000L % 60 + val seconds = millis / 1_000L % 60 + return formatString(format, hours, minutes, seconds) } fun Float.round(decimals: Int): Float { diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/GeoConverter.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/GeoConverter.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/GeoConverter.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/GeoConverter.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt diff --git a/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/SynchronizedOn.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/SynchronizedOn.kt new file mode 100644 index 00000000..b04f9c7e --- /dev/null +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/SynchronizedOn.kt @@ -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 . + */ +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 synchronizedOn(lock: Any, block: () -> T): T diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/TransponderMapper.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/TransponderMapper.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/TransponderMapper.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/TransponderMapper.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntry.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntry.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntry.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntry.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/GridEntry.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/GridEntry.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/GridEntry.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/GridEntry.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/ILotwSatellitesRepo.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/ILotwSatellitesRepo.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/ILotwSatellitesRepo.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/ILotwSatellitesRepo.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIds.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIds.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIds.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIds.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatellites.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/LotwSatellites.kt similarity index 90% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatellites.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/LotwSatellites.kt index e812efd3..36a5be06 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatellites.kt +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/LotwSatellites.kt @@ -5,6 +5,10 @@ */ 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 { private val staticNames: Set = 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") diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/PassClock.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/PassClock.kt similarity index 100% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/PassClock.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/PassClock.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt similarity index 64% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt index 63e9cfc8..77f1c43a 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt @@ -10,15 +10,17 @@ */ package com.rtbishop.look4sat.core.domain.wavelog -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONObject -import java.io.BufferedReader -import java.io.InputStreamReader -import java.io.OutputStreamWriter -import java.net.HttpURLConnection -import java.net.URL -import java.util.Locale +import com.rtbishop.look4sat.core.domain.source.HttpResult +import com.rtbishop.look4sat.core.domain.source.IHttpClient +import com.rtbishop.look4sat.core.domain.utility.formatString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put /** Station info (GET /api/v2/station/{id} result) */ data class WavelogStation( @@ -35,7 +37,19 @@ sealed class WavelogResult { 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 */ 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 */ - 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) - if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空") + if (base.isBlank()) return WavelogResult.Failure("服务器地址为空") // v2: GET /index.php/api/v2/token - val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null) - if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)") + val v2 = httpRequest("$base/index.php/api/v2/token", apiKey, null) + if (v2.code in 200..299) return WavelogResult.Success("连接成功 (API v2)") // v1: POST /index.php/api/get_contacts_adif (key in body) if (stationId.isNotBlank()) { - val body = JSONObject().apply { + val body = buildJsonObject { put("key", apiKey) put("station_id", stationId) put("fetchfromid", 0) }.toString() - val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body) - if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)") - if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)") + val v1 = httpRequest("$base/index.php/api/get_contacts_adif", apiKey, body) + if (v1.code in 200..299) return WavelogResult.Success("连接成功 (API v1)") + if (v1.code == 401) return WavelogResult.Failure("API 密钥无效 (v1: 401)") } // v1 attempt without index.php - val body = JSONObject().apply { + val body = buildJsonObject { put("key", apiKey) put("station_id", stationId) put("fetchfromid", 0) }.toString() - val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body) - if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)") - if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)") + val v1b = httpRequest("$base/api/get_contacts_adif", apiKey, body) + if (v1b.code in 200..299) return WavelogResult.Success("连接成功 (API v1)") + 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) */ - 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) - if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空") - val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null) + if (base.isBlank()) return WavelogResult.Failure("服务器地址为空") + val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", apiKey, null) if (code in 200..299) { - return@withContext try { - val obj = JSONObject(resp) - val data = obj.optJSONObject("data") ?: obj + return try { + val obj = Json.parseToJsonElement(resp).jsonObject + val data = obj["data"] as? JsonObject ?: obj val station = WavelogStation( - id = data.optInt("id"), - name = data.optString("name"), - callsign = data.optString("callsign"), - gridsquare = data.optString("gridsquare") + id = data["id"]?.jsonPrimitive?.intOrNull ?: 0, + name = data["name"]?.jsonPrimitive?.contentOrNull.orEmpty(), + callsign = data["callsign"]?.jsonPrimitive?.contentOrNull.orEmpty(), + gridsquare = data["gridsquare"]?.jsonPrimitive?.contentOrNull.orEmpty() ) - WavelogResult.Success(JSONObject().apply { + WavelogResult.Success(buildJsonObject { put("id", station.id); put("name", station.name) put("callsign", station.callsign); put("gridsquare", station.gridsquare) }.toString()) @@ -103,7 +117,7 @@ object WaveLogApi { } } // 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 } ?.let { return it } } - return trimmed.uppercase(Locale.ENGLISH) + return trimmed.uppercase() } /** 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() } - 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 */ suspend fun postQso( @@ -215,23 +229,23 @@ object WaveLogApi { stationProfileId: String, qso: WavelogQso, gridsquare: String - ): WavelogResult = withContext(Dispatchers.IO) { + ): WavelogResult { 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 }) // v2: POST /index.php/api/v2/qso (JSON fields) val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz) - val v2Body = JSONObject().apply { + val v2Body = buildJsonObject { put("station_profile_id", stationProfileId.toIntOrNull() ?: 0) put("call", qso.call) put("band", bandFromHz(qso.freqTxHz)) put("mode", qso.mode) put("qso_date", utcDate(qso.timeUtcMs)) put("time_on", utcTime(qso.timeUtcMs)) - put("freq", String.format(Locale.ENGLISH, "%.6fM", qso.freqTxHz / 1_000_000.0)) - put("freq_rx", String.format(Locale.ENGLISH, "%.6fM", qso.freqRxHz / 1_000_000.0)) + put("freq", formatString("%.6fM", qso.freqTxHz / 1_000_000.0)) + put("freq_rx", formatString("%.6fM", qso.freqRxHz / 1_000_000.0)) put("gridsquare", gridsquare) put("rst_sent", "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 // QSO arrives as HTTP 200 with {"status":"failed"}. Trusting the code marked it uploaded // 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) when (v2Verdict) { - is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v2") - WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate") + is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v2") + WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate") // 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 // 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) - val v1Body = JSONObject().apply { + val v1Body = buildJsonObject { put("key", apiKey) put("station_profile_id", stationProfileId) put("type", "adif") 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) when (v1Verdict) { - is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1") - WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate") + is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v1") + WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate") // 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. else -> Unit } // 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)) { - is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1") - WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate") + is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v1") + WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate") is WavelogResponse.Verdict.Rejected -> - return@withContext WavelogResult.Failure(verdict.reason) + return WavelogResult.Failure(verdict.reason) is WavelogResponse.Verdict.Unreadable -> Unit } @@ -288,7 +302,7 @@ object WaveLogApi { (v1Verdict as? WavelogResponse.Verdict.Rejected)?.reason, (v2Verdict as? WavelogResponse.Verdict.Rejected)?.reason ).filter { it.isNotBlank() } - WavelogResult.Failure( + return WavelogResult.Failure( reasons.firstOrNull() ?: ("no endpoint accepted it: v2 HTTP $code, v1 HTTP $code1, v1-alt HTTP $code1b" + " - " + shortError(resp1.ifBlank { resp1b })) @@ -306,9 +320,9 @@ object WaveLogApi { append(field("call", qso.call)) append(field("band", bandFromHz(qso.freqTxHz))) 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) { - 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("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 { - return try { - val conn = URL(url).openConnection() as HttpURLConnection - conn.requestMethod = method - conn.connectTimeout = TIMEOUT_MS - conn.readTimeout = TIMEOUT_MS - if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey") + /** + * POST [jsonBody] when one is given, GET otherwise. A request that could not be sent at all + * comes back as code 0, so callers only have to look at the code. + */ + private suspend fun httpRequest(url: String, apiKey: String, jsonBody: String?): HttpResult { + val client = httpClient ?: error("WaveLogApi has no HTTP client installed") + val headers = buildMap { + if (apiKey.isNotBlank()) put("Authorization", "Bearer $apiKey") if (jsonBody != null) { - conn.doOutput = true - conn.setRequestProperty("Content-Type", "application/json") - conn.setRequestProperty("Accept", "application/json") - OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) } + put("Content-Type", "application/json") + put("Accept", "application/json") } - 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 { if (body.startsWith("<")) return body.take(80) // HTML error page return try { - val obj = JSONObject(body) - val err = obj.optJSONObject("error") - err?.optString("message")?.ifBlank { body.take(120) } - ?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } } + val obj = Json.parseToJsonElement(body).jsonObject + val err = obj["error"] as? JsonObject + if (err != null) { + 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) { 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 { - val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) - cal.timeInMillis = ms - 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) - ) + val utc = utcFieldsOf(ms) + return formatString("%04d-%02d-%02d", utc.year, utc.month, utc.day) } private fun utcTime(ms: Long): String { - val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) - cal.timeInMillis = ms - 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) - ) + val utc = utcFieldsOf(ms) + return formatString("%02d:%02d:%02d", utc.hour, utc.minute, utc.second) } private fun utcDateCompact(ms: Long): String { - val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) - cal.timeInMillis = ms - 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) - ) + val utc = utcFieldsOf(ms) + return formatString("%04d%02d%02d", utc.year, utc.month, utc.day) } private fun utcTimeCompact(ms: Long): String { - val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) - cal.timeInMillis = ms - 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) - ) + val utc = utcFieldsOf(ms) + return formatString("%02d%02d%02d", utc.hour, utc.minute, utc.second) } } diff --git a/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt new file mode 100644 index 00000000..7ab6e864 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt @@ -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 { + 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) { + 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) { + 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 diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt similarity index 97% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt index f4cfb6c2..f6675a42 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt @@ -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 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, - * so a JVM unit test gets the stub and every assertion against it would be vacuous. + * Parsed as text rather than through a JSON library on purpose: what Wavelog answers is sparse and + * freely worded, and matching it as text keeps the verdict testable without a parser. */ object WavelogResponse { diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt similarity index 93% rename from core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt rename to core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt index 24f5bb14..a384e274 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt +++ b/core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt @@ -9,7 +9,10 @@ package com.rtbishop.look4sat.core.domain.wavelog 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 { data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome() @@ -99,7 +102,8 @@ class WavelogUploader( val result = WaveLogApi.getStation(url, apiKey, stationId) if (result is WavelogResult.Success) { return try { - JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() } + Json.parseToJsonElement(result.message).jsonObject["gridsquare"] + ?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: cachedStationGrid } catch (_: Exception) { cachedStationGrid } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/DataParserTest.kt similarity index 65% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/DataParserTest.kt index 0a7f8966..6ef12873 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/DataParserTest.kt @@ -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.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.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Test +@OptIn(ExperimentalTime::class) @ExperimentalCoroutinesApi class DataParserTest { private val testDispatcher = StandardTestDispatcher() 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 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 - """.trimIndent().byteInputStream() - private val invalidCSVStream = """ + """.trimIndent() + 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 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() - private val validTLEStream = """ + """.trimIndent() + private val validTLE = """ ISS (ZARYA) 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 ISS (ZARYA) 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 - """.trimIndent().byteInputStream() - private val invalidTLEStream = """ + """.trimIndent() + private val invalidTLE = """ 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 - """.trimIndent().byteInputStream() - private val validJSONStream = """ + """.trimIndent() + 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":""}] - """.trimIndent().byteInputStream() - private val invalidJSONStream = """ + """.trimIndent() + 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":""}] - """.trimIndent().byteInputStream() + """.trimIndent() @Test fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) { - val parsedList = dataParser.parseCSVStream(validCSVStream) - assert(parsedList.size == 2) - assert(parsedList[0].epoch == 21320.51955234) - assert(parsedList[1].epoch == 24069.23963816) + val parsedList = dataParser.parseCSV(validCSV) + assertTrue(parsedList.size == 2) + assertTrue(parsedList[0].epoch == 21320.51955234) + assertTrue(parsedList[1].epoch == 24069.23963816) } @Test 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 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() - val sat = dataParser.parseCSVStream(csvStream)[0] - assert(sat.name == "ISS (ZARYA)") - assert(sat.catnum == 25544) - assert(sat.meanmo == 15.48582035) - assert(sat.eccn == 0.0004694) - assert(sat.incl == 51.6447) - assert(sat.raan == 309.4881) - assert(sat.argper == 203.6966) - assert(sat.meanan == 299.8876) - assert(sat.bstar == 0.31985E-4) - assert(sat.ndot == 0.1288E-4) + """.trimIndent() + val sat = dataParser.parseCSV(csv)[0] + assertTrue(sat.name == "ISS (ZARYA)") + assertTrue(sat.catnum == 25544) + assertTrue(sat.meanmo == 15.48582035) + assertTrue(sat.eccn == 0.0004694) + assertTrue(sat.incl == 51.6447) + assertTrue(sat.raan == 309.4881) + assertTrue(sat.argper == 203.6966) + assertTrue(sat.meanan == 299.8876) + assertTrue(sat.bstar == 0.31985E-4) + assertTrue(sat.ndot == 0.1288E-4) } @Test 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 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() - val sat = dataParser.parseCSVStream(csvStream)[0] + """.trimIndent() + val sat = dataParser.parseCSV(csv)[0] // ISS is healthy, should not be decayed even years later - assert(!sat.hasDecayed(System.currentTimeMillis())) + assertTrue(!sat.hasDecayed(Clock.System.now().toEpochMilliseconds())) } @Test fun `Given CSV with high drag satellite detects decay`() = runTest(testDispatcher) { // 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 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() - val sat = dataParser.parseCSVStream(csvStream)[0] + """.trimIndent() + val sat = dataParser.parseCSV(csv)[0] // 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 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) = """ 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 - """.trimIndent().byteInputStream() + """.trimIndent() @Test 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. // 00:01:00 produced "25001.944444444444445E-4" -> 2.50019..., an epoch // roughly 26 years off, with no exception to reveal it. - val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:01:00.000000"))[0] - assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, 1e-9) + val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:01:00.000000"))[0] + assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, absoluteTolerance = 1e-9) } @Test 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] - assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, 1e-9) + val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:00:01.000000"))[0] + assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, absoluteTolerance = 1e-9) } @Test 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] - assertEquals(25001.0, sat.epoch, 1e-9) + val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:00:00.000000"))[0] + assertEquals(25001.0, sat.epoch, absoluteTolerance = 1e-9) } @Test 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] - assertEquals(25001.5, sat.epoch, 1e-9) + val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T12:00:00.000000"))[0] + assertEquals(25001.5, sat.epoch, absoluteTolerance = 1e-9) } @Test 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] - assert(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}" } + val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T23:59:59.999000"))[0] + assertTrue(sat.epoch > 25001.999, "expected almost a full day, got ${sat.epoch}") + assertTrue(sat.epoch < 25002.0, "day fraction must not roll into the next day, got ${sat.epoch}") } @Test fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) { - val parsedList = dataParser.parseTLEStream(validTLEStream) - assert(parsedList.size == 2) - assert(parsedList[0].epoch == 21320.51955234) - assert(parsedList[1].epoch == 24069.23963816) + val parsedList = dataParser.parseTLE(validTLE) + assertTrue(parsedList.size == 2) + assertTrue(parsedList[0].epoch == 21320.51955234) + assertTrue(parsedList[1].epoch == 24069.23963816) } @Test @@ -168,17 +172,17 @@ class DataParserTest { ISS (ZARYA) 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 - """.trimIndent().byteInputStream() - val sat = dataParser.parseTLEStream(tleStream)[0] - assert(sat.name == "ISS (ZARYA)") - assert(sat.catnum == 25544) - assert(sat.meanmo == 15.48582035) - assert(sat.eccn == 0.0004694) - assert(sat.incl == 51.6447) - assert(sat.raan == 309.4881) - assert(sat.argper == 203.6966) - assert(sat.meanan == 299.8876) - assert(sat.ndot == 0.00001288) + """.trimIndent() + val sat = dataParser.parseTLE(tleStream)[0] + assertTrue(sat.name == "ISS (ZARYA)") + assertTrue(sat.catnum == 25544) + assertTrue(sat.meanmo == 15.48582035) + assertTrue(sat.eccn == 0.0004694) + assertTrue(sat.incl == 51.6447) + assertTrue(sat.raan == 309.4881) + assertTrue(sat.argper == 203.6966) + assertTrue(sat.meanan == 299.8876) + assertTrue(sat.ndot == 0.00001288) } @Test @@ -187,66 +191,66 @@ class DataParserTest { ISS (ZARYA) 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 - """.trimIndent().byteInputStream() - val sat = dataParser.parseTLEStream(tleStream)[0] - assert(!sat.hasDecayed(System.currentTimeMillis())) + """.trimIndent() + val sat = dataParser.parseTLE(tleStream)[0] + assertTrue(!sat.hasDecayed(Clock.System.now().toEpochMilliseconds())) } @Test fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) { - assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty()) + assertTrue(dataParser.parseTLE(invalidTLE).isEmpty()) } @Test 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 fun `Given valid JSON stream all radio fields are parsed correctly`() = runTest(testDispatcher) { 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":""}] - """.trimIndent().byteInputStream() - val radio = dataParser.parseJSONStream(jsonStream)[0] - assert(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g") - assert(radio.info == "Upper side band (drifting)") - assert(radio.isAlive) - assert(radio.downlinkLow == 136658500L) - assert(radio.downlinkHigh == 136700000L) - assert(radio.downlinkMode == "USB") - assert(radio.uplinkLow == 145900000L) - assert(radio.uplinkHigh == 146000000L) - assert(radio.uplinkMode == "FM") - assert(radio.isInverted) - assert(radio.catnum == 965) + """.trimIndent() + val radio = dataParser.parseJSON(jsonStream)[0] + assertTrue(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g") + assertTrue(radio.info == "Upper side band (drifting)") + assertTrue(radio.isAlive) + assertTrue(radio.downlinkLow == 136658500L) + assertTrue(radio.downlinkHigh == 136700000L) + assertTrue(radio.downlinkMode == "USB") + assertTrue(radio.uplinkLow == 145900000L) + assertTrue(radio.uplinkHigh == 146000000L) + assertTrue(radio.uplinkMode == "FM") + assertTrue(radio.isInverted) + assertTrue(radio.catnum == 965) } @Test fun `Given JSON with null optional fields parses without error`() = runTest(testDispatcher) { 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":""}] - """.trimIndent().byteInputStream() - val radio = dataParser.parseJSONStream(jsonStream)[0] - assert(radio.uuid == "abc123") - assert(!radio.isAlive) - assert(radio.downlinkLow == 145800000L) - assert(radio.downlinkHigh == null) - assert(radio.downlinkMode == null) - assert(radio.uplinkLow == null) - assert(radio.uplinkHigh == null) - assert(radio.uplinkMode == null) - assert(!radio.isInverted) - assert(radio.catnum == 12345) + """.trimIndent() + val radio = dataParser.parseJSON(jsonStream)[0] + assertTrue(radio.uuid == "abc123") + assertTrue(!radio.isAlive) + assertTrue(radio.downlinkLow == 145800000L) + assertTrue(radio.downlinkHigh == null) + assertTrue(radio.downlinkMode == null) + assertTrue(radio.uplinkLow == null) + assertTrue(radio.uplinkHigh == null) + assertTrue(radio.uplinkMode == null) + assertTrue(!radio.isInverted) + assertTrue(radio.catnum == 12345) } @Test fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) { - assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty()) + assertTrue(dataParser.parseJSON(invalidJSON).isEmpty()) } @Test 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 @@ -254,38 +258,38 @@ class DataParserTest { 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 results = years.map { dataParser.isLeapYear(it) } - assert(results == expected) + assertTrue(results == expected) } @Test fun `getDayOfYear returns correct day for January 1st`() { - assert(dataParser.getDayOfYear(2024, 1, 1) == 1) - assert(dataParser.getDayOfYear(2023, 1, 1) == 1) + assertTrue(dataParser.getDayOfYear(2024, 1, 1) == 1) + assertTrue(dataParser.getDayOfYear(2023, 1, 1) == 1) } @Test fun `getDayOfYear returns correct day for March 1st in leap and non-leap years`() { // 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 - assert(dataParser.getDayOfYear(2023, 3, 1) == 60) + assertTrue(dataParser.getDayOfYear(2023, 3, 1) == 60) } @Test fun `getDayOfYear returns correct day for December 31st`() { - assert(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year - assert(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year + assertTrue(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year + assertTrue(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year } @Test fun `getDayOfYear returns correct day for November 16th`() { // 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 fun `check APRS passcode calculation`() { - assert("M7LNB".aprsPasscode() == 12443) - assert("N0CALL".aprsPasscode() == 13023) + assertTrue("M7LNB".aprsPasscode() == 12443) + assertTrue("N0CALL".aprsPasscode() == 13023) } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt similarity index 97% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt index dd832672..52a23544 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt @@ -3,12 +3,12 @@ package com.rtbishop.look4sat.core.domain import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator -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.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue class DopplerFrequencyCalculatorTest { @@ -268,6 +268,6 @@ class DopplerFrequencyCalculatorTest { val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos) assertNotNull(roundTripDownlink) 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") } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/QthConverterTest.kt similarity index 63% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/QthConverterTest.kt index 52203486..0a082166 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/QthConverterTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/QthConverterTest.kt @@ -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.qthToPosition import com.rtbishop.look4sat.core.domain.utility.qthToSquare -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertTrue class QthConverterTest { @Test fun `Given valid QTH returns correct POS`() { 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") - 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 result = qthToPosition("io91vl47") - assert(result?.latitude == 51.489583 && result.longitude == -0.2125) + assertTrue(result?.latitude == 51.489583 && result.longitude == -0.2125) result = qthToPosition("jn58td25") - assert(result?.latitude == 48.147917 && result.longitude == 11.604167) + assertTrue(result?.latitude == 48.147917 && result.longitude == 11.604167) } @Test fun `Given invalid QTH returns null`() { - assert(qthToPosition("ZZ00zz") == null) - assert(qthToPosition("JN58") == null) - assert(qthToPosition("io9") == null) - assert(qthToPosition("IO91VL7") == null) - assert(qthToPosition("IO91VL4X") == null) + assertTrue(qthToPosition("ZZ00zz") == null) + assertTrue(qthToPosition("JN58") == null) + assertTrue(qthToPosition("io9") == null) + assertTrue(qthToPosition("IO91VL7") == null) + assertTrue(qthToPosition("IO91VL4X") == null) } @Test fun `Given valid POS returns correct QTH`() { // default precision is 8 chars - assert(positionToQth(51.4878, -0.2146) == "IO91vl47") - assert(positionToQth(48.1466, 11.6083) == "JN58td25") + assertTrue(positionToQth(51.4878, -0.2146) == "IO91vl47") + assertTrue(positionToQth(48.1466, 11.6083) == "JN58td25") // 6-char precision still available for backwards compatibility - assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl") - assert(positionToQth(48.1466, 11.6083, 6) == "JN58td") + assertTrue(positionToQth(51.4878, -0.2146, 6) == "IO91vl") + assertTrue(positionToQth(48.1466, 11.6083, 6) == "JN58td") // 10-char precision - assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb") - assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe") + assertTrue(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb") + assertTrue(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe") } @Test fun `Given invalid POS returns null`() { - assert(positionToQth(91.0542, -170.1142) == null) - assert(positionToQth(89.0542, -240.1142) == null) + assertTrue(positionToQth(91.0542, -170.1142) == null) + assertTrue(positionToQth(89.0542, -240.1142) == null) } @Test fun `Given boundary POS stays in valid grid`() { // 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 // R-field/0-square combination that decodes 10°/20° away. - assert(positionToQth(90.0, 180.0, 8) == "RR99xx99") - assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00") + assertTrue(positionToQth(90.0, 180.0, 8) == "RR99xx99") + assertTrue(positionToQth(0.0, 0.0, 8) == "JJ00aa00") // roundtrip stability: 8-char roundtrip is stable across a sample of positions val positions = listOf( 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 pos = qthToPosition(qth!!) 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 } - assert(worstLat <= 0.01 && worstLon <= 0.01) { + assertTrue( + worstLat <= 0.01 && worstLon <= 0.01, "roundtrip drifted by (${worstLat}, ${worstLon}) deg, worst: $worst" - } + ) } @Test fun `Given out of range longitude returns null`() { // Maidenhead only covers -180..180; 181..360 used to be accepted and // encoded into a plausible-looking locator 20-200 deg away. - assert(positionToQth(0.0, 181.0) == null) - assert(positionToQth(0.0, 270.0) == null) - assert(positionToQth(0.0, 360.0) == null) + assertTrue(positionToQth(0.0, 181.0) == null) + assertTrue(positionToQth(0.0, 270.0) == null) + assertTrue(positionToQth(0.0, 360.0) == null) } @Test fun `Given locator with out of range field returns null`() { // Fields run A-R; S-X in the first pair decoded past the poles. - assert(qthToPosition("SS00aa") == null) - assert(qthToPosition("XX99xx") == null) - assert(qthToPosition("AS00aa") == null) - assert(qthToPosition("AX99xx") == null) + assertTrue(qthToPosition("SS00aa") == null) + assertTrue(qthToPosition("XX99xx") == null) + assertTrue(qthToPosition("AS00aa") == null) + assertTrue(qthToPosition("AX99xx") == null) } @Test fun `Given square returns correct 3x3 neighbors`() { // Reference grid from the QTH Locator screenshot: OL42 val neighbors = qthNeighbors("OL42") - assert(neighbors == listOf( - "OL33", "OL43", "OL53", - "OL32", "OL42", "OL52", - "OL31", "OL41", "OL51" - )) { "OL42 grid mismatch: $neighbors" } + assertTrue( + neighbors == listOf( + "OL33", "OL43", "OL53", + "OL32", "OL42", "OL52", + "OL31", "OL41", "OL51" + ), + "OL42 grid mismatch: $neighbors" + ) // Center cell must be the input itself - assert(neighbors[4] == "OL42") + assertTrue(neighbors[4] == "OL42") // 9 cells, all distinct - assert(neighbors.size == 9 && neighbors.toSet().size == 9) + assertTrue(neighbors.size == 9 && neighbors.toSet().size == 9) } @Test fun `Given boundary square wraps fields correctly`() { // South-west corner: AA00 neighbors wrap to RR99 / RA90 etc. val sw = qthNeighbors("AA00") - assert(sw.size == 9 && sw.toSet().size == 9) - assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19") + assertTrue(sw.size == 9 && sw.toSet().size == 9) + assertTrue(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19") // North-east corner: RR99 wraps to AA00 val ne = qthNeighbors("RR99") - assert(ne.size == 9 && ne.toSet().size == 9) - assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08") + assertTrue(ne.size == 9 && ne.toSet().size == 9) + assertTrue(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08") // Field boundary: IO91's east neighbors cross into J field val london = qthNeighbors("IO91") - assert(london[2] == "JO02" && london[5] == "JO01") + assertTrue(london[2] == "JO02" && london[5] == "JO01") } @Test fun `Given full locator returns square part`() { - assert(qthToSquare("OL42ih45") == "OL42") - assert(qthToSquare("io91VL39FX") == "IO91") - assert(qthToSquare("JN58") == "JN58") - assert(qthToSquare("garbage!!") == "----") + assertTrue(qthToSquare("OL42ih45") == "OL42") + assertTrue(qthToSquare("io91VL39FX") == "IO91") + assertTrue(qthToSquare("JN58") == "JN58") + assertTrue(qthToSquare("garbage!!") == "----") } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/TransponderMapperTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/TransponderMapperTest.kt similarity index 97% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/TransponderMapperTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/TransponderMapperTest.kt index b1aa5e58..202da424 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/TransponderMapperTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/TransponderMapperTest.kt @@ -2,9 +2,9 @@ package com.rtbishop.look4sat.core.domain import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.utility.TransponderMapper -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull class TransponderMapperTest { diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeaconTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsBeaconTest.kt similarity index 83% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeaconTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsBeaconTest.kt index df823d8a..0dd092fd 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsBeaconTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsBeaconTest.kt @@ -1,11 +1,9 @@ package com.rtbishop.look4sat.core.domain.aprs -import java.util.Locale -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * 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 { - private val original: Locale = Locale.getDefault() - - @After - fun restoreLocale() { - Locale.setDefault(original) - } - private fun line( latitude: Double? = 51.5, longitude: Double? = -0.12, @@ -30,7 +21,7 @@ class AprsBeaconTest { comment: String = "Look4Sat" ): String { 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 } @@ -41,7 +32,7 @@ class AprsBeaconTest { @Test fun `the path is exactly TCPIP star`() { 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()) } @@ -103,22 +94,22 @@ class AprsBeaconTest { // 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 // contain any printable characters the operator likes. - assertFalse(text, text.contains('\n')) - assertFalse(text, text.contains('\r')) - assertEquals("must remain a single line", 1, text.lines().size) + assertFalse(text.contains('\n'), text) + assertFalse(text.contains('\r'), text) + assertEquals(1, text.lines().size, "must remain a single line") } @Test fun `control characters are stripped from the comment`() { 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. */ @Test fun `an over-long comment is trimmed to keep the line legal`() { val text = line(comment = "x".repeat(600)) - assertTrue("line was ${text.toByteArray().size} bytes", text.toByteArray().size + 2 <= 512) + assertTrue(text.toByteArray().size + 2 <= 512, "line was ${text.toByteArray().size} bytes") } /** 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 * 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 fun `a comma-decimal locale does not corrupt the coordinates`() { - val reference = line() - for (tag in listOf("de-DE", "tr-TR", "fr-FR")) { - Locale.setDefault(Locale.forLanguageTag(tag)) - assertEquals("locale $tag changed the packet", reference, line()) - } + assertEquals("BG7NTA-5>APRS,TCPIP*:=5130.00N/00007.20W[Look4Sat", line()) } /** The whole line must be ASCII: APRS-IS is a byte protocol with no encoding negotiation. */ @Test fun `the line is pure ascii`() { val text = line(comment = "café 北京") - assertTrue(text, text.all { it.code in 0x20..0x7E }) + assertTrue(text.all { it.code in 0x20..0x7E }, text) } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsLoginTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsLoginTest.kt similarity index 94% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsLoginTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsLoginTest.kt index 35523a99..5feb14d0 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsLoginTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsLoginTest.kt @@ -1,9 +1,9 @@ package com.rtbishop.look4sat.core.domain.aprs -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue /** * 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") assertEquals("user N0CALL pass -1 vers Look4Sat 4.5.4", line) 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. */ @@ -88,7 +88,7 @@ class AprsLoginTest { val outcome = AprsLogin.parse( "# 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( "Invalid login: software name and version are not separated by a space", (outcome as AprsLogin.Outcome.Rejected).detail @@ -116,8 +116,8 @@ class AprsLoginTest { ) for (line in refusals) { 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" ) 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") } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacketLocaleTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPacketLocaleTest.kt similarity index 55% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacketLocaleTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPacketLocaleTest.kt index c1843575..8eeb173d 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsPacketLocaleTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPacketLocaleTest.kt @@ -1,10 +1,9 @@ package com.rtbishop.look4sat.core.domain.aprs -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import java.util.Locale +import com.rtbishop.look4sat.core.domain.utility.formatString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue /** * 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 * packets. * - * Regression guard: every formatted field must stay ASCII regardless of the - * default locale. + * Regression guard: every formatted field must stay ASCII, and every expected + * 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 { - private val original: Locale = Locale.getDefault() - - @After - fun restoreLocale() { - Locale.setDefault(original) - } - private val asciiPacket = Regex("^[\\x20-\\x7E]*$") + /** The primitive the fields above are built from: fixed digits, never a locale's digits. */ @Test - fun position_staysAsciiUnderArabicLocale() { - Locale.setDefault(Locale.forLanguageTag("ar-EG")) + fun formatString_producesAsciiLiterals() { + 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() - assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded)) + assertTrue(asciiPacket.matches(encoded), "not ASCII: $encoded") assertEquals("3954.25N/11624.44E>", encoded) } @Test - fun position_staysAsciiUnderBengaliLocale() { - Locale.setDefault(Locale.forLanguageTag("bn-BD")) - + fun position_staysAsciiForSouthernCoordinates() { 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) } @Test - fun altitudeAndCourseSpeed_stayAsciiUnderPersianLocale() { - Locale.setDefault(Locale.forLanguageTag("fa-IR")) - + fun altitudeAndCourseSpeed_stayAscii() { val altitude = AprsPacket.formatAltitude(100.0) val courseSpeed = AprsPacket.formatCourseSpeed(10.0, 90f) val filter = AprsPacket.formatRangeFilter(39.9042, 116.4074, 100) - assertTrue("not ASCII: $altitude", asciiPacket.matches(altitude)) - assertTrue("not ASCII: $courseSpeed", asciiPacket.matches(courseSpeed)) - assertTrue("not ASCII: $filter", asciiPacket.matches(filter)) + assertTrue(asciiPacket.matches(altitude), "not ASCII: $altitude") + assertTrue(asciiPacket.matches(courseSpeed), "not ASCII: $courseSpeed") + assertTrue(asciiPacket.matches(filter), "not ASCII: $filter") assertEquals("/A=000328", altitude) assertEquals("/090/019", courseSpeed) assertEquals("r/39.904/116.407/100", filter) @@ -79,13 +76,11 @@ class AprsPacketLocaleTest { } @Test - fun ambiguousPosition_staysAsciiUnderArabicLocale() { - Locale.setDefault(Locale.forLanguageTag("ar-EG")) - + fun ambiguousPosition_staysAscii() { for (ambiguity in 1..4) { val encoded = AprsPosition(39.9042, 116.4074, '/', '>', ambiguity) .toUncompressedString() - assertTrue("ambiguity=$ambiguity not ASCII: $encoded", asciiPacket.matches(encoded)) + assertTrue(asciiPacket.matches(encoded), "ambiguity=$ambiguity not ASCII: $encoded") } } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsPasscodeTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPasscodeTest.kt similarity index 94% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsPasscodeTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPasscodeTest.kt index f779e47e..0806ddb5 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsPasscodeTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPasscodeTest.kt @@ -1,9 +1,9 @@ package com.rtbishop.look4sat.core.domain.aprs -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * 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(blank is AprsPasscode.Entry.ReceiveOnly) - assertFalse("a typo must not read as receive-only", typo is AprsPasscode.Entry.ReceiveOnly) - assertFalse("garbage must not read as receive-only", garbage is AprsPasscode.Entry.ReceiveOnly) + assertFalse(typo is AprsPasscode.Entry.ReceiveOnly, "a typo must not read as receive-only") + 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. for (entry in listOf("-1", "", (correct + 1).toString(), "abcde")) { diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsSymbolsTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsSymbolsTest.kt similarity index 84% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsSymbolsTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsSymbolsTest.kt index 720380c8..b7eeed0a 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsSymbolsTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsSymbolsTest.kt @@ -1,10 +1,10 @@ package com.rtbishop.look4sat.core.domain.aprs -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue /** * 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`() { for (symbol in AprsSymbols.curated) { assertEquals( - symbol.descriptionKey + " table must survive tableOf", symbol.table, - AprsBeacon.tableOf(symbol.table.toString()) + AprsBeacon.tableOf(symbol.table.toString()), + symbol.descriptionKey + " table must survive tableOf" ) assertEquals( - symbol.descriptionKey + " code must survive codeOf", 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 fun `no two curated symbols are the same pair`() { 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. */ @Test fun `every curated symbol has a distinct description key`() { val keys = AprsSymbols.curated.map { it.descriptionKey } - assertEquals("description keys must be unique", keys.size, keys.toSet().size) - assertTrue("keys must be resource names", keys.all { it.startsWith("aprs_symbol_") }) + assertEquals(keys.size, keys.toSet().size, "description keys must be unique") + assertTrue(keys.all { it.startsWith("aprs_symbol_") }, "keys must be resource names") } @Test @@ -95,8 +95,8 @@ class AprsSymbolsTest { @Test fun `the list stays short`() { 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" ) } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwAntiAliasTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwAntiAliasTest.kt similarity index 91% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwAntiAliasTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwAntiAliasTest.kt index c355d638..b3d715d5 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwAntiAliasTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwAntiAliasTest.kt @@ -3,9 +3,9 @@ package com.rtbishop.look4sat.core.domain.cw import kotlin.math.PI import kotlin.math.hypot import kotlin.math.sin -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertSame +import kotlin.test.assertTrue /** * 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 after = magnitudeAt(filtered, hz, captureRate) 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) 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 ) 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]) 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. */ @@ -174,7 +174,7 @@ class CwAntiAliasTest { val ghostAfter = magnitudeAt( 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. */ @@ -188,11 +188,11 @@ class CwAntiAliasTest { val silence = FloatArray(4410) val after = streaming.process(silence) 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. 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") } } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoderTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoderTest.kt similarity index 95% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoderTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoderTest.kt index ed1386bb..cf69348d 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoderTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoderTest.kt @@ -17,8 +17,8 @@ */ package com.rtbishop.look4sat.core.domain.cw -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals /** * Greedy CTC collapse, matching the reference implementation's @@ -45,7 +45,7 @@ class CwCtcDecoderTest { @Test fun alphabetSizeMatchesModelMetadata() { - assertEquals("41 symbols + blank = 42 classes", 41, chars.size) + assertEquals(41, chars.size, "41 symbols + blank = 42 classes") } @Test diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt similarity index 77% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt index 2f6bf496..71540143 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt @@ -17,11 +17,11 @@ */ package com.rtbishop.look4sat.core.domain.cw -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * The rolling audio buffer feeding DeepCW. @@ -45,14 +45,14 @@ class CwDeepBufferTest { val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) buffer.append(floatArrayOf(1f, 2f, 3f)) buffer.append(floatArrayOf(4f, 5f)) - assertArrayEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot(), 0f) + assertContentEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot()) } @Test fun snapshotIsChronologicalAfterWrapAround() { val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) 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 @@ -60,18 +60,18 @@ class CwDeepBufferTest { val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f)) assertEquals(4, buffer.size) - assertArrayEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot(), 0f) + assertContentEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot()) } @Test fun redecodeIsSignalledOncePerInterval() { // 1.5 s at 3200 Hz is 4800 samples; 1600 samples is 0.5 s. val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500) - assertFalse("1.0s elapsed: interval not reached", buffer.append(FloatArray(3200))) - assertTrue("1.5s elapsed: first trigger", buffer.append(FloatArray(1600))) - assertFalse("2.0s: only 0.5s since trigger", buffer.append(FloatArray(1600))) - assertFalse("2.5s: only 1.0s since trigger", buffer.append(FloatArray(1600))) - assertTrue("3.0s: 1.5s since trigger, fires again", buffer.append(FloatArray(1600))) + assertFalse(buffer.append(FloatArray(3200)), "1.0s elapsed: interval not reached") + assertTrue(buffer.append(FloatArray(1600)), "1.5s elapsed: first trigger") + assertFalse(buffer.append(FloatArray(1600)), "2.0s: only 0.5s since trigger") + assertFalse(buffer.append(FloatArray(1600)), "2.5s: only 1.0s since trigger") + assertTrue(buffer.append(FloatArray(1600)), "3.0s: 1.5s since trigger, fires again") } @Test @@ -89,7 +89,7 @@ class CwDeepBufferTest { val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) buffer.append(floatArrayOf(1f, 2f, 3f, 4f)) 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 @@ -98,7 +98,7 @@ class CwDeepBufferTest { buffer.append(FloatArray(3200)) buffer.reset() 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 @@ -123,10 +123,10 @@ class CwDeepBufferTest { fun overflowCollectsEvictedSamplesInOrder() { val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) // capacity 4 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 - assertArrayEquals("evicted samples, oldest first", floatArrayOf(1f, 2f), buffer.drainOverflow(), 0f) - assertArrayEquals("live window still correct", floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f) + assertContentEquals(floatArrayOf(1f, 2f), buffer.drainOverflow(), "evicted samples, oldest first") + assertContentEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), "live window still correct") } @Test @@ -137,7 +137,7 @@ class CwDeepBufferTest { assertEquals(1, buffer.overflowCount) buffer.drainOverflow() assertEquals(0, buffer.overflowCount) - assertArrayEquals(FloatArray(0), buffer.drainOverflow(), 0f) + assertContentEquals(FloatArray(0), buffer.drainOverflow()) } @Test diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogramTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogramTest.kt similarity index 87% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogramTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogramTest.kt index e5519b53..23f0f36f 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogramTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogramTest.kt @@ -17,13 +17,13 @@ */ 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.abs 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 @@ -40,7 +40,7 @@ class CwDeepSpectrogramTest { val (start, stop) = CwDeepSpectrogram.frequencyBinRange(3200, 256, 400.0, 1200.0) assertEquals(32, start) assertEquals(97, stop) - assertEquals("metadata declares 65 frequency bins", 65, stop - start) + assertEquals(65, stop - start, "metadata declares 65 frequency bins") } @Test @@ -59,7 +59,7 @@ class CwDeepSpectrogramTest { val spec = CwDeepSpectrogram.compute(audio) val middle = spec[spec.size / 2] 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 peak = middle.indices.maxByOrNull { middle[it] } ?: -1 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. */ @@ -95,9 +95,10 @@ class CwDeepSpectrogramTest { assertEquals(CwDeepSpectrogram.FREQUENCY_BINS, model[0].size) assertEquals(model.size, explicit.size) for (frame in model.indices) { - assertArrayEquals( - "explicit model range must equal the default", - model[frame], explicit[frame], 0f + assertContentEquals( + model[frame], + explicit[frame], + "explicit model range must equal the default" ) } } @@ -108,7 +109,7 @@ class CwDeepSpectrogramTest { val spec = CwDeepSpectrogram.compute(audio) for (frame in spec) { 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 out = CwDeepSpectrogram.resampleLinear(input, 3200, 3200) assertEquals(3, out.size) - assertEquals(0.2f, out[1], 1e-6f) + assertEquals(0.2f, out[1], absoluteTolerance = 1e-6f) } @Test @@ -136,7 +137,7 @@ class CwDeepSpectrogramTest { val spec = CwDeepSpectrogram.compute(at3200) val middle = spec[spec.size / 2] 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 diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDetectionPoolTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDetectionPoolTest.kt similarity index 75% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDetectionPoolTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDetectionPoolTest.kt index bea4df8e..4358823b 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDetectionPoolTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDetectionPoolTest.kt @@ -1,9 +1,9 @@ package com.rtbishop.look4sat.core.domain.cw -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * The pool feeds [CwToneShifter.detectToneHz], which measures a waveform, so the @@ -22,8 +22,10 @@ class CwDetectionPoolTest { private fun assertAscending(values: FloatArray) { for (i in 1 until values.size) { assertEquals( - "sample $i breaks the ramp, so the ring wrap is wrong", - values[i - 1] + 1f, values[i], 0f + values[i - 1] + 1f, + values[i], + absoluteTolerance = 0f, + "sample $i breaks the ramp, so the ring wrap is wrong" ) } } @@ -31,17 +33,17 @@ class CwDetectionPoolTest { @Test fun `reports readiness only once capacity is reached`() { 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) // Three 320-sample chunks are 960 samples: still short. repeat(3) { pool.add(ramp(it * 320, 320)) } 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)) assertEquals(capacity, pool.size) - assertTrue("a full pool must report ready", pool.isReady) + assertTrue(pool.isReady, "a full pool must report ready") } @Test @@ -50,11 +52,11 @@ class CwDetectionPoolTest { pool.add(ramp(500, 320)) val drained = pool.drain() - assertEquals("only what was added may come back", 320, drained.size) - assertEquals(500f, drained.first(), 0f) - assertEquals(819f, drained.last(), 0f) + assertEquals(320, drained.size, "only what was added may come back") + assertEquals(500f, drained.first(), absoluteTolerance = 0f) + assertEquals(819f, drained.last(), absoluteTolerance = 0f) assertAscending(drained) - assertEquals("draining empties the pool", 0, pool.size) + assertEquals(0, pool.size, "draining empties the pool") } @Test @@ -65,8 +67,8 @@ class CwDetectionPoolTest { val drained = pool.drain() assertEquals(capacity, drained.size) - assertEquals("the newest sample fed must be last", 3199f, drained.last(), 0f) - assertEquals("the oldest retained sample must be first", (3200 - capacity).toFloat(), drained.first(), 0f) + assertEquals(3199f, drained.last(), absoluteTolerance = 0f, "the newest sample fed must be last") + assertEquals((3200 - capacity).toFloat(), drained.first(), absoluteTolerance = 0f, "the oldest retained sample must be first") assertAscending(drained) } @@ -77,8 +79,8 @@ class CwDetectionPoolTest { val drained = pool.drain() assertEquals(capacity, drained.size) - assertEquals(4999f, drained.last(), 0f) - assertEquals((5000 - capacity).toFloat(), drained.first(), 0f) + assertEquals(4999f, drained.last(), absoluteTolerance = 0f) + assertEquals((5000 - capacity).toFloat(), drained.first(), absoluteTolerance = 0f) assertAscending(drained) } @@ -90,8 +92,8 @@ class CwDetectionPoolTest { val drained = pool.drain() assertEquals(capacity, drained.size) - assertEquals(1999f, drained.last(), 0f) - assertEquals((2000 - capacity).toFloat(), drained.first(), 0f) + assertEquals(1999f, drained.last(), absoluteTolerance = 0f) + assertEquals((2000 - capacity).toFloat(), drained.first(), absoluteTolerance = 0f) assertAscending(drained) } @@ -105,8 +107,8 @@ class CwDetectionPoolTest { pool.add(ramp(9000, 320)) val drained = pool.drain() assertEquals(320, drained.size) - assertEquals(9000f, drained.first(), 0f) - assertEquals(9319f, drained.last(), 0f) + assertEquals(9000f, drained.first(), absoluteTolerance = 0f) + assertEquals(9319f, drained.last(), absoluteTolerance = 0f) assertAscending(drained) } @@ -120,8 +122,8 @@ class CwDetectionPoolTest { assertFalse(pool.isReady) pool.add(ramp(7000, 320)) val drained = pool.drain() - assertEquals("cleared samples must not reappear", 320, drained.size) - assertEquals(7000f, drained.first(), 0f) + assertEquals(320, drained.size, "cleared samples must not reappear") + assertEquals(7000f, drained.first(), absoluteTolerance = 0f) } @Test @@ -129,7 +131,7 @@ class CwDetectionPoolTest { val pool = CwDetectionPool(capacity) pool.add(ramp(0, 320)) 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()) } @@ -140,8 +142,8 @@ class CwDetectionPoolTest { val drained = pool.drain() assertEquals(capacity, drained.size) - assertEquals(100f, drained.first(), 0f) - assertEquals((100 + capacity - 1).toFloat(), drained.last(), 0f) + assertEquals(100f, drained.first(), absoluteTolerance = 0f) + assertEquals((100 + capacity - 1).toFloat(), drained.last(), absoluteTolerance = 0f) assertAscending(drained) } @@ -162,8 +164,10 @@ class CwDetectionPoolTest { val detected = CwToneShifter.detectToneHz(pool.drain(), sampleRate) assertEquals( - "four pooled capture chunks must be enough to detect a 1500 Hz tone", - 1500.0, detected!!.toDouble(), 25.0 + 1500.0, + detected!!.toDouble(), + absoluteTolerance = 25.0, + "four pooled capture chunks must be enough to detect a 1500 Hz tone" ) } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwShiftDeciderTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwShiftDeciderTest.kt similarity index 74% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwShiftDeciderTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwShiftDeciderTest.kt index a3807fc1..4c988e75 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwShiftDeciderTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwShiftDeciderTest.kt @@ -1,15 +1,15 @@ 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.abs import kotlin.math.sin 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]. @@ -44,21 +44,26 @@ class CwShiftDeciderTest { val decider = CwShiftDecider() val established = feed(decider, steadyTone(1400.0)) 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()) assertEquals( - "silence must be reported as no tone, not as a zero shift", - CwShiftDecider.Outcome.NO_TONE, silent.outcome + CwShiftDecider.Outcome.NO_TONE, + silent.outcome, + "silence must be reported as no tone, not as a zero shift" ) assertEquals( - "silence must not change the shift", - established.shiftHz, silent.shiftHz, 0f + established.shiftHz, + 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( - "the decider's state must still hold the shift", - established.shiftHz, decider.shiftHz, 0f + established.shiftHz, + decider.shiftHz, + absoluteTolerance = 0f, + "the decider's state must still hold the shift" ) } @@ -70,12 +75,14 @@ class CwShiftDeciderTest { repeat(8) { i -> val decision = feed(decider, noise(seed = i + 2)) assertEquals( - "silent window $i changed the shift", - established, decision.shiftHz, 0f + established, + decision.shiftHz, + absoluteTolerance = 0f, + "silent window $i changed the shift" ) } - assertEquals(established, decider.shiftHz, 0f) - assertNotNull("the anchor must survive silence", decider.anchorToneHz) + assertEquals(established, decider.shiftHz, absoluteTolerance = 0f) + assertNotNull(decider.anchorToneHz, "the anchor must survive silence") } // --- Mutants (b) and (c): hysteresis anchored on the tone ---------------------- @@ -91,32 +98,37 @@ class CwShiftDeciderTest { val hop = feed(decider, steadyTone(1200.0)) assertEquals( - "a one-bin hop back across the edge must be absorbed", - CwShiftDecider.Outcome.WITHIN_HYSTERESIS, hop.outcome + CwShiftDecider.Outcome.WITHIN_HYSTERESIS, + 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) } @Test fun `the anchor is set from the tone that produced the shift`() { 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)) assertEquals( - "the anchor must be the detected tone", - 1400.0, decider.anchorToneHz!!.toDouble(), 25.0 + 1400.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 // window to outside would be measured against a stale reference. feed(decider, steadyTone(700.0)) assertEquals( - "an in-window tone must also become the anchor", - 700.0, decider.anchorToneHz!!.toDouble(), 25.0 + 700.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 @@ -135,9 +147,9 @@ class CwShiftDeciderTest { tone += 25.0 } assertTrue( + updates >= 1, "accumulated drift must eventually re-shift; anchor started at $anchorAtStart " + - "and the shift updated $updates times", - updates >= 1 + "and the shift updated $updates times" ) } @@ -151,20 +163,21 @@ class CwShiftDeciderTest { // Well inside the margin: must be absorbed. val small = feed(decider, steadyTone(1412.5)) 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 // react to the small move instead. val large = feed(decider, steadyTone(1000.0)) 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( - "a 1000 Hz tone is inside the window, so no shift is needed", - CwShiftDecider.Outcome.NO_SHIFT_NEEDED, large.outcome + CwShiftDecider.Outcome.NO_SHIFT_NEEDED, + 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 @@ -179,8 +192,8 @@ class CwShiftDeciderTest { } } 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 worstOffset = maxOf(worstOffset, abs(landed - CwToneShifter.TARGET_HZ)) 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 } 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) decider.reset() - assertEquals("reset must clear the shift", 0f, decider.shiftHz, 0f) - assertNull("reset must clear the anchor", decider.anchorToneHz) + assertEquals(0f, decider.shiftHz, absoluteTolerance = 0f, "reset must clear the shift") + assertNull(decider.anchorToneHz, "reset must clear the anchor") // After a reset the next tone must be acted on rather than absorbed. val decision = feed(decider, steadyTone(1400.0)) @@ -237,8 +250,8 @@ class CwShiftDeciderTest { feed(decider, audio) if (decider.shiftHz != 0f) { 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 decision = feed(decider, steadyTone(hz)) assertEquals( - "a ${hz}Hz tone must be shifted to the window centre", - CwToneShifter.TARGET_HZ, hz + decision.shiftHz, 30.0 + CwToneShifter.TARGET_HZ, + 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 decision = feed(decider, steadyTone(hz)) assertEquals( - "a ${hz}Hz tone is inside the window and must not be shifted", - CwShiftDecider.Outcome.NO_SHIFT_NEEDED, decision.outcome + CwShiftDecider.Outcome.NO_SHIFT_NEEDED, + 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) } } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShiftSignalTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwToneShiftSignalTest.kt similarity index 81% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShiftSignalTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwToneShiftSignalTest.kt index 338fdf41..fa6b64a7 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShiftSignalTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwToneShiftSignalTest.kt @@ -1,13 +1,13 @@ 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.abs import kotlin.math.sin 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 @@ -49,27 +49,29 @@ class CwToneShiftSignalTest { 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, // decodable, and the region an over-tight threshold silently discards. for (hz in listOf(300.0, 800.0, 1400.0)) { val detected = CwToneShifter.detectToneHz(keyedTone(hz, noise = 0.7), sampleRate) assertEquals( - "a weak but usable ${hz}Hz signal must be detected, not rejected as noise", - hz, detected!!.toDouble(), 25.0 + hz, + detected!!.toDouble(), + absoluteTolerance = 25.0, + "a weak but usable ${hz}Hz signal must be detected, not rejected as noise" ) } assertTrue( + CwToneShifter.MIN_PROMINENCE > 3.4, "MIN_PROMINENCE ${CwToneShifter.MIN_PROMINENCE} must clear the measured noise " + - "ceiling of ~3.4", - CwToneShifter.MIN_PROMINENCE > 3.4 + "ceiling of ~3.4" ) assertTrue( + CwToneShifter.MIN_PROMINENCE < 5.2, "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", - CwToneShifter.MIN_PROMINENCE < 5.2 + "keyed CW measures 7.6-9.0 at 0 dB SNR and 5.2-6.7 at -3 dB" ) } @@ -87,23 +89,25 @@ class CwToneShiftSignalTest { val square = FloatArray(1280) { if ((it / 8) % 2 == 0) 1f else -1f } val shiftedSquare = shifter.process(square, shiftHz, sampleRate) 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() val sine = FloatArray(1280) { i -> sin(2.0 * PI * 1500.0 * i / sampleRate).toFloat() } val shiftedSine = shifter.process(sine, shiftHz, sampleRate) 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. val detected = CwToneShifter.detectToneHz(shiftedSine, sampleRate) assertEquals( - "limiting must preserve the shifted tone", - CwToneShifter.TARGET_HZ, detected!!.toDouble(), 30.0 + CwToneShifter.TARGET_HZ, + 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 shifted = CwToneShifter.shift(square, -700f, sampleRate) 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) assertEquals( - "a DC offset of $offset must not hide the tone", - 800.0, detected!!.toDouble(), 25.0 + 800.0, + detected!!.toDouble(), + absoluteTolerance = 25.0, + "a DC offset of $offset must not hide the tone" ) } 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)) { 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() } 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. @@ -159,8 +165,10 @@ class CwToneShiftSignalTest { } val detected = CwToneShifter.detectToneHz(clipped, sampleRate) assertEquals( - "at ${drive}x drive the fundamental must still win", - 500.0, detected!!.toDouble(), 25.0 + 500.0, + detected!!.toDouble(), + absoluteTolerance = 25.0, + "at ${drive}x drive the fundamental must still win" ) } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterStreamingTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwToneShifterStreamingTest.kt similarity index 90% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterStreamingTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwToneShifterStreamingTest.kt index 3df5b48c..490e4b18 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterStreamingTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwToneShifterStreamingTest.kt @@ -1,13 +1,13 @@ 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.abs import kotlin.math.sin 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 @@ -78,9 +78,9 @@ class CwToneShifterStreamingTest { // Measured 0.79% with state carried across chunks; dropping the filter history // takes it to several percent, and dropping the phase far higher. assertTrue( + streamedRipple < 2.0, "streaming envelope ripple ${streamedRipple}% is too high; chunk-edge " + - "filter state or mixer phase is not being carried", - streamedRipple < 2.0 + "filter state or mixer phase is not being carried" ) } @@ -118,15 +118,15 @@ class CwToneShifterStreamingTest { } assertTrue( + worstInterior < 0.01, "away from chunk tails the two must agree; worst divergence was " + - "$worstInterior, so filter history or mixer phase is not being carried", - worstInterior < 0.01 + "$worstInterior, so filter history or mixer phase is not being carried" ) // 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. 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) assertEquals( - "streamed audio must end up at the target pitch", - CwToneShifter.TARGET_HZ, detected!!.toDouble(), 30.0 + CwToneShifter.TARGET_HZ, + 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 chunk = continuousTone(800.0, chunkSize) assertSame( - "a zero shift must not copy or alter the chunk", - chunk, shifter.process(chunk, 0f, sampleRate) + chunk, + 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 tail = envelope(firstShifted.copyOfRange(firstShifted.size - 96, firstShifted.size)).average() 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 tail = envelope(afterReset.copyOfRange(afterReset.size - 64, afterReset.size)).average() assertTrue( + head < tail, "reset must clear history, so the head ($head) should be quieter than " + - "the settled tail ($tail)", - head < tail + "the settled tail ($tail)" ) } @@ -214,7 +217,7 @@ class CwToneShifterStreamingTest { val shiftHz = (CwToneShifter.TARGET_HZ - 1500.0).toFloat() val out = shifter.process(big, shiftHz, sampleRate) assertEquals(big.size, out.size) - assertTrue("output must be finite", out.all { it.isFinite() }) + assertTrue(out.all { it.isFinite() }, "output must be finite") } @Test @@ -230,11 +233,13 @@ class CwToneShifterStreamingTest { shifter.process(chunk, shiftHz, sampleRate).copyInto(collected, offset) 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) assertEquals( - "even tiny chunks must end up at the target pitch", - CwToneShifter.TARGET_HZ, detected!!.toDouble(), 40.0 + CwToneShifter.TARGET_HZ, + detected!!.toDouble(), + absoluteTolerance = 40.0, + "even tiny chunks must end up at the target pitch" ) } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt similarity index 79% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt index 8bde57f8..ac42834c 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt @@ -1,18 +1,18 @@ 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.abs import kotlin.math.cos import kotlin.math.hypot import kotlin.math.sin 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 @@ -70,8 +70,8 @@ class CwToneShifterTest { 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)) { val detected = CwToneShifter.detectToneHz(cwTone(tone), sampleRate) - assertNotNull("no tone detected at $tone Hz", detected) - assertEquals("detected pitch off at $tone Hz", tone, detected!!.toDouble(), 25.0) + assertNotNull(detected, "no tone detected at $tone Hz") + 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`() { val random = Random(7) 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 @@ -87,10 +87,10 @@ class CwToneShifterTest { for (tone in listOf(400.0, 500.0, 700.0, 800.0, 1100.0, 1200.0)) { val audio = cwTone(tone) val (result, analysis) = CwToneShifter.shiftIfOutsideWindow(audio, sampleRate) - assertFalse("$tone Hz is inside the window, must not shift", analysis.needsShift) - assertEquals("no shift expected at $tone Hz", 0f, analysis.shiftHz, 0f) + assertFalse(analysis.needsShift, "$tone Hz is inside the window, must not shift") + assertEquals(0f, analysis.shiftHz, absoluteTolerance = 0f, "no shift expected at $tone Hz") // 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)) { val analysis = CwToneShifter.analyse(cwTone(tone), sampleRate) val reported = analysis.toneHz - assertNotNull("$tone Hz went undetected, so the UI has nothing to report", reported) - assertEquals("$tone Hz was misreported", tone, reported!!.toDouble(), 30.0) + assertNotNull(reported, "$tone Hz went undetected, so the UI has nothing to report") + assertEquals(tone, reported!!.toDouble(), absoluteTolerance = 30.0, "$tone Hz was misreported") assertFalse( - "$tone Hz must read as outside the window", - CwToneShifter.isInsideWindow(reported) + CwToneShifter.isInsideWindow(reported), + "$tone Hz must read as outside the window" ) } } @@ -123,17 +123,17 @@ class CwToneShifterTest { @Test fun `the shift target sits inside the model window, clear of its edges`() { assertTrue( + CwToneShifter.isInsideWindow(CwToneShifter.TARGET_HZ.toFloat()), "TARGET_HZ ${CwToneShifter.TARGET_HZ} is outside the model window " + - "${CwDeepSpectrogram.MIN_FREQ_HZ}-${CwDeepSpectrogram.MAX_FREQ_HZ} Hz", - CwToneShifter.isInsideWindow(CwToneShifter.TARGET_HZ.toFloat()) + "${CwDeepSpectrogram.MIN_FREQ_HZ}-${CwDeepSpectrogram.MAX_FREQ_HZ} Hz" ) // 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. val margin = (CwDeepSpectrogram.MAX_FREQ_HZ - CwDeepSpectrogram.MIN_FREQ_HZ) / 4 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.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)) { val audio = cwTone(tone) 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) assertEquals( - "$tone Hz did not land on the target", - CwToneShifter.TARGET_HZ, strongest, 30.0 + CwToneShifter.TARGET_HZ, + strongest, + absoluteTolerance = 30.0, + "$tone Hz did not land on the target" ) 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( - "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`() { val audio = cwTone(1500.0) val shifted = CwToneShifter.shift(audio, -700f, sampleRate) - assertEquals("length must be preserved", audio.size, shifted.size) - assertTrue("output must be finite", shifted.all { it.isFinite() }) + assertEquals(audio.size, shifted.size, "length must be preserved") + assertTrue(shifted.all { it.isFinite() }, "output must be finite") } @Test @@ -198,12 +200,13 @@ class CwToneShifterTest { val shiftedEnergy = shiftedSpectrogram.sumOf { frame -> frame.sumOf { it.toDouble() } } 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( - "bin count must stay compatible with the model", - CwDeepSpectrogram.FREQUENCY_BINS, shiftedSpectrogram[0].size + CwDeepSpectrogram.FREQUENCY_BINS, + shiftedSpectrogram[0].size, + "bin count must stay compatible with the model" ) } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/navigation/MenuLayoutTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/navigation/MenuLayoutTest.kt similarity index 86% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/navigation/MenuLayoutTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/navigation/MenuLayoutTest.kt index 4090258a..a304506b 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/navigation/MenuLayoutTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/navigation/MenuLayoutTest.kt @@ -17,9 +17,9 @@ */ package com.rtbishop.look4sat.core.domain.navigation -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue /** * 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 l = layout(moved.screenOrder, moved.subMenuOrder) 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. val moved = MenuLayout.moveToMain("AMSAT", all, emptyList(), emptyList()) val l = layout(moved.screenOrder, moved.subMenuOrder) - assertTrue("AMSAT missing from the bar: ${l.mainIds}", "AMSAT" in l.mainIds) - assertTrue("AMSAT still behind More: ${l.moreIds}", "AMSAT" !in l.moreIds) + assertTrue("AMSAT" in l.mainIds, "AMSAT missing from the bar: ${l.mainIds}") + assertTrue("AMSAT" !in l.moreIds, "AMSAT still behind More: ${l.moreIds}") } @Test fun movingWavelogLogToMainActuallyTakesEffect() { val moved = MenuLayout.moveToMain("WavelogLog", all, emptyList(), emptyList()) 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 @@ -84,8 +84,8 @@ class MenuLayoutTest { screenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings"), subMenuOrder = listOf("Mutual", "Roaming", "CwDecode") ) - assertTrue("AMSAT should land behind More", "AMSAT" in l.moreIds) - assertTrue("WavelogLog should land behind More", "WavelogLog" in l.moreIds) + assertTrue("AMSAT" in l.moreIds, "AMSAT should land behind More") + assertTrue("WavelogLog" in l.moreIds, "WavelogLog should land behind More") } @Test @@ -95,7 +95,7 @@ class MenuLayoutTest { screenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "Map", "Settings"), 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 @@ -114,7 +114,7 @@ class MenuLayoutTest { @Test fun theBarNeverExceedsFiveSlots() { 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 @@ -149,6 +149,6 @@ class MenuLayoutTest { val moved = MenuLayout.moveToMain("CwDecode", all, emptyList(), emptyList()) val l = layout(moved.screenOrder, moved.subMenuOrder) 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") } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt similarity index 71% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt index d2fc7f85..9d62c926 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt @@ -17,11 +17,11 @@ */ package com.rtbishop.look4sat.core.domain.predict -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import java.time.Instant import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant class CelestialComputerTest { @@ -39,16 +39,16 @@ class CelestialComputerTest { var samples = 0 while (timeMillis < endMillis) { val gha = CelestialComputer.getMoonPosition(observer, timeMillis).gha - assertTrue("gha=$gha out of 0..360 at $timeMillis", gha >= 0.0 && gha < 360.0) + assertTrue(gha >= 0.0 && gha < 360.0, "gha=$gha out of 0..360 at $timeMillis") val longitude = subLunarLongitude(gha) assertTrue( - "sub-lunar longitude=$longitude out of -180..180 at $timeMillis", - longitude >= -180.0 && longitude <= 180.0 + longitude >= -180.0 && longitude <= 180.0, + "sub-lunar longitude=$longitude out of -180..180 at $timeMillis" ) samples++ timeMillis += stepMillis } - assertTrue("expected a meaningful sweep, got $samples samples", samples > 1000) + assertTrue(samples > 1000, "expected a meaningful sweep, got $samples samples") } private data class RiseSetCase( @@ -72,16 +72,16 @@ class CelestialComputerTest { val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis()) val daylightDuration = result.setTimeMillis - result.riseTimeMillis - assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L) - assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L) - assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis) - assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS) - assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS) + assertTrue(result.riseTimeMillis > 0L, "${testCase.name}: sunrise should be non-zero") + assertTrue(result.setTimeMillis > 0L, "${testCase.name}: sunset should be non-zero") + assertTrue(result.setTimeMillis > result.riseTimeMillis, "${testCase.name}: sunset should be after sunrise") + assertTrue(daylightDuration > HOUR_MILLIS, "${testCase.name}: daylight duration should be longer than 1 hour") + assertTrue(daylightDuration < DAY_MILLIS, "${testCase.name}: daylight duration should be shorter than 24 hours") val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation - assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02) - assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02) + assertEquals(SUNRISE_SET_THRESHOLD, riseElevation, absoluteTolerance = 0.02, "${testCase.name}: sunrise should converge near the standard threshold") + assertEquals(SUNRISE_SET_THRESHOLD, setElevation, absoluteTolerance = 0.02, "${testCase.name}: sunset should converge near the standard threshold") } } @@ -91,11 +91,12 @@ class CelestialComputerTest { val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis()) val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis) - assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS) + assertTrue(separationMillis > HOUR_MILLIS, "$startIso: sunrise and sunset should be separated") } } - private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli() + /** ISO-8601 to epoch millis; kotlin.time.Instant is the multiplatform stand-in for the JVM's Instant. */ + private fun String.toMillis(): Long = Instant.parse(this).toEpochMilliseconds() private companion object { private const val SUNRISE_SET_THRESHOLD = -0.8333 diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/qrz/QrzGridParserTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/qrz/QrzGridParserTest.kt similarity index 98% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/qrz/QrzGridParserTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/qrz/QrzGridParserTest.kt index d67a1360..5e20dbbc 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/qrz/QrzGridParserTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/qrz/QrzGridParserTest.kt @@ -1,8 +1,8 @@ package com.rtbishop.look4sat.core.domain.qrz -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull class QrzGridParserTest { diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/utility/ClipLonTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/utility/ClipLonTest.kt similarity index 71% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/utility/ClipLonTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/utility/ClipLonTest.kt index 54c43a3d..fc751a16 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/utility/ClipLonTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/utility/ClipLonTest.kt @@ -1,9 +1,9 @@ package com.rtbishop.look4sat.core.domain.utility -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * clipLon must reduce any longitude into [-180, 180] in bounded time. The old @@ -20,13 +20,13 @@ class ClipLonTest { // The old loop: subtract/add 360 until within (-180, 180] is not quite // it either - 180 stays 180, -180 stays -180. So the interval is closed // on both ends with +180 for positive-boundary hits. - assertEquals(-180.0, clipLon(-180.0), 1e-12) - assertEquals(180.0, clipLon(180.0), 1e-12) - assertEquals(0.0, clipLon(360.0), 1e-12) - assertEquals(180.0, clipLon(540.0), 1e-12) - assertEquals(-180.0, clipLon(-540.0), 1e-12) - assertEquals(-179.999, clipLon(180.001), 1e-9) - assertEquals(179.999, clipLon(-180.001), 1e-9) + assertEquals(-180.0, clipLon(-180.0), absoluteTolerance = 1e-12) + assertEquals(180.0, clipLon(180.0), absoluteTolerance = 1e-12) + assertEquals(0.0, clipLon(360.0), absoluteTolerance = 1e-12) + assertEquals(180.0, clipLon(540.0), absoluteTolerance = 1e-12) + assertEquals(-180.0, clipLon(-540.0), absoluteTolerance = 1e-12) + assertEquals(-179.999, clipLon(180.001), absoluteTolerance = 1e-9) + assertEquals(179.999, clipLon(-180.001), absoluteTolerance = 1e-9) } @Test @@ -37,7 +37,7 @@ class ClipLonTest { var v = -10000.0 while (v <= 10000.0) { val reference = clipLonByLoop(v) - assertEquals(reference, clipLon(v), 1e-9) + assertEquals(reference, clipLon(v), absoluteTolerance = 1e-9) v += 0.01 } } @@ -45,8 +45,8 @@ class ClipLonTest { @Test fun `extreme and non-finite inputs return immediately`() { // These used to hang the calling thread (Infinity loop) or take seconds. - assertEquals(Double.POSITIVE_INFINITY, clipLon(Double.POSITIVE_INFINITY), 0.0) - assertEquals(Double.NEGATIVE_INFINITY, clipLon(Double.NEGATIVE_INFINITY), 0.0) + assertEquals(Double.POSITIVE_INFINITY, clipLon(Double.POSITIVE_INFINITY), absoluteTolerance = 0.0) + assertEquals(Double.NEGATIVE_INFINITY, clipLon(Double.NEGATIVE_INFINITY), absoluteTolerance = 0.0) assertTrue(clipLon(Double.NaN).isNaN()) assertFalse(clipLon(1e15).isNaN()) assertTrue(clipLon(1e15) in -180.0..180.0) diff --git a/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/utility/CommonFormatRoundingTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/utility/CommonFormatRoundingTest.kt new file mode 100644 index 00000000..ad6f0036 --- /dev/null +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/utility/CommonFormatRoundingTest.kt @@ -0,0 +1,61 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.domain.utility + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * java.lang.String.format rounds the shortest decimal representation of a double half-up, not its + * binary value, so "%.3f" of 0.5005 is "0.501" - the stored double is 0.50049999999999994493. + * Scaling in binary first (floor(value * 10^precision + 0.5)) answers "0.500" instead, which is + * what the shared formatter used to do. + * + * Every expected string below was produced by java.lang.String.format(Locale.ROOT, ...) and pinned + * as a literal, so the iOS run checks the same digits with no JVM in reach. + */ +class CommonFormatRoundingTest { + + @Test + fun `rounds up where the decimal digits reach a half`() { + assertEquals("0.501", formatString("%.3f", 0.5005)) + assertEquals("1.01", formatString("%.2f", 1.005)) + assertEquals("2.68", formatString("%.2f", 2.675)) + assertEquals("145.68", formatString("%.2f", 145.675)) + assertEquals("0.000125", formatString("%.6f", 1.245e-4)) + assertEquals("51.507", formatString("%.3f", 51.507123)) + assertEquals("10.000", formatString("%.3f", 9.9999)) + assertEquals("1.00", formatString("%.2f", 0.999)) + } + + @Test + fun `rounds down where the decimal digits fall short of a half`() { + assertEquals("0.500", formatString("%.3f", 0.50049)) + assertEquals("51.507", formatString("%.3f", 51.5074999)) + assertEquals("2.67", formatString("%.2f", 2.6749)) + } + + @Test + fun `keeps the shapes the wire formats need`() { + assertEquals("10000000.000000", formatString("%f", 1.0e7)) + assertEquals("145.675000", formatString("%.6f", 145_675_000.0 / 1_000_000.0)) + assertEquals("0.000", formatString("%.3f", 4.9e-324)) + assertEquals("-0.000", formatString("%.3f", -0.0)) + assertEquals("-0.501", formatString("%.3f", -0.5005)) + } +} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntryTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntryTest.kt similarity index 94% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntryTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntryTest.kt index bda092c2..89428bb5 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntryTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntryTest.kt @@ -1,9 +1,9 @@ package com.rtbishop.look4sat.core.domain.wavelog -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue /** * The rule under test: never discard a plausible callsign, and never discard anything silently. @@ -28,7 +28,7 @@ class CallsignEntryTest { @Test fun `every real callsign is accepted`() { val rejected = realCallsigns.filter { CallsignEntry.check(it) !is CallsignEntry.Verdict.Acceptable } - assertEquals("these real callsigns were rejected: $rejected", emptyList(), rejected) + assertEquals(emptyList(), rejected, "these real callsigns were rejected: $rejected") } /** The entry is upper-cased for logging, since ADIF and LoTW expect that. */ @@ -59,9 +59,9 @@ class CallsignEntryTest { fun `illegal characters are named as such`() { for (bad in listOf("W1AW!", "W1 AW", "W1AW.", "БГ7НТА", "W1AW@")) { assertEquals( - bad, CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.ILLEGAL_CHARACTERS), - CallsignEntry.check(bad) + CallsignEntry.check(bad), + bad ) } } @@ -76,8 +76,8 @@ class CallsignEntryTest { fun `a prefix-first portable callsign is accepted`() { for (call in listOf("DL/W1AW", "ZL/JA1ABC", "OH/W1AW/MM", "PA/G0ABC/P", "F/BG7NTA")) { assertTrue( - "$call must be accepted", - CallsignEntry.check(call) is CallsignEntry.Verdict.Acceptable + CallsignEntry.check(call) is CallsignEntry.Verdict.Acceptable, + "$call must be accepted" ) } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/GridEntryTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/GridEntryTest.kt similarity index 90% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/GridEntryTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/GridEntryTest.kt index 679dc583..eb3ef080 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/GridEntryTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/GridEntryTest.kt @@ -1,11 +1,11 @@ package com.rtbishop.look4sat.core.domain.wavelog import com.rtbishop.look4sat.core.domain.utility.qthToPosition -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue /** * A typed grid goes into the ADIF GRIDSQUARE field and Wavelog stores whatever arrives, where a @@ -31,7 +31,7 @@ class GridEntryTest { fun `real grids are accepted`() { for (grid in realGrids) { val verdict = GridEntry.check(grid) - assertTrue("$grid must be usable, got $verdict", verdict is GridEntry.Verdict.Acceptable) + assertTrue(verdict is GridEntry.Verdict.Acceptable, "$grid must be usable, got $verdict") } } @@ -45,8 +45,8 @@ class GridEntryTest { val verdict = GridEntry.check(grid) assertTrue(verdict is GridEntry.Verdict.Acceptable) assertNotNull( - "$grid was accepted here but qthToPosition rejects it", - qthToPosition((verdict as GridEntry.Verdict.Acceptable).normalised) + qthToPosition((verdict as GridEntry.Verdict.Acceptable).normalised), + "$grid was accepted here but qthToPosition rejects it" ) } } @@ -117,8 +117,8 @@ class GridEntryTest { ) for (grid in nonAscii) { assertTrue( - "must be refused: " + grid, - GridEntry.check(grid) is GridEntry.Verdict.Unusable + GridEntry.check(grid) is GridEntry.Verdict.Unusable, + "must be refused: " + grid ) } } @@ -140,9 +140,9 @@ class GridEntryTest { for (bad in listOf("O", "OL7", "OL72A", "OL72APX", "OL72AP123")) { val verdict = GridEntry.check(bad) assertEquals( - "$bad must be refused for length, got $verdict", GridEntry.Verdict.Unusable(GridEntry.Reason.WRONG_LENGTH), - verdict + verdict, + "$bad must be refused for length, got $verdict" ) } } @@ -155,9 +155,9 @@ class GridEntryTest { fun `a field pair past R is refused`() { for (bad in listOf("SS12AA", "ZZ99ZZ", "TT34bb")) { assertEquals( - "$bad decodes outside the world", GridEntry.Verdict.Unusable(GridEntry.Reason.FIELD_OUT_OF_RANGE), - GridEntry.check(bad) + GridEntry.check(bad), + "$bad decodes outside the world" ) } } @@ -166,9 +166,9 @@ class GridEntryTest { fun `the square pair must be digits`() { for (bad in listOf("OLAAAP", "OL7AAP", "ABCDEF")) { assertEquals( - "$bad has no square digits", GridEntry.Verdict.Unusable(GridEntry.Reason.SQUARE_NOT_DIGITS), - GridEntry.check(bad) + GridEntry.check(bad), + "$bad has no square digits" ) } } @@ -188,8 +188,8 @@ class GridEntryTest { for (call in listOf("BG7NTA", "W1AW", "JA1ABC", "DL1ABC")) { val verdict = GridEntry.check(call) assertTrue( - "$call must not read as a grid, got $verdict", - verdict is GridEntry.Verdict.Unusable + verdict is GridEntry.Verdict.Unusable, + "$call must not read as a grid, got $verdict" ) } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIdsTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIdsTest.kt similarity index 92% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIdsTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIdsTest.kt index 30852c87..01ba4d3f 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIdsTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIdsTest.kt @@ -1,10 +1,10 @@ package com.rtbishop.look4sat.core.domain.wavelog -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue class LotwSatelliteIdsTest { @@ -17,7 +17,7 @@ class LotwSatelliteIdsTest { val known = LotwSatellites.names for (catnum in catnums) { val name = LotwSatelliteIds.nameFor(catnum) - assertTrue("$catnum maps to $name, which LoTW does not list", name in known) + assertTrue(name in known, "$catnum maps to $name, which LoTW does not list") } } @@ -44,7 +44,7 @@ class LotwSatelliteIdsTest { fun `ARISS is the station and not one of its modules`() { assertEquals("ARISS", LotwSatelliteIds.nameFor(25544)) for (module in listOf(25575, 26400, 26700, 49044)) { - assertNull("module $module must not map to a satellite", LotwSatelliteIds.nameFor(module)) + assertNull(LotwSatelliteIds.nameFor(module), "module $module must not map to a satellite") } } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/PassClockTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/PassClockTest.kt similarity index 94% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/PassClockTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/PassClockTest.kt index 49d57e72..1052acf4 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/PassClockTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/PassClockTest.kt @@ -1,9 +1,9 @@ package com.rtbishop.look4sat.core.domain.wavelog -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * The screen used to stamp the current time with no way to change it, which is wrong for the way @@ -100,7 +100,7 @@ class PassClockTest { val now = dayStart + 5 * minute // 00:05 UTC val resolved = PassClock.resolve(PassClock.Command.At(23 * 60 + 58), now, dayStart) assertEquals(dayStart - 86_400_000L + 23 * hour + 58 * minute, resolved) - assertTrue("must be in the past", resolved < now) + assertTrue(resolved < now, "must be in the past") } @Test @@ -117,7 +117,7 @@ class PassClockTest { assertTrue(PassClock.isHolding(PassClock.Command.Shift(-5))) assertFalse(PassClock.isHolding(PassClock.Command.Live)) assertFalse(PassClock.isHolding(PassClock.Command.Unrecognised)) - assertFalse("a zero shift is still live", PassClock.isHolding(PassClock.Command.Shift(0))) + assertFalse(PassClock.isHolding(PassClock.Command.Shift(0)), "a zero shift is still live") } /** Whitespace and case must not change the verdict - this is typed one-handed outdoors. */ diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/SatNameResolutionTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/SatNameResolutionTest.kt similarity index 97% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/SatNameResolutionTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/SatNameResolutionTest.kt index 6b71de26..b2c1f84b 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/SatNameResolutionTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/SatNameResolutionTest.kt @@ -1,9 +1,9 @@ package com.rtbishop.look4sat.core.domain.wavelog -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * LoTW rejects a QSO whose SAT_NAME is not spelled as its accepted list has it, so these pin diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApiPayloadTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApiPayloadTest.kt similarity index 86% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApiPayloadTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApiPayloadTest.kt index aa68d19b..53d1d299 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApiPayloadTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApiPayloadTest.kt @@ -1,9 +1,9 @@ package com.rtbishop.look4sat.core.domain.wavelog -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import java.util.Locale +import com.rtbishop.look4sat.core.domain.utility.formatString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue /** * Verifies the WaveLog upload payload frequency/band fields. @@ -21,8 +21,8 @@ class WaveLogApiPayloadTest { @Test fun v2_freq_usesMhzStringWithMSuffix() { - val freq = String.format(Locale.ENGLISH, "%.6fM", uplinkHz / 1_000_000.0) - val freqRx = String.format(Locale.ENGLISH, "%.6fM", downlinkHz / 1_000_000.0) + val freq = formatString("%.6fM", uplinkHz / 1_000_000.0) + val freqRx = formatString("%.6fM", downlinkHz / 1_000_000.0) assertEquals("145.850000M", freq) assertEquals("436.795000M", freqRx) // WaveLog parse_frequency: "145.850000M" -> 145850000 Hz @@ -33,10 +33,10 @@ class WaveLogApiPayloadTest { @Test fun v1_adif_freq_isBareMhzNumber() { // v1 ADIF is a bare MHz number per ADIF spec (no unit suffix) - val freq = String.format(Locale.ENGLISH, "%.6f", uplinkHz / 1_000_000.0) + val freq = formatString("%.6f", uplinkHz / 1_000_000.0) assertEquals("145.850000", freq) val adifFreq = freq.toDouble() * 1_000_000 - assertEquals(uplinkHz.toDouble(), adifFreq, 1.0) + assertEquals(uplinkHz.toDouble(), adifFreq, absoluteTolerance = 1.0) } @Test @@ -75,12 +75,12 @@ class WaveLogApiPayloadTest { val dopplerRate = 7.0 // km/s approaching val corrected = uplinkHz * (299_792_458.0 + dopplerRate * 1000.0) / 299_792_458.0 assertTrue( - "corrected within +-20kHz, got ${corrected - uplinkHz} Hz", - kotlin.math.abs(corrected - uplinkHz) < 20_000 + kotlin.math.abs(corrected - uplinkHz) < 20_000, + "corrected within +-20kHz, got ${corrected - uplinkHz} Hz" ) // band derivation: 145.x MHz -> 2m, never 160m (1.8-2.0 MHz) val mhz = corrected / 1_000_000.0 - assertTrue("145.x MHz stays in 2m, got $mhz MHz", mhz in 144.0..148.0) + assertTrue(mhz in 144.0..148.0, "145.x MHz stays in 2m, got $mhz MHz") } @Test diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt similarity index 92% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt rename to core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt index 1fbcf4e0..f2bdae98 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt +++ b/core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt @@ -1,8 +1,8 @@ package com.rtbishop.look4sat.core.domain.wavelog -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue /** * The rule these pin down: a 200 is not an acceptance. @@ -95,7 +95,7 @@ class WavelogResponseTest { @Test fun `an unrecognised status keeps the QSO queued`() { val verdict = WavelogResponse.verdict(200, """{"status":"something-new"}""") - assertTrue("got $verdict", verdict is WavelogResponse.Verdict.Unreadable) + assertTrue(verdict is WavelogResponse.Verdict.Unreadable, "got $verdict") } @Test @@ -151,8 +151,8 @@ class WavelogResponseTest { for ((code, body) in bodies) { val verdict = WavelogResponse.verdict(code, body) assertTrue( - "must be a rejection, got " + verdict + " for " + body, - verdict is WavelogResponse.Verdict.Rejected + verdict is WavelogResponse.Verdict.Rejected, + "must be a rejection, got " + verdict + " for " + body ) } } @@ -164,7 +164,7 @@ class WavelogResponseTest { 201, """{"status":"created","adif_errors":0,"messages":["Removed duplicate mode entry"]}""" ) - assertTrue("got " + verdict, verdict is WavelogResponse.Verdict.Accepted) + assertTrue(verdict is WavelogResponse.Verdict.Accepted, "got " + verdict) } /** @@ -181,8 +181,8 @@ class WavelogResponseTest { for (page in pages) { val verdict = WavelogResponse.verdict(200, page) assertTrue( - "html must not be accepted, got " + verdict, - verdict is WavelogResponse.Verdict.Unreadable + verdict is WavelogResponse.Verdict.Unreadable, + "html must not be accepted, got " + verdict ) } } @@ -207,7 +207,7 @@ class WavelogResponseTest { 201, """{"status":"created","adif_count":1,"adif_errors":0,"messages":[""]}""" ) - assertTrue("got " + verdict, verdict is WavelogResponse.Verdict.Accepted) + assertTrue(verdict is WavelogResponse.Verdict.Accepted, "got " + verdict) } /** v1 uses `abort` with a 400 when any record in a batch failed. Not a success. */ @@ -217,7 +217,7 @@ class WavelogResponseTest { 400, """{"status":"abort","messages":["Bad ADIF field CALL"]}""" ) - assertTrue("got " + verdict, verdict is WavelogResponse.Verdict.Rejected) + assertTrue(verdict is WavelogResponse.Verdict.Rejected, "got " + verdict) } /** A bulk reply that stored nothing is not an acceptance, whatever its status says. */ @@ -226,8 +226,8 @@ class WavelogResponseTest { for (body in listOf("""{"imported":0,"skipped":3}""", """{"status":"created","adif_count":0,"adif_errors":1,"messages":["bad"]}""")) { assertTrue( - "must not be accepted: " + body, - WavelogResponse.verdict(200, body) is WavelogResponse.Verdict.Rejected + WavelogResponse.verdict(200, body) is WavelogResponse.Verdict.Rejected, + "must not be accepted: " + body ) } } @@ -244,8 +244,8 @@ class WavelogResponseTest { """{"imported":2,"skipped":1}""" )) { assertTrue( - "must still be accepted: " + body, - WavelogResponse.verdict(200, body) is WavelogResponse.Verdict.Accepted + WavelogResponse.verdict(200, body) is WavelogResponse.Verdict.Accepted, + "must still be accepted: " + body ) } } diff --git a/core/domain/src/iosMain/kotlin/com/rtbishop/look4sat/core/domain/utility/SynchronizedOn.ios.kt b/core/domain/src/iosMain/kotlin/com/rtbishop/look4sat/core/domain/utility/SynchronizedOn.ios.kt new file mode 100644 index 00000000..c5a8843c --- /dev/null +++ b/core/domain/src/iosMain/kotlin/com/rtbishop/look4sat/core/domain/utility/SynchronizedOn.ios.kt @@ -0,0 +1,25 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.domain.utility + +/** + * Kotlin/Native has no equivalent of a JVM monitor, and nothing on iOS calls these mutators + * concurrently yet: the iOS app is not built (M2), and its tests are single threaded. This is a + * placeholder, not a lock - M2 has to give it a real one before any second thread touches state. + */ +internal actual fun synchronizedOn(lock: Any, block: () -> T): T = block() diff --git a/core/domain/src/jvmMain/kotlin/com/rtbishop/look4sat/core/domain/utility/SynchronizedOn.jvm.kt b/core/domain/src/jvmMain/kotlin/com/rtbishop/look4sat/core/domain/utility/SynchronizedOn.jvm.kt new file mode 100644 index 00000000..63c4ebb6 --- /dev/null +++ b/core/domain/src/jvmMain/kotlin/com/rtbishop/look4sat/core/domain/utility/SynchronizedOn.jvm.kt @@ -0,0 +1,24 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.domain.utility + +/** + * The Android app has two threads on this state - Compose and the upload coroutine - so the JVM + * keeps the real monitor that kotlin.jvm.Synchronized used to provide. + */ +internal actual fun synchronizedOn(lock: Any, block: () -> T): T = synchronized(lock, block) diff --git a/core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPacketDefaultLocaleTest.kt b/core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPacketDefaultLocaleTest.kt new file mode 100644 index 00000000..a4408060 --- /dev/null +++ b/core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPacketDefaultLocaleTest.kt @@ -0,0 +1,82 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.domain.aprs + +import java.util.Locale +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * APRS-IS is an ASCII line protocol. Formatting the position, altitude and course/speed extensions + * with the JVM default locale produced Eastern Arabic or Bengali digits on devices set to ar/fa/bn + * and the server rejects those packets. + * + * The port dropped java.util.Locale, and Locale.setDefault does not exist on iOS, so these three + * cases live here now: on the JVM, which is what the Android app runs on. The shared formatter + * never consults a locale, so the same literals hold on iOS - AprsPacketLocaleTest in commonTest + * pins them there. + */ +class AprsPacketDefaultLocaleTest { + + private val original: Locale = Locale.getDefault() + + private val asciiPacket = Regex("^[\\x20-\\x7E]*$") + + @AfterTest + fun restoreLocale() { + Locale.setDefault(original) + } + + @Test + fun `position stays ascii under an arabic locale`() { + Locale.setDefault(Locale.forLanguageTag("ar-EG")) + + val encoded = AprsPosition(39.9042, 116.4074, '/', '>').toUncompressedString() + + assertTrue(asciiPacket.matches(encoded), "not ASCII: $encoded") + assertEquals("3954.25N/11624.44E>", encoded) + } + + @Test + fun `position stays ascii under a bengali locale`() { + Locale.setDefault(Locale.forLanguageTag("bn-BD")) + + val encoded = AprsPosition(-33.8688, 151.2093, '/', '>').toUncompressedString() + + assertTrue(asciiPacket.matches(encoded), "not ASCII: $encoded") + assertEquals("3352.13S/15112.56E>", encoded) + } + + @Test + fun `altitude and course speed stay ascii under a persian locale`() { + Locale.setDefault(Locale.forLanguageTag("fa-IR")) + + val altitude = AprsPacket.formatAltitude(100.0) + val courseSpeed = AprsPacket.formatCourseSpeed(10.0, 90f) + val filter = AprsPacket.formatRangeFilter(39.9042, 116.4074, 100) + + assertTrue(asciiPacket.matches(altitude), "not ASCII: $altitude") + assertTrue(asciiPacket.matches(courseSpeed), "not ASCII: $courseSpeed") + assertTrue(asciiPacket.matches(filter), "not ASCII: $filter") + assertEquals("/A=000328", altitude) + assertEquals("/090/019", courseSpeed) + assertEquals("r/39.904/116.407/100", filter) + } +} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/ForegroundServiceTypeTest.kt b/core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/ForegroundServiceTypeTest.kt similarity index 97% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/ForegroundServiceTypeTest.kt rename to core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/ForegroundServiceTypeTest.kt index 95d1c1b0..65659d19 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/ForegroundServiceTypeTest.kt +++ b/core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/ForegroundServiceTypeTest.kt @@ -20,6 +20,9 @@ import org.junit.Test * So this reads both files as text from core:domain, which does have test infrastructure. Crude, * and it says nothing about whether the service works - but it fails the moment those two files * disagree, which is exactly the failure that shipped. + * + * JVM-only: it reads the repository's files from disk, and Kotlin/Native has no file system or + * working directory to resolve them from, so this lives in src/jvmTest and never compiles for iOS. */ class ForegroundServiceTypeTest { diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepGoldenVectorTest.kt b/core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDeepGoldenVectorTest.kt similarity index 89% rename from core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepGoldenVectorTest.kt rename to core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDeepGoldenVectorTest.kt index a687c6aa..960e02fc 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/cw/CwDeepGoldenVectorTest.kt +++ b/core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/cw/CwDeepGoldenVectorTest.kt @@ -17,12 +17,12 @@ */ package com.rtbishop.look4sat.core.domain.cw -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test import kotlin.math.PI import kotlin.math.abs import kotlin.math.sin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue /** * Pins the Kotlin front-end to the upstream Python reference implementation. @@ -91,8 +91,8 @@ class CwDeepGoldenVectorTest { ) val actual = CwDeepSpectrogram.compute(audio) - assertEquals("frame count", expected.size, actual.size) - assertEquals("bin count", expected[0].size, actual[0].size) + assertEquals(expected.size, actual.size, "frame count") + assertEquals(expected[0].size, actual[0].size, "bin count") var worstDelta = 0f var worstAt = "" @@ -106,25 +106,25 @@ class CwDeepGoldenVectorTest { } } assertTrue( - "front-end diverges from the Python reference — worst delta $worstDelta at $worstAt", - worstDelta <= TOLERANCE + worstDelta <= TOLERANCE, + "front-end diverges from the Python reference — worst delta $worstDelta at $worstAt" ) } @Test fun resampledLengthMatchesReference() { val audio = generateTestAudio() - assertEquals("source audio length", 9120, audio.size) + assertEquals(9120, audio.size, "source audio length") val resampled = CwDeepSpectrogram.resampleLinear( audio, SRC_RATE, CwDeepSpectrogram.SAMPLE_RATE ) - assertEquals("resampled length", 3648, resampled.size) + assertEquals(3648, resampled.size, "resampled length") } @Test fun goldenVectorHasExpectedShape() { val golden = readGoldenSpectrogram() - assertEquals("frames", 77, golden.size) - assertEquals("bins", CwDeepSpectrogram.FREQUENCY_BINS, golden[0].size) + assertEquals(77, golden.size, "frames") + assertEquals(CwDeepSpectrogram.FREQUENCY_BINS, golden[0].size, "bins") } } diff --git a/core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/utility/CommonFormatOracleTest.kt b/core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/utility/CommonFormatOracleTest.kt new file mode 100644 index 00000000..f95ae869 --- /dev/null +++ b/core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/utility/CommonFormatOracleTest.kt @@ -0,0 +1,138 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.domain.utility + +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test +import java.util.Locale +import kotlin.random.Random + +/** + * The shared formatter replaced java.lang.String.format, which Android used to call directly. + * The strings it builds are wire formats - APRS packets, Wavelog upload payloads - so it has to + * produce byte-identical output. java.lang.String.format is the reference and is only available + * on the JVM, which is why this test lives in jvmTest and runs with `:core:domain:jvmTest`. + */ +class CommonFormatOracleTest { + + private fun check(pattern: String, vararg args: Any?) { + val expected = String.format(Locale.ROOT, pattern, *args) + assertEquals("pattern=$pattern args=${args.toList()}", expected, formatString(pattern, *args)) + } + + @Test + fun `integers match java`() { + listOf(0, 5, 42, -7, 999, Int.MIN_VALUE, Int.MAX_VALUE).forEach { check("%d", it) } + listOf(5, -5, 0).forEach { check("%02d", it) } + listOf(5, 42, 999).forEach { check("%03d", it) } + listOf(5, 123456, -123456).forEach { check("%06d", it) } + listOf(5L, 123456789L).forEach { check("%010d", it) } + } + + @Test + fun `hexadecimal matches java`() { + listOf(0, 15, 255, 4095).forEach { check("%02X", it) } + } + + @Test + fun `decimals match java`() { + val values = listOf( + 0.0, 1.0, -1.0, 0.5, -0.5, 51.6447, 309.4881, 145.9, 145.900005, 436.795, 12345.6789, + 1.2345, 1.2344, 2.675, 2.6749, 8.835, 0.125, 0.375, 0.0005, -0.0005, 1e-7, -0.0 + ) + values.forEach { check("%.3f", it) } + values.forEach { check("%.6f", it) } + values.forEach { check("%.1f", it) } + values.forEach { check("%.4f", it) } + } + + @Test + fun `composed patterns used by the app match java`() { + // every literal pattern the shared code actually hands to formatString + check("%02d. ", 5) + check("%02d.%02d", 3, 4) + check("%02d.%d ", 5, 3) + check("%02d%02d%02d", 1, 2, 3) + check("%02d:%02d:%02d", 1, 2, 3) + check("%03d%s%c", 45, "X", 'N') + check("%04d%02d%02d", 2026, 9, 27) + check("%04d-%02d-%02d", 2026, 9, 27) + check("%02d%s%c", 12, "20", 'N') + check("%d . ", 3) + check("r/%.3f/%.3f/%d", 51.6447, 309.4881, 99999) + check("/A=%06d", 1234) + check("/%03d/%03d", 5, 7) + check("%.6fM", 145.9) + check("%.6f", 145.900005) + check("%s-%d-%.3f", "a", 1, 2.5) + check("%f", 1.5) + check("%f", -0.0000001) + check("%d%%", 50) + check("%02X %s", 255, "ok") + } + + /** + * The hardcoded values above only exercise the digits they happen to have. Rounding the + * shortest decimal representation only differs from scaling the binary value where a digit + * lands exactly on the boundary, so this samples for those: `"%.3f"` of 0.5005 is "0.501". + */ + @Test + fun `decimals match java over sampled values`() { + val random = Random(20260927) + val values = mutableListOf( + 0.5005, 1.005, 2.675, 1.245e-4, 145.675, 43123.4565, 0.50049, 8.835, 0.0005, -0.0005, + 4.9e-324, -0.0, 9_999_999.9999995, 1.0e7, 123456.789012345, 51.6447, 309.4881 + ) + repeat(20_000) { values += random.nextDouble() * 1_000_000.0 } + repeat(20_000) { values += random.nextDouble() } + val mismatches = mutableListOf() + for (value in values) { + for (pattern in listOf("%.1f", "%.2f", "%.3f", "%.4f", "%.6f", "%f")) { + val expected = String.format(Locale.ROOT, pattern, value) + val actual = formatString(pattern, value) + if (expected != actual) mismatches += "$pattern of $value: java=$expected shared=$actual" + } + } + assertEquals("first mismatches: ${mismatches.take(10)}", emptyList(), mismatches) + } + + @Test(expected = IllegalArgumentException::class) + fun `unsupported conversion fails loudly`() { + formatString("%q", 1) + } + + /** + * `%0.3f` is illegal for java.lang.String.format - the `0` flag needs a width - so the shared + * formatter has to reject it too rather than format it one way on Android and another on iOS. + */ + @Test + fun `zero flag without width is rejected, like java`() { + assertRejected { String.format(Locale.ROOT, "%0.3f", 2.5) } + assertRejected { formatString("%0.3f", 2.5) } + } + + private fun assertRejected(block: () -> Unit) { + try { + block() + fail("expected IllegalArgumentException") + } catch (expected: IllegalArgumentException) { + // both implementations agree + } + } +} diff --git a/core/domain/src/test/resources/cw/golden_spec.txt b/core/domain/src/jvmTest/resources/cw/golden_spec.txt similarity index 100% rename from core/domain/src/test/resources/cw/golden_spec.txt rename to core/domain/src/jvmTest/resources/cw/golden_spec.txt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt deleted file mode 100644 index 8caa6862..00000000 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt +++ /dev/null @@ -1,119 +0,0 @@ -/* - * 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 org.json.JSONArray -import org.json.JSONObject - -/** 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 修复: 成功后保留标记, 表格打勾) -) - -class WavelogQueue(private val store: IWavelogQueueStore) { - - private val key = "wavelog_queue" - - fun all(): List { - val raw = store.load() - return try { - val arr = JSONArray(raw) - (0 until arr.length()).map { i -> - val o = arr.getJSONObject(i) - WavelogQso( - id = o.getString("id"), - timeUtcMs = o.getLong("timeUtcMs"), - call = o.optString("call"), - mode = o.optString("mode"), - freqTxHz = o.optLong("freqTxHz"), - freqRxHz = o.optLong("freqRxHz"), - satName = o.optString("satName"), - catnum = o.optInt("catnum", 0), - sessionId = o.optString("sessionId"), - gridsquare = o.optString("gridsquare"), - uploaded = o.optBoolean("uploaded", false) - ) - } - } catch (_: Exception) { emptyList() } - } - - @Synchronized - fun add(qso: WavelogQso) { - val list = all().toMutableList() - list.add(0, qso) // 最新在前 - if (list.size > 500) list.removeAt(list.size - 1) - save(list) - } - - @Synchronized - fun remove(id: String) { - save(all().filter { it.id != id }) - } - - @Synchronized - fun removeAll(ids: Set) { - save(all().filter { it.id !in ids }) - } - - /** Mark as uploaded (kept in the queue; checkmark in the table) */ - @Synchronized - fun markUploaded(id: String) { - 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) */ - @Synchronized - fun updateGridsquare(id: String, grid: String) { - save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it }) - } - - /** Remove all uploaded entries (optional; keeps the queue lean) */ - @Synchronized - fun removeUploaded() { - save(all().filter { !it.uploaded }) - } - - private fun save(list: List) { - val arr = JSONArray() - list.forEach { q -> - arr.put(JSONObject().apply { - 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()) - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 10e950be..1358d006 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -77,6 +77,7 @@ kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } #noinspection UnusedVersionCatalogEntry kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } #noinspection UnusedVersionCatalogEntry other-onnxruntime = { module = "com.microsoft.onnxruntime:onnxruntime-android", version.ref = "other-onnxruntime" } @@ -97,6 +98,7 @@ android-library = { id = "com.android.library", version.ref = "android-gradle-pl compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } # plugins defined by this project convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" }