P4 Stream P: NotificationPreferencesScreen expansion

Per-category toggle + master toggle + system-settings shortcut matching
iOS NotificationPreferencesView depth. DataStore-backed prefs, channel
importance rewritten to NONE when category disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:24:45 -05:00
parent 3700968d00
commit 65af40ed73
12 changed files with 1053 additions and 206 deletions
@@ -0,0 +1,153 @@
package com.tt.honeyDue.ui.screens
import com.tt.honeyDue.viewmodel.NotificationCategoriesController
import com.tt.honeyDue.viewmodel.NotificationCategoryKeys
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* P4 Stream P — state-logic tests for the per-category notification UI.
*
* These mirror the iOS per-category toggles in
* `iosApp/iosApp/Profile/NotificationPreferencesView.swift` and exercise
* [NotificationCategoriesController] — a commonMain façade used by
* [NotificationPreferencesScreen] that delegates persistence to the
* Android DataStore-backed `NotificationPreferencesStore` in production
* and to a fake in these tests.
*
* We use plain kotlin.test here (no Compose UI testing) for the same
* reasons noted in ThemeSelectionScreenTest / FeatureComparisonScreenTest
* — the commonTest recomposer+Dispatchers interplay is flaky on
* iosSimulator.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NotificationPreferencesScreenTest {
/** In-memory stand-in for NotificationPreferencesStore. */
private class FakeStore(
initial: Map<String, Boolean> = NotificationCategoryKeys.ALL.associateWith { true },
) {
val state = initial.toMutableMap()
val setCategoryCalls: MutableList<Pair<String, Boolean>> = mutableListOf()
val setAllCalls: MutableList<Boolean> = mutableListOf()
suspend fun setCategoryEnabled(id: String, enabled: Boolean) {
setCategoryCalls += (id to enabled)
state[id] = enabled
}
suspend fun setAllEnabled(enabled: Boolean) {
setAllCalls += enabled
NotificationCategoryKeys.ALL.forEach { state[it] = enabled }
}
suspend fun loadAll(): Map<String, Boolean> =
NotificationCategoryKeys.ALL.associateWith { state[it] ?: true }
}
private fun controllerFor(store: FakeStore) = NotificationCategoriesController(
loadAll = { store.loadAll() },
setCategory = { id, v -> store.setCategoryEnabled(id, v) },
setAll = { v -> store.setAllEnabled(v) },
)
@Test
fun categoryKeys_matchNotificationChannels() {
// Parity guard: if NotificationChannels ever adds/removes a channel,
// the category keys used by this screen must update in lockstep.
assertEquals(
listOf("task_reminder", "task_overdue", "residence_invite", "subscription"),
NotificationCategoryKeys.ALL,
)
}
@Test
fun initialState_loadsAllCategoriesEnabled() = runTest {
val store = FakeStore()
val controller = controllerFor(store)
val snapshot = controller.load()
assertEquals(4, snapshot.size)
snapshot.values.forEach { assertTrue(it, "Every category starts enabled") }
}
@Test
fun toggleCategory_invokesSetCategoryEnabled() = runTest {
val store = FakeStore()
val controller = controllerFor(store)
controller.onCategoryToggle("task_reminder", false)
assertEquals(1, store.setCategoryCalls.size)
assertEquals("task_reminder" to false, store.setCategoryCalls[0])
assertFalse(store.state["task_reminder"]!!)
}
@Test
fun toggleDifferentCategories_isolatesUpdates() = runTest {
val store = FakeStore()
val controller = controllerFor(store)
controller.onCategoryToggle("task_overdue", false)
controller.onCategoryToggle("subscription", false)
assertFalse(store.state["task_overdue"]!!)
assertFalse(store.state["subscription"]!!)
// Untouched categories remain enabled
assertTrue(store.state["task_reminder"]!!)
assertTrue(store.state["residence_invite"]!!)
}
@Test
fun masterToggle_off_invokesSetAllEnabledFalse() = runTest {
val store = FakeStore()
val controller = controllerFor(store)
controller.onMasterToggle(false)
assertEquals(listOf(false), store.setAllCalls)
assertFalse(store.state["task_reminder"]!!)
assertFalse(store.state["task_overdue"]!!)
assertFalse(store.state["residence_invite"]!!)
assertFalse(store.state["subscription"]!!)
}
@Test
fun masterToggle_on_reenablesAllCategories() = runTest {
val store = FakeStore(
initial = mapOf(
"task_reminder" to false,
"task_overdue" to false,
"residence_invite" to false,
"subscription" to false,
),
)
val controller = controllerFor(store)
controller.onMasterToggle(true)
assertEquals(listOf(true), store.setAllCalls)
NotificationCategoryKeys.ALL.forEach { id ->
assertTrue(store.state[id]!!, "Category $id should be re-enabled")
}
}
@Test
fun computeMasterState_trueWhenAllEnabled_falseOtherwise() {
val allOn = mapOf(
"task_reminder" to true,
"task_overdue" to true,
"residence_invite" to true,
"subscription" to true,
)
val oneOff = allOn.toMutableMap().apply { put("task_overdue", false) }
assertTrue(NotificationCategoriesController.computeMasterState(allOn))
assertFalse(NotificationCategoriesController.computeMasterState(oneOff))
assertFalse(NotificationCategoriesController.computeMasterState(emptyMap()))
}
}