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", ) } }