create-cmp-cli 0.8.0 → 0.10.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 +83 -9
- package/llms.txt +5 -1
- package/package.json +1 -1
- package/src/lib/adr-seed.mjs +178 -0
- package/src/lib/tabs.mjs +97 -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 +213 -47
- package/template/README.md +32 -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/InspectorCatalog.kt +19 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -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/brand/BrandMark.kt +75 -0
- 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 +63 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -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 +22 -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 +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
- 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 +300 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
- 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 +372 -34
- package/template/docs/TESTING.md +13 -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/e2e/smoke.yaml +6 -0
- package/template/qa/golden/home.json +3 -3
- package/template/qa/lib/a11y.mjs +17 -8
- package/template/qa/lib/approvals.mjs +822 -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 +181 -15
- package/template/qa/walkthrough.mjs +499 -0
- package/template/specs/app-base.spec.md +49 -7
- package/template/specs/home.spec.md +7 -4
- package/template/specs/intent.md +50 -0
|
@@ -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,300 @@
|
|
|
1
|
+
package __PACKAGE__.inspector
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
4
|
+
import androidx.compose.foundation.layout.Box
|
|
5
|
+
import androidx.compose.foundation.layout.BoxScope
|
|
6
|
+
import androidx.compose.foundation.layout.Column
|
|
7
|
+
import androidx.compose.foundation.layout.ColumnScope
|
|
8
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
9
|
+
import androidx.compose.foundation.layout.height
|
|
10
|
+
import androidx.compose.foundation.layout.padding
|
|
11
|
+
import androidx.compose.material.icons.Icons
|
|
12
|
+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
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.Surface
|
|
17
|
+
import androidx.compose.material3.Text
|
|
18
|
+
import androidx.compose.runtime.Composable
|
|
19
|
+
import androidx.compose.ui.Alignment
|
|
20
|
+
import androidx.compose.ui.Modifier
|
|
21
|
+
import androidx.compose.ui.semantics.semantics
|
|
22
|
+
import androidx.compose.ui.semantics.testTag
|
|
23
|
+
import androidx.compose.ui.unit.dp
|
|
24
|
+
import __PACKAGE__.presentation.components.AppBottomBar
|
|
25
|
+
import __PACKAGE__.presentation.components.AppHeader
|
|
26
|
+
import __PACKAGE__.presentation.components.AppIconButton
|
|
27
|
+
import __PACKAGE__.presentation.components.AppPrimaryButton
|
|
28
|
+
import __PACKAGE__.presentation.components.AppTextButton
|
|
29
|
+
import __PACKAGE__.presentation.components.BaseScreen
|
|
30
|
+
import __PACKAGE__.presentation.components.ContentStateContainer
|
|
31
|
+
import __PACKAGE__.presentation.components.ContentStateDefaults
|
|
32
|
+
import __PACKAGE__.presentation.components.ContentUiState
|
|
33
|
+
import __PACKAGE__.presentation.components.EmptyState
|
|
34
|
+
import __PACKAGE__.presentation.components.ErrorState
|
|
35
|
+
import __PACKAGE__.presentation.components.ListItemCard
|
|
36
|
+
import __PACKAGE__.presentation.components.ListItemSkeleton
|
|
37
|
+
import __PACKAGE__.presentation.components.NavItem
|
|
38
|
+
import __PACKAGE__.presentation.components.ScreenColumn
|
|
39
|
+
import __PACKAGE__.presentation.navigation.AppTab
|
|
40
|
+
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
|
|
41
|
+
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Component stories — one preview-registry entry per `@Composable` in
|
|
45
|
+
* `presentation/components` (the Storybook analog at component granularity).
|
|
46
|
+
* Each story renders the component in isolation on a plain tokened surface;
|
|
47
|
+
* a multi-variant component stacks its variants in ONE render. Ids follow
|
|
48
|
+
* `component.<kebab-case-of-composable-name>` (`AppHeader` →
|
|
49
|
+
* `component.app-header`), derivable mechanically from the name — the
|
|
50
|
+
* verify lane's `componentStories` step (qa/lib/component-stories.mjs)
|
|
51
|
+
* enforces exactly one story per component. The console excludes
|
|
52
|
+
* `component.*` entries from the Screens grid and shows each render at the
|
|
53
|
+
* top of that component's Components-page entry instead.
|
|
54
|
+
*
|
|
55
|
+
* These are preview-surface code (desktopMain), not production API: sample
|
|
56
|
+
* args only, tokens for every design value, testTags on every interactive
|
|
57
|
+
* node — a story meets the same bar the screens do.
|
|
58
|
+
*/
|
|
59
|
+
fun componentStories(): List<ScreenPreview> = listOf(
|
|
60
|
+
// Structure: the containers a screen roots itself in.
|
|
61
|
+
story("component.screen-column", "ScreenColumn") {
|
|
62
|
+
ScreenColumn(screenTag = "story") {
|
|
63
|
+
Text("ScreenColumn owns the tagged root and the PaddingPage inset.")
|
|
64
|
+
Text("Children stack vertically; scrollable = true adds scrolling.")
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
story("component.base-screen", "BaseScreen") {
|
|
68
|
+
BaseScreen { _ ->
|
|
69
|
+
Text(
|
|
70
|
+
"BaseScreen owns the status/navigation-bar insets; body content is safe with zero ceremony.",
|
|
71
|
+
modifier = Modifier.padding(__THEME_PREFIX__Tokens.PaddingPage),
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
// Header and navigation.
|
|
76
|
+
variantsStory("component.app-header", "AppHeader") {
|
|
77
|
+
AppHeader(title = "Screen title", screenTag = "story")
|
|
78
|
+
AppHeader(
|
|
79
|
+
title = "With back and action",
|
|
80
|
+
screenTag = "story_nav",
|
|
81
|
+
onBack = {},
|
|
82
|
+
actions = {
|
|
83
|
+
AppTextButton(
|
|
84
|
+
text = "Action",
|
|
85
|
+
onClick = {},
|
|
86
|
+
modifier = Modifier.semantics { testTag = "story_header_action" },
|
|
87
|
+
)
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
},
|
|
91
|
+
story("component.app-bottom-bar", "AppBottomBar") {
|
|
92
|
+
AppBottomBar(
|
|
93
|
+
tabs = listOf(
|
|
94
|
+
AppTab("Home", Icons.Filled.Home) {},
|
|
95
|
+
AppTab("Profile", Icons.Filled.Person) {},
|
|
96
|
+
),
|
|
97
|
+
selectedIndex = 0,
|
|
98
|
+
onSelect = {},
|
|
99
|
+
modifier = Modifier.align(Alignment.BottomCenter),
|
|
100
|
+
)
|
|
101
|
+
},
|
|
102
|
+
variantsStory("component.nav-item", "NavItem") {
|
|
103
|
+
NavItem(label = "Selected", selected = true, onClick = {}) {
|
|
104
|
+
Icon(
|
|
105
|
+
Icons.Filled.Home,
|
|
106
|
+
contentDescription = "Selected",
|
|
107
|
+
tint = __THEME_PREFIX__Colors.Primary,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
NavItem(label = "Unselected", selected = false, onClick = {}) {
|
|
111
|
+
Icon(
|
|
112
|
+
Icons.Filled.Person,
|
|
113
|
+
contentDescription = "Unselected",
|
|
114
|
+
tint = __THEME_PREFIX__Colors.OnSurfaceVariant,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
// Buttons.
|
|
119
|
+
variantsStory("component.app-primary-button", "AppPrimaryButton") {
|
|
120
|
+
AppPrimaryButton(
|
|
121
|
+
text = "Primary",
|
|
122
|
+
onClick = {},
|
|
123
|
+
modifier = Modifier.semantics { testTag = "story_primary" },
|
|
124
|
+
)
|
|
125
|
+
AppPrimaryButton(
|
|
126
|
+
text = "Primary — disabled",
|
|
127
|
+
onClick = {},
|
|
128
|
+
enabled = false,
|
|
129
|
+
modifier = Modifier.semantics { testTag = "story_primary_disabled" },
|
|
130
|
+
)
|
|
131
|
+
},
|
|
132
|
+
variantsStory("component.app-text-button", "AppTextButton") {
|
|
133
|
+
AppTextButton(
|
|
134
|
+
text = "Text button",
|
|
135
|
+
onClick = {},
|
|
136
|
+
modifier = Modifier.semantics { testTag = "story_text" },
|
|
137
|
+
)
|
|
138
|
+
AppTextButton(
|
|
139
|
+
text = "Text button — disabled",
|
|
140
|
+
onClick = {},
|
|
141
|
+
enabled = false,
|
|
142
|
+
modifier = Modifier.semantics { testTag = "story_text_disabled" },
|
|
143
|
+
)
|
|
144
|
+
},
|
|
145
|
+
variantsStory("component.app-icon-button", "AppIconButton") {
|
|
146
|
+
AppIconButton(
|
|
147
|
+
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
|
148
|
+
contentDescription = "Back",
|
|
149
|
+
onClick = {},
|
|
150
|
+
modifier = Modifier.semantics { testTag = "story_icon_button" },
|
|
151
|
+
)
|
|
152
|
+
AppIconButton(
|
|
153
|
+
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
|
154
|
+
contentDescription = "Back — disabled",
|
|
155
|
+
onClick = {},
|
|
156
|
+
enabled = false,
|
|
157
|
+
modifier = Modifier.semantics { testTag = "story_icon_button_disabled" },
|
|
158
|
+
)
|
|
159
|
+
},
|
|
160
|
+
// The four-state contract: all four arms of the container, stacked.
|
|
161
|
+
variantsStory("component.content-state-container", "ContentStateContainer") {
|
|
162
|
+
ContentStateContainer<List<String>>(
|
|
163
|
+
state = ContentUiState.Loading,
|
|
164
|
+
screenTag = "story_loading",
|
|
165
|
+
modifier = Modifier.height(180.dp),
|
|
166
|
+
) { }
|
|
167
|
+
ContentStateContainer<List<String>>(
|
|
168
|
+
state = ContentUiState.Empty,
|
|
169
|
+
screenTag = "story_empty",
|
|
170
|
+
modifier = Modifier.height(180.dp),
|
|
171
|
+
) { }
|
|
172
|
+
ContentStateContainer<List<String>>(
|
|
173
|
+
state = ContentUiState.Error("Something went wrong."),
|
|
174
|
+
screenTag = "story_error",
|
|
175
|
+
onRetry = {},
|
|
176
|
+
modifier = Modifier.height(180.dp),
|
|
177
|
+
) { }
|
|
178
|
+
ContentStateContainer(
|
|
179
|
+
state = ContentUiState.Content(listOf("First item", "Second item")),
|
|
180
|
+
screenTag = "story_content",
|
|
181
|
+
modifier = Modifier.height(180.dp),
|
|
182
|
+
) { data ->
|
|
183
|
+
Column(verticalArrangement = Arrangement.spacedBy(__THEME_PREFIX__Tokens.GapCard)) {
|
|
184
|
+
data.forEachIndexed { i, title ->
|
|
185
|
+
ListItemCard(
|
|
186
|
+
title = title,
|
|
187
|
+
onClick = {},
|
|
188
|
+
modifier = Modifier.semantics { testTag = "story_state_item_$i" },
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
variantsStory("component.empty-state", "EmptyState") {
|
|
195
|
+
EmptyState(
|
|
196
|
+
screenTag = "story",
|
|
197
|
+
modifier = Modifier.height(220.dp),
|
|
198
|
+
body = "Items you add will show up here.",
|
|
199
|
+
action = {
|
|
200
|
+
AppTextButton(
|
|
201
|
+
text = "Add an item",
|
|
202
|
+
onClick = {},
|
|
203
|
+
modifier = Modifier.semantics { testTag = "story_empty_action" },
|
|
204
|
+
)
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
},
|
|
208
|
+
variantsStory("component.error-state", "ErrorState") {
|
|
209
|
+
ErrorState(
|
|
210
|
+
message = "Something went wrong.",
|
|
211
|
+
screenTag = "story",
|
|
212
|
+
onRetry = {},
|
|
213
|
+
modifier = Modifier.height(220.dp),
|
|
214
|
+
)
|
|
215
|
+
},
|
|
216
|
+
// List vocabulary.
|
|
217
|
+
variantsStory("component.list-item-card", "ListItemCard") {
|
|
218
|
+
ListItemCard(
|
|
219
|
+
title = "Title only",
|
|
220
|
+
onClick = {},
|
|
221
|
+
modifier = Modifier.semantics { testTag = "story_item_1" },
|
|
222
|
+
)
|
|
223
|
+
ListItemCard(
|
|
224
|
+
title = "With subtitle",
|
|
225
|
+
subtitle = "Secondary line",
|
|
226
|
+
onClick = {},
|
|
227
|
+
modifier = Modifier.semantics { testTag = "story_item_2" },
|
|
228
|
+
)
|
|
229
|
+
ListItemCard(
|
|
230
|
+
title = "With a leading slot",
|
|
231
|
+
subtitle = "Leading content precedes the text column",
|
|
232
|
+
onClick = {},
|
|
233
|
+
modifier = Modifier.semantics { testTag = "story_item_3" },
|
|
234
|
+
leading = { Icon(Icons.Filled.Person, contentDescription = null) },
|
|
235
|
+
)
|
|
236
|
+
},
|
|
237
|
+
story("component.list-skeleton", "ContentStateDefaults.ListSkeleton") {
|
|
238
|
+
Box(Modifier.fillMaxSize().padding(__THEME_PREFIX__Tokens.PaddingPage)) {
|
|
239
|
+
ContentStateDefaults.ListSkeleton(screenTag = "story")
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
variantsStory("component.list-item-skeleton", "ListItemSkeleton") {
|
|
243
|
+
ListItemSkeleton()
|
|
244
|
+
},
|
|
245
|
+
story("component.spinner", "ContentStateDefaults.Spinner") {
|
|
246
|
+
ContentStateDefaults.Spinner(screenTag = "story")
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* `component.<kebab-name>` entry hosting [content] on the plain story surface.
|
|
252
|
+
* The id is passed as a full literal (never concatenated) so the lane's parity
|
|
253
|
+
* gate (qa/lib/component-stories.mjs) and a plain grep both find it.
|
|
254
|
+
*/
|
|
255
|
+
private fun story(
|
|
256
|
+
id: String,
|
|
257
|
+
title: String,
|
|
258
|
+
content: @Composable BoxScope.() -> Unit,
|
|
259
|
+
): ScreenPreview = ScreenPreview(id, "$title — component story") {
|
|
260
|
+
StoryHost(content)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Stacked-variants flavor: the story surface with a padded, token-gapped column. */
|
|
264
|
+
private fun variantsStory(
|
|
265
|
+
id: String,
|
|
266
|
+
title: String,
|
|
267
|
+
content: @Composable ColumnScope.() -> Unit,
|
|
268
|
+
): ScreenPreview = story(id, title) {
|
|
269
|
+
Column(
|
|
270
|
+
modifier = Modifier.fillMaxSize().padding(__THEME_PREFIX__Tokens.PaddingPage),
|
|
271
|
+
verticalArrangement = Arrangement.spacedBy(__THEME_PREFIX__Tokens.GapCard),
|
|
272
|
+
content = content,
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The plain tokened surface every story renders on: theme background, nothing
|
|
278
|
+
* else — the component is the only subject. Internal (not private) so the
|
|
279
|
+
* generated registry can host the PlaceholderScreen story on custom-tab
|
|
280
|
+
* scaffolds (PlaceholderScreen ships only when a configured tab has no
|
|
281
|
+
* feature yet, so its story rides PreviewRegistry.kt, not this file).
|
|
282
|
+
*
|
|
283
|
+
* A `Surface`, not a bare `Box`: real screens root in [BaseScreen]'s `Scaffold`, which
|
|
284
|
+
* provides `LocalContentColor` to everything below it. A Box only PAINTS a background —
|
|
285
|
+
* it supplies no content color — so any component that correctly inherits one (an
|
|
286
|
+
* [AppIconButton] tint, a bare `Text`) fell back to `LocalContentColor`'s black default
|
|
287
|
+
* and rendered black-on-near-black. The story read as an empty rectangle while the
|
|
288
|
+
* component was in fact drawing perfectly. Stories must sit in the same content-color
|
|
289
|
+
* context as the screens they document, or they document a lie.
|
|
290
|
+
*/
|
|
291
|
+
@Composable
|
|
292
|
+
internal fun StoryHost(content: @Composable BoxScope.() -> Unit) {
|
|
293
|
+
Surface(
|
|
294
|
+
modifier = Modifier.fillMaxSize(),
|
|
295
|
+
color = __THEME_PREFIX__Colors.Background,
|
|
296
|
+
contentColor = __THEME_PREFIX__Colors.OnSurface,
|
|
297
|
+
) {
|
|
298
|
+
Box(Modifier.fillMaxSize()) { content() }
|
|
299
|
+
}
|
|
300
|
+
}
|
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt
CHANGED
|
@@ -69,6 +69,11 @@ fun main(args: Array<String>) {
|
|
|
69
69
|
put("ok", JsonPrimitive(true))
|
|
70
70
|
put("pid", JsonPrimitive(ProcessHandle.current().pid()))
|
|
71
71
|
put("port", JsonPrimitive(port))
|
|
72
|
+
// WHICH project this daemon serves. The port is machine-global, so a
|
|
73
|
+
// preview service that finds a healthy daemon here has no other way to
|
|
74
|
+
// tell "my project's daemon" from "another checkout's daemon on the
|
|
75
|
+
// same port" — and adopting the wrong one renders another app's screens.
|
|
76
|
+
put("previewsDir", JsonPrimitive(outRoot.absolutePath))
|
|
72
77
|
put("reloadCount", JsonPrimitive(reloadCount.get()))
|
|
73
78
|
put("reloadErrors", JsonPrimitive(reloadErrors.get()))
|
|
74
79
|
put("reloadHooked", JsonPrimitive(reloadHooked))
|
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt
CHANGED
|
@@ -25,12 +25,16 @@ import __PACKAGE__.di.appModules
|
|
|
25
25
|
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
|
|
26
26
|
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Theme
|
|
27
27
|
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
|
|
28
|
+
import __PACKAGE__.presentation.theme.__THEME_PREFIX__TypeRamp
|
|
28
29
|
import kotlinx.serialization.json.Json
|
|
29
30
|
import kotlinx.serialization.json.JsonElement
|
|
31
|
+
import kotlinx.serialization.json.JsonNull
|
|
30
32
|
import kotlinx.serialization.json.JsonPrimitive
|
|
31
33
|
import kotlinx.serialization.json.buildJsonArray
|
|
32
34
|
import kotlinx.serialization.json.buildJsonObject
|
|
35
|
+
import org.jetbrains.skia.Bitmap
|
|
33
36
|
import org.jetbrains.skia.EncodedImageFormat
|
|
37
|
+
import org.jetbrains.skia.Image
|
|
34
38
|
import org.koin.core.context.startKoin
|
|
35
39
|
import org.koin.dsl.module
|
|
36
40
|
import java.io.File
|
|
@@ -189,7 +193,8 @@ internal fun renderPng(entry: ScreenPreview, outFile: File, scale: Float) {
|
|
|
189
193
|
}
|
|
190
194
|
iterations++
|
|
191
195
|
}
|
|
192
|
-
val
|
|
196
|
+
val framed = if (isComponentStory(entry.id)) cropToContent(image, scale) else image
|
|
197
|
+
val data = framed.encodeToData(EncodedImageFormat.PNG)
|
|
193
198
|
?: error("Skia failed to encode ${entry.id} as PNG")
|
|
194
199
|
outFile.writeBytes(data.bytes)
|
|
195
200
|
} finally {
|
|
@@ -197,6 +202,74 @@ internal fun renderPng(entry: ScreenPreview, outFile: File, scale: Float) {
|
|
|
197
202
|
}
|
|
198
203
|
}
|
|
199
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Component stories are the ONLY previews cropped. A screen's frame is part of
|
|
207
|
+
* what is being reviewed (insets, bottom bar, where content sits on the page),
|
|
208
|
+
* so screens keep the full phone viewport. A component story is the opposite:
|
|
209
|
+
* a 48 dp button rendered on an 891 dp canvas is 3% component and 97% empty
|
|
210
|
+
* background, which is what made the Components gallery read as broken.
|
|
211
|
+
*/
|
|
212
|
+
private fun isComponentStory(id: String) = id.startsWith("component.")
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Tightest rectangle whose pixels differ from the frame's own background,
|
|
216
|
+
* plus one page-padding gutter. Measured from the PIXELS, not the semantics
|
|
217
|
+
* tree — decoration with no semantics (a ring, a shimmer, a divider) is part
|
|
218
|
+
* of the component and must not be cropped off.
|
|
219
|
+
*
|
|
220
|
+
* The background reference is the top-left pixel: [StoryHost] paints the whole
|
|
221
|
+
* canvas in the Background token before drawing, so that corner is background
|
|
222
|
+
* by construction. Two honest fallbacks, never a wrong crop:
|
|
223
|
+
* - nothing differs (a story whose content IS the background color) -> the
|
|
224
|
+
* full frame, uncropped;
|
|
225
|
+
* - a degenerate box (under 8 px on a side) -> the full frame too.
|
|
226
|
+
* Cropping is lossless: the kept pixels are the rendered pixels, untouched.
|
|
227
|
+
*/
|
|
228
|
+
private fun cropToContent(image: Image, scale: Float): Image {
|
|
229
|
+
val bitmap = Bitmap.makeFromImage(image)
|
|
230
|
+
val info = bitmap.imageInfo
|
|
231
|
+
val w = info.width
|
|
232
|
+
val h = info.height
|
|
233
|
+
val bpp = info.bytesPerPixel
|
|
234
|
+
if (w <= 0 || h <= 0 || bpp <= 0) return image
|
|
235
|
+
// One bulk read, then scan in memory — getColor(x, y) per pixel would be
|
|
236
|
+
// ~1.5M native calls per story.
|
|
237
|
+
val rowBytes = w * bpp
|
|
238
|
+
val pixels = bitmap.readPixels(info, rowBytes, 0, 0) ?: return image
|
|
239
|
+
fun samePixelAsOrigin(offset: Int): Boolean {
|
|
240
|
+
for (b in 0 until bpp) if (pixels[offset + b] != pixels[b]) return false
|
|
241
|
+
return true
|
|
242
|
+
}
|
|
243
|
+
var minX = w
|
|
244
|
+
var minY = h
|
|
245
|
+
var maxX = -1
|
|
246
|
+
var maxY = -1
|
|
247
|
+
for (y in 0 until h) {
|
|
248
|
+
val row = y * rowBytes
|
|
249
|
+
for (x in 0 until w) {
|
|
250
|
+
if (!samePixelAsOrigin(row + x * bpp)) {
|
|
251
|
+
if (x < minX) minX = x
|
|
252
|
+
if (y < minY) minY = y
|
|
253
|
+
if (x > maxX) maxX = x
|
|
254
|
+
if (y > maxY) maxY = y
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (maxX < 0 || maxY < 0) return image
|
|
259
|
+
val gutter = (__THEME_PREFIX__Tokens.PaddingPage.value * scale).toInt().coerceAtLeast(1)
|
|
260
|
+
val left = (minX - gutter).coerceAtLeast(0)
|
|
261
|
+
val top = (minY - gutter).coerceAtLeast(0)
|
|
262
|
+
val right = (maxX + 1 + gutter).coerceAtMost(w)
|
|
263
|
+
val bottom = (maxY + 1 + gutter).coerceAtMost(h)
|
|
264
|
+
val cw = right - left
|
|
265
|
+
val ch = bottom - top
|
|
266
|
+
if (cw < 8 || ch < 8) return image
|
|
267
|
+
val croppedInfo = info.withWidthHeight(cw, ch)
|
|
268
|
+
val croppedRowBytes = cw * bpp
|
|
269
|
+
val cropped = bitmap.readPixels(croppedInfo, croppedRowBytes, left, top) ?: return image
|
|
270
|
+
return Image.makeRaster(croppedInfo, cropped, croppedRowBytes)
|
|
271
|
+
}
|
|
272
|
+
|
|
200
273
|
/** The declared design-system catalog — generated FROM the theme objects, so it can't drift. */
|
|
201
274
|
internal fun designSystemCatalog(): String {
|
|
202
275
|
val pretty = Json { prettyPrint = true }
|
|
@@ -237,6 +310,23 @@ internal fun designSystemCatalog(): String {
|
|
|
237
310
|
put("RadiusModal", dp(__THEME_PREFIX__Tokens.RadiusModal))
|
|
238
311
|
put("RadiusInput", dp(__THEME_PREFIX__Tokens.RadiusInput))
|
|
239
312
|
})
|
|
313
|
+
// The type ramp, published from the SAME data the Typography factory
|
|
314
|
+
// builds its styles from (presentation/theme/Typography.kt) — the
|
|
315
|
+
// console's Design language page renders the ramp from this block, so a
|
|
316
|
+
// ramp that ships and a ramp that is documented cannot diverge. Font
|
|
317
|
+
// family is deliberately absent: it resolves through @Composable Font()
|
|
318
|
+
// and is not derivable here, and a guessed name would be a fabrication.
|
|
319
|
+
put("typography", buildJsonArray {
|
|
320
|
+
__THEME_PREFIX__TypeRamp.forEach { spec ->
|
|
321
|
+
add(buildJsonObject {
|
|
322
|
+
put("name", JsonPrimitive(spec.name))
|
|
323
|
+
put("weight", JsonPrimitive(spec.weight))
|
|
324
|
+
put("size", JsonPrimitive("${spec.sizeSp}sp"))
|
|
325
|
+
put("lineHeight", JsonPrimitive("${spec.lineHeightSp}sp"))
|
|
326
|
+
put("tracking", spec.tracking?.let { JsonPrimitive("${it}sp") } ?: JsonNull)
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
})
|
|
240
330
|
}
|
|
241
331
|
return pretty.encodeToString(JsonElement.serializer(), doc)
|
|
242
332
|
}
|
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
|
+
}))
|