Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions app/src/main/java/nart/simpleanki/core/domain/fsrs/DueTicker.kt
Original file line number Diff line number Diff line change
@@ -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<List<Card>>.withDueTicks(now: () -> Long): Flow<Pair<List<Card>, 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))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -50,11 +51,10 @@ class DeckDetailViewModel(

val uiState: StateFlow<DeckDetailUiState> =
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,13 +98,12 @@ class StudyQueueViewModel(

val uiState: StateFlow<StudyQueueUiState> =
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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 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 }
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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 })
Expand Down
Loading
Loading