diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8843ca8..ea24e7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,4 +82,10 @@ contract: number fails CI; a missing or stale API in an opt-in compile block fails compilation. Plain ` ```kotlin ` blocks are illustrative and are not executed. +The executable claim format supports a few trust-oriented knobs: + +- `// repeat: N` runs the same claim `N` times to catch flaky or unstable rules. +- `// assert-not: text = "..."` checks that a string is absent from the rendered semantics tree. +- `// assert: has-click-action = "..."` checks clickable semantics on a node with matching text. + The `verify-claims` CI job runs this on every PR and master push. diff --git a/buildSrc/src/main/kotlin/ClaimParser.kt b/buildSrc/src/main/kotlin/ClaimParser.kt index de18d05..43fb1bb 100644 --- a/buildSrc/src/main/kotlin/ClaimParser.kt +++ b/buildSrc/src/main/kotlin/ClaimParser.kt @@ -1,5 +1,5 @@ -data class Assertion(val prop: String, val dp: Int) -data class Claim(val name: String, val subjectSource: String, val asserts: List) +data class Assertion(val prop: String, val value: String, val negated: Boolean = false) +data class Claim(val name: String, val subjectSource: String, val asserts: List, val repeatCount: Int) data class CompileBlock(val name: String, val subjectSource: String) object ClaimParser { @@ -7,8 +7,11 @@ object ClaimParser { private val compileFenceOpen = Regex("""^```kotlin\s+compile\s*$""") private val fenceClose = Regex("""^```\s*$""") private val nameLine = Regex("""^//\s*name:\s*([a-z0-9-]+)\s*$""") - private val assertLine = Regex("""^//\s*assert:\s*(width|height)\s*=\s*(\d+)\.dp\s*$""") - private val allowedProps = setOf("width", "height") + private val repeatLine = Regex("""^//\s*repeat:\s*(\d+)\s*$""") + private val dpAssertLine = Regex("""^//\s*assert:\s*(width|height)\s*=\s*(\d+)\.dp\s*$""") + private val stringAssertLine = Regex("""^//\s*assert:\s*(text|has-click-action)\s*=\s*"([^"]+)"\s*$""") + private val stringNegativeAssertLine = Regex("""^//\s*assert-not:\s*(text)\s*=\s*"([^"]+)"\s*$""") + private val allowedProps = setOf("width", "height", "text", "has-click-action") fun parse(markdown: String, sourceFile: String): List { val lines = markdown.lines() @@ -62,20 +65,32 @@ object ClaimParser { ?: err("missing '// name:' line") if (!seenNames.add(name)) err("duplicate name '$name'") + val repeatCount = body.firstNotNullOfOrNull { repeatLine.matchEntire(it.trim())?.groupValues?.get(1)?.toInt() } ?: 1 + if (repeatCount < 1) err("repeat count must be >= 1") + val asserts = body.mapNotNull { line -> - assertLine.matchEntire(line.trim())?.let { m -> Assertion(m.groupValues[1], m.groupValues[2].toInt()) } + val trimmed = line.trim() + dpAssertLine.matchEntire(trimmed)?.let { m -> + Assertion(m.groupValues[1], m.groupValues[2]) + } ?: stringAssertLine.matchEntire(trimmed)?.let { m -> + Assertion(m.groupValues[1], m.groupValues[2]) + } ?: stringNegativeAssertLine.matchEntire(trimmed)?.let { m -> + Assertion(m.groupValues[1], m.groupValues[2], true) + } + } + if (asserts.isEmpty()) { + err("no '// assert:' or '// assert-not:' lines") } - if (asserts.isEmpty()) err("no '// assert: = N.dp' lines") asserts.forEach { if (it.prop !in allowedProps) err("unsupported assert prop '${it.prop}'") } val subject = body.filterNot { l -> val t = l.trim() - nameLine.matches(t) || assertLine.matches(t) + nameLine.matches(t) || repeatLine.matches(t) || dpAssertLine.matches(t) || stringAssertLine.matches(t) || stringNegativeAssertLine.matches(t) }.joinToString("\n").trim() if (!subject.contains(Regex("""@Composable\s+fun\s+Subject\s*\("""))) { err("no '@Composable fun Subject()' found") } - return Claim(name, subject, asserts) + return Claim(name, subject, asserts, repeatCount) } private fun toCompileBlock( diff --git a/buildSrc/src/main/kotlin/GenerateClaimTests.kt b/buildSrc/src/main/kotlin/GenerateClaimTests.kt index 947068a..a3b006f 100644 --- a/buildSrc/src/main/kotlin/GenerateClaimTests.kt +++ b/buildSrc/src/main/kotlin/GenerateClaimTests.kt @@ -21,16 +21,16 @@ abstract class GenerateClaimTests : DefaultTask() { out.deleteRecursively(); out.mkdirs() val tests = claims.joinToString("\n\n") { c -> - val asserts = c.asserts.joinToString("\n") { a -> - val fn = if (a.prop == "width") "assertWidthIsEqualTo" else "assertHeightIsEqualTo" - " rule.onRoot().$fn(${a.dp}.dp)" + (1..c.repeatCount).joinToString("\n\n") { trial -> + val asserts = c.asserts.joinToString("\n") { a -> emitAssertion(a) } + val suffix = if (c.repeatCount == 1) "" else "_trial$trial" + """ + @Test fun `${c.name}$suffix`() { + rule.setContent { ClaimSubjects.`${c.name}`() } + $asserts + } + """.trimIndent() } - """ - @Test fun `${c.name}`() { - rule.setContent { ClaimSubjects.`${c.name}`() } - $asserts - } - """.trimIndent() } val subjects = claims.joinToString("\n\n") { c -> @@ -87,11 +87,30 @@ abstract class GenerateClaimTests : DefaultTask() { require(duplicate == null) { "duplicate kotlin $marker block name '$duplicate'" } } + private fun emitAssertion(assertion: Assertion): String = + when (assertion.prop) { + "width" -> " rule.onRoot().assertWidthIsEqualTo(${assertion.value}.dp)" + "height" -> " rule.onRoot().assertHeightIsEqualTo(${assertion.value}.dp)" + "text" -> { + val call = if (assertion.negated) "assertDoesNotExist()" else "assertExists()" + " rule.onNodeWithText(\"${assertion.value.escapeKotlinString()}\").$call" + } + "has-click-action" -> + " rule.onNodeWithText(\"${assertion.value.escapeKotlinString()}\").assertHasClickAction()" + else -> error("unsupported assertion prop '${assertion.prop}'") + } + + private fun String.escapeKotlinString(): String = + replace("\\", "\\\\").replace("\"", "\\\"") + private fun generatedImports(): String = """ import androidx.compose.foundation.background import androidx.compose.foundation.clickable + import androidx.compose.foundation.lazy.LazyColumn + import androidx.compose.foundation.lazy.items import androidx.compose.foundation.layout.* + import androidx.compose.animation.AnimatedVisibility import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -99,6 +118,8 @@ abstract class GenerateClaimTests : DefaultTask() { import androidx.compose.ui.unit.dp import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot + import androidx.compose.ui.test.onNodeWithText + import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertWidthIsEqualTo import androidx.compose.ui.test.assertHeightIsEqualTo import org.junit.Rule diff --git a/buildSrc/src/test/kotlin/ClaimParserTest.kt b/buildSrc/src/test/kotlin/ClaimParserTest.kt index cd89a04..8c6c11e 100644 --- a/buildSrc/src/test/kotlin/ClaimParserTest.kt +++ b/buildSrc/src/test/kotlin/ClaimParserTest.kt @@ -26,10 +26,29 @@ class ClaimParserTest { assertEquals(1, claims.size) val c = claims.single() assertEquals("size-before-padding", c.name) - assertEquals(listOf("width" to 100, "height" to 100), c.asserts.map { it.prop to it.dp }) + assertEquals(listOf("width" to "100", "height" to "100"), c.asserts.map { it.prop to it.value }) assert(c.subjectSource.contains("fun Subject()")) } + @Test fun parsesTextAndClickActionAssertions() { + val md = """ + ```kotlin verify + // name: button-semantics + @Composable fun Subject() { + Button(onClick = {}) { Text("Save") } + } + // assert: text = "Save" + // assert: has-click-action = "Save" + ``` + """.trimIndent() + + val claim = ClaimParser.parse(md, "x.md").single() + assertEquals( + listOf("text" to "Save", "has-click-action" to "Save"), + claim.asserts.map { it.prop to it.value }, + ) + } + @Test fun rejectsMissingName() { val md = "```kotlin verify\n@Composable fun Subject() {}\n// assert: width = 1.dp\n```" val ex = assertThrows(IllegalStateException::class.java) { ClaimParser.parse(md, "x.md") } diff --git a/skills/compose-expert/references/accessibility.md b/skills/compose-expert/references/accessibility.md index 4d210ba..03ce6d5 100644 --- a/skills/compose-expert/references/accessibility.md +++ b/skills/compose-expert/references/accessibility.md @@ -19,6 +19,20 @@ Box( } ``` +Executable smoke check for built-in button semantics: + +```kotlin verify +// name: button-text-exposes-click-action +// repeat: 3 +// assert: text = "Click me" +// assert: has-click-action = "Click me" +// assert-not: text = "Delete" +@Composable +fun Subject() { + Button(onClick = { }) { Text("Click me") } +} +``` + **Source**: `androidx/compose/ui/semantics/` --- diff --git a/skills/compose-expert/references/animation.md b/skills/compose-expert/references/animation.md index 40a36ac..6ae3383 100644 --- a/skills/compose-expert/references/animation.md +++ b/skills/compose-expert/references/animation.md @@ -33,7 +33,13 @@ Each automatically handles coroutines and recomposition. Use the `label` paramet Controls appear/disappear animations with enter and exit transitions. -```kotlin +```kotlin verify +// name: animated-visibility-renders-visible-content +// repeat: 3 +// assert: text = "Hello!" +// assert-not: text = "Goodbye!" +@Composable +fun Subject() { var visible by remember { mutableStateOf(true) } AnimatedVisibility(visible = visible) { @@ -42,6 +48,7 @@ AnimatedVisibility(visible = visible) { // Trigger Button(onClick = { visible = !visible }) { Text("Toggle") } +} ``` ### Enter/Exit Transitions diff --git a/skills/compose-expert/references/composition-locals.md b/skills/compose-expert/references/composition-locals.md index 10df9aa..ddc5a5a 100644 --- a/skills/compose-expert/references/composition-locals.md +++ b/skills/compose-expert/references/composition-locals.md @@ -6,11 +6,15 @@ CompositionLocals provide a way to pass data implicitly down the composition tre A CompositionLocal is a slot in the composition that holds a value accessible to any descendant composable without explicit parameter passing. Values are provided using `CompositionLocalProvider` and accessed via `current`. -```kotlin +```kotlin verify +// name: composition-local-provider-overrides-default +// repeat: 3 +// assert: text = "Dark" +// assert-not: text = "Light" val localAppTheme = compositionLocalOf { "Light" } @Composable -fun MyScreen() { +fun Subject() { CompositionLocalProvider(localAppTheme provides "Dark") { DescendantComposable() // Can access "Dark" via localAppTheme.current } diff --git a/skills/compose-expert/references/lists-scrolling.md b/skills/compose-expert/references/lists-scrolling.md index b5ea079..3ac11c8 100644 --- a/skills/compose-expert/references/lists-scrolling.md +++ b/skills/compose-expert/references/lists-scrolling.md @@ -67,6 +67,23 @@ LazyColumn { } ``` +Executable smoke check for the count overload: + +```kotlin verify +// name: lazy-column-count-renders-visible-items +// repeat: 3 +// assert: text = "Item 0" +// assert-not: text = "Item 3" +@Composable +fun Subject() { + LazyColumn { + items(3) { index -> + Text("Item $index") + } + } +} +``` + ### `itemsIndexed` — With Index ```kotlin LazyColumn { diff --git a/skills/compose-expert/references/modifiers.md b/skills/compose-expert/references/modifiers.md index 09b3070..2103c1f 100644 --- a/skills/compose-expert/references/modifiers.md +++ b/skills/compose-expert/references/modifiers.md @@ -10,6 +10,7 @@ Order matters. Modifiers are applied left-to-right in the DSL, but conceptually ```kotlin verify // name: padding-before-size-adds-to-footprint +// repeat: 3 // assert: width = 132.dp // assert: height = 132.dp // Footprint = 132x132. size fixes the 100x100 inner box, padding adds 16dp on @@ -26,6 +27,7 @@ Order matters. Modifiers are applied left-to-right in the DSL, but conceptually ```kotlin verify // name: size-before-padding-keeps-footprint +// repeat: 3 // assert: width = 100.dp // assert: height = 100.dp // Footprint = 100x100, NOT 132x132. size fixes the element at 100x100 first; diff --git a/skills/compose-expert/references/performance.md b/skills/compose-expert/references/performance.md index a697a8c..ee8f55d 100644 --- a/skills/compose-expert/references/performance.md +++ b/skills/compose-expert/references/performance.md @@ -57,6 +57,23 @@ A type is **stable** if: - Overrides to `equals()` and `hashCode()` are based on stable properties - Recomposition is skipped when the same instance is passed +Executable check for a stable model rendered through a composable: + +```kotlin verify +// name: stable-model-renders-text +// repeat: 3 +// assert: text = "Ada" +// assert-not: text = "Bob" +@Immutable +data class Person(val name: String, val age: Int) + +@Composable +fun Subject() { + val person = Person("Ada", 42) + Text(person.name) +} +``` + Mark stable types explicitly: ```kotlin diff --git a/skills/compose-expert/references/side-effects.md b/skills/compose-expert/references/side-effects.md index b735bba..635c203 100644 --- a/skills/compose-expert/references/side-effects.md +++ b/skills/compose-expert/references/side-effects.md @@ -86,6 +86,23 @@ Source: `compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/ `LaunchedEffect` launches a coroutine in a scope tied to the composable's lifecycle. The coroutine is cancelled if the key changes or the composable leaves composition. +Executable check for the common "load then render" pattern: + +```kotlin verify +// name: launched-effect-updates-ui +// repeat: 3 +// assert: text = "loaded" +// assert-not: text = "loading" +@Composable +fun Subject() { + var data by remember { mutableStateOf("loading") } + LaunchedEffect(Unit) { + data = "loaded" + } + Text(data) +} +``` + ```kotlin @Composable fun DataLoader(userId: String) { diff --git a/skills/compose-expert/references/state-management.md b/skills/compose-expert/references/state-management.md index 7389a5d..e3e82a6 100644 --- a/skills/compose-expert/references/state-management.md +++ b/skills/compose-expert/references/state-management.md @@ -29,9 +29,14 @@ Both associate state with a composition key, but differ in persistence scope. - Lost on process death, configuration changes, back navigation - Best for UI state: selection, expanded/collapsed, scroll position -```kotlin +```kotlin verify +// name: remember-counter-renders-clickable-state +// repeat: 3 +// assert: text = "Count: 0" +// assert: has-click-action = "Count: 0" +// assert-not: text = "Count: 1" @Composable -fun Counter() { +fun Subject() { var count by remember { mutableIntStateOf(0) } Button(onClick = { count++ }) { Text("Count: $count") diff --git a/skills/compose-expert/references/theming-material3.md b/skills/compose-expert/references/theming-material3.md index 16d2b43..03534f6 100644 --- a/skills/compose-expert/references/theming-material3.md +++ b/skills/compose-expert/references/theming-material3.md @@ -4,8 +4,11 @@ `MaterialTheme` is the root provider for design tokens in Compose Material 3. It establishes `colorScheme`, `typography`, and `shapes` across your app. -```kotlin compile +```kotlin verify // name: material-theme-provides-tokens +// repeat: 3 +// assert: text = "Uses MaterialTheme.typography.bodyLarge" +// assert-not: text = "MaterialExpressiveTheme" @Composable fun Subject() { MaterialTheme( diff --git a/skills/compose-expert/references/view-composition.md b/skills/compose-expert/references/view-composition.md index 0cb83ce..48318e9 100644 --- a/skills/compose-expert/references/view-composition.md +++ b/skills/compose-expert/references/view-composition.md @@ -109,6 +109,21 @@ ListItem( **Key principle:** Slots accept `@Composable` lambdas, not pre-composed values. This ensures composition is deferred and scope-aware. +Executable check for the slot pattern: + +```kotlin verify +// name: slot-pattern-renders-child-content +// repeat: 3 +// assert: text = "Hello" +// assert-not: text = "World" +@Composable +fun Subject() { + Card { + Text("Hello") + } +} +``` + ```kotlin // ❌ Wrong: passes composed value fun CustomLayout(content: String) { ... }