create-cmp-cli 0.10.1 → 0.12.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 +11 -9
- package/package.json +1 -1
- package/src/lib/package-name.mjs +72 -0
- package/src/lib/tabs.mjs +26 -0
- package/src/scaffold.mjs +7 -2
- package/template/.claude/settings.json +30 -0
- package/template/.claude/skills/add-feature/SKILL.md +20 -0
- package/template/.claude/skills/add-repository/SKILL.md +6 -0
- package/template/.claude/skills/add-screen/SKILL.md +6 -0
- package/template/CLAUDE.md +129 -8
- package/template/composeApp/build.gradle.kts +69 -0
- package/template/composeApp/proguard-rules.pro +12 -0
- package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +16 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +2 -5
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
- package/template/docs/ARCHITECTURE.md +41 -2
- package/template/docs/TESTING.md +165 -0
- package/template/gradle/libs.versions.toml +15 -0
- package/template/manifest.json +1 -0
- package/template/qa/approve.mjs +119 -11
- package/template/qa/evidence/schema.json +20 -2
- package/template/qa/lib/affected-tests.mjs +147 -0
- package/template/qa/lib/approvals.mjs +602 -21
- package/template/qa/lib/device-lease.mjs +249 -0
- package/template/qa/lib/evidence-level.mjs +117 -0
- package/template/qa/lib/feature-brief.mjs +324 -0
- package/template/qa/lib/inputs-hash.mjs +43 -6
- package/template/qa/lib/reachability.mjs +211 -0
- package/template/qa/lib/spec-coverage.mjs +131 -0
- package/template/qa/lib/step-cache.mjs +221 -0
- package/template/qa/receipt-check.mjs +22 -2
- package/template/qa/scaffold-feature.mjs +20 -1
- package/template/qa/verify.mjs +776 -95
- package/template/qa/watch.mjs +622 -0
- package/template/specs/app-base.spec.md +11 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
package __PACKAGE__.testing
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.media.AudioManager
|
|
5
|
+
import androidx.test.platform.app.InstrumentationRegistry
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Snapshot/restore of device audio state (ringer mode, Do Not Disturb) so audio-routing
|
|
9
|
+
* behavior can be asserted — "does the alert still sound when the phone is on silent?"
|
|
10
|
+
* is THE question that shipped broken repeatedly, because ringer mode only exists on a
|
|
11
|
+
* device and every JVM tier is deaf to it.
|
|
12
|
+
*
|
|
13
|
+
* Being honest about what instrumentation can and cannot control:
|
|
14
|
+
*
|
|
15
|
+
* CAN (this file):
|
|
16
|
+
* - Read and set the ringer mode. Setting SILENT/VIBRATE via AudioManager flips the
|
|
17
|
+
* device through DND on modern Android, so both knobs are driven through the shell
|
|
18
|
+
* (`cmd notification set_dnd`, `settings global zen_mode`) with the instrumentation's
|
|
19
|
+
* shell uid ([Shell.exec]) — no notification-policy grant dance, works on a stock emulator.
|
|
20
|
+
* - Read/set DND (zen mode) as a global: off / priority-only / total-silence / alarms-only.
|
|
21
|
+
* - Observe playback routing: which stream/usage an active player is on
|
|
22
|
+
* (AudioManager.activePlaybackConfigurations — see PlatformBehaviorSeamTest for the
|
|
23
|
+
* assertion shape: a sound with USAGE_ALARM is what survives a silenced ringer).
|
|
24
|
+
*
|
|
25
|
+
* CANNOT (manual tier — document the claim in your spec, verify it by hand per release):
|
|
26
|
+
* - OEM sound policy: whether a channel's sound reaches the alarm stream on a Samsung
|
|
27
|
+
* is Samsung's decision; assert your app plays with USAGE_ALARM, not that a given
|
|
28
|
+
* handset makes air move.
|
|
29
|
+
* - Physical volume keys / hardware mute switches, Bluetooth routing, and whether the
|
|
30
|
+
* speaker is audible — there is no API for "a human heard it".
|
|
31
|
+
* - Lock-screen rendering: whether a takeover actually draws over a locked, OEM-skinned
|
|
32
|
+
* screen stays manual. Doze delivery, by contrast, stopped being a manual rehearsal
|
|
33
|
+
* when [DozeControl] landed — force the idle state and watch the alarm arrive.
|
|
34
|
+
*
|
|
35
|
+
* Plain helpers, not a TestRule: state here is two scalars, and an explicit
|
|
36
|
+
* snapshot-in-@Before / restore-in-@After (or [withRingerMode]) keeps the mechanism
|
|
37
|
+
* visible in the test. Wrap it in a rule when a suite grows enough tests to justify one.
|
|
38
|
+
*/
|
|
39
|
+
object SystemState {
|
|
40
|
+
|
|
41
|
+
/** The restorable slice of device audio state. */
|
|
42
|
+
data class Snapshot(val ringerMode: Int, val zenMode: Int)
|
|
43
|
+
|
|
44
|
+
/** DND (zen) global values — `settings get global zen_mode`. */
|
|
45
|
+
const val ZEN_OFF = 0
|
|
46
|
+
const val ZEN_PRIORITY_ONLY = 1
|
|
47
|
+
const val ZEN_TOTAL_SILENCE = 2
|
|
48
|
+
const val ZEN_ALARMS_ONLY = 3
|
|
49
|
+
|
|
50
|
+
private val audio: AudioManager
|
|
51
|
+
get() = InstrumentationRegistry.getInstrumentation().targetContext
|
|
52
|
+
.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
53
|
+
|
|
54
|
+
/** Capture the current ringer + DND state. Take it BEFORE the test mutates anything. */
|
|
55
|
+
fun snapshot(): Snapshot = Snapshot(ringerMode = audio.ringerMode, zenMode = readZen())
|
|
56
|
+
|
|
57
|
+
/** Put the device back exactly as [snapshot] found it — call from @After, always. */
|
|
58
|
+
fun restore(s: Snapshot) {
|
|
59
|
+
setZen(s.zenMode)
|
|
60
|
+
setRingerMode(s.ringerMode)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Set the ringer mode (AudioManager.RINGER_MODE_NORMAL / _VIBRATE / _SILENT).
|
|
65
|
+
* SILENT is driven as alarms-only DND + the mode itself — on API 23+ that is what the
|
|
66
|
+
* volume-key gesture actually does, and it is the honest reproduction of "the user
|
|
67
|
+
* silenced the phone" (alarms are still allowed to sound; a total-silence test should
|
|
68
|
+
* say so explicitly via [setZen] with [ZEN_TOTAL_SILENCE]).
|
|
69
|
+
*/
|
|
70
|
+
fun setRingerMode(mode: Int) {
|
|
71
|
+
when (mode) {
|
|
72
|
+
AudioManager.RINGER_MODE_SILENT -> setZen(ZEN_ALARMS_ONLY)
|
|
73
|
+
else -> setZen(ZEN_OFF)
|
|
74
|
+
}
|
|
75
|
+
// Best-effort after the zen change; on emulators this lands reliably. Poll-check
|
|
76
|
+
// rather than trust: callers get the real mode back and can assume-skip if the
|
|
77
|
+
// device refused (an OEM with a hardware mute state may).
|
|
78
|
+
audio.ringerMode = mode
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Set DND directly (one of the ZEN_* constants). Shell-driven; applies globally. */
|
|
82
|
+
fun setZen(zen: Int) {
|
|
83
|
+
val arg = when (zen) {
|
|
84
|
+
ZEN_PRIORITY_ONLY -> "priority"
|
|
85
|
+
ZEN_TOTAL_SILENCE -> "none"
|
|
86
|
+
ZEN_ALARMS_ONLY -> "alarms"
|
|
87
|
+
else -> "off"
|
|
88
|
+
}
|
|
89
|
+
Shell.exec("cmd notification set_dnd $arg")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The device's current DND global (ZEN_* value; unreadable/absent reads as off). */
|
|
93
|
+
fun readZen(): Int =
|
|
94
|
+
Shell.exec("settings get global zen_mode").trim().toIntOrNull() ?: ZEN_OFF
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Run [block] with the ringer in [mode], restoring the full snapshot afterwards even
|
|
98
|
+
* when the block throws — the composable form for a single-assertion test:
|
|
99
|
+
*
|
|
100
|
+
* SystemState.withRingerMode(AudioManager.RINGER_MODE_SILENT) {
|
|
101
|
+
* // trigger the alert path, then assert a player is active with USAGE_ALARM
|
|
102
|
+
* }
|
|
103
|
+
*/
|
|
104
|
+
fun <T> withRingerMode(mode: Int, block: () -> T): T {
|
|
105
|
+
val before = snapshot()
|
|
106
|
+
return try {
|
|
107
|
+
setRingerMode(mode)
|
|
108
|
+
block()
|
|
109
|
+
} finally {
|
|
110
|
+
restore(before)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
package __PACKAGE__.testing
|
|
2
|
+
|
|
3
|
+
import android.os.SystemClock
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Device-clock manipulation for the on-device tier — the primitive that turns "the alarm
|
|
7
|
+
* ladder is unit-tested to the minute" into "we watched the alarm actually arrive".
|
|
8
|
+
*
|
|
9
|
+
* Why this exists: scheduled delivery is the one claim the other helpers still could not
|
|
10
|
+
* close. [AlarmAsserts] proves REGISTRATION (the OS holds the alarm), unit tests prove the
|
|
11
|
+
* ladder's arithmetic, and yet "the notification arrives at 08:00" shipped untested every
|
|
12
|
+
* time — nobody waits two days inside a test run. Warping the device clock past the
|
|
13
|
+
* trigger time makes delivery observable in seconds: schedule for T, warp to T+ε, and the
|
|
14
|
+
* OS either fires the alarm or it doesn't. The same mechanism makes DST transitions and
|
|
15
|
+
* date rollovers ([withTimeZone]) a test input instead of a twice-a-year production
|
|
16
|
+
* surprise.
|
|
17
|
+
*
|
|
18
|
+
* EMULATOR-ONLY, NON-NEGOTIABLE — every entry point runs [assumeOnEmulator] first:
|
|
19
|
+
* warping a real phone's clock corrupts the owner's world, not just the test's — alarms
|
|
20
|
+
* re-fire or vanish, TLS certificates fall outside their validity window, auth tokens
|
|
21
|
+
* expire or refuse to, and messaging apps reorder history. A QA emulator is disposable
|
|
22
|
+
* state; a person's device never is. On a non-emulator the guard SKIPs (assumption
|
|
23
|
+
* violation), never fails — the suite stays honest about where it can run.
|
|
24
|
+
*
|
|
25
|
+
* Constraints the code can't show:
|
|
26
|
+
* - No root involved. `adb root` is refused on production (user-build) images, including
|
|
27
|
+
* the stock Play-image AVDs — but `cmd alarm set-time` / `set-timezone` work from the
|
|
28
|
+
* SHELL uid, which is exactly what [Shell.exec]'s UiAutomation channel provides.
|
|
29
|
+
* Time-warp proofs therefore run on any stock emulator, no special image.
|
|
30
|
+
* - Network time sync fights the warp: with `auto_time` on, the device re-corrects the
|
|
31
|
+
* clock underneath the test. Every warp is bracketed — sync off, warp, run, restore,
|
|
32
|
+
* sync back on — and the restore runs in `finally`, so a crashed block still leaves
|
|
33
|
+
* the device usable for the next test.
|
|
34
|
+
* - The restore is not "set the clock back to what it was": real time keeps passing
|
|
35
|
+
* while the block runs, and restoring the ORIGINAL instant would leave the clock
|
|
36
|
+
* slow by the block's duration (compounding across a suite). The restore target is
|
|
37
|
+
* `original + elapsed`, with elapsed measured on [SystemClock.elapsedRealtime] —
|
|
38
|
+
* the monotonic clock, immune to the warp itself.
|
|
39
|
+
* - Inside a warped block, bound your waits on monotonic time (`CountDownLatch.await`,
|
|
40
|
+
* [SystemClock.elapsedRealtime]), never on `System.currentTimeMillis()` — wall-clock
|
|
41
|
+
* deadlines computed before the warp are nonsense after it.
|
|
42
|
+
*/
|
|
43
|
+
object TimeWarp {
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* SKIPs the test unless it is running on an emulator (`ro.kernel.qemu == 1`).
|
|
47
|
+
* Called by every warp entry point; call it yourself at the top of a warp test so
|
|
48
|
+
* the skip names the test, not a helper frame. See the header for why a real
|
|
49
|
+
* device is refused: a warped personal phone breaks the owner's alarms, tokens,
|
|
50
|
+
* and messaging — QA emulators only.
|
|
51
|
+
*/
|
|
52
|
+
fun assumeOnEmulator() {
|
|
53
|
+
Shell.assumeOnEmulator(
|
|
54
|
+
"TimeWarp runs only on emulators (ro.kernel.qemu != 1 here). Warping a real " +
|
|
55
|
+
"device's clock corrupts its owner's alarms, TLS validity, and auth " +
|
|
56
|
+
"tokens — run this suite on a stock QA AVD instead.",
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run [block] with the device wall clock set to [targetEpochMillis], restoring
|
|
62
|
+
* everything afterwards even when the block throws:
|
|
63
|
+
*
|
|
64
|
+
* 1. `auto_time` off — otherwise network time sync re-corrects mid-block;
|
|
65
|
+
* 2. `cmd alarm set-time target` — the warp (shell uid, root-free);
|
|
66
|
+
* 3. the block — schedule/await/assert;
|
|
67
|
+
* 4. finally: `set-time (original + elapsed)` then `auto_time` back on.
|
|
68
|
+
*
|
|
69
|
+
* The restore adds the block's real duration (monotonic-measured) to the original
|
|
70
|
+
* instant so the device clock never falls behind true time — see the header.
|
|
71
|
+
*/
|
|
72
|
+
fun <T> withWarpedClock(targetEpochMillis: Long, block: () -> T): T {
|
|
73
|
+
assumeOnEmulator()
|
|
74
|
+
val autoTimeBefore = readGlobal("auto_time")
|
|
75
|
+
val originalEpochMillis = System.currentTimeMillis()
|
|
76
|
+
val startElapsed = SystemClock.elapsedRealtime()
|
|
77
|
+
Shell.exec("settings put global auto_time 0")
|
|
78
|
+
try {
|
|
79
|
+
Shell.exec("cmd alarm set-time $targetEpochMillis")
|
|
80
|
+
return block()
|
|
81
|
+
} finally {
|
|
82
|
+
val elapsed = SystemClock.elapsedRealtime() - startElapsed
|
|
83
|
+
Shell.exec("cmd alarm set-time ${originalEpochMillis + elapsed}")
|
|
84
|
+
Shell.exec("settings put global auto_time ${autoTimeBefore ?: "1"}")
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run [block] with the device in time zone [olsonId] (e.g. `"Pacific/Chatham"`,
|
|
90
|
+
* `"America/New_York"`), restoring the original zone and `auto_time_zone` afterwards
|
|
91
|
+
* even when the block throws. This is the DST/date-rollover assertion primitive:
|
|
92
|
+
* combine with [withWarpedClock] to place the device just before a transition and
|
|
93
|
+
* watch what a "tomorrow at 08:00" schedule actually does. No elapsed-time math here
|
|
94
|
+
* — a zone, unlike a clock, does not drift while the block runs.
|
|
95
|
+
*/
|
|
96
|
+
fun <T> withTimeZone(olsonId: String, block: () -> T): T {
|
|
97
|
+
assumeOnEmulator()
|
|
98
|
+
val autoZoneBefore = readGlobal("auto_time_zone")
|
|
99
|
+
val originalZone = Shell.exec("getprop persist.sys.timezone").trim()
|
|
100
|
+
Shell.exec("settings put global auto_time_zone 0")
|
|
101
|
+
try {
|
|
102
|
+
Shell.exec("cmd alarm set-timezone $olsonId")
|
|
103
|
+
return block()
|
|
104
|
+
} finally {
|
|
105
|
+
if (originalZone.isNotEmpty()) {
|
|
106
|
+
Shell.exec("cmd alarm set-timezone $originalZone")
|
|
107
|
+
}
|
|
108
|
+
Shell.exec("settings put global auto_time_zone ${autoZoneBefore ?: "1"}")
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A global setting's current value, or null when unset — see [Shell.readSetting]. */
|
|
113
|
+
private fun readGlobal(key: String): String? = Shell.readSetting("global", key)
|
|
114
|
+
}
|
|
@@ -5,7 +5,7 @@ import __PACKAGE__.domain.repository.ItemRepository
|
|
|
5
5
|
import __PACKAGE__.domain.usecase.GetItemsUseCase
|
|
6
6
|
import __PACKAGE__.presentation.home.HomeViewModel
|
|
7
7
|
// cmp:anchor di-imports
|
|
8
|
-
import org.koin.core.module.dsl.
|
|
8
|
+
import org.koin.core.module.dsl.viewModel
|
|
9
9
|
import org.koin.dsl.module
|
|
10
10
|
|
|
11
11
|
val repositoryModule = module {
|
|
@@ -19,7 +19,10 @@ val useCaseModule = module {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
val viewModelModule = module {
|
|
22
|
-
viewModelOf(
|
|
22
|
+
// Explicit factories only — never reflection-based viewModelOf (ARCH-14): it silently
|
|
23
|
+
// ignores constructor default parameter values, turning a compile-time wiring error
|
|
24
|
+
// into a runtime resolution crash. One get() per constructor dependency.
|
|
25
|
+
viewModel { HomeViewModel(get()) }
|
|
23
26
|
// cmp:anchor di-viewmodels
|
|
24
27
|
}
|
|
25
28
|
|
|
@@ -118,7 +118,7 @@ internal fun NavItem(
|
|
|
118
118
|
.clip(RoundedCornerShape(8.dp))
|
|
119
119
|
.clickable(onClick = onClick)
|
|
120
120
|
// a11y: guarantee the 48dp minimum touch target regardless of label width
|
|
121
|
-
// (the
|
|
121
|
+
// (the verify lane's a11y step flags anything smaller).
|
|
122
122
|
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
|
|
123
123
|
// Durable selection handle (tests/E2E select by testTag, never display text).
|
|
124
124
|
.semantics { testTag = navItemTag(label) }
|
|
@@ -11,7 +11,7 @@ import androidx.compose.ui.unit.dp
|
|
|
11
11
|
/**
|
|
12
12
|
* The filled call-to-action button: M3 `Button` with a 48 dp minimum touch target
|
|
13
13
|
* applied. Stock M3 buttons sit below that floor by default; wrapping them here clears
|
|
14
|
-
* WCAG 2.2 SC 2.5.8 and the
|
|
14
|
+
* WCAG 2.2 SC 2.5.8 and the verify lane's a11y bar once, for every call site.
|
|
15
15
|
*/
|
|
16
16
|
@Composable
|
|
17
17
|
fun AppPrimaryButton(
|
|
@@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|
|
13
13
|
* The registry's icon button: M3 `IconButton` held to the 48 dp touch-target floor.
|
|
14
14
|
*
|
|
15
15
|
* Stock M3 `IconButton` defaults to a 40x40 dp target — below WCAG 2.2 SC 2.5.8 and this
|
|
16
|
-
*
|
|
16
|
+
* verify lane's own a11y bar — so every raw use is a violation waiting to be measured
|
|
17
17
|
* (and historically why a text link masqueraded as a back button). This wrapper clears the
|
|
18
18
|
* floor once, by construction, the same way [AppPrimaryButton] does for filled buttons.
|
|
19
19
|
*
|
|
@@ -2,6 +2,7 @@ package __PACKAGE__.presentation.navigation
|
|
|
2
2
|
|
|
3
3
|
import androidx.compose.runtime.Composable
|
|
4
4
|
import androidx.compose.runtime.LaunchedEffect
|
|
5
|
+
import androidx.compose.ui.Modifier
|
|
5
6
|
import androidx.navigation.NavType
|
|
6
7
|
import androidx.navigation.compose.NavHost
|
|
7
8
|
import androidx.navigation.compose.composable
|
|
@@ -10,6 +11,9 @@ import androidx.navigation.navArgument
|
|
|
10
11
|
// Nav 2.9 (multiplatform): backStackEntry.arguments is a SavedState, not an Android Bundle.
|
|
11
12
|
// Read it via the androidx.savedstate.read extension, NOT Bundle.getString().
|
|
12
13
|
import androidx.savedstate.read
|
|
14
|
+
// Kept OUTSIDE the screen-imports block below: the tab rewriter (src/lib/tabs.mjs) replaces
|
|
15
|
+
// exactly those two lines with the configured tabs' screen imports.
|
|
16
|
+
import __PACKAGE__.presentation.components.exposeTestTagsForAutomation
|
|
13
17
|
import __PACKAGE__.presentation.home.HomeScreen
|
|
14
18
|
import __PACKAGE__.presentation.profile.ProfileScreen
|
|
15
19
|
|
|
@@ -38,7 +42,18 @@ fun AppNavHost() {
|
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
|
|
45
|
+
// Expose Compose testTags to the platform automation layer (Android resource-ids / iOS
|
|
46
|
+
// accessibilityIdentifiers) for the WHOLE graph. The property is inherited by descendants,
|
|
47
|
+
// so it belongs on the graph root, not on a destination: applied inside AppShell it would
|
|
48
|
+
// cover the tabs and nothing else, leaving every destination registered directly here
|
|
49
|
+
// (detail screens, trays) with testTags no id-selector can see — e2e flows could not
|
|
50
|
+
// assert arrival on them at all. Here, a destination added later inherits it without
|
|
51
|
+
// anyone remembering to. Desktop: no-op.
|
|
52
|
+
NavHost(
|
|
53
|
+
navController = navController,
|
|
54
|
+
startDestination = Screen.Shell.route,
|
|
55
|
+
modifier = Modifier.exposeTestTagsForAutomation(),
|
|
56
|
+
) {
|
|
42
57
|
composable(Screen.Shell.route) {
|
|
43
58
|
val tabs = appTabs(
|
|
44
59
|
home = {
|
|
@@ -10,7 +10,6 @@ import androidx.compose.runtime.setValue
|
|
|
10
10
|
import androidx.compose.ui.Modifier
|
|
11
11
|
import __PACKAGE__.presentation.components.AppBottomBar
|
|
12
12
|
import __PACKAGE__.presentation.components.BaseScreen
|
|
13
|
-
import __PACKAGE__.presentation.components.exposeTestTagsForAutomation
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
15
|
* Generic bottom-nav shell. Parameterized by a [tabs] list — NOT role-hardcoded.
|
|
@@ -27,10 +26,8 @@ fun AppShell(tabs: List<AppTab>) {
|
|
|
27
26
|
var selected by rememberSaveable { mutableIntStateOf(0) }
|
|
28
27
|
|
|
29
28
|
BaseScreen(
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// cmp-test-generated suites find them; covers the whole subtree. Desktop: no-op.
|
|
33
|
-
modifier = Modifier.exposeTestTagsForAutomation(),
|
|
29
|
+
// testTag exposure for automation is applied once on the NavHost in AppNavHost.kt —
|
|
30
|
+
// the graph root, so every destination inherits it, not just these tabs.
|
|
34
31
|
// The bottom bar owns the navigation-bar inset; the body must not also pad it.
|
|
35
32
|
applyNavBarPadding = false,
|
|
36
33
|
bottomBar = {
|
|
@@ -378,6 +378,64 @@ class ArchitectureConformanceTest {
|
|
|
378
378
|
)
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
// SPEC: ARCH-13
|
|
382
|
+
@Test
|
|
383
|
+
fun `ARCH-13 ambient time is read only inside the designated time provider`() {
|
|
384
|
+
// The classic "inject the clock" rule. Ambient time reads make rendered structure
|
|
385
|
+
// and test evidence a function of WHEN you run them — a golden tree that passes at
|
|
386
|
+
// 23:00 and fails at 09:00 with no code change, an assertion that only reds out
|
|
387
|
+
// across a midnight boundary. Time is injected from one provider (a package ending
|
|
388
|
+
// in `.core.time`) so tests can pin it; everywhere else these APIs are banned.
|
|
389
|
+
// Scanned at reference level (imports AND fully-qualified inline calls), the same
|
|
390
|
+
// pragmatic granularity as the layer-boundary rules above. Covers kotlinx-datetime
|
|
391
|
+
// and kotlin.time (`Clock.System`), the JVM clock (`System.currentTimeMillis`),
|
|
392
|
+
// java.time (`LocalDate.now()` et al.), and the ambient zone
|
|
393
|
+
// (`TimeZone.currentSystemDefault()`) — the zone is time-of-run state too.
|
|
394
|
+
val ambientTime = Regex(
|
|
395
|
+
"""Clock\.System|System\.currentTimeMillis|LocalDate\.now\s*\(|LocalDateTime\.now\s*\(|""" +
|
|
396
|
+
"""LocalTime\.now\s*\(|Instant\.now\s*\(|TimeZone\.currentSystemDefault\s*\("""
|
|
397
|
+
)
|
|
398
|
+
val offenders = sources(commonMain)
|
|
399
|
+
.filterNot { it.path.replace(File.separatorChar, '/').contains("/core/time/") }
|
|
400
|
+
.filter { file -> nonCommentLines(file).any { ambientTime.containsMatchIn(it) } }
|
|
401
|
+
.map { it.path }
|
|
402
|
+
if (offenders.isNotEmpty()) fail(
|
|
403
|
+
violation(
|
|
404
|
+
"ARCH-13", "ambient time APIs (Clock.System, System.currentTimeMillis, *.now(), " +
|
|
405
|
+
"TimeZone.currentSystemDefault()) are called only inside the designated time " +
|
|
406
|
+
"provider (a `core/time` package) — everywhere else, time is an injected value.",
|
|
407
|
+
offenders,
|
|
408
|
+
"inject a clock/time provider (constructor parameter, wired in di/) that lives in " +
|
|
409
|
+
"`core/time`, and read the current moment through it — never ambiently. Tests then " +
|
|
410
|
+
"pin the provider instead of inheriting the wall clock.",
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// SPEC: ARCH-14
|
|
416
|
+
@Test
|
|
417
|
+
fun `ARCH-14 ViewModels are registered with explicit viewModel factories - viewModelOf is banned`() {
|
|
418
|
+
// Koin's reflection-based `viewModelOf(::X)` silently ignores Kotlin default
|
|
419
|
+
// parameter values: it resolves EVERY constructor parameter from the graph, so a
|
|
420
|
+
// ViewModel that compiles and previews fine crashes at runtime resolution the
|
|
421
|
+
// first time a defaulted parameter matters (the default masked a dependency the
|
|
422
|
+
// graph never registered). An explicit `viewModel { X(get(), …) }` factory states
|
|
423
|
+
// each dependency and fails at compile time instead.
|
|
424
|
+
val offenders = sources(commonMain)
|
|
425
|
+
.filter { file -> nonCommentLines(file).any { it.contains("viewModelOf") } }
|
|
426
|
+
.map { it.path }
|
|
427
|
+
if (offenders.isNotEmpty()) fail(
|
|
428
|
+
violation(
|
|
429
|
+
"ARCH-14", "ViewModels are registered with explicit `viewModel { … }` factories — " +
|
|
430
|
+
"reflection-based `viewModelOf` is banned (it silently ignores constructor " +
|
|
431
|
+
"default parameter values; failures move from compile time to runtime resolution).",
|
|
432
|
+
offenders,
|
|
433
|
+
"replace `viewModelOf(::XViewModel)` with `viewModel { XViewModel(get(), …) }` " +
|
|
434
|
+
"(import org.koin.core.module.dsl.viewModel), one get() per constructor dependency.",
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
381
439
|
// SPEC: SHELL-05
|
|
382
440
|
@Test
|
|
383
441
|
fun `SHELL-05 every non-shell nav destination wraps its content in BaseScreen`() {
|
|
@@ -21,6 +21,7 @@ app").
|
|
|
21
21
|
| Maintainability | An AI session adds a feature; the lane names any layer violation as a clause, not a style nit | `[enforced: ARCH-01..05]` |
|
|
22
22
|
| Reliability | A source fails; the failure crosses layers as a typed `DomainError`, never a raw exception, and the screen shows a mapped error state | `[enforced: ARCH-06/07/08]` |
|
|
23
23
|
| Reliability (offline) | Network drops mid-session; cached Room data still renders, UI shows degraded state | `[advisory]` today — `NetworkMonitor` and Room ship as infrastructure (§4, §7) but no repository wires the cache-first fallback yet; clause candidate |
|
|
24
|
+
| Reliability (platform behavior) | A feature that alarms or notifies actually alerts on a real device — the claim crosses the process boundary instead of stopping at a green JVM | instrumented tier (`androidInstrumentedTest`, lane step `androidChecks`); platform-behavior spec clauses cite instrumented tests, never desktop ones |
|
|
24
25
|
| Interaction capability (a11y) | Every interactive element is perceivable by assistive tech and automation | `[enforced: SHELL-04 + A11y gates]` |
|
|
25
26
|
| Security | The debug inspector HTTP server (§3) never ships in a release build | `[advisory]` today — true by source-set placement (`androidDebug`), not yet gated; clause candidate (`DEBUG-01`) |
|
|
26
27
|
|
|
@@ -79,6 +80,7 @@ app").
|
|
|
79
80
|
| `androidMain` | Android entry point (`AppApplication`), platform `actual`s. |
|
|
80
81
|
| `androidDebug` | Debug-only additions layered on `androidMain` — the inspector HTTP server, crash recorder, DB inspector. Never in `androidRelease`. |
|
|
81
82
|
| `androidRelease` | Release-only twins (currently a no-op inspector stub) that make the `androidDebug` additions compile out cleanly. |
|
|
83
|
+
| `androidInstrumentedTest` | The on-device behavior tier: platform facts no JVM test can see (alarms, notifications, PendingIntent identity, audio routing) asserted in the app's real process — generic helpers in `testing/`, exemplar in `PlatformBehaviorSeamTest`. Runs via the lane's `androidChecks` step (`connectedDebugAndroidTest`; SKIPs without a device). See `docs/TESTING.md` §"The instrumented tier". |
|
|
82
84
|
| `iosMain` | iOS entry point (`MainViewController.kt`, `KoinHelper.kt`), platform `actual`s. |
|
|
83
85
|
| `desktopMain` | The JVM tier: dev-client window/hot-reload, the preview-render harness, and desktop DI — **harness infrastructure**, not a shipped app target (project ADR `0003`). |
|
|
84
86
|
| `desktopTest` | Conformance gates (this document's enforced clauses), Compose UI Tests, golden-tree structural baselines — the fast, device-free verification tier. |
|
|
@@ -180,7 +182,7 @@ Three named scenarios ground that loop in what actually happens on this codebase
|
|
|
180
182
|
Room when `NetworkMonitor.isOnline` is false. Both pieces of infrastructure exist (§3, §7);
|
|
181
183
|
no repository reads them today — hence the offline goal's `[advisory]` status in §1.
|
|
182
184
|
3. **Navigate + process death.** `AppNavHost` (single-Activity/single-`ComposeUIViewController`)
|
|
183
|
-
owns the back stack; each screen's `ViewModel` (
|
|
185
|
+
owns the back stack; each screen's `ViewModel` (a Koin `viewModel { }` factory) survives
|
|
184
186
|
configuration change but not process death — a killed-and-restored process re-runs cold
|
|
185
187
|
start and reloads from the repository, the same path as scenario 1.
|
|
186
188
|
|
|
@@ -232,7 +234,22 @@ injected, not hardcoded, so tests can pass a test dispatcher. Do **not** add `wi
|
|
|
232
234
|
around calls that are already main-safe by contract; it's dead weight that hides where the
|
|
233
235
|
real guarantee lives.
|
|
234
236
|
|
|
235
|
-
###
|
|
237
|
+
### Time (inject the clock) `[enforced: ARCH-13]`
|
|
238
|
+
|
|
239
|
+
**Ambient time reads are banned outside the designated time provider.** `Clock.System`,
|
|
240
|
+
`System.currentTimeMillis`, `LocalDate.now()` / `LocalDateTime.now()` / `LocalTime.now()` /
|
|
241
|
+
`Instant.now()`, and `TimeZone.currentSystemDefault()` may be referenced only inside a
|
|
242
|
+
`core/time` package (the one place allowed to touch the wall clock; a fresh scaffold ships
|
|
243
|
+
no time usage at all, so the package appears with your first time-dependent feature).
|
|
244
|
+
Everywhere else, the current moment arrives as an **injected value** — a clock/time-provider
|
|
245
|
+
interface declared in `core/time` and wired through Koin like any other dependency.
|
|
246
|
+
|
|
247
|
+
The rationale is determinism of evidence: an ambient time read makes rendered structure and
|
|
248
|
+
test results a function of *when* you run them — a golden tree that passes at 23:00 and
|
|
249
|
+
fails at 09:00 with no code change, a "today" grouping that flips across a midnight
|
|
250
|
+
boundary, a defaulted `Clock.System` constructor parameter that no test can pin. Injected
|
|
251
|
+
time gives tests one seam to freeze, and gives every screen's rendered output a single
|
|
252
|
+
source of "now" the harness can control.
|
|
236
253
|
|
|
237
254
|
One Koin module per concern: `repositoryModule` / `useCaseModule` / `viewModelModule`
|
|
238
255
|
(`di/AppModule.kt`, common to every platform), plus one platform module for platform-only
|
|
@@ -243,6 +260,21 @@ no service-locator lookups inside domain or presentation code. Platform modules
|
|
|
243
260
|
`actual`-backed singletons (`NetworkMonitor`, `AppDatabase`) that common modules then depend on
|
|
244
261
|
via the domain-facing interface, not the concrete platform type.
|
|
245
262
|
|
|
263
|
+
**ViewModels are registered with explicit `viewModel { … }` factories; reflection-based
|
|
264
|
+
`viewModelOf` is disallowed** `[enforced: ARCH-14]`. Koin's `viewModelOf` silently ignores
|
|
265
|
+
Kotlin constructor default parameter values — a ViewModel that compiles and previews fine
|
|
266
|
+
crashes at runtime resolution the first time a defaulted parameter matters (the default was
|
|
267
|
+
masking a dependency the graph never registered). An explicit factory states every
|
|
268
|
+
dependency (`viewModel { XViewModel(get(), …) }`) and fails at compile time instead.
|
|
269
|
+
|
|
270
|
+
Shared-vs-per-destination ViewModel scope is an **intent** decision no static rule can make
|
|
271
|
+
for you `[advisory]`: a screen that default-instantiates its own ViewModel gets a fresh
|
|
272
|
+
instance per destination, so state silently resets on re-entry. State meant to be shared
|
|
273
|
+
across screens must be scoped explicitly — register it once and resolve the same instance
|
|
274
|
+
at each destination (e.g. a `single { … }`-scoped holder, or a factory resolved against a
|
|
275
|
+
shared owner) rather than letting each screen construct its own. Decide the scope when you
|
|
276
|
+
introduce the ViewModel, not after the reset surprises you.
|
|
277
|
+
|
|
246
278
|
### Logging `[advisory — no shared logger ships today]`
|
|
247
279
|
|
|
248
280
|
The scaffold ships **no cross-platform logging library**. The only logging present is Koin's
|
|
@@ -274,6 +306,13 @@ map `ItemEntity` → the domain `Item` inside `data/` — and schema changes get
|
|
|
274
306
|
bump. `fallbackToDestructiveMigration` is a scaffold-stage convenience; replace it before
|
|
275
307
|
shipping user data you can't afford to lose.
|
|
276
308
|
|
|
309
|
+
Room's exported schema history (`composeApp/schemas/`) is **append-only**
|
|
310
|
+
`[enforced: schemaHistory lane step]`: every version below the current highest is a frozen
|
|
311
|
+
record of a database that shipped, and migrations are validated against those exact bytes.
|
|
312
|
+
The lane fails if a historical `<version>.json` is rewritten or deleted (`git checkout --
|
|
313
|
+
<file>` restores it); only the current highest version — the live, in-progress schema — may
|
|
314
|
+
change between commits.
|
|
315
|
+
|
|
277
316
|
### Design tokens `[enforced: ARCH-05]`
|
|
278
317
|
|
|
279
318
|
`presentation/theme/` (`Tokens.kt`, `Theme.kt`, `Typography.kt`, `Shape.kt`) is the only source
|