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
|
@@ -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,
|
|
12
|
-
* time (the simulated I/O delay costs nothing
|
|
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 `
|
|
33
|
-
repository.
|
|
34
|
+
fun `passes a typed failure through untouched`() = runTest {
|
|
35
|
+
repository.failure = DomainError.Network
|
|
34
36
|
|
|
35
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
assertEquals(
|
|
64
|
-
assertEquals(
|
|
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 `
|
|
71
|
-
repository.
|
|
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
|
-
|
|
90
|
+
assertEquals(ContentUiState.Loading, awaitItem())
|
|
76
91
|
|
|
77
|
-
val failed = awaitItem()
|
|
78
|
-
assertEquals(
|
|
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.
|
|
100
|
+
repository.failure = DomainError.Network
|
|
88
101
|
val viewModel = viewModel()
|
|
89
102
|
|
|
90
103
|
viewModel.state.test {
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
assertEquals(ContentUiState.Loading, awaitItem())
|
|
105
|
+
assertIs<ContentUiState.Error>(awaitItem(), "first load should fail")
|
|
93
106
|
|
|
94
|
-
repository.
|
|
107
|
+
repository.failure = null
|
|
95
108
|
repository.items = listOf(Item(id = "1", title = "Recovered", subtitle = "sub"))
|
|
96
109
|
viewModel.load()
|
|
97
110
|
|
|
98
|
-
|
|
99
|
-
val recovered = awaitItem()
|
|
100
|
-
|
|
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`, `
|
|
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
|
|
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
|
-
|
|
27
|
-
return items
|
|
30
|
+
failure?.let { return AppResult.Failure(it) }
|
|
31
|
+
return AppResult.Success(items)
|
|
28
32
|
}
|
|
29
33
|
}
|
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
package __PACKAGE__.inspector
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.background
|
|
4
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
5
|
+
import androidx.compose.foundation.layout.Box
|
|
6
|
+
import androidx.compose.foundation.layout.BoxScope
|
|
7
|
+
import androidx.compose.foundation.layout.Column
|
|
8
|
+
import androidx.compose.foundation.layout.ColumnScope
|
|
9
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
10
|
+
import androidx.compose.foundation.layout.height
|
|
11
|
+
import androidx.compose.foundation.layout.padding
|
|
12
|
+
import androidx.compose.material.icons.Icons
|
|
13
|
+
import androidx.compose.material.icons.filled.Home
|
|
14
|
+
import androidx.compose.material.icons.filled.Person
|
|
15
|
+
import androidx.compose.material3.Icon
|
|
16
|
+
import androidx.compose.material3.Text
|
|
17
|
+
import androidx.compose.runtime.Composable
|
|
18
|
+
import androidx.compose.ui.Alignment
|
|
19
|
+
import androidx.compose.ui.Modifier
|
|
20
|
+
import androidx.compose.ui.semantics.semantics
|
|
21
|
+
import androidx.compose.ui.semantics.testTag
|
|
22
|
+
import androidx.compose.ui.unit.dp
|
|
23
|
+
import __PACKAGE__.presentation.components.AppBottomBar
|
|
24
|
+
import __PACKAGE__.presentation.components.AppHeader
|
|
25
|
+
import __PACKAGE__.presentation.components.AppPrimaryButton
|
|
26
|
+
import __PACKAGE__.presentation.components.AppTextButton
|
|
27
|
+
import __PACKAGE__.presentation.components.BaseScreen
|
|
28
|
+
import __PACKAGE__.presentation.components.ContentStateContainer
|
|
29
|
+
import __PACKAGE__.presentation.components.ContentStateDefaults
|
|
30
|
+
import __PACKAGE__.presentation.components.ContentUiState
|
|
31
|
+
import __PACKAGE__.presentation.components.EmptyState
|
|
32
|
+
import __PACKAGE__.presentation.components.ErrorState
|
|
33
|
+
import __PACKAGE__.presentation.components.ListItemCard
|
|
34
|
+
import __PACKAGE__.presentation.components.ListItemSkeleton
|
|
35
|
+
import __PACKAGE__.presentation.components.NavItem
|
|
36
|
+
import __PACKAGE__.presentation.components.ScreenColumn
|
|
37
|
+
import __PACKAGE__.presentation.navigation.AppTab
|
|
38
|
+
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
|
|
39
|
+
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Component stories — one preview-registry entry per `@Composable` in
|
|
43
|
+
* `presentation/components` (the Storybook analog at component granularity).
|
|
44
|
+
* Each story renders the component in isolation on a plain tokened surface;
|
|
45
|
+
* a multi-variant component stacks its variants in ONE render. Ids follow
|
|
46
|
+
* `component.<kebab-case-of-composable-name>` (`AppHeader` →
|
|
47
|
+
* `component.app-header`), derivable mechanically from the name — the
|
|
48
|
+
* verify lane's `componentStories` step (qa/lib/component-stories.mjs)
|
|
49
|
+
* enforces exactly one story per component. The console excludes
|
|
50
|
+
* `component.*` entries from the Screens grid and shows each render at the
|
|
51
|
+
* top of that component's Components-page entry instead.
|
|
52
|
+
*
|
|
53
|
+
* These are preview-surface code (desktopMain), not production API: sample
|
|
54
|
+
* args only, tokens for every design value, testTags on every interactive
|
|
55
|
+
* node — a story meets the same bar the screens do.
|
|
56
|
+
*/
|
|
57
|
+
fun componentStories(): List<ScreenPreview> = listOf(
|
|
58
|
+
// Structure: the containers a screen roots itself in.
|
|
59
|
+
story("component.screen-column", "ScreenColumn") {
|
|
60
|
+
ScreenColumn(screenTag = "story") {
|
|
61
|
+
Text("ScreenColumn owns the tagged root and the PaddingPage inset.")
|
|
62
|
+
Text("Children stack vertically; scrollable = true adds scrolling.")
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
story("component.base-screen", "BaseScreen") {
|
|
66
|
+
BaseScreen { _ ->
|
|
67
|
+
Text(
|
|
68
|
+
"BaseScreen owns the status/navigation-bar insets; body content is safe with zero ceremony.",
|
|
69
|
+
modifier = Modifier.padding(__THEME_PREFIX__Tokens.PaddingPage),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
// Header and navigation.
|
|
74
|
+
variantsStory("component.app-header", "AppHeader") {
|
|
75
|
+
AppHeader(title = "Screen title", screenTag = "story")
|
|
76
|
+
AppHeader(
|
|
77
|
+
title = "With back and action",
|
|
78
|
+
screenTag = "story_nav",
|
|
79
|
+
onBack = {},
|
|
80
|
+
actions = {
|
|
81
|
+
AppTextButton(
|
|
82
|
+
text = "Action",
|
|
83
|
+
onClick = {},
|
|
84
|
+
modifier = Modifier.semantics { testTag = "story_header_action" },
|
|
85
|
+
)
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
},
|
|
89
|
+
story("component.app-bottom-bar", "AppBottomBar") {
|
|
90
|
+
AppBottomBar(
|
|
91
|
+
tabs = listOf(
|
|
92
|
+
AppTab("Home", Icons.Filled.Home) {},
|
|
93
|
+
AppTab("Profile", Icons.Filled.Person) {},
|
|
94
|
+
),
|
|
95
|
+
selectedIndex = 0,
|
|
96
|
+
onSelect = {},
|
|
97
|
+
modifier = Modifier.align(Alignment.BottomCenter),
|
|
98
|
+
)
|
|
99
|
+
},
|
|
100
|
+
variantsStory("component.nav-item", "NavItem") {
|
|
101
|
+
NavItem(label = "Selected", selected = true, onClick = {}) {
|
|
102
|
+
Icon(
|
|
103
|
+
Icons.Filled.Home,
|
|
104
|
+
contentDescription = "Selected",
|
|
105
|
+
tint = __THEME_PREFIX__Colors.Primary,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
NavItem(label = "Unselected", selected = false, onClick = {}) {
|
|
109
|
+
Icon(
|
|
110
|
+
Icons.Filled.Person,
|
|
111
|
+
contentDescription = "Unselected",
|
|
112
|
+
tint = __THEME_PREFIX__Colors.OnSurfaceVariant,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
// Buttons.
|
|
117
|
+
variantsStory("component.app-primary-button", "AppPrimaryButton") {
|
|
118
|
+
AppPrimaryButton(
|
|
119
|
+
text = "Primary",
|
|
120
|
+
onClick = {},
|
|
121
|
+
modifier = Modifier.semantics { testTag = "story_primary" },
|
|
122
|
+
)
|
|
123
|
+
AppPrimaryButton(
|
|
124
|
+
text = "Primary — disabled",
|
|
125
|
+
onClick = {},
|
|
126
|
+
enabled = false,
|
|
127
|
+
modifier = Modifier.semantics { testTag = "story_primary_disabled" },
|
|
128
|
+
)
|
|
129
|
+
},
|
|
130
|
+
variantsStory("component.app-text-button", "AppTextButton") {
|
|
131
|
+
AppTextButton(
|
|
132
|
+
text = "Text button",
|
|
133
|
+
onClick = {},
|
|
134
|
+
modifier = Modifier.semantics { testTag = "story_text" },
|
|
135
|
+
)
|
|
136
|
+
AppTextButton(
|
|
137
|
+
text = "Text button — disabled",
|
|
138
|
+
onClick = {},
|
|
139
|
+
enabled = false,
|
|
140
|
+
modifier = Modifier.semantics { testTag = "story_text_disabled" },
|
|
141
|
+
)
|
|
142
|
+
},
|
|
143
|
+
// The four-state contract: all four arms of the container, stacked.
|
|
144
|
+
variantsStory("component.content-state-container", "ContentStateContainer") {
|
|
145
|
+
ContentStateContainer<List<String>>(
|
|
146
|
+
state = ContentUiState.Loading,
|
|
147
|
+
screenTag = "story_loading",
|
|
148
|
+
modifier = Modifier.height(180.dp),
|
|
149
|
+
) { }
|
|
150
|
+
ContentStateContainer<List<String>>(
|
|
151
|
+
state = ContentUiState.Empty,
|
|
152
|
+
screenTag = "story_empty",
|
|
153
|
+
modifier = Modifier.height(180.dp),
|
|
154
|
+
) { }
|
|
155
|
+
ContentStateContainer<List<String>>(
|
|
156
|
+
state = ContentUiState.Error("Something went wrong."),
|
|
157
|
+
screenTag = "story_error",
|
|
158
|
+
onRetry = {},
|
|
159
|
+
modifier = Modifier.height(180.dp),
|
|
160
|
+
) { }
|
|
161
|
+
ContentStateContainer(
|
|
162
|
+
state = ContentUiState.Content(listOf("First item", "Second item")),
|
|
163
|
+
screenTag = "story_content",
|
|
164
|
+
modifier = Modifier.height(180.dp),
|
|
165
|
+
) { data ->
|
|
166
|
+
Column(verticalArrangement = Arrangement.spacedBy(__THEME_PREFIX__Tokens.GapCard)) {
|
|
167
|
+
data.forEachIndexed { i, title ->
|
|
168
|
+
ListItemCard(
|
|
169
|
+
title = title,
|
|
170
|
+
onClick = {},
|
|
171
|
+
modifier = Modifier.semantics { testTag = "story_state_item_$i" },
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
variantsStory("component.empty-state", "EmptyState") {
|
|
178
|
+
EmptyState(
|
|
179
|
+
screenTag = "story",
|
|
180
|
+
modifier = Modifier.height(220.dp),
|
|
181
|
+
body = "Items you add will show up here.",
|
|
182
|
+
action = {
|
|
183
|
+
AppTextButton(
|
|
184
|
+
text = "Add an item",
|
|
185
|
+
onClick = {},
|
|
186
|
+
modifier = Modifier.semantics { testTag = "story_empty_action" },
|
|
187
|
+
)
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
},
|
|
191
|
+
variantsStory("component.error-state", "ErrorState") {
|
|
192
|
+
ErrorState(
|
|
193
|
+
message = "Something went wrong.",
|
|
194
|
+
screenTag = "story",
|
|
195
|
+
onRetry = {},
|
|
196
|
+
modifier = Modifier.height(220.dp),
|
|
197
|
+
)
|
|
198
|
+
},
|
|
199
|
+
// List vocabulary.
|
|
200
|
+
variantsStory("component.list-item-card", "ListItemCard") {
|
|
201
|
+
ListItemCard(
|
|
202
|
+
title = "Title only",
|
|
203
|
+
onClick = {},
|
|
204
|
+
modifier = Modifier.semantics { testTag = "story_item_1" },
|
|
205
|
+
)
|
|
206
|
+
ListItemCard(
|
|
207
|
+
title = "With subtitle",
|
|
208
|
+
subtitle = "Secondary line",
|
|
209
|
+
onClick = {},
|
|
210
|
+
modifier = Modifier.semantics { testTag = "story_item_2" },
|
|
211
|
+
)
|
|
212
|
+
ListItemCard(
|
|
213
|
+
title = "With a leading slot",
|
|
214
|
+
subtitle = "Leading content precedes the text column",
|
|
215
|
+
onClick = {},
|
|
216
|
+
modifier = Modifier.semantics { testTag = "story_item_3" },
|
|
217
|
+
leading = { Icon(Icons.Filled.Person, contentDescription = null) },
|
|
218
|
+
)
|
|
219
|
+
},
|
|
220
|
+
story("component.list-skeleton", "ContentStateDefaults.ListSkeleton") {
|
|
221
|
+
Box(Modifier.fillMaxSize().padding(__THEME_PREFIX__Tokens.PaddingPage)) {
|
|
222
|
+
ContentStateDefaults.ListSkeleton(screenTag = "story")
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
variantsStory("component.list-item-skeleton", "ListItemSkeleton") {
|
|
226
|
+
ListItemSkeleton()
|
|
227
|
+
},
|
|
228
|
+
story("component.spinner", "ContentStateDefaults.Spinner") {
|
|
229
|
+
ContentStateDefaults.Spinner(screenTag = "story")
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* `component.<kebab-name>` entry hosting [content] on the plain story surface.
|
|
235
|
+
* The id is passed as a full literal (never concatenated) so the lane's parity
|
|
236
|
+
* gate (qa/lib/component-stories.mjs) and a plain grep both find it.
|
|
237
|
+
*/
|
|
238
|
+
private fun story(
|
|
239
|
+
id: String,
|
|
240
|
+
title: String,
|
|
241
|
+
content: @Composable BoxScope.() -> Unit,
|
|
242
|
+
): ScreenPreview = ScreenPreview(id, "$title — component story") {
|
|
243
|
+
StoryHost(content)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Stacked-variants flavor: the story surface with a padded, token-gapped column. */
|
|
247
|
+
private fun variantsStory(
|
|
248
|
+
id: String,
|
|
249
|
+
title: String,
|
|
250
|
+
content: @Composable ColumnScope.() -> Unit,
|
|
251
|
+
): ScreenPreview = story(id, title) {
|
|
252
|
+
Column(
|
|
253
|
+
modifier = Modifier.fillMaxSize().padding(__THEME_PREFIX__Tokens.PaddingPage),
|
|
254
|
+
verticalArrangement = Arrangement.spacedBy(__THEME_PREFIX__Tokens.GapCard),
|
|
255
|
+
content = content,
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The plain tokened surface every story renders on: theme background, nothing
|
|
261
|
+
* else — the component is the only subject. Internal (not private) so the
|
|
262
|
+
* generated registry can host the PlaceholderScreen story on custom-tab
|
|
263
|
+
* scaffolds (PlaceholderScreen ships only when a configured tab has no
|
|
264
|
+
* feature yet, so its story rides PreviewRegistry.kt, not this file).
|
|
265
|
+
*/
|
|
266
|
+
@Composable
|
|
267
|
+
internal fun StoryHost(content: @Composable BoxScope.() -> Unit) {
|
|
268
|
+
Box(Modifier.fillMaxSize().background(__THEME_PREFIX__Colors.Background)) { content() }
|
|
269
|
+
}
|
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt
CHANGED
|
@@ -4,12 +4,19 @@ import androidx.compose.foundation.layout.Box
|
|
|
4
4
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
5
5
|
import androidx.compose.runtime.Composable
|
|
6
6
|
import androidx.compose.ui.Modifier
|
|
7
|
+
import __PACKAGE__.domain.model.DomainError
|
|
8
|
+
import __PACKAGE__.domain.model.Item
|
|
9
|
+
import __PACKAGE__.domain.repository.ItemRepository
|
|
10
|
+
import __PACKAGE__.domain.result.AppResult
|
|
11
|
+
import __PACKAGE__.domain.usecase.GetItemsUseCase
|
|
7
12
|
import __PACKAGE__.presentation.components.BaseScreen
|
|
8
13
|
import __PACKAGE__.presentation.home.DetailScreen
|
|
9
14
|
import __PACKAGE__.presentation.home.HomeScreen
|
|
15
|
+
import __PACKAGE__.presentation.home.HomeViewModel
|
|
10
16
|
import __PACKAGE__.presentation.navigation.AppShell
|
|
11
17
|
import __PACKAGE__.presentation.navigation.appTabs
|
|
12
18
|
import __PACKAGE__.presentation.profile.ProfileScreen
|
|
19
|
+
import kotlinx.coroutines.awaitCancellation
|
|
13
20
|
|
|
14
21
|
/**
|
|
15
22
|
* One previewable screen: a stable [id] (the `-Pscreen=` selector and output directory
|
|
@@ -29,6 +36,11 @@ import __PACKAGE__.presentation.profile.ProfileScreen
|
|
|
29
36
|
* preview-only fakes behind its usual parameters). Every entry renders the same way
|
|
30
37
|
* (gallery card, `-Pscreen=` selector, golden baseline), so loading/empty/error states
|
|
31
38
|
* sit side by side with the default seeded state.
|
|
39
|
+
*
|
|
40
|
+
* Component stories (`component.<kebab-name>` ids, ComponentStories.kt) are appended
|
|
41
|
+
* below — one isolated render per `presentation/components` composable. The console
|
|
42
|
+
* keeps them out of the Screens grid and shows each at the top of its Components-page
|
|
43
|
+
* entry; the verify lane's `componentStories` step enforces one story per component.
|
|
32
44
|
*/
|
|
33
45
|
data class ScreenPreview(
|
|
34
46
|
val id: String,
|
|
@@ -49,8 +61,21 @@ fun previewRegistry(): List<ScreenPreview> = listOf(
|
|
|
49
61
|
ScreenPreview("home", "Home tab") { TabHost { HomeScreen(onItemClick = {}) } },
|
|
50
62
|
ScreenPreview("profile", "Profile tab") { TabHost { ProfileScreen() } },
|
|
51
63
|
ScreenPreview("detail", "Detail (nav destination)") { DetailScreen(itemId = "1", onBack = {}) },
|
|
64
|
+
// State variants (§6.5, component-system-deep-dive.md): the same ContentUiState arms
|
|
65
|
+
// ContentStateContainer dispatches on, forced via a preview-only repository — the
|
|
66
|
+
// console's genesis workbench and the golden baselines get loading/empty/error as
|
|
67
|
+
// first-class screens beside the default seeded "home" entry.
|
|
68
|
+
ScreenPreview("home@loading", "Home — loading") {
|
|
69
|
+
TabHost { HomeScreen(onItemClick = {}, viewModel = previewHomeViewModel { awaitCancellation() }) }
|
|
70
|
+
},
|
|
71
|
+
ScreenPreview("home@empty", "Home — empty") {
|
|
72
|
+
TabHost { HomeScreen(onItemClick = {}, viewModel = previewHomeViewModel { AppResult.Success(emptyList()) }) }
|
|
73
|
+
},
|
|
74
|
+
ScreenPreview("home@error", "Home — error") {
|
|
75
|
+
TabHost { HomeScreen(onItemClick = {}, viewModel = previewHomeViewModel { AppResult.Failure(DomainError.Network) }) }
|
|
76
|
+
},
|
|
52
77
|
// cmp:anchor preview-registry
|
|
53
|
-
)
|
|
78
|
+
) + componentStories()
|
|
54
79
|
|
|
55
80
|
/**
|
|
56
81
|
* Hosts a single tab's content the way [AppShell] does — inside [BaseScreen] — minus the
|
|
@@ -62,3 +87,14 @@ private fun TabHost(content: @Composable () -> Unit) {
|
|
|
62
87
|
Box(Modifier.fillMaxSize()) { content() }
|
|
63
88
|
}
|
|
64
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Forces one `ContentUiState` arm on a real [HomeViewModel] for the state-variant previews
|
|
93
|
+
* above. `desktopMain` cannot depend on `commonTest`'s `FakeItemRepository` (test sources
|
|
94
|
+
* never leak into main), so this is a minimal, self-contained equivalent — the real
|
|
95
|
+
* ViewModel and screen render unmodified, only the repository result is forced.
|
|
96
|
+
*/
|
|
97
|
+
private fun previewHomeViewModel(result: suspend () -> AppResult<List<Item>>): HomeViewModel =
|
|
98
|
+
HomeViewModel(GetItemsUseCase(object : ItemRepository {
|
|
99
|
+
override suspend fun getItems(): AppResult<List<Item>> = result()
|
|
100
|
+
}))
|