From 1a3c94f1e079957b4c580f757e080dee254ca758 Mon Sep 17 00:00:00 2001 From: MCKero Date: Sun, 27 Sep 2026 08:55:49 +0100 Subject: [PATCH] refactor(domain): make core:domain multiplatform so iOS can reuse it The orbital maths, the satellite models and the repository contracts sat in a Kotlin/JVM module, so an iOS target could not share a single line of them: java.lang.String.format, InputStream, System.currentTimeMillis, java.util.Locale and org.json are all JVM-only, and the tests that covered them used JUnit4. core:domain now declares jvm, iosArm64 and iosSimulatorArm64 targets, its sources moved to commonMain/commonTest, and the JVM-only pieces were replaced with multiplatform equivalents: java.lang.String.format by a shared printf implementation, System.currentTimeMillis by kotlin.time.Clock, InputStream by ByteArray, org.json by kotlinx-serialization, Locale by nothing at all. Tests that read classpath resources (javaClass.classLoader) moved to jvmTest, because that is JVM-only behaviour rather than a JVM-only API. Auditing the migration against the old module turned up four things that were wrong rather than merely ported: - java.lang.String.format rounds the shortest decimal representation of a double half-up, not the binary value: "%.3f" of 0.5005 is "0.501", because the stored double is 0.50049999999999994493. The shared implementation scaled in binary first and printed "0.500", which would have changed APRS position packets and the Wavelog frequency fields against the released Android app. It now takes the digits from the decimal representation and rounds them with integer arithmetic, and jvmTest compares it against String.format(Locale.ROOT, ...) over 40 000 sampled doubles plus the boundary cases, while commonTest pins literals so the iOS run checks the same digits. - The queue mutators lost the kotlin.jvm.Synchronized monitor each of them had. It is not a JVM-only annotation - it is an optional expectation, so it still compiles in common code - but the stdlib deprecated it for common use in 1.8 and made it an error in 2.1. The monitor is a platform actual now: the JVM keeps the real monitor, since Compose and the upload coroutine both reach the queue there, and iOS carries a documented placeholder until the iOS side has a second thread to protect against. - 107 assertions in DataParserTest and QthConverterTest were bare kotlin.assert calls, which a build without -ea skips silently: they are assertTrue now, so the iOS run cannot pass vacuously. The three Locale.setDefault cases (ar-EG, bn-BD, fa-IR) that used to guard APRS output against Eastern Arabic digits moved to jvmTest instead of being deleted with the Locale dependency - APRS-IS is an ASCII protocol, and Locale.setDefault does not exist on iOS. - @Volatile on the LoTW name cache would not have compiled for iOS either: kotlin.jvm's variant is an error in common code since 2.1. kotlin.concurrent.Volatile is the multiplatform annotation, and it is the stronger form: it takes effect on Kotlin/Native rather than being ignored. A second audit pass over the files the first one could not reach - the HTTP client, the parsers, the queue and the injection - found three more: - OkHttpHttpClient built its Request outside the try, so a URL OkHttp refuses to parse left postQso/testToken/getStation as an exception, and neither caller catches one. The client it replaced reported HTTP -1 and let the caller treat it as a failure; building the request inside the try restores that, and a transport failure reports -1 again rather than 0. - WavelogQueue's readers were stricter than the org.json ones they replaced. A timestamp stored as 1234.0 (or "1234.0") read back as 0L instead of 1234 - a QSO uploaded as 1970 - and a field holding an object or array threw the whole list away instead of falling back. The readers coerce decimals, keep the old defaults and no longer throw, matching optLong, optInt, optString and optBoolean. - The ADIF dates went through the JVM default locale before, so a device set to Arabic wrote Eastern Arabic digits into the QSO date. The shared formatter only ever produces ASCII, which the locale cases in AprsPacketDefaultLocaleTest pin down. Verified locally with ./gradlew jvmTest (343 tests, 0 failures) and the multiplatform gate in check-multiplatform.sh, which now also refuses JVM-only stdlib APIs that resolve in common code but fail to compile for iOS: @Synchronized, kotlin.jvm.Volatile, synchronized(), toUpperCase/toLowerCase/capitalize, BigDecimal. The iOS targets themselves need the macOS runner in .github/workflows/ios-kmp.yml. --- .github/workflows/ios-kmp.yml | 58 +++++ AGENTS.md | 28 ++- .../look4sat/convention/CoreDomainPlugin.kt | 42 +++- .../core/data/injection/MainContainer.kt | 11 + .../core/data/repository/DatabaseRepo.kt | 49 ++-- .../core/data/source/OkHttpHttpClient.kt | 89 +++++++ .../look4sat/core/data/source/RemoteSource.kt | 18 +- .../core/data/repository/AmSatAuditTest.kt | 5 +- .../data/repository/AmSatSlotBuildTest.kt | 5 +- .../core/data/repository/DatabaseRepoTest.kt | 29 ++- core/domain/build.gradle.kts | 5 - .../look4sat/core/domain/aprs/AprsBeacon.kt | 0 .../look4sat/core/domain/aprs/AprsLogin.kt | 0 .../look4sat/core/domain/aprs/AprsPacket.kt | 20 +- .../look4sat/core/domain/aprs/AprsPasscode.kt | 0 .../look4sat/core/domain/aprs/AprsSymbols.kt | 0 .../look4sat/core/domain/cw/CwAntiAlias.kt | 0 .../look4sat/core/domain/cw/CwCtcDecoder.kt | 0 .../look4sat/core/domain/cw/CwDeepBuffer.kt | 0 .../core/domain/cw/CwDeepSpectrogram.kt | 0 .../core/domain/cw/CwDetectionPool.kt | 0 .../look4sat/core/domain/cw/CwShiftDecider.kt | 0 .../look4sat/core/domain/cw/CwToneShifter.kt | 0 .../look4sat/core/domain/cw/ICwDecoder.kt | 0 .../look4sat/core/domain/model/Constants.kt | 0 .../look4sat/core/domain/model/SatItem.kt | 0 .../look4sat/core/domain/model/SatRadio.kt | 0 .../look4sat/core/domain/model/SatStatus.kt | 0 .../look4sat/core/domain/model/Settings.kt | 0 .../core/domain/navigation/MenuLayout.kt | 0 .../core/domain/predict/CelestialComputer.kt | 0 .../look4sat/core/domain/predict/Constants.kt | 0 .../core/domain/predict/DeepSpaceObject.kt | 0 .../look4sat/core/domain/predict/GeoPos.kt | 0 .../core/domain/predict/NearEarthObject.kt | 0 .../core/domain/predict/OrbitalData.kt | 0 .../core/domain/predict/OrbitalMath.kt | 0 .../core/domain/predict/OrbitalObject.kt | 0 .../core/domain/predict/OrbitalPass.kt | 0 .../core/domain/predict/OrbitalPos.kt | 0 .../core/domain/qrz/IQrzGridLookup.kt | 0 .../look4sat/core/domain/qrz/QrzGrid.kt | 3 +- .../domain/repository/IAmSatRepository.kt | 0 .../core/domain/repository/IDatabaseRepo.kt | 0 .../core/domain/repository/IMainContainer.kt | 0 .../domain/repository/IRadioController.kt | 0 .../repository/IRadioTrackingService.kt | 0 .../core/domain/repository/IReporterRepo.kt | 0 .../core/domain/repository/ISatelliteRepo.kt | 0 .../core/domain/repository/ISelectionRepo.kt | 0 .../core/domain/repository/ISensorsRepo.kt | 8 +- .../core/domain/repository/ISettingsRepo.kt | 0 .../core/domain/source/IHttpClient.kt | 28 +++ .../core/domain/source/ILocalSource.kt | 0 .../core/domain/source/IRemoteSource.kt | 6 +- .../look4sat/core/domain/source/Sources.kt | 0 .../look4sat/core/domain/sstv/SstvDecoder.kt | 0 .../look4sat/core/domain/sstv/SstvDsp.kt | 0 .../look4sat/core/domain/sstv/SstvModes.kt | 0 .../core/domain/usecase/IAddToCalendar.kt | 0 .../core/domain/usecase/IAudioCapture.kt | 0 .../core/domain/usecase/ISaveImage.kt | 0 .../core/domain/usecase/IShowToast.kt | 0 .../core/domain/utility/CommonFormat.kt | 186 +++++++++++++++ .../core/domain/utility/DataParser.kt | 15 +- .../utility/DopplerFrequencyCalculator.kt | 5 +- .../core/domain/utility/Extensions.kt | 11 +- .../core/domain/utility/GeoConverter.kt | 0 .../core/domain/utility/QthConverter.kt | 0 .../core/domain/utility/SynchronizedOn.kt | 29 +++ .../core/domain/utility/TransponderMapper.kt | 0 .../core/domain/wavelog/CallsignEntry.kt | 0 .../look4sat/core/domain/wavelog/GridEntry.kt | 0 .../domain/wavelog/ILotwSatellitesRepo.kt | 0 .../core/domain/wavelog/LotwSatelliteIds.kt | 0 .../core/domain/wavelog/LotwSatellites.kt | 4 + .../look4sat/core/domain/wavelog/PassClock.kt | 0 .../core/domain/wavelog/WaveLogApi.kt | 224 ++++++++++-------- .../core/domain/wavelog/WavelogQueue.kt | 162 +++++++++++++ .../core/domain/wavelog/WavelogResponse.kt | 4 +- .../core/domain/wavelog/WavelogUploader.kt | 8 +- .../look4sat/core/domain/DataParserTest.kt | 222 ++++++++--------- .../domain/DopplerFrequencyCalculatorTest.kt | 14 +- .../look4sat/core/domain/QthConverterTest.kt | 99 ++++---- .../core/domain/TransponderMapperTest.kt | 6 +- .../core/domain/aprs/AprsBeaconTest.kt | 43 ++-- .../core/domain/aprs/AprsLoginTest.kt | 18 +- .../core/domain/aprs/AprsPacketLocaleTest.kt | 57 ++--- .../core/domain/aprs/AprsPasscodeTest.kt | 12 +- .../core/domain/aprs/AprsSymbolsTest.kt | 28 +-- .../core/domain/cw/CwAntiAliasTest.kt | 24 +- .../core/domain/cw/CwCtcDecoderTest.kt | 6 +- .../core/domain/cw/CwDeepBufferTest.kt | 38 +-- .../core/domain/cw/CwDeepSpectrogramTest.kt | 27 ++- .../core/domain/cw/CwDetectionPoolTest.kt | 60 ++--- .../core/domain/cw/CwShiftDeciderTest.kt | 116 +++++---- .../core/domain/cw/CwToneShiftSignalTest.kt | 66 +++--- .../domain/cw/CwToneShifterStreamingTest.kt | 49 ++-- .../core/domain/cw/CwToneShifterTest.kt | 71 +++--- .../core/domain/navigation/MenuLayoutTest.kt | 26 +- .../domain/predict/CelestialComputerTest.kt | 35 +-- .../core/domain/qrz/QrzGridParserTest.kt | 6 +- .../core/domain/utility/ClipLonTest.kt | 28 +-- .../utility/CommonFormatRoundingTest.kt | 61 +++++ .../core/domain/wavelog/CallsignEntryTest.kt | 18 +- .../core/domain/wavelog/GridEntryTest.kt | 36 +-- .../domain/wavelog/LotwSatelliteIdsTest.kt | 14 +- .../core/domain/wavelog/PassClockTest.kt | 12 +- .../domain/wavelog/SatNameResolutionTest.kt | 8 +- .../domain/wavelog/WaveLogApiPayloadTest.kt | 22 +- .../domain/wavelog/WavelogResponseTest.kt | 30 +-- .../core/domain/utility/SynchronizedOn.ios.kt | 25 ++ .../core/domain/utility/SynchronizedOn.jvm.kt | 24 ++ .../aprs/AprsPacketDefaultLocaleTest.kt | 82 +++++++ .../domain/aprs/ForegroundServiceTypeTest.kt | 3 + .../core/domain/cw/CwDeepGoldenVectorTest.kt | 22 +- .../domain/utility/CommonFormatOracleTest.kt | 138 +++++++++++ .../resources/cw/golden_spec.txt | 0 .../core/domain/wavelog/WavelogQueue.kt | 119 ---------- gradle/libs.versions.toml | 2 + 120 files changed, 1794 insertions(+), 925 deletions(-) create mode 100644 .github/workflows/ios-kmp.yml create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/source/OkHttpHttpClient.kt rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/aprs/AprsBeacon.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/aprs/AprsLogin.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/aprs/AprsPacket.kt (85%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/aprs/AprsPasscode.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/aprs/AprsSymbols.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwAntiAlias.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoder.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwDeepBuffer.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogram.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwDetectionPool.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwShiftDecider.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwToneShifter.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/cw/ICwDecoder.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/model/Constants.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/model/SatItem.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/model/SatRadio.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/model/SatStatus.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/model/Settings.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/navigation/MenuLayout.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/predict/CelestialComputer.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/predict/Constants.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/predict/DeepSpaceObject.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/predict/GeoPos.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/predict/NearEarthObject.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/predict/OrbitalData.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/predict/OrbitalMath.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/predict/OrbitalObject.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/predict/OrbitalPass.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/predict/OrbitalPos.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/qrz/IQrzGridLookup.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/qrz/QrzGrid.kt (96%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/repository/IDatabaseRepo.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/repository/IRadioTrackingService.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/repository/IReporterRepo.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/repository/ISelectionRepo.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/repository/ISensorsRepo.kt (75%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt (100%) create mode 100644 core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/source/IHttpClient.kt rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/source/ILocalSource.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt (90%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/source/Sources.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/usecase/IAddToCalendar.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/usecase/IAudioCapture.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/usecase/ISaveImage.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/usecase/IShowToast.kt (100%) create mode 100644 core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/CommonFormat.kt rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/utility/DataParser.kt (89%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/utility/DopplerFrequencyCalculator.kt (97%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/utility/Extensions.kt (89%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/utility/GeoConverter.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt (100%) create mode 100644 core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/utility/SynchronizedOn.kt rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/utility/TransponderMapper.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntry.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/GridEntry.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/ILotwSatellitesRepo.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIds.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/LotwSatellites.kt (90%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/PassClock.kt (100%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt (64%) create mode 100644 core/domain/src/commonMain/kotlin/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt (97%) rename core/domain/src/{main/java => commonMain/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt (93%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/DataParserTest.kt (65%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/DopplerFrequencyCalculatorTest.kt (97%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/QthConverterTest.kt (63%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/TransponderMapperTest.kt (97%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/aprs/AprsBeaconTest.kt (83%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/aprs/AprsLoginTest.kt (94%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/aprs/AprsPacketLocaleTest.kt (55%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/aprs/AprsPasscodeTest.kt (94%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/aprs/AprsSymbolsTest.kt (84%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwAntiAliasTest.kt (91%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwCtcDecoderTest.kt (95%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwDeepBufferTest.kt (77%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwDeepSpectrogramTest.kt (87%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwDetectionPoolTest.kt (75%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwShiftDeciderTest.kt (74%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwToneShiftSignalTest.kt (81%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwToneShifterStreamingTest.kt (90%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwToneShifterTest.kt (79%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/navigation/MenuLayoutTest.kt (86%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/predict/CelestialComputerTest.kt (71%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/qrz/QrzGridParserTest.kt (98%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/utility/ClipLonTest.kt (71%) create mode 100644 core/domain/src/commonTest/kotlin/com/rtbishop/look4sat/core/domain/utility/CommonFormatRoundingTest.kt rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntryTest.kt (94%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/GridEntryTest.kt (90%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/LotwSatelliteIdsTest.kt (92%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/PassClockTest.kt (94%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/SatNameResolutionTest.kt (97%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApiPayloadTest.kt (86%) rename core/domain/src/{test/java => commonTest/kotlin}/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt (92%) create mode 100644 core/domain/src/iosMain/kotlin/com/rtbishop/look4sat/core/domain/utility/SynchronizedOn.ios.kt create mode 100644 core/domain/src/jvmMain/kotlin/com/rtbishop/look4sat/core/domain/utility/SynchronizedOn.jvm.kt create mode 100644 core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/aprs/AprsPacketDefaultLocaleTest.kt rename core/domain/src/{test/java => jvmTest/kotlin}/com/rtbishop/look4sat/core/domain/aprs/ForegroundServiceTypeTest.kt (97%) rename core/domain/src/{test/java => jvmTest/kotlin}/com/rtbishop/look4sat/core/domain/cw/CwDeepGoldenVectorTest.kt (89%) create mode 100644 core/domain/src/jvmTest/kotlin/com/rtbishop/look4sat/core/domain/utility/CommonFormatOracleTest.kt rename core/domain/src/{test => jvmTest}/resources/cw/golden_spec.txt (100%) delete mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt 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" }