From 39455bfe569dd1b075eebba9c26c20f0ba880535 Mon Sep 17 00:00:00 2001 From: MaxDac <25101798+MaxDac@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:14:16 +0000 Subject: [PATCH 1/2] test: parity runner honours fixture appliesTo, with an edition key Co-Authored-By: Claude Sonnet 5.5 --- .../marknotes/core/parity/FixtureSetTest.kt | 63 +++++++++++++ .../marknotes/core/parity/SpecParityTest.kt | 10 ++ .../marknotes/core/parity/SpecFixtures.kt | 93 ++++++++++++++----- 3 files changed, 141 insertions(+), 25 deletions(-) create mode 100644 core/src/test/kotlin/io/github/maxdac/marknotes/core/parity/FixtureSetTest.kt diff --git a/core/src/test/kotlin/io/github/maxdac/marknotes/core/parity/FixtureSetTest.kt b/core/src/test/kotlin/io/github/maxdac/marknotes/core/parity/FixtureSetTest.kt new file mode 100644 index 0000000..437dfba --- /dev/null +++ b/core/src/test/kotlin/io/github/maxdac/marknotes/core/parity/FixtureSetTest.kt @@ -0,0 +1,63 @@ +package io.github.maxdac.marknotes.core.parity + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** The `appliesTo` selection of [FixtureSet], on synthetic fixture files (no shared spec checkout needed). */ +class FixtureSetTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun fixtures(): File = tmp.newFolder("fixtures").also { dir -> + write(dir, "front-matter/split", null) + write(dir, "issues/validate", """["sync", "desktop", "android-pro"]""") + write(dir, "issues/pull", """["sync", "desktop", "android-pro"]""") + write(dir, "everywhere/some", """["sync", "android", "android-pro"]""") + } + + private fun write(dir: File, id: String, appliesTo: String?) { + val file = File(dir, "$id.json").also { it.parentFile.mkdirs() } + val applies = appliesTo?.let { """"appliesTo": $it,""" }.orEmpty() + file.writeText( + """{ $applies "cases": [ { "name": "c", "input": {"a": "b"}, "expected": {"x": 1} } ] }""" + ) + } + + @Test + fun libreSkipsIssuesFiles() { + val set = FixtureSet(fixtures()) + assertEquals(listOf("everywhere/some", "front-matter/split"), set.ids()) + assertEquals(listOf("issues/pull", "issues/validate"), set.skippedIds()) + assertEquals(emptyList(), set.load("issues/pull")) + } + + @Test + fun proSelectsIssuesFiles() { + val set = FixtureSet(fixtures(), SpecFixtures.PRO_KEY) + assertEquals( + listOf("everywhere/some", "front-matter/split", "issues/pull", "issues/validate"), + set.ids() + ) + assertEquals(emptyList(), set.skippedIds()) + assertEquals(1, set.load("issues/pull").size) + } + + @Test + fun filesWithoutAppliesToApplyToEveryone() { + val dir = fixtures() + assertTrue(FixtureSet(dir).isApplicable("front-matter/split")) + assertTrue(FixtureSet(dir, "web").isApplicable("front-matter/split")) + } + + @Test + fun anUnmappedFileStillFails() { + // The coverage rule of SpecCoverageTest: applicable ids must equal the mapped ones. + val set = FixtureSet(fixtures()) + val mapped = listOf("front-matter/split") + assertEquals(listOf("everywhere/some"), set.ids() - mapped.toSet()) + } +} diff --git a/core/src/test/kotlin/io/github/maxdac/marknotes/core/parity/SpecParityTest.kt b/core/src/test/kotlin/io/github/maxdac/marknotes/core/parity/SpecParityTest.kt index c3cdab5..32894cf 100644 --- a/core/src/test/kotlin/io/github/maxdac/marknotes/core/parity/SpecParityTest.kt +++ b/core/src/test/kotlin/io/github/maxdac/marknotes/core/parity/SpecParityTest.kt @@ -1,6 +1,7 @@ package io.github.maxdac.marknotes.core.parity import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized @@ -35,6 +36,15 @@ class SpecCoverageTest { ) } + @Test + fun filesExcludedByAppliesToAreSkipped() { + val skipped = SpecFixtures.skippedIds() + assumeTrue("every fixture file applies to Libre", skipped.isNotEmpty()) + // Recognised, not run and not unmapped. + assertEquals(emptyList(), skipped.filter(CoreParity.mappings::containsKey)) + assumeTrue("Skipped by appliesTo for ${SpecFixtures.APP_KEY}: $skipped", false) + } + @Test fun pendingSpecIssuesReferToExistingCases() { SpecFixtures.requireDir() diff --git a/core/src/testFixtures/kotlin/io/github/maxdac/marknotes/core/parity/SpecFixtures.kt b/core/src/testFixtures/kotlin/io/github/maxdac/marknotes/core/parity/SpecFixtures.kt index 0db76cf..86aee50 100644 --- a/core/src/testFixtures/kotlin/io/github/maxdac/marknotes/core/parity/SpecFixtures.kt +++ b/core/src/testFixtures/kotlin/io/github/maxdac/marknotes/core/parity/SpecFixtures.kt @@ -47,12 +47,65 @@ class FixtureCase( } } -/** Loads the shared golden fixtures from `marknotes.specDir` (Gradle property, default `../spec`). The - * fixtures are not part of this repository: without them the parity tests skip. */ +/** + * The fixture files under one `fixtures/` folder, as seen by the runner of [edition]: `android` for Libre, + * `android-pro` for Pro. A file with an `appliesTo` list that lacks [edition] is recognised but not applicable: + * it is reported as skipped, never run and never counted as unmapped. Files without `appliesTo` apply to all. + */ +class FixtureSet(private val dir: File, val edition: String = SpecFixtures.APP_KEY) { + private val json = Json { ignoreUnknownKeys = true } + + /** Ids ("area/function") of every fixture file, sorted, applicable or not. */ + fun allIds(): List = dir.walkTopDown() + .filter { it.isFile && it.extension == "json" } + .map { it.relativeTo(dir).invariantSeparatorsPath.removeSuffix(".json") } + .sorted() + .toList() + + /** The runner keys listed by the file's `appliesTo`, or null when it applies to every runner. */ + fun appliesTo( + id: String + ): Set? = root(id)["appliesTo"]?.jsonArray?.map { it.jsonPrimitive.content }?.toSet() + + fun isApplicable(id: String): Boolean = appliesTo(id)?.contains(edition) ?: true + + /** Ids of the files this edition must run (or map as "elsewhere"). */ + fun ids(): List = allIds().filter(::isApplicable) + + /** Ids of the files recognised but excluded for this edition by `appliesTo`. */ + fun skippedIds(): List = allIds().filterNot(::isApplicable) + + /** Cases of fixture [id]; none when the file does not apply to this edition. */ + fun load(id: String): List { + if (!isApplicable(id)) return emptyList() + return root(id).getValue("cases").jsonArray.map { element -> + val case = element.jsonObject + FixtureCase( + fixture = id, + name = case.getValue("name").jsonPrimitive.content, + input = case.getValue("input").jsonObject, + expected = case.getValue("expected"), + // Divergences are keyed by app (`android`), not by edition: Pro shares Libre's. + knownDivergence = (case["knownDivergence"] as? JsonObject) + ?.get(SpecFixtures.APP_KEY)?.jsonPrimitive?.content + ) + } + } + + private fun root(id: String): JsonObject = json.parseToJsonElement(File(dir, "$id.json").readText()).jsonObject +} + +/** + * Loads the shared golden fixtures from `marknotes.specDir` (Gradle property, default `../spec`). The fixtures + * are not part of this repository: without them the parity tests skip. Every function takes the runner's + * [edition] key (default [APP_KEY]); Pro passes [PRO_KEY] to also run the `android-pro` files. + */ object SpecFixtures { + /** Runner key of Libre, and of Pro for files that apply to every Android edition. */ const val APP_KEY = "android" - private val json = Json { ignoreUnknownKeys = true } + /** Runner key of Pro (MarkNotes Plus), selected by `appliesTo: ["android-pro"]`. */ + const val PRO_KEY = "android-pro" private val fixturesDir: File? get() = System.getProperty("marknotes.specDir")?.let { File(it, "fixtures") }?.takeIf { it.isDirectory } @@ -62,6 +115,9 @@ object SpecFixtures { /** True when `marknotes.parity.required` is set: missing fixtures are then an error instead of a skip. */ private val required: Boolean get() = System.getProperty("marknotes.parity.required").toBoolean() + /** The fixtures of [edition]; skips the calling test when the fixtures aren't there (fails if [required]). */ + fun requireSet(edition: String = APP_KEY): FixtureSet = FixtureSet(requireDir(), edition) + /** The fixtures folder; skips the calling test when the fixtures aren't there (fails if [required]). */ fun requireDir(): File { val dir = fixturesDir @@ -70,31 +126,18 @@ object SpecFixtures { return dir!! } - /** Ids ("area/function") of every fixture file, sorted. */ - fun ids(): List { - val dir = requireDir() - return dir.walkTopDown() - .filter { it.isFile && it.extension == "json" } - .map { it.relativeTo(dir).invariantSeparatorsPath.removeSuffix(".json") } - .sorted() - .toList() - } + /** Ids ("area/function") of the fixture files that apply to [edition], sorted. */ + fun ids(edition: String = APP_KEY): List = requireSet(edition).ids() - /** Cases of fixture [id], or none when the fixtures aren't there (an error when [required]). */ - fun load(id: String): List { + /** Ids of the files [edition] recognises but skips through `appliesTo`. */ + fun skippedIds(edition: String = APP_KEY): List = requireSet(edition).skippedIds() + + /** Cases of fixture [id], or none when the fixtures aren't there (an error when [required]) or the file does + * not apply to [edition]. */ + fun load(id: String, edition: String = APP_KEY): List { val dir = fixturesDir check(dir != null || !required) { MISSING_FIXTURES } if (dir == null) return emptyList() - val root = json.parseToJsonElement(File(dir, "$id.json").readText()).jsonObject - return root.getValue("cases").jsonArray.map { element -> - val case = element.jsonObject - FixtureCase( - fixture = id, - name = case.getValue("name").jsonPrimitive.content, - input = case.getValue("input").jsonObject, - expected = case.getValue("expected"), - knownDivergence = (case["knownDivergence"] as? JsonObject)?.get(APP_KEY)?.jsonPrimitive?.content - ) - } + return FixtureSet(dir, edition).load(id) } } From ed1ccdc1e188dd845e81a7ceafaa1c6a8f6c38cf Mon Sep 17 00:00:00 2001 From: MaxDac <25101798+MaxDac@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:16:32 +0000 Subject: [PATCH 2/2] test: android-pro also runs fixtures that list android Co-Authored-By: Claude Sonnet 5.5 --- .../maxdac/marknotes/core/parity/FixtureSetTest.kt | 9 +++++++++ .../github/maxdac/marknotes/core/parity/SpecFixtures.kt | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/core/src/test/kotlin/io/github/maxdac/marknotes/core/parity/FixtureSetTest.kt b/core/src/test/kotlin/io/github/maxdac/marknotes/core/parity/FixtureSetTest.kt index 437dfba..b39982a 100644 --- a/core/src/test/kotlin/io/github/maxdac/marknotes/core/parity/FixtureSetTest.kt +++ b/core/src/test/kotlin/io/github/maxdac/marknotes/core/parity/FixtureSetTest.kt @@ -53,6 +53,15 @@ class FixtureSetTest { assertTrue(FixtureSet(dir, "web").isApplicable("front-matter/split")) } + @Test + fun proAlsoRunsFilesListingOnlyAndroid() { + val dir = fixtures() + write(dir, "libre/only", """["android"]""") + assertTrue(FixtureSet(dir, SpecFixtures.PRO_KEY).isApplicable("libre/only")) + assertTrue(FixtureSet(dir).isApplicable("libre/only")) + assertEquals(false, FixtureSet(dir, "web").isApplicable("libre/only")) + } + @Test fun anUnmappedFileStillFails() { // The coverage rule of SpecCoverageTest: applicable ids must equal the mapped ones. diff --git a/core/src/testFixtures/kotlin/io/github/maxdac/marknotes/core/parity/SpecFixtures.kt b/core/src/testFixtures/kotlin/io/github/maxdac/marknotes/core/parity/SpecFixtures.kt index 86aee50..108366b 100644 --- a/core/src/testFixtures/kotlin/io/github/maxdac/marknotes/core/parity/SpecFixtures.kt +++ b/core/src/testFixtures/kotlin/io/github/maxdac/marknotes/core/parity/SpecFixtures.kt @@ -67,7 +67,11 @@ class FixtureSet(private val dir: File, val edition: String = SpecFixtures.APP_K id: String ): Set? = root(id)["appliesTo"]?.jsonArray?.map { it.jsonPrimitive.content }?.toSet() - fun isApplicable(id: String): Boolean = appliesTo(id)?.contains(edition) ?: true + /** `android` covers Libre and Pro, so Pro (`android-pro`) also runs files that list only `android`. */ + fun isApplicable(id: String): Boolean { + val keys = appliesTo(id) ?: return true + return edition in keys || (edition == SpecFixtures.PRO_KEY && SpecFixtures.APP_KEY in keys) + } /** Ids of the files this edition must run (or map as "elsewhere"). */ fun ids(): List = allIds().filter(::isApplicable)