create-cmp-cli 0.7.1 → 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.
Files changed (79) hide show
  1. package/README.md +72 -11
  2. package/llms.txt +6 -2
  3. package/package.json +1 -1
  4. package/src/commands/upgrade.mjs +8 -1
  5. package/src/lib/adr-seed.mjs +178 -0
  6. package/src/lib/registry.mjs +15 -2
  7. package/src/lib/tabs.mjs +91 -4
  8. package/src/lib/upgrade.mjs +49 -5
  9. package/src/scaffold.mjs +52 -1
  10. package/src/versions/candidates.json +4 -0
  11. package/src/versions/registry.json +88 -0
  12. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  13. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  14. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  15. package/template/.githooks/pre-push +24 -0
  16. package/template/CLAUDE.md +196 -48
  17. package/template/README.md +23 -27
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  20. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +69 -2
  21. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  22. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  48. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  49. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  50. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  51. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  52. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  53. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +269 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  55. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
  56. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  57. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  58. package/template/docs/ARCHITECTURE.md +317 -34
  59. package/template/docs/TESTING.md +6 -5
  60. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  61. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  62. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  63. package/template/qa/approvals.json +42 -0
  64. package/template/qa/approve.mjs +139 -0
  65. package/template/qa/arch-doc.mjs +69 -0
  66. package/template/qa/comment.mjs +76 -0
  67. package/template/qa/comments.json +4 -0
  68. package/template/qa/golden/home.json +3 -3
  69. package/template/qa/lib/approvals.mjs +806 -0
  70. package/template/qa/lib/arch-doc.mjs +451 -0
  71. package/template/qa/lib/comments.mjs +252 -0
  72. package/template/qa/lib/component-stories.mjs +183 -0
  73. package/template/qa/lib/inputs-hash.mjs +5 -1
  74. package/template/qa/scaffold-feature.mjs +184 -67
  75. package/template/qa/setup-hooks.mjs +33 -0
  76. package/template/qa/verify.mjs +118 -9
  77. package/template/specs/app-base.spec.md +44 -7
  78. package/template/specs/home.spec.md +7 -4
  79. package/template/specs/intent.md +50 -0
@@ -2,25 +2,35 @@ package __PACKAGE__.presentation.home
2
2
 
3
3
  import androidx.lifecycle.ViewModel
4
4
  import androidx.lifecycle.viewModelScope
5
+ import __PACKAGE__.domain.model.DomainError
5
6
  import __PACKAGE__.domain.model.Item
7
+ import __PACKAGE__.domain.result.AppResult
6
8
  import __PACKAGE__.domain.usecase.GetItemsUseCase
9
+ import __PACKAGE__.presentation.components.ContentUiState
10
+ import __PACKAGE__.presentation.components.toContentState
7
11
  import kotlinx.coroutines.flow.MutableStateFlow
8
12
  import kotlinx.coroutines.flow.StateFlow
9
13
  import kotlinx.coroutines.flow.asStateFlow
10
14
  import kotlinx.coroutines.launch
11
15
 
12
- data class HomeUiState(
13
- val isLoading: Boolean = true,
14
- val items: List<Item> = emptyList(),
15
- val errorMessage: String? = null,
16
- )
17
-
16
+ /**
17
+ * No `try`/`catch` here — ever (ARCH-07). Failures arrive as typed [AppResult.Failure]
18
+ * values from the use case; the ViewModel folds over the result and maps [DomainError]
19
+ * KINDS to user-facing copy. A `CancellationException` thrown while suspended simply
20
+ * cancels this coroutine (structured concurrency) — it never becomes an error state.
21
+ *
22
+ * The state machine is the shared [ContentUiState] (`presentation/components`) — the
23
+ * generalization of this feature's pre-generalization per-feature sealed state (which
24
+ * EH-1 landed): same four arms (Loading/Content/Empty/Error), same fold, made generic once
25
+ * so `ContentStateContainer` can own the non-content arms' rendering instead of every
26
+ * screen hand-folding its own copy.
27
+ */
18
28
  class HomeViewModel(
19
29
  private val getItems: GetItemsUseCase,
20
30
  ) : ViewModel() {
21
31
 
22
- private val _state = MutableStateFlow(HomeUiState())
23
- val state: StateFlow<HomeUiState> = _state.asStateFlow()
32
+ private val _state = MutableStateFlow<ContentUiState<List<Item>>>(ContentUiState.Loading)
33
+ val state: StateFlow<ContentUiState<List<Item>>> = _state.asStateFlow()
24
34
 
25
35
  init {
26
36
  load()
@@ -28,12 +38,22 @@ class HomeViewModel(
28
38
 
29
39
  fun load() {
30
40
  viewModelScope.launch {
31
- _state.value = HomeUiState(isLoading = true)
32
- _state.value = try {
33
- HomeUiState(isLoading = false, items = getItems())
34
- } catch (e: Exception) {
35
- HomeUiState(isLoading = false, errorMessage = e.message ?: "Something went wrong")
41
+ _state.value = ContentUiState.Loading
42
+ _state.value = when (val result = getItems()) {
43
+ is AppResult.Success -> result.value.toContentState()
44
+ is AppResult.Failure -> ContentUiState.Error(result.error.toUserMessage())
36
45
  }
37
46
  }
38
47
  }
39
48
  }
49
+
50
+ /**
51
+ * Presentation owns user-facing copy: error KINDS become strings here, next to the screen
52
+ * that shows them. A raw `Throwable.message` never reaches the UI — the domain carries no
53
+ * display text at all.
54
+ */
55
+ internal fun DomainError.toUserMessage(): String = when (this) {
56
+ DomainError.Network -> "Can't reach the server. Check your connection and try again."
57
+ DomainError.NotFound -> "That content isn't available."
58
+ is DomainError.Unexpected -> "Something went wrong. Please try again."
59
+ }
@@ -1,6 +1,7 @@
1
1
  package __PACKAGE__.presentation.navigation
2
2
 
3
3
  import androidx.compose.runtime.Composable
4
+ import androidx.compose.runtime.LaunchedEffect
4
5
  import androidx.navigation.NavType
5
6
  import androidx.navigation.compose.NavHost
6
7
  import androidx.navigation.compose.composable
@@ -16,6 +17,18 @@ import __PACKAGE__.presentation.profile.ProfileScreen
16
17
  fun AppNavHost() {
17
18
  val navController = rememberNavController()
18
19
 
20
+ // Report every back-stack change to the common inspection seam — a no-op unless the
21
+ // androidDebug inspector registered a listener (see NavInspectionHook.kt). Best-effort:
22
+ // `currentBackStack` is a live snapshot, not a durable history.
23
+ LaunchedEffect(navController) {
24
+ navController.currentBackStack.collect { stack ->
25
+ NavInspectionHook.listener?.invoke(
26
+ navController.currentDestination?.route,
27
+ stack.mapNotNull { it.destination.route },
28
+ )
29
+ }
30
+ }
31
+
19
32
  NavHost(navController = navController, startDestination = Screen.Shell.route) {
20
33
  composable(Screen.Shell.route) {
21
34
  val tabs = appTabs(
@@ -1,44 +1,26 @@
1
1
  package __PACKAGE__.presentation.navigation
2
2
 
3
- import androidx.compose.foundation.background
4
- import androidx.compose.foundation.clickable
5
- import androidx.compose.foundation.layout.Arrangement
6
3
  import androidx.compose.foundation.layout.Box
7
- import androidx.compose.foundation.layout.Column
8
- import androidx.compose.foundation.layout.Row
9
- import androidx.compose.foundation.layout.defaultMinSize
10
4
  import androidx.compose.foundation.layout.fillMaxSize
11
- import androidx.compose.foundation.layout.fillMaxWidth
12
- import androidx.compose.foundation.layout.height
13
- import androidx.compose.foundation.layout.navigationBarsPadding
14
- import androidx.compose.foundation.layout.padding
15
- import androidx.compose.foundation.layout.size
16
- import androidx.compose.foundation.shape.RoundedCornerShape
17
- import androidx.compose.material3.Icon
18
- import androidx.compose.material3.Text
19
5
  import androidx.compose.runtime.Composable
20
6
  import androidx.compose.runtime.getValue
21
7
  import androidx.compose.runtime.mutableIntStateOf
22
8
  import androidx.compose.runtime.saveable.rememberSaveable
23
9
  import androidx.compose.runtime.setValue
24
- import androidx.compose.ui.Alignment
25
10
  import androidx.compose.ui.Modifier
26
- import androidx.compose.ui.draw.clip
27
- import androidx.compose.ui.semantics.semantics
28
- import androidx.compose.ui.semantics.testTag
29
- import androidx.compose.ui.text.font.FontWeight
30
- import androidx.compose.ui.unit.dp
31
- import androidx.compose.ui.unit.sp
11
+ import __PACKAGE__.presentation.components.AppBottomBar
32
12
  import __PACKAGE__.presentation.components.BaseScreen
33
13
  import __PACKAGE__.presentation.components.exposeTestTagsForAutomation
34
- import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
35
- import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
36
- import __PACKAGE__.presentation.theme.designToken
37
14
 
38
15
  /**
39
16
  * Generic bottom-nav shell. Parameterized by a [tabs] list — NOT role-hardcoded.
40
17
  * Hosts the selected tab's content inside a [BaseScreen] so each tab gets correct insets;
41
18
  * the bottom bar reserves the navigation-bar inset exactly once.
19
+ *
20
+ * The bar itself is [__PACKAGE__.presentation.components.AppBottomBar] — promoted out of
21
+ * this file into the governed component registry (§4.3 of the component-vocabulary
22
+ * proposal): it was already a mature component, just invisible to the registry as a
23
+ * `private` composable here.
42
24
  */
43
25
  @Composable
44
26
  fun AppShell(tabs: List<AppTab>) {
@@ -52,7 +34,7 @@ fun AppShell(tabs: List<AppTab>) {
52
34
  // The bottom bar owns the navigation-bar inset; the body must not also pad it.
53
35
  applyNavBarPadding = false,
54
36
  bottomBar = {
55
- AppBottomNav(
37
+ AppBottomBar(
56
38
  tabs = tabs,
57
39
  selectedIndex = selected,
58
40
  onSelect = { selected = it },
@@ -64,87 +46,3 @@ fun AppShell(tabs: List<AppTab>) {
64
46
  }
65
47
  }
66
48
  }
67
-
68
- @Composable
69
- private fun AppBottomNav(
70
- tabs: List<AppTab>,
71
- selectedIndex: Int,
72
- onSelect: (Int) -> Unit,
73
- ) {
74
- Column(Modifier.fillMaxWidth()) {
75
- Box(
76
- Modifier
77
- .fillMaxWidth()
78
- .height(1.dp)
79
- .background(__THEME_PREFIX__Colors.OutlineVariant)
80
- )
81
- Row(
82
- modifier = Modifier
83
- .fillMaxWidth()
84
- .background(__THEME_PREFIX__Colors.Surface)
85
- // Lift tabs above the gesture pill / 3-button nav (maps to iOS safe area).
86
- .navigationBarsPadding()
87
- .height(__THEME_PREFIX__Tokens.BottomNavHeight)
88
- // Inspector: the bottom-nav container self-reports its height token.
89
- .designToken(
90
- tokens = listOf("BottomNavHeight"),
91
- resolved = mapOf("height" to "72dp"),
92
- )
93
- .semantics { testTag = "app_bottom_nav" }
94
- .padding(bottom = 8.dp),
95
- verticalAlignment = Alignment.CenterVertically,
96
- horizontalArrangement = Arrangement.SpaceEvenly,
97
- ) {
98
- tabs.forEachIndexed { index, tab ->
99
- NavItem(
100
- label = tab.label,
101
- selected = selectedIndex == index,
102
- onClick = { onSelect(index) },
103
- ) {
104
- Icon(
105
- imageVector = tab.icon,
106
- contentDescription = tab.label,
107
- tint = if (selectedIndex == index) __THEME_PREFIX__Colors.Primary
108
- else __THEME_PREFIX__Colors.OnSurfaceVariant.copy(alpha = 0.55f),
109
- modifier = Modifier.size(24.dp),
110
- )
111
- }
112
- }
113
- }
114
- }
115
- }
116
-
117
- /** Deterministic automation tag for a nav item: `nav_` + the label lowercased with every non-[a-z0-9] run collapsed to `_` and trimmed (e.g. "My Stuff!" → `nav_my_stuff`). Must mirror `navSlug` in create-cmp's engine (src/lib/tabs.mjs), which generates `qa/e2e/smoke.yaml`'s id selectors from the configured tabs — keep the two in sync. */
118
- private fun navItemTag(label: String): String =
119
- "nav_" + label.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_')
120
-
121
- @Composable
122
- private fun NavItem(
123
- label: String,
124
- selected: Boolean,
125
- onClick: () -> Unit,
126
- icon: @Composable () -> Unit,
127
- ) {
128
- Column(
129
- modifier = Modifier
130
- .clip(RoundedCornerShape(8.dp))
131
- .clickable(onClick = onClick)
132
- // a11y: guarantee the 48dp minimum touch target regardless of label width
133
- // (the inspector's audit_a11y flags anything smaller).
134
- .defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
135
- // Durable selection handle (tests/E2E select by testTag, never display text).
136
- .semantics { testTag = navItemTag(label) }
137
- .padding(horizontal = 8.dp, vertical = 4.dp),
138
- horizontalAlignment = Alignment.CenterHorizontally,
139
- verticalArrangement = Arrangement.Center,
140
- ) {
141
- icon()
142
- Text(
143
- text = label,
144
- fontSize = 10.sp,
145
- fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
146
- color = if (selected) __THEME_PREFIX__Colors.Primary else __THEME_PREFIX__Colors.OnSurfaceVariant,
147
- modifier = Modifier.padding(top = 4.dp),
148
- )
149
- }
150
- }
@@ -0,0 +1,21 @@
1
+ package __PACKAGE__.presentation.navigation
2
+
3
+ /**
4
+ * A tiny common seam [AppNavHost] reports every navigation change to. [listener] is `null` by
5
+ * default — a structural no-op — and is registered only by the optional debug-only on-device
6
+ * inspector, when that feature is included in the project. Release builds never register a
7
+ * listener, so invoking a `null` one stays free.
8
+ *
9
+ * This indirection is the whole point: `AppNavHost` lives in commonMain and must never
10
+ * reference a debug-only class directly (a debug-only source set may not even exist in every
11
+ * build of this project) — it only ever talks to this common object.
12
+ */
13
+ object NavInspectionHook {
14
+ /**
15
+ * Invoked on every back-stack change with `currentRoute` (nullable — no destination
16
+ * resolved yet) and `backStack` (root-to-top, best-effort: derived from
17
+ * `NavController.currentBackStack`, which is itself a live/soon-to-be-live snapshot, not a
18
+ * durable history).
19
+ */
20
+ var listener: ((currentRoute: String?, backStack: List<String>) -> Unit)? = null
21
+ }
@@ -1,39 +1,16 @@
1
1
  package __PACKAGE__.presentation.profile
2
2
 
3
- import androidx.compose.foundation.layout.Arrangement
4
- import androidx.compose.foundation.layout.Column
5
- import androidx.compose.foundation.layout.fillMaxSize
6
- import androidx.compose.foundation.layout.padding
7
3
  import androidx.compose.material3.MaterialTheme
8
4
  import androidx.compose.material3.Text
9
5
  import androidx.compose.runtime.Composable
10
- import androidx.compose.ui.Alignment
11
- import androidx.compose.ui.Modifier
12
- import androidx.compose.ui.semantics.semantics
13
- import androidx.compose.ui.semantics.testTag
14
- import androidx.compose.ui.unit.dp
15
- import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
16
- import __PACKAGE__.presentation.theme.designToken
6
+ import __PACKAGE__.presentation.components.AppHeader
7
+ import __PACKAGE__.presentation.components.ScreenColumn
17
8
 
18
9
  // Feature stub. Copy the `home` feature's data→domain→presentation→DI wiring to flesh this out.
19
10
  @Composable
20
11
  fun ProfileScreen() {
21
- Column(
22
- modifier = Modifier
23
- .fillMaxSize()
24
- .designToken(
25
- tokens = listOf("PaddingPage"),
26
- resolved = mapOf("padding" to "16dp"),
27
- )
28
- .padding(__THEME_PREFIX__Tokens.PaddingPage),
29
- verticalArrangement = Arrangement.spacedBy(8.dp),
30
- horizontalAlignment = Alignment.Start,
31
- ) {
32
- Text(
33
- text = "Profile",
34
- style = MaterialTheme.typography.headlineMedium,
35
- modifier = Modifier.semantics { testTag = "profile_title" },
36
- )
12
+ ScreenColumn(screenTag = "profile") {
13
+ AppHeader(title = "Profile", screenTag = "profile")
37
14
  Text(
38
15
  text = "This is a stub screen. Wire it up like the Home feature.",
39
16
  style = MaterialTheme.typography.bodyMedium,
@@ -0,0 +1,52 @@
1
+ package __PACKAGE__.data
2
+
3
+ import __PACKAGE__.domain.model.DomainError
4
+ import __PACKAGE__.domain.result.AppResult
5
+ import kotlin.coroutines.cancellation.CancellationException
6
+ import kotlin.test.Test
7
+ import kotlin.test.assertEquals
8
+ import kotlin.test.assertFailsWith
9
+ import kotlin.test.assertIs
10
+ import kotlinx.coroutines.test.runTest
11
+
12
+ /**
13
+ * The exception-translation helper's contract (`specs/app-base.spec.md` ARCH-08):
14
+ * success wraps, failures map to typed kinds, and cancellation ALWAYS propagates.
15
+ */
16
+ class AppResultCatchingTest {
17
+
18
+ @Test
19
+ fun `wraps the block's value in Success`() = runTest {
20
+ val result = suspendRunCatching { 42 }
21
+
22
+ assertEquals(AppResult.Success(42), result)
23
+ }
24
+
25
+ @Test
26
+ fun `maps a thrown exception through mapError to a typed Failure`() = runTest {
27
+ val boom = IllegalStateException("io broke")
28
+
29
+ val result = suspendRunCatching(mapError = { DomainError.Network }) { throw boom }
30
+
31
+ assertEquals(AppResult.Failure(DomainError.Network), result)
32
+ }
33
+
34
+ @Test
35
+ fun `files unclassified exceptions under Unexpected with the cause preserved`() = runTest {
36
+ val boom = IllegalStateException("io broke")
37
+
38
+ val result = suspendRunCatching { throw boom }
39
+
40
+ val failure = assertIs<AppResult.Failure>(result)
41
+ val error = assertIs<DomainError.Unexpected>(failure.error)
42
+ assertEquals(boom, error.cause)
43
+ }
44
+
45
+ // SPEC: ARCH-08
46
+ @Test
47
+ fun `rethrows CancellationException instead of mapping it`() = runTest {
48
+ assertFailsWith<CancellationException> {
49
+ suspendRunCatching { throw CancellationException("scope cancelled") }
50
+ }
51
+ }
52
+ }
@@ -1,26 +1,51 @@
1
1
  package __PACKAGE__.data.remote
2
2
 
3
+ import __PACKAGE__.domain.result.AppResult
3
4
  import kotlin.test.Test
4
5
  import kotlin.test.assertEquals
6
+ import kotlin.test.assertNull
5
7
  import kotlin.test.assertTrue
8
+ import kotlin.test.fail
9
+ import kotlinx.coroutines.launch
6
10
  import kotlinx.coroutines.test.runTest
7
11
 
8
12
  /**
9
13
  * The exemplar data-layer test. [ItemRepositoryImpl] is the template's dependency-light
10
14
  * example source; when you swap it for a real Firestore/Ktor + Room implementation, keep
11
- * this shape: test the repository through its DOMAIN contract, under `runTest` virtual
12
- * time (the simulated I/O delay costs nothing here — delays are skipped, not slept).
15
+ * this shape: test the repository through its DOMAIN contract (AppResult in, never an
16
+ * exception out), under `runTest` virtual time (the simulated I/O delay costs nothing
17
+ * here — delays are skipped, not slept).
13
18
  */
14
19
  class ItemRepositoryImplTest {
15
20
 
16
21
  private val repository = ItemRepositoryImpl()
17
22
 
18
23
  @Test
19
- fun `returns the seeded example items`() = runTest {
20
- val items = repository.getItems()
24
+ fun `returns the seeded example items as Success`() = runTest {
25
+ val items = when (val result = repository.getItems()) {
26
+ is AppResult.Success -> result.value
27
+ is AppResult.Failure -> fail("example source should succeed, got $result")
28
+ }
21
29
 
22
30
  assertTrue(items.isNotEmpty(), "example source should seed items")
23
31
  assertEquals(items.size, items.map { it.id }.toSet().size, "item ids must be unique")
24
32
  assertTrue(items.all { it.title.isNotBlank() }, "every item needs a title")
25
33
  }
34
+
35
+ // SPEC: ARCH-08
36
+ @Test
37
+ fun `cancellation propagates - a cancelled load never completes as a Failure`() = runTest {
38
+ // A REAL cancellation: getItems() is suspended in its simulated I/O when the caller's
39
+ // job is cancelled. suspendRunCatching must rethrow the CancellationException — if it
40
+ // mapped it, `result` would hold a Failure and this test would fail.
41
+ var result: Any? = null
42
+ val job = launch { result = repository.getItems() }
43
+ testScheduler.runCurrent() // enter getItems() up to the suspension point
44
+
45
+ job.cancel()
46
+ job.join()
47
+
48
+ assertTrue(job.isCancelled, "the load job should end cancelled, not completed")
49
+ assertNull(result, "a cancelled load must produce NO result — especially not a Failure")
50
+ }
26
51
  }
@@ -1,15 +1,17 @@
1
1
  package __PACKAGE__.domain.usecase
2
2
 
3
+ import __PACKAGE__.domain.model.DomainError
3
4
  import __PACKAGE__.domain.model.Item
5
+ import __PACKAGE__.domain.result.AppResult
4
6
  import __PACKAGE__.testing.fakes.FakeItemRepository
5
7
  import kotlin.test.Test
6
8
  import kotlin.test.assertEquals
7
- import kotlin.test.assertFailsWith
8
9
  import kotlinx.coroutines.test.runTest
9
10
 
10
11
  /**
11
12
  * The exemplar use-case test. Use cases are pure business actions with no framework
12
13
  * dependencies, so their tests are the simplest in the pyramid: fake in, behavior out.
14
+ * Results stay typed end-to-end — nothing here throws or catches.
13
15
  */
14
16
  class GetItemsUseCaseTest {
15
17
 
@@ -17,21 +19,21 @@ class GetItemsUseCaseTest {
17
19
  private val getItems = GetItemsUseCase(repository)
18
20
 
19
21
  @Test
20
- fun `returns the repository's items`() = runTest {
22
+ fun `returns the repository's items as Success`() = runTest {
21
23
  val expected = listOf(
22
24
  Item(id = "1", title = "First", subtitle = "a"),
23
25
  Item(id = "2", title = "Second", subtitle = "b"),
24
26
  )
25
27
  repository.items = expected
26
28
 
27
- assertEquals(expected, getItems())
29
+ assertEquals(AppResult.Success(expected), getItems())
28
30
  assertEquals(1, repository.getItemsCallCount)
29
31
  }
30
32
 
31
33
  @Test
32
- fun `propagates repository failures to the caller`() = runTest {
33
- repository.shouldFail = true
34
+ fun `passes a typed failure through untouched`() = runTest {
35
+ repository.failure = DomainError.Network
34
36
 
35
- assertFailsWith<IllegalStateException> { getItems() }
37
+ assertEquals(AppResult.Failure(DomainError.Network), getItems())
36
38
  }
37
39
  }
@@ -1,16 +1,16 @@
1
1
  package __PACKAGE__.presentation.home
2
2
 
3
3
  import app.cash.turbine.test
4
+ import __PACKAGE__.domain.model.DomainError
4
5
  import __PACKAGE__.domain.model.Item
5
6
  import __PACKAGE__.domain.usecase.GetItemsUseCase
7
+ import __PACKAGE__.presentation.components.ContentUiState
6
8
  import __PACKAGE__.testing.fakes.FakeItemRepository
7
9
  import kotlin.test.AfterTest
8
10
  import kotlin.test.BeforeTest
9
11
  import kotlin.test.Test
10
12
  import kotlin.test.assertEquals
11
- import kotlin.test.assertNotNull
12
- import kotlin.test.assertNull
13
- import kotlin.test.assertTrue
13
+ import kotlin.test.assertIs
14
14
  import kotlinx.coroutines.Dispatchers
15
15
  import kotlinx.coroutines.ExperimentalCoroutinesApi
16
16
  import kotlinx.coroutines.test.StandardTestDispatcher
@@ -25,6 +25,10 @@ import kotlinx.coroutines.test.setMain
25
25
  * so coroutines run under the test scheduler's virtual time.
26
26
  * - Turbine (`state.test { … }`) for StateFlow assertions.
27
27
  * - Hand-written fakes from `testing/fakes` — never mocks.
28
+ * - Sealed-state assertions: each emission IS one state (`assertEquals` on the state,
29
+ * `assertIs` on the branch) — no boolean-flag poking. The state type is the shared
30
+ * [ContentUiState] (the generalization of this feature's pre-generalization per-feature
31
+ * sealed state).
28
32
  */
29
33
  @OptIn(ExperimentalCoroutinesApi::class)
30
34
  class HomeViewModelTest {
@@ -47,58 +51,66 @@ class HomeViewModelTest {
47
51
  // SPEC: HOME-01
48
52
  @Test
49
53
  fun `starts in loading state`() = runTest(dispatcher) {
54
+ repository.items = listOf(Item(id = "1", title = "First", subtitle = "sub"))
55
+
50
56
  viewModel().state.test {
51
- assertTrue(awaitItem().isLoading, "initial state should be loading")
57
+ assertEquals(ContentUiState.Loading, awaitItem(), "initial state should be Loading")
52
58
  }
53
59
  }
54
60
 
61
+ // SPEC: HOME-02
55
62
  @Test
56
- fun `emits items when repository succeeds`() = runTest(dispatcher) {
57
- repository.items = listOf(Item(id = "1", title = "First", subtitle = "sub"))
63
+ fun `emits Content when the repository returns items`() = runTest(dispatcher) {
64
+ val items = listOf(Item(id = "1", title = "First", subtitle = "sub"))
65
+ repository.items = items
58
66
 
59
67
  viewModel().state.test {
60
- assertTrue(awaitItem().isLoading)
68
+ assertEquals(ContentUiState.Loading, awaitItem())
69
+ assertEquals(ContentUiState.Content(items), awaitItem())
70
+ }
71
+ }
72
+
73
+ // SPEC: HOME-07
74
+ @Test
75
+ fun `emits Empty when the repository succeeds with no items`() = runTest(dispatcher) {
76
+ repository.items = emptyList()
61
77
 
62
- val loaded = awaitItem()
63
- assertEquals(false, loaded.isLoading)
64
- assertEquals(listOf("First"), loaded.items.map { it.title })
65
- assertNull(loaded.errorMessage)
78
+ viewModel().state.test {
79
+ assertEquals(ContentUiState.Loading, awaitItem())
80
+ assertEquals(ContentUiState.Empty, awaitItem())
66
81
  }
67
82
  }
68
83
 
84
+ // SPEC: HOME-03
69
85
  @Test
70
- fun `emits error message when repository fails`() = runTest(dispatcher) {
71
- repository.shouldFail = true
72
- repository.failureMessage = "network down"
86
+ fun `maps a typed failure to presentation copy - never a raw exception message`() = runTest(dispatcher) {
87
+ repository.failure = DomainError.Network
73
88
 
74
89
  viewModel().state.test {
75
- assertTrue(awaitItem().isLoading)
90
+ assertEquals(ContentUiState.Loading, awaitItem())
76
91
 
77
- val failed = awaitItem()
78
- assertEquals(false, failed.isLoading)
79
- assertTrue(failed.items.isEmpty())
80
- assertEquals("network down", failed.errorMessage)
92
+ val failed = assertIs<ContentUiState.Error>(awaitItem())
93
+ assertEquals(DomainError.Network.toUserMessage(), failed.message)
81
94
  }
82
95
  }
83
96
 
84
97
  // SPEC: HOME-04
85
98
  @Test
86
99
  fun `reload after failure clears the error and loads items`() = runTest(dispatcher) {
87
- repository.shouldFail = true
100
+ repository.failure = DomainError.Network
88
101
  val viewModel = viewModel()
89
102
 
90
103
  viewModel.state.test {
91
- assertTrue(awaitItem().isLoading)
92
- assertNotNull(awaitItem().errorMessage, "first load should fail")
104
+ assertEquals(ContentUiState.Loading, awaitItem())
105
+ assertIs<ContentUiState.Error>(awaitItem(), "first load should fail")
93
106
 
94
- repository.shouldFail = false
107
+ repository.failure = null
95
108
  repository.items = listOf(Item(id = "1", title = "Recovered", subtitle = "sub"))
96
109
  viewModel.load()
97
110
 
98
- assertTrue(awaitItem().isLoading, "reload should show loading again")
99
- val recovered = awaitItem()
100
- assertNull(recovered.errorMessage)
101
- assertEquals(listOf("Recovered"), recovered.items.map { it.title })
111
+ assertEquals(ContentUiState.Loading, awaitItem(), "reload should show loading again")
112
+ val recovered = assertIs<ContentUiState.Content<List<Item>>>(awaitItem())
113
+ assertEquals(listOf("Recovered"), recovered.data.map { it.title })
102
114
  }
103
115
  }
104
116
  }
@@ -1,29 +1,33 @@
1
1
  package __PACKAGE__.testing.fakes
2
2
 
3
+ import __PACKAGE__.domain.model.DomainError
3
4
  import __PACKAGE__.domain.model.Item
4
5
  import __PACKAGE__.domain.repository.ItemRepository
6
+ import __PACKAGE__.domain.result.AppResult
5
7
 
6
8
  /**
7
9
  * Hand-written fake — the template's testing convention (no mocking frameworks: they are
8
10
  * JVM-only in KMP, and interface-driven fakes keep the architecture honest).
9
11
  *
10
12
  * The pattern every fake follows:
11
- * - configurable behavior (`items`, `shouldFail`) so a test arranges its scenario,
13
+ * - configurable behavior (`items`, `failure`) so a test arranges its scenario,
12
14
  * - recorded interactions (`getItemsCallCount`) so a test can assert usage,
13
15
  * - implements the DOMAIN interface, never a concrete data source.
16
+ *
17
+ * Failures are arranged as typed [DomainError] KINDS, mirroring the real contract — the
18
+ * fake returns [AppResult.Failure]; it never throws (repositories don't, per ARCH-06).
14
19
  */
15
20
  class FakeItemRepository : ItemRepository {
16
21
 
17
22
  var items: List<Item> = emptyList()
18
- var shouldFail: Boolean = false
19
- var failureMessage: String = "fake failure"
23
+ var failure: DomainError? = null
20
24
 
21
25
  var getItemsCallCount: Int = 0
22
26
  private set
23
27
 
24
- override suspend fun getItems(): List<Item> {
28
+ override suspend fun getItems(): AppResult<List<Item>> {
25
29
  getItemsCallCount++
26
- if (shouldFail) throw IllegalStateException(failureMessage)
27
- return items
30
+ failure?.let { return AppResult.Failure(it) }
31
+ return AppResult.Success(items)
28
32
  }
29
33
  }