create-cmp-cli 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -9
- package/llms.txt +5 -1
- package/package.json +1 -1
- package/src/lib/adr-seed.mjs +178 -0
- package/src/lib/tabs.mjs +91 -4
- package/src/scaffold.mjs +52 -1
- package/template/.claude/skills/add-feature/SKILL.md +35 -10
- package/template/.claude/skills/add-repository/SKILL.md +1 -1
- package/template/.claude/skills/add-screen/SKILL.md +13 -7
- package/template/.githooks/pre-push +24 -0
- package/template/CLAUDE.md +196 -48
- package/template/README.md +23 -27
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +69 -2
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
- package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
- package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
- package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +269 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
- package/template/docs/ARCHITECTURE.md +317 -34
- package/template/docs/TESTING.md +6 -5
- package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
- package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
- package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
- package/template/qa/approvals.json +42 -0
- package/template/qa/approve.mjs +139 -0
- package/template/qa/arch-doc.mjs +69 -0
- package/template/qa/comment.mjs +76 -0
- package/template/qa/comments.json +4 -0
- package/template/qa/golden/home.json +3 -3
- package/template/qa/lib/approvals.mjs +806 -0
- package/template/qa/lib/arch-doc.mjs +451 -0
- package/template/qa/lib/comments.mjs +252 -0
- package/template/qa/lib/component-stories.mjs +183 -0
- package/template/qa/lib/inputs-hash.mjs +5 -1
- package/template/qa/scaffold-feature.mjs +184 -67
- package/template/qa/setup-hooks.mjs +33 -0
- package/template/qa/verify.mjs +118 -9
- package/template/specs/app-base.spec.md +44 -7
- package/template/specs/home.spec.md +7 -4
- package/template/specs/intent.md +50 -0
|
@@ -28,16 +28,23 @@ class ArchitectureConformanceTest {
|
|
|
28
28
|
file.readLines().filter { it.trimStart().startsWith("import ") }.map { it.trim() }
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Source lines with comment lines
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* Source lines with comments stripped — whole comment lines AND trailing `// …` tails
|
|
32
|
+
* (a `(?<!:)` guard keeps `https://` URLs inside strings intact). Layer-boundary rules
|
|
33
|
+
* scan these for BOTH `import x.y.` statements AND fully-qualified inline references
|
|
34
|
+
* (`x.y.Type(...)`) — import-only matching leaves a one-edit evasion open: delete the
|
|
35
|
+
* import, qualify the name inline, and the gate goes green while the violation remains.
|
|
36
|
+
* Stripping trailing comments closes the inverse hole: prose like
|
|
37
|
+
* `get() // wires com.app.data.ItemRepositoryImpl` must not fail a boundary gate.
|
|
35
38
|
*/
|
|
39
|
+
private val trailingLineComment = Regex("""(?<!:)//.*""")
|
|
40
|
+
|
|
36
41
|
private fun nonCommentLines(file: File): List<String> =
|
|
37
|
-
file.readLines()
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
file.readLines()
|
|
43
|
+
.filterNot {
|
|
44
|
+
val t = it.trimStart()
|
|
45
|
+
t.startsWith("//") || t.startsWith("*") || t.startsWith("/*")
|
|
46
|
+
}
|
|
47
|
+
.map { it.replace(trailingLineComment, "") }
|
|
41
48
|
|
|
42
49
|
private fun bannedReference(file: File, banned: List<String>): Boolean =
|
|
43
50
|
nonCommentLines(file).any { line -> banned.any { line.contains(it) } }
|
|
@@ -113,8 +120,26 @@ class ArchitectureConformanceTest {
|
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
// SPEC: ARCH-04
|
|
123
|
+
// Component-derived tags (component-system-deep-dive.md §6.4) count as tag provenance:
|
|
124
|
+
// a screen built entirely from registry components (ScreenColumn/AppHeader/
|
|
125
|
+
// ContentStateContainer/…) is automation-reachable through the tags THOSE components
|
|
126
|
+
// emit from their required `screenTag` parameter, even with no literal `testTag` of its
|
|
127
|
+
// own. The acceptance is narrow by design (mitigates the risk table's sloppy-scan
|
|
128
|
+
// concern): `screenTag\s*=` in a call-argument position AND the file actually imports
|
|
129
|
+
// from `presentation.components` — a file that merely contains the substring
|
|
130
|
+
// "screenTag" in a comment, or that never touches the registry, still fails.
|
|
131
|
+
private val screenTagArgument = Regex("""screenTag\s*=""")
|
|
132
|
+
|
|
133
|
+
private fun hasTagProvenance(file: File): Boolean {
|
|
134
|
+
val text = file.readText()
|
|
135
|
+
val importsComponents = text.lines().any {
|
|
136
|
+
it.trimStart().startsWith("import ") && it.contains(".presentation.components.")
|
|
137
|
+
}
|
|
138
|
+
return text.contains("testTag") || (importsComponents && screenTagArgument.containsMatchIn(text))
|
|
139
|
+
}
|
|
140
|
+
|
|
116
141
|
@Test
|
|
117
|
-
fun `ARCH-04 every feature composable file
|
|
142
|
+
fun `ARCH-04 every feature composable file is automation-reachable - literal testTag or screenTag provenance`() {
|
|
118
143
|
// Scoped by CONTENT (contains @Composable), not by *Screen.kt filename: real apps
|
|
119
144
|
// split features into Screen.kt (often ViewModel-only) and Content.kt (the UI).
|
|
120
145
|
// Filename scoping produced both false negatives (untagged FooContent.kt slid
|
|
@@ -123,14 +148,36 @@ class ArchitectureConformanceTest {
|
|
|
123
148
|
.filter { inPresentationFeatureDir(it) }
|
|
124
149
|
.filterNot { under(it, "components") || under(it, "navigation") || under(it, "theme") }
|
|
125
150
|
.filter { it.readText().contains("@Composable") }
|
|
126
|
-
.filterNot { it
|
|
151
|
+
.filterNot { hasTagProvenance(it) }
|
|
127
152
|
.map { it.path }
|
|
128
153
|
if (offenders.isNotEmpty()) fail(
|
|
129
154
|
violation(
|
|
130
155
|
"ARCH-04", "every feature UI file is automation-reachable: files containing a " +
|
|
131
|
-
"@Composable declare at least one testTag
|
|
156
|
+
"@Composable declare at least one literal testTag OR pass screenTag = to a " +
|
|
157
|
+
"registry component imported from presentation.components.",
|
|
132
158
|
offenders,
|
|
133
|
-
"add Modifier.semantics { testTag = \"<feature>_<element>\" } to the file's key nodes
|
|
159
|
+
"add Modifier.semantics { testTag = \"<feature>_<element>\" } to the file's key nodes, " +
|
|
160
|
+
"or compose it from registry components (e.g. ScreenColumn(screenTag = \"<feature>\") { … }).",
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// SPEC: ARCH-11
|
|
166
|
+
@Test
|
|
167
|
+
fun `ARCH-11 screens present loading through the components registry, never a hand-rolled indicator`() {
|
|
168
|
+
val banned = listOf("CircularProgressIndicator", "LinearProgressIndicator")
|
|
169
|
+
val offenders = sources(commonMain)
|
|
170
|
+
.filter { inPresentationFeatureDir(it) }
|
|
171
|
+
.filterNot { under(it, "components") || under(it, "navigation") || under(it, "theme") }
|
|
172
|
+
.filter { bannedReference(it, banned) }
|
|
173
|
+
.map { it.path }
|
|
174
|
+
if (offenders.isNotEmpty()) fail(
|
|
175
|
+
violation(
|
|
176
|
+
"ARCH-11", "screens never reference CircularProgressIndicator/LinearProgressIndicator " +
|
|
177
|
+
"directly — loading is presented through ContentStateContainer/ContentStateDefaults.",
|
|
178
|
+
offenders,
|
|
179
|
+
"bind the screen's loading arm to ContentStateContainer(state = …, screenTag = …) instead " +
|
|
180
|
+
"of drawing a progress indicator by hand (see the exemplar HomeScreen).",
|
|
134
181
|
)
|
|
135
182
|
)
|
|
136
183
|
}
|
|
@@ -141,7 +188,7 @@ class ArchitectureConformanceTest {
|
|
|
141
188
|
val colorLiteral = Regex("""Color\(0x""")
|
|
142
189
|
val offenders = sources(commonMain)
|
|
143
190
|
.filterNot { under(it, "theme") }
|
|
144
|
-
.filter { colorLiteral.containsMatchIn(it
|
|
191
|
+
.filter { file -> nonCommentLines(file).any { colorLiteral.containsMatchIn(it) } }
|
|
145
192
|
.map { it.path }
|
|
146
193
|
if (offenders.isNotEmpty()) fail(
|
|
147
194
|
violation(
|
|
@@ -152,6 +199,147 @@ class ArchitectureConformanceTest {
|
|
|
152
199
|
)
|
|
153
200
|
}
|
|
154
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Each `suspend fun` declaration in [file] as one whitespace-normalized signature —
|
|
204
|
+
* from the keyword through its balanced parameter list to the end of that line, so a
|
|
205
|
+
* multiline (trailing-comma style) signature is judged by its return type, not by
|
|
206
|
+
* whichever fragment happens to share a line with `suspend fun`.
|
|
207
|
+
*/
|
|
208
|
+
private fun suspendFunSignatures(file: File): List<String> {
|
|
209
|
+
val text = nonCommentLines(file).joinToString("\n")
|
|
210
|
+
val signatures = mutableListOf<String>()
|
|
211
|
+
var start = text.indexOf("suspend fun")
|
|
212
|
+
while (start >= 0) {
|
|
213
|
+
val open = text.indexOf('(', start)
|
|
214
|
+
if (open == -1) break
|
|
215
|
+
var depth = 0
|
|
216
|
+
var i = open
|
|
217
|
+
while (i < text.length) {
|
|
218
|
+
val c = text[i]
|
|
219
|
+
if (c == '(') depth += 1
|
|
220
|
+
if (c == ')') {
|
|
221
|
+
depth -= 1
|
|
222
|
+
if (depth == 0) break
|
|
223
|
+
}
|
|
224
|
+
i += 1
|
|
225
|
+
}
|
|
226
|
+
val end = text.indexOf('\n', i).let { if (it == -1) text.length else it }
|
|
227
|
+
signatures.add(text.substring(start, end).replace(Regex("\\s+"), " ").trim())
|
|
228
|
+
start = text.indexOf("suspend fun", end)
|
|
229
|
+
}
|
|
230
|
+
return signatures
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// SPEC: ARCH-06
|
|
234
|
+
@Test
|
|
235
|
+
fun `ARCH-06 repository interfaces return AppResult - exceptions never cross the boundary`() {
|
|
236
|
+
val offenders = sources(commonMain)
|
|
237
|
+
.filter { under(it, "domain") && under(it, "repository") }
|
|
238
|
+
.flatMap { file ->
|
|
239
|
+
suspendFunSignatures(file)
|
|
240
|
+
.filterNot { it.contains(": AppResult<") }
|
|
241
|
+
.map { "${file.path} — $it" }
|
|
242
|
+
}
|
|
243
|
+
if (offenders.isNotEmpty()) fail(
|
|
244
|
+
violation(
|
|
245
|
+
"ARCH-06", "every one-shot repository operation (suspend fun) declares an AppResult<…> " +
|
|
246
|
+
"return type — failures cross the data boundary as typed DomainError values, never as exceptions.",
|
|
247
|
+
offenders,
|
|
248
|
+
"return AppResult<T> and translate exceptions inside the data implementation via suspendRunCatching.",
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// SPEC: ARCH-07
|
|
254
|
+
@Test
|
|
255
|
+
fun `ARCH-07 ViewModels contain no exception handling`() {
|
|
256
|
+
// Syntactic forms only (`try {`, `catch (`, `runCatching`) — a bare-word scan would
|
|
257
|
+
// false-positive on user-facing copy like "…try again." inside the ViewModel's strings.
|
|
258
|
+
val exceptionHandling = Regex("""\btry\s*\{|\bcatch\s*[({]|\brunCatching\b""")
|
|
259
|
+
val offenders = sources(commonMain)
|
|
260
|
+
.filter { under(it, "presentation") && it.name.endsWith("ViewModel.kt") }
|
|
261
|
+
.filter { file -> nonCommentLines(file).any { exceptionHandling.containsMatchIn(it) } }
|
|
262
|
+
.map { it.path }
|
|
263
|
+
if (offenders.isNotEmpty()) fail(
|
|
264
|
+
violation(
|
|
265
|
+
"ARCH-07", "ViewModels contain no try/catch/runCatching — they fold over AppResult " +
|
|
266
|
+
"and map DomainError kinds to user copy; exception translation is the data layer's job.",
|
|
267
|
+
offenders,
|
|
268
|
+
"remove the exception handling; `when` over the use case's AppResult instead (see the exemplar ViewModel).",
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// SPEC: ARCH-08
|
|
274
|
+
@Test
|
|
275
|
+
fun `ARCH-08 the data layer's only catch mechanism is suspendRunCatching with its cancellation guard`() {
|
|
276
|
+
val helperName = "AppResultCatching.kt"
|
|
277
|
+
val catching = Regex("""\bcatch\s*\(|\brunCatching\b""")
|
|
278
|
+
val dataFiles = sources(commonMain).filter { under(it, "data") }
|
|
279
|
+
|
|
280
|
+
val offenders = dataFiles
|
|
281
|
+
.filterNot { it.name == helperName }
|
|
282
|
+
.filter { file -> nonCommentLines(file).any { catching.containsMatchIn(it) } }
|
|
283
|
+
.map { it.path }
|
|
284
|
+
.toMutableList()
|
|
285
|
+
|
|
286
|
+
// The helper itself must keep the guard that makes the convention safe:
|
|
287
|
+
// CancellationException is rethrown, never mapped to a Failure.
|
|
288
|
+
val helper = dataFiles.firstOrNull { it.name == helperName }
|
|
289
|
+
if (helper != null) {
|
|
290
|
+
val text = helper.readText()
|
|
291
|
+
if (!text.contains("catch (e: CancellationException)") || !text.contains("throw e")) {
|
|
292
|
+
offenders.add("${helper.path} (the CancellationException rethrow guard is missing)")
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (offenders.isNotEmpty()) fail(
|
|
297
|
+
violation(
|
|
298
|
+
"ARCH-08", "the ONLY exception-catching mechanism in the data layer is the shared " +
|
|
299
|
+
"suspendRunCatching helper ($helperName), which always rethrows CancellationException — " +
|
|
300
|
+
"ad-hoc catch blocks can silently swallow cancellation.",
|
|
301
|
+
offenders,
|
|
302
|
+
"wrap the I/O in suspendRunCatching { … } (with a mapError classifier) instead of catching directly.",
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// SPEC: ARCH-09
|
|
308
|
+
@Test
|
|
309
|
+
fun `ARCH-09 data never references presentation or di`() {
|
|
310
|
+
val offenders = sources(commonMain)
|
|
311
|
+
.filter { under(it, "data") }
|
|
312
|
+
.filter { bannedReference(it, listOf("__PACKAGE__.presentation.", "__PACKAGE__.di.")) }
|
|
313
|
+
.map { it.path }
|
|
314
|
+
if (offenders.isNotEmpty()) fail(
|
|
315
|
+
violation(
|
|
316
|
+
"ARCH-09", "data serves domain contracts — it never references presentation or di " +
|
|
317
|
+
"(neither imports nor fully-qualified inline names).",
|
|
318
|
+
offenders,
|
|
319
|
+
"move the presentation/di-touching code out of data; data implements domain's repository " +
|
|
320
|
+
"interfaces and stops there.",
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// SPEC: ARCH-10
|
|
326
|
+
@Test
|
|
327
|
+
fun `ARCH-10 core is leaf utility code - imports domain at most`() {
|
|
328
|
+
val banned = listOf("__PACKAGE__.presentation.", "__PACKAGE__.data.", "__PACKAGE__.di.")
|
|
329
|
+
val offenders = sources(commonMain)
|
|
330
|
+
.filter { under(it, "core") }
|
|
331
|
+
.filter { bannedReference(it, banned) }
|
|
332
|
+
.map { it.path }
|
|
333
|
+
if (offenders.isNotEmpty()) fail(
|
|
334
|
+
violation(
|
|
335
|
+
"ARCH-10", "core is leaf utility code, importable by every other layer — it references " +
|
|
336
|
+
"nothing in presentation, data, or di (neither imports nor fully-qualified inline names).",
|
|
337
|
+
offenders,
|
|
338
|
+
"move the app-layer-touching code out of core; core stays a leaf (domain types at most).",
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
155
343
|
// SPEC: SHELL-05
|
|
156
344
|
@Test
|
|
157
345
|
fun `SHELL-05 every non-shell nav destination wraps its content in BaseScreen`() {
|
|
@@ -159,7 +347,11 @@ class ArchitectureConformanceTest {
|
|
|
159
347
|
// insets at all (bare Column at the nav layer) passes that rule while rendering
|
|
160
348
|
// under the status bar. Tab screens are exempt — AppShell wraps them — so the rule
|
|
161
349
|
// targets exactly the destinations registered directly on the NavHost.
|
|
162
|
-
|
|
350
|
+
// Filename first; a renamed nav host is still found by content (the `composable(`
|
|
351
|
+
// registrations this rule parses) so the gate cannot go silently vacuous.
|
|
352
|
+
val navHost = sources(commonMain).firstOrNull { it.name == "AppNavHost.kt" }
|
|
353
|
+
?: sources(commonMain).firstOrNull { under(it, "navigation") && it.readText().contains("composable(") }
|
|
354
|
+
?: return
|
|
163
355
|
val text = navHost.readText()
|
|
164
356
|
val screenCall = Regex("""([A-Z][A-Za-z0-9]*Screen)\s*\(""")
|
|
165
357
|
// A call with only a trailing lambda has no paren — `BaseScreen { … }` — so match both.
|
|
@@ -201,7 +393,7 @@ class ArchitectureConformanceTest {
|
|
|
201
393
|
val offenders = sources(commonMain)
|
|
202
394
|
.filterNot { under(it, "components") }
|
|
203
395
|
.filterNot { under(it, "navigation") }
|
|
204
|
-
.filter { insetApi.containsMatchIn(it
|
|
396
|
+
.filter { file -> nonCommentLines(file).any { insetApi.containsMatchIn(it) } }
|
|
205
397
|
.map { it.path }
|
|
206
398
|
if (offenders.isNotEmpty()) fail(
|
|
207
399
|
violation(
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
package __PACKAGE__.conformance
|
|
2
|
+
|
|
3
|
+
import androidx.compose.material3.MaterialTheme
|
|
4
|
+
import androidx.compose.ui.test.ExperimentalTestApi
|
|
5
|
+
import androidx.compose.ui.test.assertHeightIsAtLeast
|
|
6
|
+
import androidx.compose.ui.test.assertWidthIsAtLeast
|
|
7
|
+
import androidx.compose.ui.test.hasTestTag
|
|
8
|
+
import androidx.compose.ui.test.hasText
|
|
9
|
+
import androidx.compose.ui.test.onNodeWithTag
|
|
10
|
+
import androidx.compose.ui.test.runComposeUiTest
|
|
11
|
+
import androidx.compose.ui.unit.dp
|
|
12
|
+
import __PACKAGE__.domain.model.DomainError
|
|
13
|
+
import __PACKAGE__.domain.model.Item
|
|
14
|
+
import __PACKAGE__.domain.usecase.GetItemsUseCase
|
|
15
|
+
import __PACKAGE__.presentation.home.HomeScreen
|
|
16
|
+
import __PACKAGE__.presentation.home.HomeViewModel
|
|
17
|
+
import __PACKAGE__.testing.awaitNode
|
|
18
|
+
import __PACKAGE__.testing.fakes.FakeItemRepository
|
|
19
|
+
import kotlin.test.Test
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The COMP clauses (`specs/app-base.spec.md`) as executable checks — the component
|
|
23
|
+
* vocabulary's runtime contract, proven against the exemplar screen the same way the
|
|
24
|
+
* ARCH/SHELL conformance gates prove the architecture. Component substructure/tag
|
|
25
|
+
* stability is additionally pinned by the golden-tree baseline (`qa/golden/`); this class
|
|
26
|
+
* proves the STATE/A11Y contract every registry consumer inherits for free.
|
|
27
|
+
*/
|
|
28
|
+
@OptIn(ExperimentalTestApi::class)
|
|
29
|
+
class ComponentConformanceTest {
|
|
30
|
+
|
|
31
|
+
// SPEC: COMP-01
|
|
32
|
+
@Test
|
|
33
|
+
fun `a failed load is presented by ContentStateContainer with the screen-derived error tag`() = runComposeUiTest {
|
|
34
|
+
val repository = FakeItemRepository().apply { failure = DomainError.Network }
|
|
35
|
+
setContent {
|
|
36
|
+
MaterialTheme {
|
|
37
|
+
HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
awaitNode(hasTestTag("home_error"))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// SPEC: COMP-01
|
|
44
|
+
@Test
|
|
45
|
+
fun `a zero-item load is presented by ContentStateContainer with the screen-derived empty tag`() = runComposeUiTest {
|
|
46
|
+
val repository = FakeItemRepository().apply { items = emptyList() }
|
|
47
|
+
setContent {
|
|
48
|
+
MaterialTheme {
|
|
49
|
+
HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
awaitNode(hasTestTag("home_empty"))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// SPEC: COMP-02
|
|
56
|
+
@Test
|
|
57
|
+
fun `a recoverable error renders a retry control of at least 48dp`() = runComposeUiTest {
|
|
58
|
+
val repository = FakeItemRepository().apply { failure = DomainError.Network }
|
|
59
|
+
setContent {
|
|
60
|
+
MaterialTheme {
|
|
61
|
+
HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
awaitNode(hasTestTag("home_retry"))
|
|
65
|
+
onNodeWithTag("home_retry")
|
|
66
|
+
.assertWidthIsAtLeast(48.dp)
|
|
67
|
+
.assertHeightIsAtLeast(48.dp)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// SPEC: COMP-03
|
|
71
|
+
@Test
|
|
72
|
+
fun `every ListItemCard row clears the 48dp minimum pointer target`() = runComposeUiTest {
|
|
73
|
+
val repository = FakeItemRepository().apply {
|
|
74
|
+
items = listOf(Item(id = "1", title = "Row", subtitle = "sub"))
|
|
75
|
+
}
|
|
76
|
+
setContent {
|
|
77
|
+
MaterialTheme {
|
|
78
|
+
HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
awaitNode(hasText("Row"))
|
|
82
|
+
onNodeWithTag("home_item_1").assertHeightIsAtLeast(48.dp)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -10,6 +10,7 @@ import androidx.compose.ui.test.onFirst
|
|
|
10
10
|
import androidx.compose.ui.test.onNodeWithTag
|
|
11
11
|
import androidx.compose.ui.test.performClick
|
|
12
12
|
import androidx.compose.ui.test.runComposeUiTest
|
|
13
|
+
import __PACKAGE__.domain.model.DomainError
|
|
13
14
|
import __PACKAGE__.domain.model.Item
|
|
14
15
|
import __PACKAGE__.domain.usecase.GetItemsUseCase
|
|
15
16
|
import __PACKAGE__.testing.awaitNode
|
|
@@ -47,16 +48,27 @@ class HomeScreenTest {
|
|
|
47
48
|
|
|
48
49
|
// SPEC: HOME-03
|
|
49
50
|
@Test
|
|
50
|
-
fun `shows
|
|
51
|
-
repository.
|
|
52
|
-
repository.failureMessage = "network down"
|
|
51
|
+
fun `shows presentation-mapped error copy when loading fails`() = runComposeUiTest {
|
|
52
|
+
repository.failure = DomainError.Network
|
|
53
53
|
|
|
54
54
|
setContent {
|
|
55
55
|
MaterialTheme { HomeScreen(onItemClick = {}, viewModel = viewModel()) }
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
awaitNode(hasTestTag("home_error"))
|
|
59
|
-
onAllNodesWithText(
|
|
59
|
+
onAllNodesWithText(DomainError.Network.toUserMessage()).assertCountEquals(1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// SPEC: HOME-07
|
|
63
|
+
@Test
|
|
64
|
+
fun `shows the empty state when the repository returns no items`() = runComposeUiTest {
|
|
65
|
+
repository.items = emptyList()
|
|
66
|
+
|
|
67
|
+
setContent {
|
|
68
|
+
MaterialTheme { HomeScreen(onItemClick = {}, viewModel = viewModel()) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
awaitNode(hasTestTag("home_empty"))
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
// SPEC: HOME-05
|
|
@@ -74,4 +86,24 @@ class HomeScreenTest {
|
|
|
74
86
|
waitUntil(timeoutMillis = 5_000) { clickedId != null }
|
|
75
87
|
kotlin.test.assertEquals("item-42", clickedId)
|
|
76
88
|
}
|
|
89
|
+
|
|
90
|
+
// SPEC: HOME-04
|
|
91
|
+
@Test
|
|
92
|
+
fun `tapping retry after a failure reloads and shows recovered items`() = runComposeUiTest {
|
|
93
|
+
repository.failure = DomainError.Network
|
|
94
|
+
val vm = viewModel()
|
|
95
|
+
|
|
96
|
+
setContent {
|
|
97
|
+
MaterialTheme { HomeScreen(onItemClick = {}, viewModel = vm) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
awaitNode(hasTestTag("home_error"))
|
|
101
|
+
onNodeWithTag("home_retry", useUnmergedTree = true).assertExists()
|
|
102
|
+
|
|
103
|
+
repository.failure = null
|
|
104
|
+
repository.items = listOf(Item(id = "1", title = "Recovered", subtitle = "sub"))
|
|
105
|
+
onNodeWithTag("home_retry").performClick()
|
|
106
|
+
|
|
107
|
+
awaitNode(hasText("Recovered"))
|
|
108
|
+
}
|
|
77
109
|
}
|