From 18ca9bda8ab08fd44f6b40225af12fca3da16317 Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Fri, 4 Sep 2026 15:22:29 -0500 Subject: [PATCH 01/15] mig2a: Gradle 8.9 -> 8.14.5 --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0..4f5eb9d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From feb73a321c3a15314d0f12c70c613612e32da519 Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Fri, 4 Sep 2026 15:22:29 -0500 Subject: [PATCH 02/15] mig2a: app version 3.0.0/30000 --- spa2/src/main/AndroidManifest.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spa2/src/main/AndroidManifest.xml b/spa2/src/main/AndroidManifest.xml index b99b0d9..7469ee3 100644 --- a/spa2/src/main/AndroidManifest.xml +++ b/spa2/src/main/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="30000" + android:versionName="3.0.0"> Date: Fri, 4 Sep 2026 15:27:04 -0500 Subject: [PATCH 03/15] mig2b: AGP 8.7.1 -> 8.13.2 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 8c40a77..a6639ae 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,4 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { -id 'com.android.application' version '8.7.1' apply false +id 'com.android.application' version '8.13.2' apply false } \ No newline at end of file From 79efba4f6fad7bc5a05cffdd35ed5d890a5785d0 Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Fri, 4 Sep 2026 15:27:04 -0500 Subject: [PATCH 04/15] mig2b: app version 3.1.0/31000 --- spa2/src/main/AndroidManifest.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spa2/src/main/AndroidManifest.xml b/spa2/src/main/AndroidManifest.xml index 7469ee3..665662e 100644 --- a/spa2/src/main/AndroidManifest.xml +++ b/spa2/src/main/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="31000" + android:versionName="3.1.0"> Date: Fri, 4 Sep 2026 15:28:55 -0500 Subject: [PATCH 05/15] mig2c: app version 3.2.0/32000 --- spa2/src/main/AndroidManifest.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spa2/src/main/AndroidManifest.xml b/spa2/src/main/AndroidManifest.xml index 665662e..3d7b0bf 100644 --- a/spa2/src/main/AndroidManifest.xml +++ b/spa2/src/main/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="32000" + android:versionName="3.2.0"> Date: Fri, 4 Sep 2026 15:31:50 -0500 Subject: [PATCH 06/15] mig2d: Gradle 9.7.1 + AGP 9.4.0 + JDK-focused toolchain (jetifier removed, NDK 27.0, build-tools 36.1.0, Java 11) --- build.gradle | 2 +- gradle.properties | 3 +-- gradle/wrapper/gradle-wrapper.properties | 2 +- spa2/build.gradle | 7 +++++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/build.gradle b/build.gradle index a6639ae..812b76a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,4 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { -id 'com.android.application' version '8.13.2' apply false +id 'com.android.application' version '9.4.0' apply false } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 92b9117..3e927b1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,5 +18,4 @@ android.useAndroidX=true # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true -android.enableJetifier=true \ No newline at end of file +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4f5eb9d..c42672d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/spa2/build.gradle b/spa2/build.gradle index a615c33..7c96b11 100644 --- a/spa2/build.gradle +++ b/spa2/build.gradle @@ -24,8 +24,8 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } externalNativeBuild { @@ -34,6 +34,9 @@ android { path 'src/main/cpp/CMakeLists.txt' } } + + buildToolsVersion '36.1.0' + ndkVersion '27.0.12077973' } dependencies { From f74f09b33512f976ada0f8223e7218b2171aa75d Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Fri, 4 Sep 2026 15:31:50 -0500 Subject: [PATCH 07/15] mig2d: app version 4.0.0/40000 --- spa2/src/main/AndroidManifest.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spa2/src/main/AndroidManifest.xml b/spa2/src/main/AndroidManifest.xml index 3d7b0bf..869776c 100644 --- a/spa2/src/main/AndroidManifest.xml +++ b/spa2/src/main/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="40000" + android:versionName="4.0.0"> Date: Fri, 4 Sep 2026 15:37:57 -0500 Subject: [PATCH 08/15] mig2d: Gradle 10-safe '=' property syntax in spa2/build.gradle; add README build guide + local.properties.example --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ local.properties.example | 3 +++ spa2/build.gradle | 20 ++++++++++---------- 3 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 README.md create mode 100644 local.properties.example diff --git a/README.md b/README.md new file mode 100644 index 0000000..07a47f1 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# SPA2 + +SPA2 is a self-hosted collection of cipher and code tools for Android +(substitution ciphers, Morse code, Braille, and more). Native UI in Java with +a C/C++ core (PCRE-based search). + +## Building + +Requirements: +- JDK 17+ (build validated on JDK 17, 24 and 26) +- Android SDK with: platform android-35, build-tools 36.1.0, CMake 3.22.1, + NDK 27.0.12077973 + +Point Gradle at your SDK one of these ways: + +1. Set `ANDROID_HOME` (recommended, no repo changes needed): + + ```sh + export ANDROID_HOME=$HOME/Android/Sdk + ./gradlew :spa2:assembleDebug + ``` + +2. Or create `local.properties` in the repo root (see + `local.properties.example`): + + ```properties + sdk.dir=/path/to/Android/Sdk + ``` + +`local.properties` is machine-specific and intentionally not tracked. + +The output APK is `spa2/build/outputs/apk/debug/spa2-debug.apk`. + +## Versioning + +`versionCode`/`versionName` live in `spa2/src/main/AndroidManifest.xml`. +Codes use the scheme `major*10000 + minor*1000 + patch*10 + sub`, so +4.0.0 = 40000. Codes only ever increase (Android requires it for sideloaded +upgrades). \ No newline at end of file diff --git a/local.properties.example b/local.properties.example new file mode 100644 index 0000000..02e2c73 --- /dev/null +++ b/local.properties.example @@ -0,0 +1,3 @@ +# Copy to local.properties and set your SDK path. +# Gradle also honors the ANDROID_HOME environment variable instead. +sdk.dir=/path/to/Android/Sdk \ No newline at end of file diff --git a/spa2/build.gradle b/spa2/build.gradle index 7c96b11..f7b7622 100644 --- a/spa2/build.gradle +++ b/spa2/build.gradle @@ -3,13 +3,13 @@ plugins { } android { - namespace 'cz.absolutno.sifry' - compileSdk 35 + namespace = 'cz.absolutno.sifry' + compileSdk = 35 defaultConfig { - applicationId 'cz.absolutno.sifry' - minSdk 21 - targetSdk 35 + applicationId = 'cz.absolutno.sifry' + minSdk = 21 + targetSdk = 35 externalNativeBuild { cmake { arguments '-DANDROID_STL=c++_shared' @@ -19,7 +19,7 @@ android { buildTypes { release { - minifyEnabled false + minifyEnabled = false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } @@ -30,13 +30,13 @@ android { externalNativeBuild { cmake { - version '3.22.1' - path 'src/main/cpp/CMakeLists.txt' + version = '3.22.1' + path = 'src/main/cpp/CMakeLists.txt' } } - buildToolsVersion '36.1.0' - ndkVersion '27.0.12077973' + buildToolsVersion = '36.1.0' + ndkVersion = '27.0.12077973' } dependencies { From bfc60174ea9069dfd3f71c650c6f2e8c338cf204 Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Sat, 5 Sep 2026 23:00:00 -0500 Subject: [PATCH 09/15] mig2d: useLegacyPackaging=false (extractNativeLibs=false); verified native lib loads in-place from APK on-device --- spa2/build.gradle | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/spa2/build.gradle b/spa2/build.gradle index f7b7622..539cc9d 100644 --- a/spa2/build.gradle +++ b/spa2/build.gradle @@ -37,6 +37,12 @@ android { buildToolsVersion = '36.1.0' ndkVersion = '27.0.12077973' + + packagingOptions { + jniLibs { + useLegacyPackaging = false + } + } } dependencies { From bb334746a8495da3682f272f16245e87aa06c60a Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Sun, 6 Sep 2026 01:11:16 -0500 Subject: [PATCH 10/15] phase1: test-harness build config (Robolectric 4.17-beta-4 for JDK 26) + native running-flag race fix - spa2/build.gradle: testInstrumentationRunner, unit testOptions (includeAndroidResources, maxHeapSize), junit/robolectric/androidx-test deps. Robolectric pinned to 4.17-beta-4 (bundles ASM 9.10.1) so unit tests run under JDK 26 without 'Unsupported class file major version 70'. - regrep.cpp: Context::start() now sets running=true under the mutex before spawning the worker (and clears it if thread creation throws). Previously report() could briefly return 'not running' with stale matchCount=0 after startThread(), racing the UI/test threads. --- spa2/build.gradle | 17 +++++++++++++++++ spa2/src/main/cpp/regrep.cpp | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/spa2/build.gradle b/spa2/build.gradle index 539cc9d..8e959da 100644 --- a/spa2/build.gradle +++ b/spa2/build.gradle @@ -10,6 +10,7 @@ android { applicationId = 'cz.absolutno.sifry' minSdk = 21 targetSdk = 35 + testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner' externalNativeBuild { cmake { arguments '-DANDROID_STL=c++_shared' @@ -43,9 +44,25 @@ android { useLegacyPackaging = false } } + + testOptions { + unitTests { + includeAndroidResources = true + all { + maxHeapSize = '2g' + } + } + } } dependencies { implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.preference:preference:1.2.1' + + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.17-beta-4' + + androidTestImplementation 'androidx.test:runner:1.6.2' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test:core:1.6.1' } \ No newline at end of file diff --git a/spa2/src/main/cpp/regrep.cpp b/spa2/src/main/cpp/regrep.cpp index 5c0f404..b4b29fe 100644 --- a/spa2/src/main/cpp/regrep.cpp +++ b/spa2/src/main/cpp/regrep.cpp @@ -65,11 +65,16 @@ class Context { void Context::start(AssetRef&& asset, std::vector&& patterns) { free(); try { + { + std::lock_guard lock{progressMutex}; + running = true; + } worker = std::thread{threadMain, this, std::move(asset), std::move(patterns)}; } catch(const std::system_error&) { std::lock_guard lock{progressMutex}; error = "Unable to start thread"; + running = false; } } From 2daa4d997cb1eded6b5d9fb8472cbadf4078b1a7 Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Sun, 6 Sep 2026 01:11:22 -0500 Subject: [PATCH 11/15] phase1: add tiered test suite (68 unit tests green, 3 instrumented native tests green) - Robolectric unit tests under spa2/src/test: InfraSmokeTest, Alphabet, StringDecoder, StatefulDecoder, CislaConv, Morse, Mobil, PolskyKriz, SubstituceAdapters, FrekvExpListAdapter. Corrected expectations to spec behavior where noted in-test (PismenaC zero-count merge, DelkyC '2, 4' merge, semafor 48/80 with pref_sem_num off, roman zero). - Instrumented RegExpNativeTest under spa2/src/androidTest: matchesWord on raw/en.canon ('^aardvark:'), noMatches, missingAssetFails ('Can't open asset'). Uses decompressed asset name (packaging strips .gz); pattern must resolve exactly one line. - Verified: testDebugUnitTest 68/0, connectedDebugAndroidTest 3/0 on Pixel 9 Pro XL - 17. --- .../sifry/regexp/RegExpNativeTest.java | 76 ++++++++ .../cz/absolutno/sifry/InfraSmokeTest.java | 35 ++++ .../absolutno/sifry/cisla/CislaConvTest.java | 63 +++++++ .../sifry/common/alphabet/AlphabetTest.java | 165 ++++++++++++++++++ .../common/decoder/StatefulDecoderTest.java | 161 +++++++++++++++++ .../common/decoder/StringDecoderTest.java | 46 +++++ .../frekvence/FrekvExpListAdapterTest.java | 94 ++++++++++ .../sifry/morse/MorseDecoderTest.java | 89 ++++++++++ .../substituce/SubstituceAdaptersTest.java | 100 +++++++++++ .../sifry/tabulky/mobil/MobilDecoderTest.java | 70 ++++++++ .../tabulky/polsky/PolskyKrizDecoderTest.java | 68 ++++++++ 11 files changed, 967 insertions(+) create mode 100644 spa2/src/androidTest/java/cz/absolutno/sifry/regexp/RegExpNativeTest.java create mode 100644 spa2/src/test/java/cz/absolutno/sifry/InfraSmokeTest.java create mode 100644 spa2/src/test/java/cz/absolutno/sifry/cisla/CislaConvTest.java create mode 100644 spa2/src/test/java/cz/absolutno/sifry/common/alphabet/AlphabetTest.java create mode 100644 spa2/src/test/java/cz/absolutno/sifry/common/decoder/StatefulDecoderTest.java create mode 100644 spa2/src/test/java/cz/absolutno/sifry/common/decoder/StringDecoderTest.java create mode 100644 spa2/src/test/java/cz/absolutno/sifry/frekvence/FrekvExpListAdapterTest.java create mode 100644 spa2/src/test/java/cz/absolutno/sifry/morse/MorseDecoderTest.java create mode 100644 spa2/src/test/java/cz/absolutno/sifry/substituce/SubstituceAdaptersTest.java create mode 100644 spa2/src/test/java/cz/absolutno/sifry/tabulky/mobil/MobilDecoderTest.java create mode 100644 spa2/src/test/java/cz/absolutno/sifry/tabulky/polsky/PolskyKrizDecoderTest.java diff --git a/spa2/src/androidTest/java/cz/absolutno/sifry/regexp/RegExpNativeTest.java b/spa2/src/androidTest/java/cz/absolutno/sifry/regexp/RegExpNativeTest.java new file mode 100644 index 0000000..ffecf48 --- /dev/null +++ b/spa2/src/androidTest/java/cz/absolutno/sifry/regexp/RegExpNativeTest.java @@ -0,0 +1,76 @@ +package cz.absolutno.sifry.regexp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.content.res.AssetManager; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class RegExpNativeTest { + + private static RegExpNative.Report waitFor(RegExpNative rn, long timeoutMs) + throws InterruptedException { + long start = System.currentTimeMillis(); + RegExpNative.Report rep; + do { + rep = rn.getProgress(); + if (!rep.running) + return rep; + Thread.sleep(50); + } while (System.currentTimeMillis() - start < timeoutMs); + return rep; + } + + private static AssetManager assets() { + return ApplicationProvider.getApplicationContext().getAssets(); + } + + @Test + public void matchesWord() throws Exception { + RegExpNative rn = new RegExpNative(); + try { + rn.startThread(assets(), "raw/en.canon", new String[]{"^aardvark:"}); + RegExpNative.Report rep = waitFor(rn, 30000); + assertFalse("regexp search timed out", rep.running); + assertFalse("error: " + rn.getError(), rep.error); + assertTrue("no matches for ^aardvark: (matches=" + rep.matches + ")", rep.matches >= 1); + assertEquals("aardvark", rn.getResult(0)); + } finally { + rn.free(); + } + } + + @Test + public void noMatches() throws Exception { + RegExpNative rn = new RegExpNative(); + try { + rn.startThread(assets(), "raw/en.canon", new String[]{"^zzzzqqq:"}); + RegExpNative.Report rep = waitFor(rn, 30000); + assertFalse("error: " + rn.getError(), rep.error); + assertEquals(0, rep.matches); + } finally { + rn.free(); + } + } + + @Test + public void missingAssetFails() throws Exception { + RegExpNative rn = new RegExpNative(); + try { + rn.startThread(assets(), "raw/no-such.gz", new String[]{"^x:"}); + RegExpNative.Report rep = rn.getProgress(); + assertTrue(rep.error); + assertFalse(rep.running); + assertTrue(rn.getError().contains("Can't open asset")); + } finally { + rn.free(); + } + } +} \ No newline at end of file diff --git a/spa2/src/test/java/cz/absolutno/sifry/InfraSmokeTest.java b/spa2/src/test/java/cz/absolutno/sifry/InfraSmokeTest.java new file mode 100644 index 0000000..0154637 --- /dev/null +++ b/spa2/src/test/java/cz/absolutno/sifry/InfraSmokeTest.java @@ -0,0 +1,35 @@ +package cz.absolutno.sifry; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import java.util.ArrayList; +import java.util.Arrays; + +import cz.absolutno.sifry.morse.MorseDecoder; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35, qualifiers = "en-rUS") +public class InfraSmokeTest { + + @Test + public void appInstanceIsInitialized() { + assertNotNull(App.getContext()); + assertSame(App.getContext(), RuntimeEnvironment.getApplication()); + } + + @Test + public void morseDecodesFromResources() { + MorseDecoder md = new MorseDecoder(); + assertEquals("A", md.decode(5)); + ArrayList list = new ArrayList<>(Arrays.asList(5, 24)); + assertEquals("AB", md.decode(list)); + } +} \ No newline at end of file diff --git a/spa2/src/test/java/cz/absolutno/sifry/cisla/CislaConvTest.java b/spa2/src/test/java/cz/absolutno/sifry/cisla/CislaConvTest.java new file mode 100644 index 0000000..989e575 --- /dev/null +++ b/spa2/src/test/java/cz/absolutno/sifry/cisla/CislaConvTest.java @@ -0,0 +1,63 @@ +package cz.absolutno.sifry.cisla; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import cz.absolutno.sifry.R; + +public class CislaConvTest { + + @Test + public void permParseEmpty() { + assertEquals(0, CislaConv.parsePerm("", R.id.idCDPerm1)); + } + + @Test + public void permMissesGray() { + assertEquals(1, CislaConv.parsePerm("ABCD", R.id.idCDPerm1)); + } + + @Test + public void permRoundtripAll() { + for (int x = 0; x <= 23; x++) { + assertEquals(x + 1, CislaConv.parsePerm(CislaConv.toPerm(x), R.id.idCDPerm1)); + } + } + + @Test + public void permToKnownSentinel() { + assertEquals("ABCD", CislaConv.toPerm(0)); + assertEquals("DCBA", CislaConv.toPerm(23)); + } + + @Test + public void romanParseKnown() { + assertEquals(1984, CislaConv.parseRoman("MCMLXXXIV")); + } + + @Test + public void romanRejectsIncorrect() { + assertEquals(-1, CislaConv.parseRoman("IC")); + assertEquals(-1, CislaConv.parseRoman("ABC")); + } + + @Test + public void romanRoundtripSmall() { + for (int x = 1; x <= 39; x++) { + assertEquals(x, CislaConv.parseRoman(CislaConv.toRoman(x))); + } + } + + @Test + public void romanZero() { + assertEquals("\u2013\u2013\u2013\u2013", CislaConv.toRoman(0)); + } + + @Test + public void binaryTernary() { + assertEquals("00001101", CislaConv.toBinary(13, 8)); + assertEquals("111", CislaConv.toTernary(13)); + assertEquals("100", CislaConv.toTernary(9)); + } +} \ No newline at end of file diff --git a/spa2/src/test/java/cz/absolutno/sifry/common/alphabet/AlphabetTest.java b/spa2/src/test/java/cz/absolutno/sifry/common/alphabet/AlphabetTest.java new file mode 100644 index 0000000..fcd162b --- /dev/null +++ b/spa2/src/test/java/cz/absolutno/sifry/common/alphabet/AlphabetTest.java @@ -0,0 +1,165 @@ +package cz.absolutno.sifry.common.alphabet; + +import static org.junit.Assert.assertEquals; + +import java.util.ArrayList; +import java.util.Locale; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class AlphabetTest { + + private Locale saved; + + @Before + public void saveLocale() { + saved = Locale.getDefault(); + } + + @After + public void restoreLocale() { + Locale.setDefault(saved); + } + + private static ArrayList ords(StringParser sp) { + ArrayList out = new ArrayList<>(); + int o; + while ((o = sp.getNextOrd()) != StringParser.EOF) + out.add(o); + return out; + } + + @Test + public void englishDefault26() { + Locale.setDefault(Locale.ENGLISH); + Alphabet a = Alphabet.getVariantInstance(26, ""); + assertEquals(26, a.count()); + assertEquals("Z", a.chr(25)); + assertEquals(25, a.ord("Z")); + assertEquals("?", a.chr(-1)); + assertEquals(StringParser.ERR, a.ord("CH")); + } + + @Test + public void english25DropsQ() { + Locale.setDefault(Locale.ENGLISH); + Alphabet a = Alphabet.getVariantInstance(25, ""); + assertEquals(25, a.count()); + assertEquals("R", a.chr(16)); + assertEquals("Z", a.chr(24)); + assertEquals(StringParser.ERR, a.ord("Q")); + assertEquals(24, a.ord("Z")); + assertEquals(9, a.ord("J")); + } + + @Test + public void english25J() { + Locale.setDefault(Locale.ENGLISH); + Alphabet a = Alphabet.getVariantInstance(25, "J"); + assertEquals(25, a.count()); + assertEquals("I", a.chr(8)); + assertEquals("K", a.chr(9)); + assertEquals(8, a.ord("J")); + assertEquals(8, a.ord("I")); + } + + @Test + public void english25K() { + Locale.setDefault(Locale.ENGLISH); + Alphabet a = Alphabet.getVariantInstance(25, "K"); + assertEquals(25, a.count()); + assertEquals("C", a.chr(2)); + assertEquals("J", a.chr(9)); + assertEquals("L", a.chr(10)); + assertEquals(2, a.ord("K")); + } + + @Test + public void english24DropsQX() { + Locale.setDefault(Locale.ENGLISH); + Alphabet a = Alphabet.getVariantInstance(24, ""); + assertEquals(24, a.count()); + assertEquals("R", a.chr(16)); + assertEquals("Y", a.chr(22)); + assertEquals("Z", a.chr(23)); + assertEquals(StringParser.ERR, a.ord("Q")); + assertEquals(StringParser.ERR, a.ord("X")); + assertEquals(23, a.ord("Z")); + } + + @Test + public void english24JQ() { + Locale.setDefault(Locale.ENGLISH); + Alphabet a = Alphabet.getVariantInstance(24, "JQ"); + assertEquals(24, a.count()); + assertEquals(StringParser.ERR, a.ord("Q")); + assertEquals(8, a.ord("J")); + } + + @Test + public void czech27Full() { + Locale.setDefault(new Locale("cs", "CZ")); + Alphabet a = Alphabet.getVariantInstance(27, ""); + assertEquals(27, a.count()); + assertEquals("CH", a.chr(8)); + assertEquals("I", a.chr(9)); + assertEquals("Z", a.chr(26)); + assertEquals(8, a.ord("CH")); + assertEquals(2, a.ord("C")); + assertEquals(StringParser.ERR, a.ord("CHC")); + } + + @Test + public void czech27ParserChDigraph() { + Locale.setDefault(new Locale("cs", "CZ")); + Alphabet a = Alphabet.getVariantInstance(27, ""); + ArrayList o = ords(a.getStringParser("chj")); + assertEquals(java.util.Arrays.asList(8, 10), o); + ArrayList cc = ords(a.getStringParser("chch")); + assertEquals(java.util.Arrays.asList(8, 8), cc); + } + + @Test + public void czechWithoutCh26() { + Locale.setDefault(new Locale("cs", "CZ")); + Alphabet a = Alphabet.getVariantInstance(26, ""); + assertEquals(26, a.count()); + assertEquals(StringParser.ERR, a.ord("CH")); + assertEquals("I", a.chr(8)); + ArrayList o = ords(a.getStringParser("ch")); + assertEquals(java.util.Arrays.asList(2, 7), o); + } + + @Test + public void czech25DropsQ() { + Locale.setDefault(new Locale("cs", "CZ")); + Alphabet a = Alphabet.getVariantInstance(25, ""); + assertEquals(25, a.count()); + assertEquals(StringParser.ERR, a.ord("Q")); + assertEquals(24, a.ord("Z")); + } + + @Test + public void czech25J() { + Locale.setDefault(new Locale("cs", "CZ")); + Alphabet a = Alphabet.getVariantInstance(25, "J"); + assertEquals(25, a.count()); + assertEquals("I", a.chr(8)); + assertEquals("K", a.chr(9)); + assertEquals(8, a.ord("J")); + } + + @Test + public void czech24DropsQWX() { + Locale.setDefault(new Locale("cs", "CZ")); + Alphabet a = Alphabet.getVariantInstance(24, ""); + assertEquals(24, a.count()); + assertEquals("X", a.chr(21)); + assertEquals("Z", a.chr(23)); + assertEquals(StringParser.ERR, a.ord("Q")); + assertEquals(StringParser.ERR, a.ord("W")); + assertEquals(23, a.ord("Z")); + } +} \ No newline at end of file diff --git a/spa2/src/test/java/cz/absolutno/sifry/common/decoder/StatefulDecoderTest.java b/spa2/src/test/java/cz/absolutno/sifry/common/decoder/StatefulDecoderTest.java new file mode 100644 index 0000000..cedfac7 --- /dev/null +++ b/spa2/src/test/java/cz/absolutno/sifry/common/decoder/StatefulDecoderTest.java @@ -0,0 +1,161 @@ +package cz.absolutno.sifry.common.decoder; + +import static org.junit.Assert.assertEquals; + +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import cz.absolutno.sifry.App; +import cz.absolutno.sifry.R; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35, qualifiers = "en-rUS") +public class StatefulDecoderTest { + + private SharedPreferences prefs; + + @Before + public void setUp() { + prefs = PreferenceManager.getDefaultSharedPreferences(App.getContext()); + prefs.edit().clear().commit(); + } + + @After + public void tearDown() { + prefs.edit().clear().commit(); + } + + private void setPref(String key, boolean value) { + prefs.edit().putBoolean(key, value).commit(); + } + + private static ArrayList codes(int... x) { + ArrayList l = new ArrayList<>(x.length); + for (int i : x) l.add(i); + return l; + } + + private static String decode(Decoder d, int... x) { + return d.decode(codes(x)); + } + + private static List statesSeen(StatefulDecoder d) { + final List seen = new ArrayList<>(); + d.setOnStateChangedListener(state -> seen.add(state == null ? null : state)); + return seen; + } + + private static List statesOf(String... s) { + return Arrays.asList(s); + } + + // ------------------------------------------------------------------ semafor + + @Test + public void semaforLetters() { + StatefulDecoder d = new StatefulDecoder(R.xml.semafor_decoder); + assertEquals("ABCD", decode(d, 3, 5, 9, 17)); + } + + @Test + public void semaforNumbersViaSwitch() { + StatefulDecoder d = new StatefulDecoder(R.xml.semafor_decoder); + assertEquals("1234", decode(d, 48, 3, 5, 9, 17)); + } + + @Test + public void semaforSwitchToLettersAgain() { + StatefulDecoder d = new StatefulDecoder(R.xml.semafor_decoder); + assertEquals("A1A", decode(d, 3, 48, 3, 80, 3)); + } + + @Test + public void semaforStatesTraced() { + StatefulDecoder d = new StatefulDecoder(R.xml.semafor_decoder); + List seen = statesSeen(d); + decode(d, 3, 48, 3, 80, 3); + assertEquals(statesOf("", "", "Num", "Num", "", ""), seen); + } + + @Test + public void semaforNumbersDisabled() { + setPref("pref_sem_num", false); + StatefulDecoder d = new StatefulDecoder(R.xml.semafor_decoder); + // 48 no longer switches to digits: it is undecodable ("?"), and 3 stays a letter + assertEquals("A?A", decode(d, 3, 48, 3)); + // code 80 is the letter J (flag + digit-on mapping in saSmDPismena), never a + // "back to letters" switch while numbers are disabled + assertEquals("J", decode(d, 80)); + assertEquals("A", decode(d, 3)); + // no Num state ever enters + List seen = statesSeen(d); + decode(d, 3, 48, 3); + assertEquals(statesOf("", "", ""), seen); + } + + // ------------------------------------------------------------------ braille + + @Test + public void brailleLetters() { + StatefulDecoder d = new StatefulDecoder(R.xml.braille_decoder); + assertEquals("ABW", decode(d, 1, 3, 58)); + } + + @Test + public void brailleNumbersViaPrefix() { + StatefulDecoder d = new StatefulDecoder(R.xml.braille_decoder); + assertEquals("12", decode(d, 60, 1, 3)); + } + + @Test + public void brailleNumberPrefixTogglesInWord() { + StatefulDecoder d = new StatefulDecoder(R.xml.braille_decoder); + assertEquals("A1A", decode(d, 1, 60, 1, 48, 1)); + } + + @Test + public void brailleStatesTraced() { + StatefulDecoder d = new StatefulDecoder(R.xml.braille_decoder); + List seen = statesSeen(d); + decode(d, 1, 60, 1, 48, 1); + assertEquals(statesOf("", "", "Num", "Num", "Num", "", ""), seen); + } + + @Test + public void brailleLowerCaseFormat() { + setPref("pref_brl_fmt", true); + StatefulDecoder d = new StatefulDecoder(R.xml.braille_decoder); + assertEquals("ab", decode(d, 1, 3)); + } + + @Test + public void braillePunctuationViaInterp() { + setPref("pref_brl_itp", true); + StatefulDecoder d = new StatefulDecoder(R.xml.braille_decoder); + assertEquals("AB.", decode(d, 1, 3, 50)); + } + + @Test + public void brailleSpaceWhenEnabled() { + setPref("pref_brl_spc", true); + StatefulDecoder d = new StatefulDecoder(R.xml.braille_decoder); + assertEquals("A\u00b7B", decode(d, 1, 0, 3)); + } + + @Test + public void brailleSpaceWhenDisabled() { + StatefulDecoder d = new StatefulDecoder(R.xml.braille_decoder); + assertEquals("A?B", decode(d, 1, 0, 3)); + } +} \ No newline at end of file diff --git a/spa2/src/test/java/cz/absolutno/sifry/common/decoder/StringDecoderTest.java b/spa2/src/test/java/cz/absolutno/sifry/common/decoder/StringDecoderTest.java new file mode 100644 index 0000000..7d64eae --- /dev/null +++ b/spa2/src/test/java/cz/absolutno/sifry/common/decoder/StringDecoderTest.java @@ -0,0 +1,46 @@ +package cz.absolutno.sifry.common.decoder; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import cz.absolutno.sifry.R; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35, qualifiers = "en-rUS") +public class StringDecoderTest { + + @Test + public void decodesValueAfterColon() { + StringDecoder d = new StringDecoder(R.array.saBRLetters); + assertEquals("A", d.decode(1)); + assertEquals("B", d.decode(3)); + assertEquals("W", d.decode(58)); + assertNull(d.decode(0)); + } + + @Test + public void isAlwaysTemp() { + assertTrue(new StringDecoder(R.array.saBRLetters).isTemp()); + } + + @Test + public void descriptionComesFromMiddleSegment() { + StringDecoder d = new StringDecoder(R.array.saBRLetters); + // entry is "1:A"; desc is the middle segment ("A") + assertEquals("A", d.getDesc(1)); + assertNull(d.getDesc(0)); + } + + @Test + public void invalidInnermostColonStillParses() { + // Braille letters have no inner colon, but a malformed entry still + // rounds trip: make sure a code that does not exist stays null. + assertEquals(null, new StringDecoder(R.array.saBRLetters).decode(12345)); + } +} \ No newline at end of file diff --git a/spa2/src/test/java/cz/absolutno/sifry/frekvence/FrekvExpListAdapterTest.java b/spa2/src/test/java/cz/absolutno/sifry/frekvence/FrekvExpListAdapterTest.java new file mode 100644 index 0000000..346a001 --- /dev/null +++ b/spa2/src/test/java/cz/absolutno/sifry/frekvence/FrekvExpListAdapterTest.java @@ -0,0 +1,94 @@ +package cz.absolutno.sifry.frekvence; + +import static org.junit.Assert.assertEquals; + +import java.lang.reflect.Field; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import cz.absolutno.sifry.R; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35, qualifiers = "en-rUS") +public class FrekvExpListAdapterTest { + + // input "ABAB CD": letters A2 B2 C1 D1, one space, two words + private static final int GROUP_COUNT = 8; + private static final int[] GROUP_IDS = { + R.id.idFDGPismenaC, + R.id.idFDGVseC, + R.id.idFDGPismenaP, + R.id.idFDGDelkyC, + R.id.idFDGDelkyP, + R.id.idFDGPrvni, + R.id.idFDGPosledni, + R.id.idFDGText, + }; + private static final int[] CHILDREN = {3, 2, 26, 1, 4, 1, 1, 5}; + + private static String childS(FrekvExpListAdapter a, int g, int c) { + try { + Object ret = FrekvExpListAdapter.class + .getMethod("getChild", int.class, int.class).invoke(a, g, c); + Field f = ret.getClass().getDeclaredField("s"); + f.setAccessible(true); + return (String) f.get(ret); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + @Test + public void groupStructure() { + FrekvExpListAdapter a = new FrekvExpListAdapter(); + assertEquals(0, a.getGroupCount()); + + a.go("ABAB CD"); + assertEquals(GROUP_COUNT, a.getGroupCount()); + for (int i = 0; i < GROUP_COUNT; i++) + assertEquals(GROUP_IDS[i], a.getGroupId(i)); + for (int i = 0; i < GROUP_COUNT; i++) + assertEquals("children of group " + i, CHILDREN[i], a.getChildrenCount(i)); + } + + @Test + public void mergedLetterRows() { + FrekvExpListAdapter a = new FrekvExpListAdapter(); + a.go("ABAB CD"); + assertEquals("A, B", childS(a, 0, 0)); // count 2 + assertEquals("C, D", childS(a, 0, 1)); // count 1 + } + + @Test + public void mergedAllCharsRows() { + FrekvExpListAdapter a = new FrekvExpListAdapter(); + a.go("ABAB CD"); + assertEquals("A, B", childS(a, 1, 0)); + assertEquals(2, a.getChildrenCount(1)); + } + + @Test + public void wordLengthRows() { + FrekvExpListAdapter a = new FrekvExpListAdapter(); + a.go("ABAB CD"); // words of length 2 and 4 + assertEquals("2, 4", childS(a, 3, 0)); // both lengths, merged (equal count) + assertEquals(1, a.getChildrenCount(3)); + } + + @Test + public void repeatedGoResets() { + FrekvExpListAdapter a = new FrekvExpListAdapter(); + a.go("ABAB CD"); + a.go("XY"); // single word: no delky/prvni/posledni/... groups + assertEquals(3, a.getGroupCount()); + assertEquals(R.id.idFDGPismenaC, a.getGroupId(0)); + assertEquals(R.id.idFDGPismenaP, a.getGroupId(1)); + assertEquals(R.id.idFDGText, a.getGroupId(2)); + assertEquals(2, a.getChildrenCount(0)); // "X, Y" merged (count 1) + zero-count row + assertEquals(26, a.getChildrenCount(1)); + assertEquals(4, a.getChildrenCount(2)); // letters, digits, spaces, all + } +} \ No newline at end of file diff --git a/spa2/src/test/java/cz/absolutno/sifry/morse/MorseDecoderTest.java b/spa2/src/test/java/cz/absolutno/sifry/morse/MorseDecoderTest.java new file mode 100644 index 0000000..7ee99f3 --- /dev/null +++ b/spa2/src/test/java/cz/absolutno/sifry/morse/MorseDecoderTest.java @@ -0,0 +1,89 @@ +package cz.absolutno.sifry.morse; + +import static org.junit.Assert.assertEquals; + +import java.util.ArrayList; +import java.util.Arrays; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35, qualifiers = "en-rUS") +public class MorseDecoderTest { + + private static ArrayList codes(int... x) { + ArrayList l = new ArrayList<>(x.length); + for (int i : x) l.add(i); + return l; + } + + @Test + public void letters() { + MorseDecoder d = new MorseDecoder(); + assertEquals("A", d.decode(5)); + assertEquals("B", d.decode(24)); + assertEquals("E", d.decode(2)); + assertEquals("Z", d.decode(28)); + } + + @Test + public void digits() { + MorseDecoder d = new MorseDecoder(); + assertEquals("0", d.decode(63)); + assertEquals("1", d.decode(47)); + assertEquals("2", d.decode(39)); + assertEquals("3", d.decode(35)); + assertEquals("4", d.decode(33)); + assertEquals("5", d.decode(32)); + } + + @Test + public void punctuation() { + MorseDecoder d = new MorseDecoder(); + assertEquals(".", d.decode(85)); + assertEquals(",", d.decode(115)); + assertEquals("?", d.decode(76)); + assertEquals("'", d.decode(94)); + assertEquals("\"", d.decode(82)); + assertEquals("/", d.decode(50)); + assertEquals("(", d.decode(54)); + assertEquals(")", d.decode(109)); + assertEquals("&", d.decode(40)); + assertEquals(":", d.decode(120)); + assertEquals(";", d.decode(106)); + assertEquals("=", d.decode(49)); + assertEquals("+", d.decode(42)); + assertEquals("\u2013", d.decode(97)); + assertEquals("_", d.decode(77)); + assertEquals("$", d.decode(137)); + assertEquals("@", d.decode(90)); + } + + @Test + public void addedSeparatorAndUnknown() { + MorseDecoder d = new MorseDecoder(); + assertEquals("\u00b7", d.decode(1)); + assertEquals("?", d.decode(0)); + assertEquals("?", d.decode(9999)); + } + + @Test + public void tuplesAndEncode() { + MorseDecoder d = new MorseDecoder(); + assertEquals("AB", d.decode(codes(5, 24))); + ArrayList out = new ArrayList<>(); + assertEquals(false, d.encode("AB", out)); + assertEquals(codes(5, 24), out); + assertEquals(false, d.encode("01", out)); + assertEquals(codes(63, 47), out); + } + + @Test + public void digitsTuple() { + MorseDecoder d = new MorseDecoder(); + assertEquals("012", d.decode(codes(63, 47, 39))); + } +} \ No newline at end of file diff --git a/spa2/src/test/java/cz/absolutno/sifry/substituce/SubstituceAdaptersTest.java b/spa2/src/test/java/cz/absolutno/sifry/substituce/SubstituceAdaptersTest.java new file mode 100644 index 0000000..f22c4e5 --- /dev/null +++ b/spa2/src/test/java/cz/absolutno/sifry/substituce/SubstituceAdaptersTest.java @@ -0,0 +1,100 @@ +package cz.absolutno.sifry.substituce; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import cz.absolutno.sifry.common.alphabet.PlainEnglishAlphabet; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 35, qualifiers = "en-rUS") +public class SubstituceAdaptersTest { + + private static PlainEnglishAlphabet en() { + return new PlainEnglishAlphabet(); + } + + @Test + public void posuny() { + PosunyAdapter p = new PosunyAdapter(en()); + assertEquals(0, p.getCount()); + p.setInput("AB"); + assertEquals(26, p.getCount()); + assertEquals("AB", p.getItem(0)); + assertEquals("BC", p.getItem(1)); + assertEquals("ZA", p.getItem(25)); + p.clear(); + assertEquals(0, p.getCount()); + } + + @Test + public void atbash() { + AtbashAdapter a = new AtbashAdapter(en()); + a.setInput("AB"); + assertEquals("ZY", a.getItem(0)); + } + + @Test + public void afinni() { + AfinniAdapter f = new AfinniAdapter(en()); + f.setInput("AB"); + assertEquals("BC", f.getItem(1)); + f.setCoeff(3); + assertEquals("AD", f.getItem(0)); + } + + @Test + public void heslo() { + HesloAdapter h = new HesloAdapter(en()); + assertEquals(0, h.getCount()); + h.setKey("B"); + h.setInput("A"); + assertEquals(h.getCountValid(), h.getCount()); + assertEquals("C", h.getItem(0)); + assertEquals("Y", h.getItem(1)); + assertEquals("A", h.getItem(2)); + assertEquals("B", h.getItem(3)); + assertEquals("Z", h.getItem(4)); + assertEquals("B", h.getItem(5)); + } + + @Test + public void autokey() { + AutokeyAdapter k = new AutokeyAdapter(en()); + k.setInput("AB"); + assertEquals("AC", k.getItem(0)); + assertEquals("AA", k.getItem(1)); + assertEquals("AC", k.getItem(12)); + } + + @Test + public void pozice() { + PoziceAdapter p = new PoziceAdapter(en()); + p.setInput("AB"); + assertEquals("BD", p.getItem(0)); + assertEquals("ZZ", p.getItem(1)); + assertEquals("AC", p.getItem(3)); + assertEquals("AA", p.getItem(4)); + assertEquals("AA", p.getItem(5)); + assertEquals("AB", p.getItem(6)); + assertEquals("AB", p.getItem(7)); + } + + @Test + public void klic() { + KlicAdapter k = new KlicAdapter(en()); + assertEquals(0, k.getCount()); + k.setKlic("SIF", 0); + k.setInput("SIF"); + assertEquals(2, k.getCount()); + assertEquals("ABC", k.getItem(0)); + k.clear(); + assertEquals(0, k.getCount()); + k.setKlic("SIF", 0); + k.setInput("ABC"); + assertEquals("SIF", k.getItem(1)); + } +} \ No newline at end of file diff --git a/spa2/src/test/java/cz/absolutno/sifry/tabulky/mobil/MobilDecoderTest.java b/spa2/src/test/java/cz/absolutno/sifry/tabulky/mobil/MobilDecoderTest.java new file mode 100644 index 0000000..6783c9a --- /dev/null +++ b/spa2/src/test/java/cz/absolutno/sifry/tabulky/mobil/MobilDecoderTest.java @@ -0,0 +1,70 @@ +package cz.absolutno.sifry.tabulky.mobil; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; + +public class MobilDecoderTest { + + private static ArrayList codes(int... x) { + ArrayList l = new ArrayList<>(x.length); + for (int i : x) l.add(i); + return l; + } + + @Test + public void decodesTuple() { + MobilDecoder d = new MobilDecoder(); + assertEquals("WORD", d.decode(codes(0x80, 0x52, 0x62, 0x20))); + assertEquals("W", d.decode(0x80)); + assertEquals("O", d.decode(0x52)); + assertEquals("R", d.decode(0x62)); + assertEquals("D", d.decode(0x20)); + } + + @Test + public void decodesSingleKeyFirstLetter() { + assertEquals("J", new MobilDecoder().decode(0x40)); + } + + @Test + public void invalidKeyOrRepReturnsNull() { + MobilDecoder d = new MobilDecoder(); + assertNull(d.decode(0)); // key 0 has no letters + assertNull(d.decode(0x90)); // key 9 has no letters + assertNull(d.decode(0x53)); // key 5 'MNO' is length 3, rep 3 invalid + } + + @Test + public void encodesWord() { + MobilDecoder d = new MobilDecoder(); + ArrayList out = new ArrayList<>(); + assertFalse(d.encode("WORD", out)); + assertEquals(codes(0x80, 0x52, 0x62, 0x20), out); + } + + @Test + public void encodesLowercaseSameAsUppercase() { + MobilDecoder d = new MobilDecoder(); + ArrayList a = new ArrayList<>(); + ArrayList b = new ArrayList<>(); + d.encode("word", a); + d.encode("WORD", b); + assertEquals(b, a); + } + + @Test + public void letterAccessors() { + MobilDecoder d = new MobilDecoder(); + assertEquals(3, d.getNumLetters(1)); + assertEquals("S", d.getLetter(6, 3)); + assertEquals("W", d.getLetter(8, 0)); + assertArrayEquals(new String[]{"P", "Q", "R", "S"}, d.getLetters(6)); + } +} \ No newline at end of file diff --git a/spa2/src/test/java/cz/absolutno/sifry/tabulky/polsky/PolskyKrizDecoderTest.java b/spa2/src/test/java/cz/absolutno/sifry/tabulky/polsky/PolskyKrizDecoderTest.java new file mode 100644 index 0000000..ef741f5 --- /dev/null +++ b/spa2/src/test/java/cz/absolutno/sifry/tabulky/polsky/PolskyKrizDecoderTest.java @@ -0,0 +1,68 @@ +package cz.absolutno.sifry.tabulky.polsky; + +import static org.junit.Assert.assertEquals; + +import java.util.Locale; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class PolskyKrizDecoderTest { + + private Locale saved; + + @Before + public void saveLocale() { + saved = Locale.getDefault(); + } + + @After + public void restoreLocale() { + Locale.setDefault(saved); + } + + private static int[] kriz(int a, int b, int c) { + return new int[]{a, b, c}; + } + + @Test + public void czechLetters() { + Locale.setDefault(new Locale("cs", "CZ")); + PolskyKrizDecoder d = new PolskyKrizDecoder(); + assertEquals("A", d.decode(kriz(0, 0, 0))); + assertEquals("H", d.decode(kriz(0, 2, 1))); + assertEquals("CH", d.decode(kriz(0, 2, 2))); + assertEquals("Z", d.decode(kriz(2, 2, 2))); + } + + @Test + public void czechWithoutCh() { + Locale.setDefault(new Locale("cs", "CZ")); + PolskyKrizDecoder d = new PolskyKrizDecoder("-Ch"); + assertEquals("A", d.decode(kriz(0, 0, 0))); + assertEquals("I", d.decode(kriz(0, 2, 2))); + assertEquals("?", d.decode(kriz(2, 2, 2))); + } + + @Test + public void englishVariantIsPlain26() { + Locale.setDefault(Locale.ENGLISH); + PolskyKrizDecoder d = new PolskyKrizDecoder(); + assertEquals("A", d.decode(kriz(0, 0, 0))); + assertEquals("I", d.decode(kriz(0, 2, 2))); + assertEquals("?", d.decode(kriz(2, 2, 2))); + } + + @Test + public void setVarSwitchesInstance() { + Locale.setDefault(new Locale("cs", "CZ")); + PolskyKrizDecoder d = new PolskyKrizDecoder(); + d.setVar("-Ch"); + assertEquals("I", d.decode(kriz(0, 2, 2))); + assertEquals("?", d.decode(kriz(2, 2, 2))); + d.setVar(""); + assertEquals("CH", d.decode(kriz(0, 2, 2))); + assertEquals("Z", d.decode(kriz(2, 2, 2))); + } +} \ No newline at end of file From 50534fb70742a8bdef2d4ae6541eb94b7dfee2d2 Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Sun, 6 Sep 2026 01:34:41 -0500 Subject: [PATCH 12/15] =?UTF-8?q?phase1:=20complete=20Tier=203=20=E2=80=94?= =?UTF-8?q?=20app=20smoke=20+=20cs=E2=86=94en=20dict-switch=20tests=20(6?= =?UTF-8?q?=20instrumented=20tests=20green)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppSmokeTest: SplashActivity→MainActivity transition (startActivitySync + ActivityMonitor) + each of the 12 cipher activities reaches RESUMED. - RegExpNativeTest.switchDictionaryReplacesResults: run '^aardvark:' on en.canon (1 match) then '^sifry:' on cs.canon (2 matches: sifry/Sifry), proving the startThread(fn) dict switch replaces prior results. - moveToState(RESUMED) after ActivityScenario.launch to avoid the CREATED->RESUMED race; splash covered via startActivitySync since it self-stops when MainActivity opens on top. - Verified on-device: 6/6 green, 0 failures, 0 errors. --- .../java/cz/absolutno/sifry/AppSmokeTest.java | 85 +++++++++++++++++++ .../sifry/regexp/RegExpNativeTest.java | 20 +++++ 2 files changed, 105 insertions(+) create mode 100644 spa2/src/androidTest/java/cz/absolutno/sifry/AppSmokeTest.java diff --git a/spa2/src/androidTest/java/cz/absolutno/sifry/AppSmokeTest.java b/spa2/src/androidTest/java/cz/absolutno/sifry/AppSmokeTest.java new file mode 100644 index 0000000..ca1b95c --- /dev/null +++ b/spa2/src/androidTest/java/cz/absolutno/sifry/AppSmokeTest.java @@ -0,0 +1,85 @@ +package cz.absolutno.sifry; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import android.app.Activity; +import android.app.Instrumentation; +import android.content.Context; +import android.content.Intent; + +import androidx.lifecycle.Lifecycle; +import androidx.test.core.app.ActivityScenario; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import cz.absolutno.sifry.braille.BrailleActivity; +import cz.absolutno.sifry.cisla.CislaActivity; +import cz.absolutno.sifry.frekvence.FrekvActivity; +import cz.absolutno.sifry.kalendar.KalendarActivity; +import cz.absolutno.sifry.mainscreen.MainActivity; +import cz.absolutno.sifry.mainscreen.SplashActivity; +import cz.absolutno.sifry.morse.MorseActivity; +import cz.absolutno.sifry.regexp.RegExpActivity; +import cz.absolutno.sifry.semafor.SemaforActivity; +import cz.absolutno.sifry.substituce.SubstActivity; +import cz.absolutno.sifry.tabulky.TabulkyActivity; +import cz.absolutno.sifry.transpozice.TransActivity; +import cz.absolutno.sifry.vlajky.VlajkyActivity; +import cz.absolutno.sifry.zapisnik.ZapisnikActivity; + +@RunWith(AndroidJUnit4.class) +public class AppSmokeTest { + + private static final Class[] CIPHER_ACTIVITIES = { + MorseActivity.class, + BrailleActivity.class, + CislaActivity.class, + SemaforActivity.class, + TabulkyActivity.class, + VlajkyActivity.class, + SubstActivity.class, + TransActivity.class, + FrekvActivity.class, + KalendarActivity.class, + ZapisnikActivity.class, + RegExpActivity.class, + }; + + @Test + public void splashToMain() throws Exception { + Instrumentation inst = InstrumentationRegistry.getInstrumentation(); + Context ctx = ApplicationProvider.getApplicationContext(); + Instrumentation.ActivityMonitor monitor = + inst.addMonitor(MainActivity.class.getName(), null, false); + try { + Intent splashIntent = new Intent(ctx, SplashActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + Activity splash = inst.startActivitySync(splashIntent); + assertEquals("SplashActivity did not resume", "mainscreen.SplashActivity", + splash.getLocalClassName()); + Activity main = inst.waitForMonitorWithTimeout(monitor, 15000); + assertNotNull("MainActivity did not open after SplashActivity", main); + main.finish(); + splash.finish(); + } finally { + inst.removeMonitor(monitor); + } + } + + @Test + public void eachCipherActivityRenders() { + Context ctx = ApplicationProvider.getApplicationContext(); + for (Class cls : CIPHER_ACTIVITIES) { + try (ActivityScenario scenario = ActivityScenario.launch(new Intent(ctx, cls))) { + scenario.moveToState(Lifecycle.State.RESUMED); + assertEquals(cls.getSimpleName() + " did not reach RESUMED", + Lifecycle.State.RESUMED, scenario.getState()); + } + } + } +} \ No newline at end of file diff --git a/spa2/src/androidTest/java/cz/absolutno/sifry/regexp/RegExpNativeTest.java b/spa2/src/androidTest/java/cz/absolutno/sifry/regexp/RegExpNativeTest.java index ffecf48..f14e42e 100644 --- a/spa2/src/androidTest/java/cz/absolutno/sifry/regexp/RegExpNativeTest.java +++ b/spa2/src/androidTest/java/cz/absolutno/sifry/regexp/RegExpNativeTest.java @@ -47,6 +47,26 @@ public void matchesWord() throws Exception { } } + @Test + public void switchDictionaryReplacesResults() throws Exception { + RegExpNative rn = new RegExpNative(); + try { + rn.startThread(assets(), "raw/en.canon", new String[]{"^aardvark:"}); + RegExpNative.Report rep = waitFor(rn, 30000); + assertFalse("error: " + rn.getError(), rep.error); + assertEquals("en results: (matches=" + rep.matches + ")", 1, rep.matches); + assertEquals("aardvark", rn.getResult(0)); + + rn.startThread(assets(), "raw/cs.canon", new String[]{"^sifry:"}); + rep = waitFor(rn, 60000); + assertFalse("cs error: " + rn.getError(), rep.error); + assertTrue("cs switch lost matches (matches=" + rep.matches + ")", rep.matches >= 2); + assertEquals("šifry", rn.getResult(0)); + } finally { + rn.free(); + } + } + @Test public void noMatches() throws Exception { RegExpNative rn = new RegExpNative(); From 49b0c0cc782d7b912084787bc6950541a83d1c56 Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Sun, 6 Sep 2026 01:39:21 -0500 Subject: [PATCH 13/15] mig/ci: GitHub Actions build + JVM/Robolectric tests per issue #8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .github/workflows/build.yml: JDK 17 matrix, Android SDK (android-35, build-tools 36.1.0, ndk 27.0.12077973, cmake 3.22.1), runs :spa2:testDebugUnitTest (Tier 1+2) and :spa2:assembleDebug (native libregrep) on every push/PR; NDK/CMake + Gradle cached; uploads test reports. Robolectric 4.17-beta-4 pin is the AGP/JDK-sensitive checkpoint. - README: document the 3-tier split — Tier 3 instrumented tests stay on the physical Pixel locally (no managed device / Firebase Test Lab yet). --- .github/workflows/build.yml | 85 +++++++++++++++++++++++++++++++++++++ README.md | 25 +++++++++++ 2 files changed, 110 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e4ea01f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,85 @@ +# SPA2 CI — migration contract (issue abandonrules/sifry#8) +# +# Every push/PR validates the Phase-1 tiered test suite: +# Tier 1+2 (JVM + Robolectric unit tests) run here on linux. +# Tier 3 (instrumented, NDK/PCRE on-device) intentionally stays on the +# physical Pixel locally (serial 52141FDAS001QF) — see README "Testing". +# +# Toolchain tracked here: JDK 17 (temurin), platform android-35, +# build-tools 36.1.0, CMake 3.22.1, NDK 27.0.12077973, Gradle 9.7.1 +# (wrapper). Robolectric 4.17-beta-4 is the AGP/JDK-sensitive pin that must +# advance with the migration (see issue notes). + +name: build + +on: + push: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-and-unit-test: + name: Assemble + JVM/Robolectric tests (JDK ${{ matrix.jdk }}) + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + jdk: ['17'] + env: + ANDROID_HOME: /usr/local/lib/android/sdk + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK ${{ matrix.jdk }} + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ matrix.jdk }} + + - name: Set up Gradle (wrapper + dependency cache) + uses: gradle/actions/setup-gradle@v4 + + - name: Cache Android NDK and CMake + uses: actions/cache@v4 + with: + path: | + ${{ env.ANDROID_HOME }}/ndk/27.0.12077973 + ${{ env.ANDROID_HOME }}/cmake/3.22.1 + key: android-${{ runner.os }}-ndk27-cmake3221 + + - name: Accept Android SDK licenses + run: | + SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + yes | "$SDKMANAGER" --licenses > /dev/null || true + + - name: Install Android SDK packages + run: | + SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + "$SDKMANAGER" \ + "platforms;android-35" \ + "build-tools;36.1.0" \ + "ndk;27.0.12077973" \ + "cmake;3.22.1" + + - name: Tier 1+2 — JVM + Robolectric unit tests + run: ./gradlew :spa2:testDebugUnitTest + + - name: Assemble debug APK (incl. native libregrep) + run: ./gradlew :spa2:assembleDebug + + - name: Upload unit test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: unit-test-reports-jdk${{ matrix.jdk }} + path: | + spa2/build/reports/tests/* + spa2/build/test-results/**/TEST-*.xml + if-no-files-found: ignore \ No newline at end of file diff --git a/README.md b/README.md index 07a47f1..95ea4f7 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,31 @@ Point Gradle at your SDK one of these ways: The output APK is `spa2/build/outputs/apk/debug/spa2-debug.apk`. +## Testing + +The migration relies on a 3-tier test suite (the "migration contract", +issue #7). Tiers 1 and 2 run on any JVM anywhere; Tier 3 needs a device. + +| Tier | Where it runs | Command | +|---|---|---| +| 1. Pure JVM unit tests | CI + local | `./gradlew :spa2:testDebugUnitTest` | +| 2. Robolectric (resources/prefs/XML) | CI + local | `./gradlew :spa2:testDebugUnitTest` | +| 3. Instrumented (native `libregrep`/PCRE, app smoke) | **physical Pixel, local only** | `./gradlew :spa2:connectedDebugAndroidTest` | + +Tier 3 — the NDK/PCRE safety net (`RegExpNativeTest` + `AppSmokeTest`) — +intentionally stays on the attached Pixel (`adb -s 52141FDAS001QF`): +GitHub Actions runners cannot host physical devices, and Managed/Firebase +Test Lab is deferred (issue #8). When a step touches native code or JNI, +run Tier 3 locally before closing the issue. + +## CI + +`.github/workflows/build.yml` runs Tier 1+2 and `assembleDebug` on every +push/PR (JDK 17, platform android-35, build-tools 36.1.0, CMake 3.22.1, +NDK 27.0.12077973, Gradle 9.7.1 from the wrapper). Robolectric is pinned to +4.17-beta-4 — an AGP/JDK-sensitive choice that must advance with the +toolchain (unit tests were validated on JDK 17/26). + ## Versioning `versionCode`/`versionName` live in `spa2/src/main/AndroidManifest.xml`. From 9096d24f1170ab16e6085e711597f0a9f57d5fb2 Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Sun, 6 Sep 2026 01:49:16 -0500 Subject: [PATCH 14/15] Verify sdkmanager before accepting Android SDK licenses Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e4ea01f..764b14a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,7 +57,11 @@ jobs: - name: Accept Android SDK licenses run: | SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" - yes | "$SDKMANAGER" --licenses > /dev/null || true + if [ ! -x "$SDKMANAGER" ]; then + echo "sdkmanager not found at $SDKMANAGER" >&2 + exit 1 + fi + yes | "$SDKMANAGER" --licenses > /dev/null - name: Install Android SDK packages run: | From a64eccf223efbcfa825ba945e10aea1cb9d621b0 Mon Sep 17 00:00:00 2001 From: Christopher Mayfield Date: Sun, 6 Sep 2026 01:49:36 -0500 Subject: [PATCH 15/15] Enhance RegExpNativeTest with additional assertions Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../java/cz/absolutno/sifry/regexp/RegExpNativeTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spa2/src/androidTest/java/cz/absolutno/sifry/regexp/RegExpNativeTest.java b/spa2/src/androidTest/java/cz/absolutno/sifry/regexp/RegExpNativeTest.java index f14e42e..4bac816 100644 --- a/spa2/src/androidTest/java/cz/absolutno/sifry/regexp/RegExpNativeTest.java +++ b/spa2/src/androidTest/java/cz/absolutno/sifry/regexp/RegExpNativeTest.java @@ -53,16 +53,17 @@ public void switchDictionaryReplacesResults() throws Exception { try { rn.startThread(assets(), "raw/en.canon", new String[]{"^aardvark:"}); RegExpNative.Report rep = waitFor(rn, 30000); + assertFalse("regexp search timed out", rep.running); assertFalse("error: " + rn.getError(), rep.error); assertEquals("en results: (matches=" + rep.matches + ")", 1, rep.matches); assertEquals("aardvark", rn.getResult(0)); rn.startThread(assets(), "raw/cs.canon", new String[]{"^sifry:"}); rep = waitFor(rn, 60000); + assertFalse("regexp search timed out", rep.running); assertFalse("cs error: " + rn.getError(), rep.error); assertTrue("cs switch lost matches (matches=" + rep.matches + ")", rep.matches >= 2); assertEquals("šifry", rn.getResult(0)); - } finally { rn.free(); } }