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

View File

@@ -0,0 +1,169 @@
package com.tt.honeyDue.notifications
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.yield
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* P4 Stream P — tests for [NotificationPreferencesStore].
*
* Robolectric-backed because the store both reads/writes DataStore and
* rewrites Android [android.app.NotificationChannel] importance when a
* category toggle flips.
*
* Mirrors the iOS behaviour in
* `iosApp/iosApp/Profile/NotificationPreferencesView.swift` where each
* category toggle persists independently and a master switch can disable
* everything in one tap.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
class NotificationPreferencesStoreTest {
private lateinit var context: Context
private lateinit var store: NotificationPreferencesStore
private lateinit var manager: NotificationManager
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Clean slate
manager.notificationChannels.forEach { manager.deleteNotificationChannel(it.id) }
NotificationChannels.ensureChannels(context)
store = NotificationPreferencesStore(context)
}
@After
fun tearDown() = runTest {
store.clearAll()
}
@Test
fun defaults_allCategoriesEnabled() = runTest {
assertTrue(store.isCategoryEnabled(NotificationChannels.TASK_REMINDER))
assertTrue(store.isCategoryEnabled(NotificationChannels.TASK_OVERDUE))
assertTrue(store.isCategoryEnabled(NotificationChannels.RESIDENCE_INVITE))
assertTrue(store.isCategoryEnabled(NotificationChannels.SUBSCRIPTION))
}
@Test
fun defaults_masterToggleEnabled() = runTest {
assertTrue(store.isAllEnabled())
}
@Test
fun setCategoryEnabled_false_persists() = runTest {
store.setCategoryEnabled(NotificationChannels.TASK_REMINDER, false)
assertFalse(store.isCategoryEnabled(NotificationChannels.TASK_REMINDER))
// Other categories untouched
assertTrue(store.isCategoryEnabled(NotificationChannels.TASK_OVERDUE))
}
@Test
fun setCategoryEnabled_roundtrip_trueThenFalseThenTrue() = runTest {
val id = NotificationChannels.TASK_OVERDUE
store.setCategoryEnabled(id, false)
assertFalse(store.isCategoryEnabled(id))
store.setCategoryEnabled(id, true)
assertTrue(store.isCategoryEnabled(id))
}
@Test
fun setAllEnabled_false_disablesEveryCategory() = runTest {
store.setAllEnabled(false)
assertFalse(store.isAllEnabled())
assertFalse(store.isCategoryEnabled(NotificationChannels.TASK_REMINDER))
assertFalse(store.isCategoryEnabled(NotificationChannels.TASK_OVERDUE))
assertFalse(store.isCategoryEnabled(NotificationChannels.RESIDENCE_INVITE))
assertFalse(store.isCategoryEnabled(NotificationChannels.SUBSCRIPTION))
}
@Test
fun setAllEnabled_true_reenablesEveryCategory() = runTest {
store.setAllEnabled(false)
store.setAllEnabled(true)
assertTrue(store.isAllEnabled())
assertTrue(store.isCategoryEnabled(NotificationChannels.TASK_REMINDER))
assertTrue(store.isCategoryEnabled(NotificationChannels.TASK_OVERDUE))
assertTrue(store.isCategoryEnabled(NotificationChannels.RESIDENCE_INVITE))
assertTrue(store.isCategoryEnabled(NotificationChannels.SUBSCRIPTION))
}
@Test
fun observePreferences_emitsInitialSnapshot() = runTest {
val snapshot = store.observePreferences().first()
assertEquals(true, snapshot[NotificationChannels.TASK_REMINDER])
assertEquals(true, snapshot[NotificationChannels.TASK_OVERDUE])
assertEquals(true, snapshot[NotificationChannels.RESIDENCE_INVITE])
assertEquals(true, snapshot[NotificationChannels.SUBSCRIPTION])
}
@Test
fun observePreferences_emitsUpdatesOnChange() = runTest {
// Collect first two distinct emissions: the initial snapshot and the
// update produced by flipping TASK_OVERDUE.
val collected = mutableListOf<Map<String, Boolean>>()
val job = launch {
store.observePreferences().take(2).toList(collected)
}
// Let the first emission land, then flip the flag.
yield()
store.setCategoryEnabled(NotificationChannels.TASK_OVERDUE, false)
job.join()
assertEquals(2, collected.size)
assertEquals(true, collected[0][NotificationChannels.TASK_OVERDUE])
assertEquals(false, collected[1][NotificationChannels.TASK_OVERDUE])
}
@Test
fun setCategoryEnabled_false_rewritesChannelImportanceToNone() = runTest {
// Precondition: TASK_REMINDER was created with IMPORTANCE_DEFAULT.
val before = manager.getNotificationChannel(NotificationChannels.TASK_REMINDER)
assertEquals(NotificationManager.IMPORTANCE_DEFAULT, before.importance)
store.setCategoryEnabled(NotificationChannels.TASK_REMINDER, false)
val after = manager.getNotificationChannel(NotificationChannels.TASK_REMINDER)
assertEquals(NotificationManager.IMPORTANCE_NONE, after.importance)
}
@Test
fun setAllEnabled_false_silencesAllChannels() = runTest {
store.setAllEnabled(false)
listOf(
NotificationChannels.TASK_REMINDER,
NotificationChannels.TASK_OVERDUE,
NotificationChannels.RESIDENCE_INVITE,
NotificationChannels.SUBSCRIPTION,
).forEach { id ->
val channel = manager.getNotificationChannel(id)
assertEquals(
"Channel $id should be IMPORTANCE_NONE after master toggle off",
NotificationManager.IMPORTANCE_NONE,
channel.importance,
)
}
}
}