From 3c50684b01dc4f46edf3bf5f1c361f321d0b5e02 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Thu, 4 Jun 2026 22:15:15 +0400 Subject: [PATCH 1/5] Add design spec for real-time study queue updates --- .../2026-06-04-realtime-study-queue-design.md | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-04-realtime-study-queue-design.md diff --git a/docs/superpowers/specs/2026-06-04-realtime-study-queue-design.md b/docs/superpowers/specs/2026-06-04-realtime-study-queue-design.md new file mode 100644 index 0000000..39cdd79 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-realtime-study-queue-design.md @@ -0,0 +1,189 @@ +# Real-Time Study Queue Updates — Design + +**Date:** 2026-06-04 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/realtime-due-queue` (off `main`; does not touch the flip-card files in the +open PR #11, so no conflict). + +## Goal + +Make the study-queue surfaces refresh **the instant a card becomes due**, without requiring a data +change, navigation, or app restart. Specifically: the global **Today/Queue home** +(`StudyQueueViewModel`) and the **deck-detail screen** (`DeckDetailViewModel`) should update their +due/new counts, per-deck/per-folder chips, queue preview, and the Study ↔ "You're all caught up!" +action slot live as wall-clock time crosses each card's `fsrsDue`. + +## Background: root cause + +Both view models compute dueness as `fsrsState != New && fsrsDue <= now()` inside a `combine { }` +block: + +- `StudyQueueViewModel.uiState` combines `observeAllCards()`, `observeDecks()`, + `observeFolders()`, `settings`, `entitlement`; it reads `val nowMillis = now()` once inside the + lambda and derives `readyCount`, `dueCount`/`newCount`, per-deck and per-folder chips, and the + queue preview from it. +- `DeckDetailViewModel.uiState` combines `observeCards(deckId)`, `queryFlow`, `deckNameFlow`; it + reads `val nowMillis = now()` once and derives `dueCount`/`newCount`. + +A `combine` lambda only re-runs when one of its **upstream Flows emits**. A card "becoming due" is +purely wall-clock time crossing `fsrsDue` — **no data changes at that instant** — so nothing +re-emits, `now()` is never re-sampled, and the counts stay stale until some unrelated event (a card +edit, a sync, a settings change, or re-subscription after the `WhileSubscribed(5_000)` timeout) +forces a recompute. There is currently **no time-ticker / clock Flow** anywhere in the codebase. + +**Decision (from brainstorming): exact-moment scheduling.** Rather than blind polling, manufacture +the "card became due" event: from the current card set, find the soonest future `fsrsDue`, sleep +precisely until then, re-emit, and reschedule. This is the event-driven ("listener") equivalent — +there is no free OS/data event for dueness, so we create a timer at the exact deadline. +(`AlarmManager`/`WorkManager` were rejected: they're background/notification tools — `WorkManager`'s +periodic minimum is 15 min — and wrong for a live on-screen counter.) + +## Components + +### `core/domain/fsrs/DueTicker.kt` (new — the entire mechanism) + +A single Flow operator that pairs each card list with a `now` timestamp that re-emits exactly when +the next card becomes due: + +```kotlin +package nart.simpleanki.core.domain.fsrs + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.CardState + +/** + * Pairs each emitted card list with a "now" timestamp that re-emits the instant the next card + * becomes due. + * + * Dueness is purely time-based — no data change fires when a card crosses its fsrsDue — so this + * operator manufactures that event: for a given card set it emits now, finds the soonest FUTURE + * due (non-New, fsrsDue > now), sleeps exactly until then, re-emits with a fresh now, and + * reschedules. When the upstream card list changes, flatMapLatest cancels the pending wait and + * restarts with the new list (recomputing the deadline). When no card is due in the future, it + * emits once and idles — no busy-wait — until the card list changes. + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun Flow>.withDueTicks(now: () -> Long): Flow, Long>> = + flatMapLatest { cards -> + flow { + while (true) { + val nowMillis = now() + emit(cards to nowMillis) + val nextDue = cards + .filter { !it.isDeleted && it.fsrsState != CardState.New.value && it.fsrsDue > nowMillis } + .minOfOrNull { it.fsrsDue } ?: break + delay((nextDue - nowMillis).coerceAtLeast(0)) + } + } + } +``` + +Behavior: +- **Emit → schedule → re-emit:** emits `(cards, now)`, computes the soonest future due + (`non-New && fsrsDue > now`), `delay`s precisely until then, then re-emits with a fresh `now` + (so that card now satisfies `fsrsDue <= now` downstream), and loops to the next deadline. +- **Cards change:** `flatMapLatest` cancels the in-flight `delay` and restarts the inner flow with + the new list — recomputing the deadline. Handles add/edit/delete/rate for free. +- **No future-due card** (empty, all-New, or all-already-due): `minOfOrNull` returns `null` → + `break` → emit once, then idle. No wasted wake-ups. +- **`@OptIn(ExperimentalCoroutinesApi::class)` is contained in this file;** callers receive a plain + `Flow, Long>>` and need no opt-in. +- `coerceAtLeast(0)` guards a race where a card is already due by the time the deadline is computed. + +The "future-due" filter intentionally watches only **non-New** cards crossing `fsrsDue`, matching +`StudyQueueBuilder.buildStudyQueue` semantics: New cards are already available, so only review +cards crossing their due time change what is studyable over time. + +### `feature/queue/StudyQueueViewModel.kt` (modify, ~3 lines) + +Apply the operator to the cards flow and consume the reactive timestamp: + +```kotlin +combine( + cardRepository.observeAllCards().withDueTicks(now), // was: cardRepository.observeAllCards() + deckRepository.observeDecks(), + folderRepository.observeFolders(), + settingsRepository.settings, + entitlementRepository.entitlement, +) { (cards, nowMillis), decks, folders, settings, entitlement -> + // DELETE the old `val nowMillis = now()` — everything below already uses `nowMillis`. + ... +} +``` + +Add `import nart.simpleanki.core.domain.fsrs.withDueTicks`. The lambda already funnels every +derived value (`readyCount`, `dueCount`/`newCount`, `perDeck`, `perFolder`, `queueCards`, +`studiedToday`'s `startOfDay(nowMillis)`) through a single `nowMillis`, so one destructure makes the +whole screen live. Stays at 5 `combine` inputs (no array-combine needed). + +### `feature/deckdetail/DeckDetailViewModel.kt` (modify, ~3 lines) + +```kotlin +combine( + cardRepository.observeCards(deckId).withDueTicks(now), // was: cardRepository.observeCards(deckId) + queryFlow, + deckNameFlow, +) { (cards, nowMillis), query, name -> + // DELETE the old `val nowMillis = now()`. + ... +} +``` + +Add `import nart.simpleanki.core.domain.fsrs.withDueTicks`. Makes the Due/New stat card and the +Study ↔ "You're all caught up!" action slot flip live the instant a card comes due. Stays at 3 +`combine` inputs. + +## Data flow + +`observeCards()` emits the card set → `withDueTicks` emits `(cards, now)` immediately and schedules +a `delay` until the soonest `fsrsDue` → at that exact instant it re-emits `(cards, laterNow)` → +`combine` recomputes counts → `StateFlow` pushes new state to Compose. `WhileSubscribed(5_000)` +already gates the subscription: when the screen leaves, the ticker's `delay` is cancelled within +~5 s (zero background battery); when it returns, the operator restarts and emits the current time +immediately. + +## Error handling + +Pure presentation timing — no new I/O or failure modes. `coerceAtLeast(0)` guards an already-due +race; `minOfOrNull ?: break` handles the empty / all-New / all-already-due cases without a +busy-wait. Arbitrarily long `delay`s (days) are valid and are realistically cancelled by +`WhileSubscribed` long before firing. + +## Testing + +- **`DueTickerTest`** (new, JVM `runTest` + turbine, mirroring `StudyQueueViewModelTest`'s + `UnconfinedTestDispatcher` + `var`-clock idiom — a `var nowMs` returned by `now`, advanced + alongside `advanceTimeBy`): + - emits immediately with the initial `now`; + - after advancing virtual time (and the clock) past a future `fsrsDue`, re-emits with the updated + `now`; + - idles (no second emission) when only New and/or already-due cards exist; + - re-emits immediately when the source card list changes (flatMapLatest restart); + - with two future-due cards, fires at the soonest first, then at the next. +- **`StudyQueueViewModelTest`**: add a case — a review card with `fsrsDue` slightly in the future + starts uncounted (`dueCount`/`readyCount` exclude it); after advancing virtual time past its due, + it appears. Existing assertions stay green (the operator is transparent at `t = now`). +- **Deck-detail VM test** (`LibraryAndDeckDetailViewModelTest`): analogous case — a near-future + review card moves into `dueCount` after time advances. + +**Build/test prefix:** all Gradle commands MUST be prefixed with +`export JAVA_HOME=/opt/homebrew/opt/openjdk &&`, run from +`/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. The emulator is currently +unavailable, so instrumented (`androidTest`) sources are compile-verified only; these new tests are +JVM unit tests and run normally. + +## Out of scope + +- **Active study session** (`StudyViewModel`): stays a fixed snapshot built once with `.first()`. + Appending newly-due cards mid-session is jarring; the user finishes and re-enters to pick them up. +- **`studiedToday` midnight rollover**: a different time boundary (device-local midnight), not a + due-based one; not driven by this ticker. +- **OS background / notification refresh** (`AlarmManager`/`WorkManager`): background concern, + separate from live in-app UI. +- **HomeScreen**: not a due-count surface. +- No change to FSRS scheduling, the queue-building algorithm, or the study flow itself. From 23d98de2334a17e4e0c8ab6f65e07fb245d6a827 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Thu, 4 Jun 2026 22:22:54 +0400 Subject: [PATCH 2/5] Add withDueTicks operator: re-emit time at each card's due moment --- .../simpleanki/core/domain/fsrs/DueTicker.kt | 38 ++++++++++ .../core/domain/fsrs/DueTickerTest.kt | 72 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 app/src/main/java/nart/simpleanki/core/domain/fsrs/DueTicker.kt create mode 100644 app/src/test/java/nart/simpleanki/core/domain/fsrs/DueTickerTest.kt diff --git a/app/src/main/java/nart/simpleanki/core/domain/fsrs/DueTicker.kt b/app/src/main/java/nart/simpleanki/core/domain/fsrs/DueTicker.kt new file mode 100644 index 0000000..34c252e --- /dev/null +++ b/app/src/main/java/nart/simpleanki/core/domain/fsrs/DueTicker.kt @@ -0,0 +1,38 @@ +package nart.simpleanki.core.domain.fsrs + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.CardState + +/** + * Pairs each emitted card list with a "now" timestamp that re-emits the instant the next card + * becomes due. + * + * Dueness is purely time-based — no data change fires when a card crosses its fsrsDue — so this + * operator manufactures that event: for a given card set it emits now, finds the soonest FUTURE + * due (non-New, fsrsDue > now), sleeps exactly until then, re-emits with a fresh now, and + * reschedules. When the upstream card list changes, flatMapLatest cancels the pending wait and + * restarts with the new list (recomputing the deadline). When no card is due in the future, it + * emits once and idles — no busy-wait — until the card list changes. + * + * Only non-New cards are watched: New cards are already available, so only review cards crossing + * their fsrsDue change what is studyable over time (matches StudyQueueBuilder.buildStudyQueue). + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun Flow>.withDueTicks(now: () -> Long): Flow, Long>> = + flatMapLatest { cards -> + flow { + while (true) { + val nowMillis = now() + emit(cards to nowMillis) + val nextDue = cards + .filter { !it.isDeleted && it.fsrsState != CardState.New.value && it.fsrsDue > nowMillis } + .minOfOrNull { it.fsrsDue } ?: break + delay((nextDue - nowMillis).coerceAtLeast(0)) + } + } + } diff --git a/app/src/test/java/nart/simpleanki/core/domain/fsrs/DueTickerTest.kt b/app/src/test/java/nart/simpleanki/core/domain/fsrs/DueTickerTest.kt new file mode 100644 index 0000000..307a801 --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/domain/fsrs/DueTickerTest.kt @@ -0,0 +1,72 @@ +package nart.simpleanki.core.domain.fsrs + +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import nart.simpleanki.core.domain.model.Card +import nart.simpleanki.core.domain.model.CardState +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DueTickerTest { + + private val base = 1_700_000_000_000L + + private fun reviewCard(id: String, due: Long) = Card( + id = id, front = "Q", back = "A", deckId = "d1", + dateCreated = 0, lastModified = 0, fsrsDue = due, fsrsState = CardState.Review.value, + ) + + private fun newCard(id: String) = Card( + id = id, front = "Q", back = "A", deckId = "d1", + dateCreated = 0, lastModified = 0, fsrsDue = 0, fsrsState = CardState.New.value, + ) + + // `clock` is tied to the test scheduler's virtual time so that as runTest auto-advances past + // a `delay`, now() reflects the advanced time. + @Test + fun emitsImmediately_thenCompletes_whenNoFutureDue() = runTest { + val clock = { base + testScheduler.currentTime } + flowOf(listOf(newCard("n1"))).withDueTicks(clock).test { + assertEquals(base, awaitItem().second) // emits current now immediately + awaitComplete() // only New cards → nothing to wait for, idles + } + } + + @Test + fun reEmits_whenAFutureReviewCardBecomesDue() = runTest { + val clock = { base + testScheduler.currentTime } + flowOf(listOf(reviewCard("r1", due = base + 60_000L))).withDueTicks(clock).test { + assertEquals(base, awaitItem().second) // before due + assertEquals(base + 60_000L, awaitItem().second) // re-emits exactly at the due moment + awaitComplete() // now already due → no further ticks + } + } + + @Test + fun firesAtSoonestDue_thenTheNext() = runTest { + val clock = { base + testScheduler.currentTime } + val cards = listOf(reviewCard("r1", base + 30_000L), reviewCard("r2", base + 60_000L)) + flowOf(cards).withDueTicks(clock).test { + assertEquals(base, awaitItem().second) + assertEquals(base + 30_000L, awaitItem().second) // soonest first + assertEquals(base + 60_000L, awaitItem().second) // then the next + awaitComplete() + } + } + + @Test + fun reEmitsImmediately_whenCardListChanges() = runTest { + val clock = { base + testScheduler.currentTime } + val source = MutableStateFlow(listOf(newCard("n1"))) + source.withDueTicks(clock).test { + assertEquals(1, awaitItem().first.size) // initial list + source.value = listOf(newCard("n1"), newCard("n2")) + assertEquals(2, awaitItem().first.size) // flatMapLatest restarts with the new list + cancelAndIgnoreRemainingEvents() // StateFlow stays open; stop collecting + } + } +} From bff67a6d3f79f48ef349e5ae9676fe39dc004757 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Thu, 4 Jun 2026 22:26:53 +0400 Subject: [PATCH 3/5] Cover Learning-state and deleted-card filter branches in DueTicker tests --- .../core/domain/fsrs/DueTickerTest.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/src/test/java/nart/simpleanki/core/domain/fsrs/DueTickerTest.kt b/app/src/test/java/nart/simpleanki/core/domain/fsrs/DueTickerTest.kt index 307a801..05b281d 100644 --- a/app/src/test/java/nart/simpleanki/core/domain/fsrs/DueTickerTest.kt +++ b/app/src/test/java/nart/simpleanki/core/domain/fsrs/DueTickerTest.kt @@ -58,6 +58,30 @@ class DueTickerTest { } } + @Test + fun reEmits_whenAFutureLearningCardBecomesDue() = runTest { + val clock = { base + testScheduler.currentTime } + val learning = Card( + id = "l1", front = "Q", back = "A", deckId = "d1", + dateCreated = 0, lastModified = 0, fsrsDue = base + 45_000L, fsrsState = CardState.Learning.value, + ) + flowOf(listOf(learning)).withDueTicks(clock).test { + assertEquals(base, awaitItem().second) + assertEquals(base + 45_000L, awaitItem().second) // Learning cards are watched, not just Review + awaitComplete() + } + } + + @Test + fun idles_whenTheOnlyFutureDueCardIsDeleted() = runTest { + val clock = { base + testScheduler.currentTime } + val deleted = reviewCard("r1", due = base + 60_000L).copy(isDeleted = true) + flowOf(listOf(deleted)).withDueTicks(clock).test { + assertEquals(base, awaitItem().second) // emits current now once + awaitComplete() // deleted card ignored → no tick scheduled + } + } + @Test fun reEmitsImmediately_whenCardListChanges() = runTest { val clock = { base + testScheduler.currentTime } From c29fc639e7b447ee9b655c2bf09ae7c8bc2b7bb3 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Thu, 4 Jun 2026 22:28:35 +0400 Subject: [PATCH 4/5] Refresh Today queue live as cards become due --- .../feature/queue/StudyQueueViewModel.kt | 6 ++-- .../feature/queue/StudyQueueViewModelTest.kt | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueViewModel.kt b/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueViewModel.kt index d8d1391..bec6b8e 100644 --- a/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueViewModel.kt +++ b/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueViewModel.kt @@ -15,6 +15,7 @@ import nart.simpleanki.core.data.settings.SettingsRepository import nart.simpleanki.core.data.settings.dailyGoalTotal import nart.simpleanki.core.domain.fsrs.QueueSortOrder import nart.simpleanki.core.domain.fsrs.StudyQueueBuilder +import nart.simpleanki.core.domain.fsrs.withDueTicks import kotlinx.coroutines.launch import kotlin.random.Random import java.util.Calendar @@ -97,13 +98,12 @@ class StudyQueueViewModel( val uiState: StateFlow = combine( - cardRepository.observeAllCards(), + cardRepository.observeAllCards().withDueTicks(now), deckRepository.observeDecks(), folderRepository.observeFolders(), settingsRepository.settings, entitlementRepository.entitlement, - ) { cards, decks, folders, settings, entitlement -> - val nowMillis = now() + ) { (cards, nowMillis), decks, folders, settings, entitlement -> val queue = StudyQueueBuilder.buildStudyQueue( cards = cards, nowMillis = nowMillis, diff --git a/app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt b/app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt index fd695fc..65eef08 100644 --- a/app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt +++ b/app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain @@ -77,6 +78,36 @@ class StudyQueueViewModelTest { assertEquals(1, alpha.newCount) } + @Test + fun dueCount_updatesLive_whenAFutureCardBecomesDue() = runTest { + // The @Before sets Main to an UnconfinedTestDispatcher with its OWN scheduler; override it + // here so the VM's coroutines (incl. the ticker's delay) share THIS test's scheduler and + // are driven by advanceTimeBy below. + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + // Clock tied to virtual time: advancing the scheduler advances now(). + val clock = { now + testScheduler.currentTime } + val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + deckRepo.upsert(Deck(id = "A", name = "Alpha", dateCreated = now, lastModified = now)) + // A review card due 60s from now — not ready yet. + cardRepo.upsert(review("a1", "A").copy(fsrsDue = now + 60_000L)) + + val vm = StudyQueueViewModel( + cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), + FakeSettingsRepository(), FakeEntitlementRepository(), now = clock, + ) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + assertEquals("not due yet", 0, vm.uiState.value.dueCount) + assertFalse("no work yet", vm.uiState.value.hasWork) + + advanceTimeBy(61_000L) // cross the due moment + runCurrent() + assertEquals("became due", 1, vm.uiState.value.dueCount) + assertEquals(1, vm.uiState.value.readyCount) + assertTrue(vm.uiState.value.hasWork) + } + @Test fun allCaughtUp_whenNothingDueOrNew() = runTest { val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) From c37e31e02dc5dc56509f2840b42c1557f87ae971 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Thu, 4 Jun 2026 22:32:59 +0400 Subject: [PATCH 5/5] Refresh deck detail due count live as cards become due --- .../feature/deckdetail/DeckDetailViewModel.kt | 6 ++-- .../LibraryAndDeckDetailViewModelTest.kt | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailViewModel.kt b/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailViewModel.kt index e25e6a2..77aa22e 100644 --- a/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailViewModel.kt +++ b/app/src/main/java/nart/simpleanki/feature/deckdetail/DeckDetailViewModel.kt @@ -12,6 +12,7 @@ import nart.simpleanki.core.data.repository.CardRepository import nart.simpleanki.core.data.repository.DeckRepository import nart.simpleanki.core.domain.model.Card import nart.simpleanki.core.domain.model.CardState +import nart.simpleanki.core.domain.fsrs.withDueTicks data class DeckDetailUiState( val deckId: String, @@ -50,11 +51,10 @@ class DeckDetailViewModel( val uiState: StateFlow = combine( - cardRepository.observeCards(deckId), + cardRepository.observeCards(deckId).withDueTicks(now), queryFlow, deckNameFlow, - ) { cards, query, name -> - val nowMillis = now() + ) { (cards, nowMillis), query, name -> DeckDetailUiState( deckId = deckId, deckName = name, diff --git a/app/src/test/java/nart/simpleanki/feature/LibraryAndDeckDetailViewModelTest.kt b/app/src/test/java/nart/simpleanki/feature/LibraryAndDeckDetailViewModelTest.kt index d81ab5b..447df12 100644 --- a/app/src/test/java/nart/simpleanki/feature/LibraryAndDeckDetailViewModelTest.kt +++ b/app/src/test/java/nart/simpleanki/feature/LibraryAndDeckDetailViewModelTest.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -89,6 +90,33 @@ class LibraryAndDeckDetailViewModelTest { assertEquals(setOf("c1", "c2"), vm.uiState.value.cards.map { it.id }.toSet()) // undo brings it back } + @Test + fun deckDetail_dueCountUpdatesLive_whenACardBecomesDue() = runTest { + // Override the @Before Main dispatcher so the VM shares THIS test's scheduler (so the + // ticker's delay is advanced by advanceTimeBy below). + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val base = 1_700_000_000_000L + val clock = { base + testScheduler.currentTime } + val cardRepo = CardRepository(FakeCardDao(), now = { base }) + // A review card due 60s from now — not due yet. + cardRepo.upsert( + Card( + id = "c1", front = "Q", back = "A", deckId = "d1", + dateCreated = base, lastModified = base, + fsrsDue = base + 60_000L, fsrsState = CardState.Review.value, + ), + ) + + val vm = DeckDetailViewModel("d1", cardRepo, now = clock) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + assertEquals("not due yet", 0, vm.uiState.value.dueCount) + + advanceTimeBy(61_000L) // cross the due moment + runCurrent() + assertEquals("became due", 1, vm.uiState.value.dueCount) + } + @Test fun folderDetail_listsOnlyDecksInThatFolder() = runTest { val folderRepo = FolderRepository(FakeFolderDao(), now = { 1L })