diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/architecture/NoIndependentViewModelStateFileScanTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/architecture/NoIndependentViewModelStateFileScanTest.kt new file mode 100644 index 0000000..325f76d --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/architecture/NoIndependentViewModelStateFileScanTest.kt @@ -0,0 +1,130 @@ +package com.tt.honeyDue.architecture + +import java.io.File +import kotlin.test.Test +import kotlin.test.fail + +/** + * Architecture regression gate. + * + * Scans `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/` and + * asserts every ViewModel either: + * a) accepts `dataManager: IDataManager` as a constructor parameter, or + * b) is explicitly allowlisted in [WORKFLOW_ONLY_VMS] as a + * workflow/mutation-only VM. + * + * Prevents the Dec 3 2025 regression (4 VMs holding independent + * `MutableStateFlow` read-state instead of deriving from DataManager). + * See `docs/parity-gallery.md` "Known limitations" for the history. + * + * Workflow / write-only (no read-state to mirror): + * * `TaskCompletionViewModel` — single-shot create mutation + * * `OnboardingViewModel` — wizard form + per-step ApiResult + * * `PasswordResetViewModel` — wizard form + per-step ApiResult + * + * Everyone else must accept the `dataManager` ctor param. + */ +class NoIndependentViewModelStateFileScanTest { + + @Test + fun every_read_state_vm_accepts_iDataManager_ctor_param() { + val vmSources = findViewModelSources() + val violations = mutableListOf() + + vmSources.forEach { file -> + val name = file.name + if (name in WORKFLOW_ONLY_VMS) return@forEach + + val body = file.readText() + val hasCtorParam = body.contains(Regex("""dataManager:\s*IDataManager""")) + if (!hasCtorParam) { + violations.add( + "$name — expected `dataManager: IDataManager = DataManager` " + + "constructor parameter. Without this, read-state can't derive " + + "from the DataManager single source of truth and snapshot " + + "tests can't substitute a fixture. " + + "If this VM genuinely has no read-state (workflow / mutation only), " + + "add its filename to WORKFLOW_ONLY_VMS with justification.", + ) + } + } + + if (violations.isNotEmpty()) { + fail( + "ViewModel architecture regression (see docs/parity-gallery.md):\n" + + violations.joinToString(separator = "\n") { " - $it" }, + ) + } + } + + @Test + fun read_state_flows_should_be_derived_not_independent() { + val vmSources = findViewModelSources() + val violations = mutableListOf() + + // Names of fields that track one-shot mutation/workflow feedback — + // exempt from the "must be derived" rule. + val mutationFieldPrefixes = listOf( + "create", "update", "delete", "toggle", "download", + "upload", "archive", "unarchive", "cancel", "uncancel", + "mark", "generate", "request", "submit", "login", "register", + "reset", "forgot", "verify", "apple", "google", + "join", "addNew", "addTask", "taskAddNew", + "action", "currentStep", "resetToken", "email", "selected", + // Local-only state not cached by DataManager: + "category", // NotificationPreferencesViewModel per-channel local toggles + ) + + vmSources.forEach { file -> + val name = file.name + if (name in WORKFLOW_ONLY_VMS) return@forEach + if (name == "AuthViewModel.kt") return@forEach // 11 one-shot states, all mutation-feedback; allowlisted as a file + + val body = file.readText() + val mutableReads = Regex("""private val (_[a-zA-Z]+State)\s*=\s*MutableStateFlow""") + .findAll(body).map { it.groupValues[1] }.toList() + + mutableReads.forEach { fieldName -> + val root = fieldName.removePrefix("_").removeSuffix("State") + val isMutationFeedback = mutationFieldPrefixes.any { + root.lowercase().startsWith(it.lowercase()) + } + if (!isMutationFeedback) { + violations.add( + "$name — field `$fieldName` looks like cached read-state " + + "(not matching any mutation-feedback prefix). Derive it from " + + "DataManager via `dataManager.xxx.map { ... }.stateIn(...)` " + + "instead of owning a MutableStateFlow. If this field really " + + "is mutation feedback, add its name prefix to " + + "mutationFieldPrefixes in this test.", + ) + } + } + } + + if (violations.isNotEmpty()) { + fail( + "ViewModel state-ownership regression (see docs/parity-gallery.md):\n" + + violations.joinToString(separator = "\n") { " - $it" }, + ) + } + } + + private fun findViewModelSources(): List { + // Test cwd is `composeApp/` — resolve from the project dir. + val vmDir = File("src/commonMain/kotlin/com/tt/honeyDue/viewmodel") + check(vmDir.exists()) { + "expected VM source directory not found: ${vmDir.absolutePath} (cwd=${File(".").absolutePath})" + } + return vmDir.listFiles { f -> f.extension == "kt" }?.toList().orEmpty() + } + + companion object { + /** VMs that legitimately don't need DataManager injection. */ + val WORKFLOW_ONLY_VMS: Set = setOf( + "TaskCompletionViewModel.kt", + "OnboardingViewModel.kt", + "PasswordResetViewModel.kt", + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/architecture/NoIndependentViewModelStateTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/architecture/NoIndependentViewModelStateTest.kt new file mode 100644 index 0000000..78a7cdd --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/architecture/NoIndependentViewModelStateTest.kt @@ -0,0 +1,19 @@ +package com.tt.honeyDue.architecture + +// Stub — the real enforcement lives in androidUnitTest where +// `java.io.File` is available and the scan can read VM source text +// directly from disk. Keeping a commonTest placeholder documents the +// architectural rule to anyone browsing test code cross-platform. +// +// See: +// composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/architecture/ +// NoIndependentViewModelStateFileScanTest.kt +// +// Rule (enforced by the file-scan test): +// Every ViewModel in commonMain/kotlin/com/tt/honeyDue/viewmodel/ must +// either accept `dataManager: IDataManager = DataManager` as its +// constructor parameter (so read-state can be derived reactively from +// DataManager) or be explicitly allowlisted as a workflow/mutation-only +// VM that has no cached state to mirror. +// +// Context: docs/parity-gallery.md "Known limitations" section.