Fails CI if any future VM regresses to the pre-migration pattern of owning independent MutableStateFlow read-state. Two assertions: 1. every_read_state_vm_accepts_iDataManager_ctor_param Scans composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ and requires every VM to either declare `dataManager: IDataManager` as a constructor param or be in WORKFLOW_ONLY_VMS allowlist (currently TaskCompletion, Onboarding, PasswordReset). 2. read_state_flows_should_be_derived_not_independent Flags any `private val _xxxState = MutableStateFlow(...)` whose field-name prefix isn't on the mutation-feedback allowlist (create/ update/delete/toggle/…). Read-state MUST derive from DataManager via .map + .stateIn pattern. AuthViewModel file-level allowlisted (every one of its 11 states is legitimate one-shot mutation feedback). Paired stub in commonTest documents the rule cross-platform; real scan lives in androidUnitTest where java.io.File works. Runs with ./gradlew :composeApp:testDebugUnitTest --tests "*architecture*". See docs/parity-gallery.md "Known limitations" for the history of the Dec 3 2025 partial migration this gate prevents regressing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
5.4 KiB
Kotlin
131 lines
5.4 KiB
Kotlin
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<String>()
|
|
|
|
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<String>()
|
|
|
|
// 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<File> {
|
|
// 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<String> = setOf(
|
|
"TaskCompletionViewModel.kt",
|
|
"OnboardingViewModel.kt",
|
|
"PasswordResetViewModel.kt",
|
|
)
|
|
}
|
|
}
|