Introduction
QuizViewModel is the state machine coordinating the quiz session lifecycle. It loads due cards from FlashcardService, feeds them one at a time to QuizScreen, applies SM-2 ratings via ReviewService, and manages an in-memory session queue where AGAIN-rated cards get re-queued at the end. It also handles context filtering (tag, backlink), statistics loading, card suspension, notification updates, and parent-node breadcrumb loading.
The ViewModel is the bridge between the pure-domain flashcard services (quiz/flashcard.org) and the Compose UI (quiz/screen.org). It takes domain models (FlashcardPosition, ReviewRating) and produces UI-ready state (QuizCardState, QuizSessionState, BreadcrumbEntry).
Design Decisions
In-memory session queue, not re-querying due cards every transition.
sessionQueue is a MutableList<FlashcardPosition> that starts as the due-cards result and is drained as cards are processed. AGAIN-rated cards are appended to the end — this is the spaced-repetition "re-review" pattern: cards the user struggled with come back for another pass in the same session. When the queue empties, the ViewModel queries for new due cards (some may have become due during the session since their prior due date was within the session window).
AGAIN moves to end of queue, not directly rescheduled.
When a user rates AGAIN, the card's SM-2 state is reset (box=0, interval=0) and it's re-added to sessionQueue. This differs from a pure SM-2 implementation where AGAIN would just set the due date to "now" — the session-internal re-queue ensures the user sees the card again within the same session rather than having it disappear and show up immediately in the next session's due list.
Pre-parsed AST to avoid re-parsing on every recomposition.
Each QuizCardState carries both questionParseResult and answerParseResult (OrgParseResult? from the parser). These are computed once on Dispatchers.Default (background thread) using OrgDocumentCache, then passed to QuizScreen which renders them via OrgDocumentRenderer without re-parsing. Without this, every Compose recomposition would re-parse the org content — expensive for large cards.
Content extraction by card type routes title/body differently.
extractContentForCardType maps card type + position to front/back content: NORMAL shows title as front, body as back; DOUBLE shows title as front and body as back (tracked independently); CLOZE combines title+body for both sides (holes are managed by the cloze renderer); TEXT_INPUT and VOCAB show title on the front. This is the same content routing that org-fc's Emacs review does — title is the "question" side, body is the "answer" side.
Implementation
The ViewModel is composed from five narrative blocks using noweb composition, plus the self-contained test file.
Imports, Enums & Data Classes
QuizSessionState is the session lifecycle enum: Idle (no session), Active (card showing), Completed (all cards reviewed), NoCardsDue (nothing to review), Error (failure). QuizCardState bundles everything the UI needs: FlashcardPosition (for review data), nodeId, cardType, title, questionContent + answerContent (pre-extracted strings), clozeType, and pre-parsed ASTs.
The private helper functions extractContentForCardType and extractClozeContent route title/body by card type and position. These are package-private because they're consumed only by loadNextCardFromQueue.
package computer.whatthefuck.arcology.app.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import computer.whatthefuck.arcology.app.data.AppPreferencesInterface
import computer.whatthefuck.arcology.app.notification.QuizNotificationManager
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.database.QuizRepository
import computer.whatthefuck.arcology.domain.ClozeType
import computer.whatthefuck.arcology.domain.FlashcardPosition
import computer.whatthefuck.arcology.domain.FlashcardReview
import computer.whatthefuck.arcology.domain.FlashcardStatistics
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.domain.QuizContext
import computer.whatthefuck.arcology.domain.QuizContextType
import computer.whatthefuck.arcology.domain.ReviewRating
import computer.whatthefuck.arcology.editor.BreadcrumbEntry
import computer.whatthefuck.arcology.editor.HeadingTextUtils
import computer.whatthefuck.arcology.editor.NodeContentParser
import computer.whatthefuck.arcology.editor.OrgDocumentEditor
import computer.whatthefuck.arcology.flashcard.FlashcardService
import computer.whatthefuck.arcology.flashcard.ReviewService
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import computer.whatthefuck.arcology.app.cache.OrgDocumentCache
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import xyz.lepisma.orgmode.OrgParseResult
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.time.Instant
import kotlin.time.ExperimentalTime
/**
* Content extraction by card type for quiz cards.
*/
private fun extractContentForCardType(
node: OrgNode,
cardType: String,
positionName: String,
fileContent: String,
bodyContent: String
): String {
val title = node.title ?: "Untitled"
return when (cardType.lowercase()) {
"normal" -> {
// NORMAL: title as question, body as answer
when (positionName.lowercase()) {
"back" -> bodyContent
else -> title
}
}
"double" -> {
// DOUBLE: bidirectional - same content on both sides
// Front shows title (question), Back shows body (answer)
// The positionName is for tracking review data independently
when (positionName.lowercase()) {
"back" -> bodyContent
else -> title
}
}
"cloze" -> {
// CLOZE: Clozes can appear in both title and body
// Combine title and body for cloze rendering
extractClozeContent(title, bodyContent)
}
"text-input" -> {
// TEXT_INPUT: Show question prompt
title
}
"vocab" -> {
// VOCAB: Show term/word
title
}
else -> bodyContent
}
}
/**
* Extract cloze content for a specific position.
* Cloze cards have multiple positions (0, 1, 2, etc.) for each deletion.
* Uses org-fc syntax: {{text}@id}, {{text}{hint}@id}
*
* Combines title and body since clozes can appear in either.
* Returns the combined content with cloze markup preserved.
*/
private fun extractClozeContent(title: String, bodyContent: String): String {
// Combine title and body for cloze cards
// Clozes can appear in the title, body, or both
return if (bodyContent.isNotBlank()) {
"$title\n\n$bodyContent"
} else {
title
}
}
/**
* Quiz session states
*/
enum class QuizSessionState {
Idle, // No session running
Active, // Card is showing, waiting for rating
Completed, // Session completed
NoCardsDue, // No cards are due for review
Error // An error occurred
}
/**
* State for a single quiz card
*/
data class QuizCardState(
val position: FlashcardPosition,
val nodeId: String,
val cardType: String,
val positionName: String,
val title: String = "",
val questionContent: String = "", // Front of card
val answerContent: String = "", // Back of card
val filePath: String = "",
/** Cloze type for this card, determines sibling hole visibility. */
val clozeType: ClozeType? = null,
/** Pre-parsed AST to avoid re-parsing on every recomposition. */
val questionParseResult: OrgParseResult? = null,
val answerParseResult: OrgParseResult? = null
)Constructor & State Flows
QuizViewModel receives FlashcardService, ReviewService, QuizNotificationManager, RoamRepository, QuizRepository, AppPreferencesInterface, a filesystem factory, an editor factory, and a NodeContentParser. It maintains seven StateFlow fields (sessionState, currentCard, statistics, errorMessage, progress, totalCards, parentNodes, contextFilter, lastReview) and an in-memory sessionQueue.
init loads persisted context from preferences (getQuizContextType / getQuizContextValue). setContext and clearContext persist the context and restart the session.
/**
* QuizViewModel for managing the spaced repetition quiz session.
* Handles session state, card loading, rating application, and statistics.
*/
open class QuizViewModel(
private val flashcardService: FlashcardService,
private val reviewService: ReviewService,
private val notificationManager: QuizNotificationManager,
private val repository: RoamRepository,
private val quizRepository: QuizRepository,
private val appPreferences: AppPreferencesInterface,
private val fileSystemFactory: (android.net.Uri) -> AndroidFileSystem,
private val documentEditorFactory: (AndroidFileSystem) -> OrgDocumentEditor,
private val nodeContentParser: NodeContentParser,
private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.Default
) : ViewModel() {
private val _sessionState = MutableStateFlow(QuizSessionState.Idle)
val sessionState: StateFlow<QuizSessionState> = _sessionState.asStateFlow()
private val _currentCard = MutableStateFlow<QuizCardState?>(null)
val currentCard: StateFlow<QuizCardState?> = _currentCard.asStateFlow()
private val _statistics = MutableStateFlow<FlashcardStatistics?>(null)
val statistics: StateFlow<FlashcardStatistics?> = _statistics.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
private val _progress = MutableStateFlow(0)
val progress: StateFlow<Int> = _progress.asStateFlow()
private val _totalCards = MutableStateFlow(0)
val totalCards: StateFlow<Int> = _totalCards.asStateFlow()
private val _parentNodes = MutableStateFlow<List<BreadcrumbEntry>>(emptyList())
val parentNodes: StateFlow<List<BreadcrumbEntry>> = _parentNodes.asStateFlow()
private val _contextFilter = MutableStateFlow<QuizContext?>(null)
val contextFilter: StateFlow<QuizContext?> = _contextFilter.asStateFlow()
private val _lastReview = MutableStateFlow<FlashcardReview?>(null)
val lastReview: StateFlow<FlashcardReview?> = _lastReview.asStateFlow()
// In-memory queue for the current session
// Cards rated AGAIN are moved to the end of this queue
private var sessionQueue: MutableList<FlashcardPosition> = mutableListOf()
init {
loadPersistedContext()
}
/**
* Load persisted quiz context from preferences.
*/
private fun loadPersistedContext() {
val type = appPreferences.getQuizContextType()
val value = appPreferences.getQuizContextValue()
if (type != null && value != null) {
val contextType = when (type) {
"TAG" -> QuizContextType.TAG
"BACKLINK" -> QuizContextType.BACKLINK
else -> QuizContextType.ALL
}
_contextFilter.value = QuizContext(contextType, value)
}
}
/**
* Set the quiz context filter and restart the session.
* @param context The quiz context (tag or backlink node) to filter by
*/
fun setContext(context: QuizContext) {
_contextFilter.value = context
appPreferences.setQuizContextType(context.type.name)
appPreferences.setQuizContextValue(context.value)
restartSession()
}
/**
* Clear the quiz context filter and restart the session.
*/
fun clearContext() {
_contextFilter.value = null
appPreferences.clearQuizContext()
restartSession()
}
/**
* Restart the current session to apply context changes.
*/
private fun restartSession() {
viewModelScope.launch {
// Load statistics upfront so footer is available immediately
loadStatistics()
startSession()
}
}Session Management
startSession loads due cards (filtered by context if set), initializes sessionQueue, loads the first card via loadNextCardFromQueue, updates notifications, and sets session state to Active. If no cards are due, it transitions to NoCardsDue.
loadNextCardFromQueue pops the first position from the queue, loads its flashcard type from getAllFlashcards(), loads the node, extracts content, pre-parses the AST on Dispatchers.Default using OrgDocumentCache, builds QuizCardState, and loads parent breadcrumbs. When the queue empties, it queries for new due cards and transitions to Completed if none remain.
loadParentNodes populates the breadcrumb chain for the current card's node, used by the UI to show where a card lives in the org-roam hierarchy.
/**
* Start a new quiz session.
* Loads due cards and sets up the first card.
*/
fun startSession() {
viewModelScope.launch {
_sessionState.value = QuizSessionState.Idle
_errorMessage.value = null
// Load statistics upfront so footer is available immediately
loadStatistics()
try {
// Get due cards for session (use max from preferences)
val maxCards = appPreferences.getMaxCardsPerSession().toLong()
val context = _contextFilter.value
val duePositions = if (context != null) {
flashcardService.getDueCardsByContext(context, maxCount = maxCards)
} else {
flashcardService.getDueCards(maxCount = maxCards)
}
if (duePositions.isEmpty()) {
_sessionState.value = QuizSessionState.NoCardsDue
notificationManager.showDueCardsNotification(0)
notificationManager.updateBadgeCount(0)
return@launch
}
// Initialize session queue
sessionQueue = duePositions.toMutableList()
// Set up session
_totalCards.value = duePositions.size
_progress.value = 0
// Load first card
loadNextCardFromQueue()
// Update notification with total due count (not just session size)
val totalDue = if (context != null) {
flashcardService.countDueCardsByContext(context).toInt()
} else {
flashcardService.countDueCards().toInt()
}
notificationManager.showDueCardsNotification(totalDue)
notificationManager.updateBadgeCount(totalDue)
_sessionState.value = QuizSessionState.Active
} catch (e: Exception) {
_errorMessage.value = "Failed to start session: ${e.message}"
_sessionState.value = QuizSessionState.Error
}
}
}
/**
* Load the next card from the session queue.
* If the queue is empty, queries for due cards.
*/
fun loadNextCard() {
viewModelScope.launch {
if (sessionQueue.isEmpty()) {
// No cards in queue, query for due cards
val maxCards = appPreferences.getMaxCardsPerSession().toLong()
val context = _contextFilter.value
val duePositions = if (context != null) {
flashcardService.getDueCardsByContext(context, maxCount = maxCards)
} else {
flashcardService.getDueCards(maxCount = maxCards)
}
if (duePositions.isEmpty()) {
_sessionState.value = QuizSessionState.Completed
notificationManager.showDueCardsNotification(0)
notificationManager.updateBadgeCount(0)
_currentCard.value = null
return@launch
}
sessionQueue = duePositions.toMutableList()
_totalCards.value = duePositions.size
}
loadNextCardFromQueue()
}
}
private suspend fun loadNextCardFromQueue() {
if (sessionQueue.isEmpty()) {
_sessionState.value = QuizSessionState.Completed
notificationManager.showDueCardsNotification(0)
notificationManager.updateBadgeCount(0)
_currentCard.value = null
return
}
val position = sessionQueue.removeAt(0)
android.util.Log.d("QuizViewModel", "DEBUG: loadNextCardFromQueue - nodeId=${position.nodeId}, positionName=${position.positionName}")
// Extract card type and cloze type from the flashcard record
val allFlashcards = flashcardService.getAllFlashcards()
val flashcard = allFlashcards.firstOrNull { it.nodeId == position.nodeId }
val cardType = flashcard?.cardType?.name ?: "unknown"
val clozeType = flashcard?.clozeType
android.util.Log.d("QuizViewModel", "DEBUG: cardType=$cardType clozeType=$clozeType")
// Load node content
val node = flashcardService.getAllFlashcards()
.firstOrNull { it.nodeId == position.nodeId }
?.let { loadNodeForFlashcard(it.nodeId) }
// Extract both question (front) and answer (back) content
val questionContent: String
val answerContent: String
val questionParse: OrgParseResult?
val answerParse: OrgParseResult?
if (node != null) {
// Read the file content for body extraction
val fileContent = readFileContent(node.file)
if (fileContent != null) {
// Extract body content from file
val bodyContent = extractBodyContent(node, fileContent, position.positionName)
// For DOUBLE cards (bidirectional):
// - When quizzing FRONT position: question=title, answer=body
// - When quizzing BACK position: question=body, answer=title
// The positionName determines which side is the "front" (question) and which is the "back" (answer)
questionContent = extractContentForCardType(node, cardType, position.positionName, fileContent, bodyContent)
answerContent = extractContentForCardType(node, cardType, if (position.positionName.lowercase() == "front") "back" else "front", fileContent, bodyContent)
// Pre-parse on background thread using the global cache
val (qParse, aParse) = withContext(backgroundDispatcher) {
val qp = OrgDocumentCache.parse(questionContent)
val ap = OrgDocumentCache.parse(answerContent)
Pair(qp, ap)
}
questionParse = qParse
answerParse = aParse
} else {
// Fallback - just use title
val title = node.title ?: ""
questionContent = when (cardType.lowercase()) {
"normal", "double" -> title
"cloze" -> extractClozeContent(title, "")
"text-input", "vocab" -> title
else -> ""
}
answerContent = when (cardType.lowercase()) {
"normal", "double" -> ""
"cloze" -> extractClozeContent(title, "")
"text-input", "vocab" -> title
else -> ""
}
val (qParse, aParse) = withContext(backgroundDispatcher) {
val qp = OrgDocumentCache.parse(questionContent)
val ap = OrgDocumentCache.parse(answerContent)
Pair(qp, ap)
}
questionParse = qParse
answerParse = aParse
}
} else {
questionContent = ""
answerContent = ""
questionParse = null
answerParse = null
}
val title = node?.title ?: "Untitled"
val filePath = node?.file ?: ""
_currentCard.value = QuizCardState(
position = position,
nodeId = position.nodeId,
cardType = cardType,
positionName = position.positionName,
title = title,
questionContent = questionContent,
answerContent = answerContent,
filePath = filePath,
clozeType = clozeType,
questionParseResult = questionParse,
answerParseResult = answerParse
)
// Load parent nodes for breadcrumbs
loadParentNodes(position.nodeId)
_progress.value = (_progress.value + 1).coerceAtMost(_totalCards.value)
_sessionState.value = QuizSessionState.Active
}
private suspend fun loadParentNodes(nodeId: String) {
try {
val parents = nodeContentParser.getParentNodes(nodeId)
_parentNodes.value = parents
} catch (e: Exception) {
android.util.Log.w("QuizViewModel", "Failed to load parent nodes", e)
_parentNodes.value = emptyList()
}
}Content Extraction
readFileContent constructs an AndroidFileSystem from the stored tree URI and reads the file. extractBodyContent uses OrgDocumentEditor.findHeadingBoundaries and HeadingTextUtils.extractHeadingBody to slice out the heading's body text from the file content. loadNodeForFlashcard is a simple repository lookup. These methods exist because the ViewModel needs the full file content to extract body text for cards — the database only stores heading titles, not body text.
private suspend fun readFileContent(filePath: String): String? {
return try {
// Get the tree URI from preferences to create the file system
val treeUri = appPreferences.getSelectedDirectoryUri()
?: return null
val fileSystem = fileSystemFactory(treeUri)
// filePath is now a relative path, AndroidFileSystem.readFile handles conversion
fileSystem.readFile(filePath)
} catch (e: Exception) {
android.util.Log.w("QuizViewModel", "Failed to read file content for $filePath: ${e.message}")
null
}
}
private suspend fun loadNodeForFlashcard(nodeId: String): OrgNode? {
return repository.getNodeById(nodeId)
}
private suspend fun getFilePathForNode(nodeId: String): String? {
val node = repository.getNodeById(nodeId)
return node?.file
}
/**
* Extract body content from a node by reading the org file.
* Uses OrgDocumentEditor's findHeadingBoundaries and HeadingTextUtils's extractHeadingBody.
*/
private suspend fun extractBodyContent(node: OrgNode, fileContent: String, positionName: String): String {
// Find the heading boundaries in the file
// Get the tree URI from preferences to create the file system
val treeUri = appPreferences.getSelectedDirectoryUri()
?: return ""
val boundaries = fileSystemFactory(treeUri).let { fs ->
val editor = documentEditorFactory(fs)
editor.findHeadingBoundaries(fileContent, node.id, node.level)
}
return if (boundaries != null) {
val (headingPos, nextHeadingPos) = boundaries
HeadingTextUtils.extractHeadingBody(fileContent, headingPos, nextHeadingPos)
} else {
""
}
}Card Rating, Skipping & Suspension
rateCard records the review via ReviewService.recordReview() on Dispatchers.Default, updates statistics, handles AGAIN re-queue, loads the next card, and updates notifications. skipCard resets the position via SM-2 AGAIN and advances. suspendCard calls FlashcardService.suspendFlashcard and advances. All three reset the session to Completed when the queue is exhausted.
/**
* Rate the current card with the given rating.
* AGAIN rating moves the card to the end of the session queue instead of scheduling.
*/
fun rateCard(rating: ReviewRating) {
val cardState = _currentCard.value ?: return
android.util.Log.d("QuizViewModel", "DEBUG: rateCard - nodeId=${cardState.nodeId}, positionName=${cardState.positionName}, cardType=${cardState.cardType}, rating=${rating}")
viewModelScope.launch {
try {
val review = withContext(backgroundDispatcher) {
reviewService.recordReview(
nodeId = cardState.nodeId,
positionName = cardState.positionName,
rating = rating,
filePath = cardState.filePath
)
}
android.util.Log.d("QuizViewModel", "DEBUG: review recorded - positionName=${review.positionName}, box=${review.box}, interval=${review.intervalDays}")
_lastReview.value = review
// Reload statistics
_statistics.value = flashcardService.getStatistics()
if (rating == ReviewRating.AGAIN) {
// Move card to end of session queue for later review
sessionQueue.add(cardState.position)
}
// Load next card
loadNextCardFromQueue()
// Update notification with remaining queue size and badge with total due
val quizContext = _contextFilter.value
val totalDue = if (quizContext != null) {
flashcardService.countDueCardsByContext(quizContext).toInt()
} else {
flashcardService.countDueCards().toInt()
}
val remaining = sessionQueue.size + if (_currentCard.value != null) 1 else 0
notificationManager.showDueCardsNotification(remaining)
notificationManager.updateBadgeCount(totalDue)
_sessionState.value = if (sessionQueue.isEmpty() && _currentCard.value == null) {
QuizSessionState.Completed
} else {
QuizSessionState.Active
}
} catch (e: Exception) {
_errorMessage.value = "Failed to rate card: ${e.message}"
}
}
}
/**
* Skip the current card and move to the next one.
*/
@OptIn(ExperimentalTime::class)
fun skipCard() {
val currentCard = _currentCard.value ?: return
viewModelScope.launch {
try {
// Update position to mark as reviewed (but without rating)
// This will move it to the next due date
val position = currentCard.position
val algorithm = computer.whatthefuck.arcology.flashcard.SM2Algorithm()
// Calculate next review with neutral values (similar to AGAIN but not resetting completely)
val result = algorithm.calculateNextReview(
currentEase = position.easeFactor,
currentBox = position.box,
currentInterval = position.intervalDays,
rating = ReviewRating.AGAIN // Reset position for skip
)
val updatedPosition = position.copy(
easeFactor = result.newEase,
box = result.newBox,
intervalDays = result.newIntervalDays,
dueDate = Instant.fromEpochSeconds(result.nextReviewDate)
)
quizRepository.updateFlashcardPosition(updatedPosition)
// Load next card from queue (state will be set by loadNextCardFromQueue)
loadNextCardFromQueue()
} catch (e: Exception) {
_errorMessage.value = "Failed to skip card: ${e.message}"
}
}
}
/**
* Mark the current card as suspended.
*/
fun suspendCard() {
val cardState = _currentCard.value ?: return
viewModelScope.launch {
try {
flashcardService.suspendFlashcard(cardState.nodeId)
// Load next card from queue (state will be set by loadNextCardFromQueue)
loadNextCardFromQueue()
} catch (e: Exception) {
_errorMessage.value = "Failed to suspend card: ${e.message}"
}
}
}
/**
* Alias for suspendCard() to maintain consistency with rateCard() naming.
*/
fun suspendCurrentCard() = suspendCard()
/**
* End the current quiz session and transition to Completed state.
*/
fun endSession() {
_sessionState.value = QuizSessionState.Completed
}Statistics & Utilities
loadStatistics fetches from FlashcardService.getStatistics(). hasDueCards is a lightweight check used for notification logic. refreshSession and resetSession control the lifecycle. clearLastReview is consumed by the screen's LaunchedEffect to show a snackbar after each review.
/**
* Get or load statistics.
*/
fun loadStatistics() {
viewModelScope.launch {
_statistics.value = flashcardService.getStatistics()
}
}
/**
* Get the count of cards due today.
*/
fun getDueCardsCount(): Int {
return _totalCards.value
}
/**
* Check if there are cards due for review.
*/
suspend fun hasDueCards(): Boolean {
val context = _contextFilter.value
return if (context != null) {
flashcardService.getDueCardsByContext(context, maxCount = 1).isNotEmpty()
} else {
flashcardService.getDueCards(maxCount = 1).isNotEmpty()
}
}
/**
* Refresh the session with current due cards.
*/
fun refreshSession() {
startSession()
}
/**
* Reset the session to idle state.
*/
fun resetSession() {
_sessionState.value = QuizSessionState.Idle
_currentCard.value = null
_progress.value = 0
_lastReview.value = null
sessionQueue.clear()
// Don't clear context - it should persist across session resets
}
fun clearLastReview() {
_lastReview.value = null
}
}Composed Tangle Target
<<quiz-vm-imports-state>>
<<quiz-vm-constructor>>
<<quiz-vm-session>>
<<quiz-vm-content>>
<<quiz-vm-actions>>
<<quiz-vm-utilities>>Tests
QuizViewModelTest is an 812-line test using StandardTestDispatcher for coroutine control, MockK for service doubles, and AppPreferencesTestDouble for preferences. It covers the full session lifecycle: initial state, startSession with/without due cards, card loading for NORMAL/CLOZE types, error handling, rateCard (AGAIN re-queues, GOOD advances, null guard), skipCard (resets via SM-2, advances), suspendCard (suspends and completes session when queue empty), statistics loading, hasDueCards, resetSession, refreshSession, getDueCardsCount, and maxCardsPerSession preference respect.
Each test follows the same pattern: set up mocks, create ViewModel, call action, advance the test dispatcher, assert state. The createViewModel factory builds a ViewModel with mocked file reading (returns null for findHeadingBoundaries by default) and empty parent nodes.
package computer.whatthefuck.arcology.app.viewmodel
import android.net.Uri
import android.util.Log
import computer.whatthefuck.arcology.app.data.AppPreferencesInterface
import computer.whatthefuck.arcology.app.notification.QuizNotificationManager
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.database.QuizRepository
import computer.whatthefuck.arcology.domain.Flashcard
import computer.whatthefuck.arcology.domain.FlashcardPosition
import computer.whatthefuck.arcology.domain.FlashcardStatistics
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.domain.ReviewRating
import computer.whatthefuck.arcology.editor.OrgDocumentEditor
import computer.whatthefuck.arcology.flashcard.FlashcardService
import computer.whatthefuck.arcology.flashcard.ReviewService
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContain
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.datetime.Instant
import org.junit.After
import org.junit.Before
import org.junit.Test
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalCoroutinesApi::class)
class QuizViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val backgroundDispatcher = testDispatcher
private lateinit var appPreferences: AppPreferencesInterface
private lateinit var flashcardService: FlashcardService
private lateinit var reviewService: ReviewService
private lateinit var notificationManager: QuizNotificationManager
private lateinit var repository: RoamRepository
private lateinit var quizRepository: QuizRepository
private lateinit var fileSystem: AndroidFileSystem
private val mockUri = mockk<Uri>()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
appPreferences = computer.whatthefuck.arcology.app.testutils.AppPreferencesTestDouble()
flashcardService = mockk(relaxed = true)
reviewService = mockk(relaxed = true)
notificationManager = mockk(relaxed = true)
repository = mockk()
quizRepository = mockk()
fileSystem = mockk(relaxed = true)
// Mock Android Log class to avoid "Method not mocked" errors
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel(): QuizViewModel {
val documentEditor = mockk<OrgDocumentEditor>()
coEvery { documentEditor.findHeadingBoundaries(any(), any(), any()) } returns null
val nodeContentParser = mockk<computer.whatthefuck.arcology.editor.NodeContentParser>()
coEvery { nodeContentParser.getParentNodes(any()) } returns emptyList()
return QuizViewModel(
flashcardService = flashcardService,
reviewService = reviewService,
notificationManager = notificationManager,
repository = repository,
quizRepository = quizRepository,
appPreferences = appPreferences,
fileSystemFactory = { fileSystem },
documentEditorFactory = { documentEditor },
nodeContentParser = nodeContentParser,
backgroundDispatcher = backgroundDispatcher
)
}
@Test
fun `initial state is Idle`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.sessionState.value shouldBe QuizSessionState.Idle
}
@Test
fun `initial current card is null`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.currentCard.value shouldBe null
}
@Test
fun `initial statistics is null`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.statistics.value shouldBe null
}
@Test
fun `initial error message is null`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.errorMessage.value shouldBe null
}
@Test
fun `initial progress is 0`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.progress.value shouldBe 0
}
@Test
fun `initial total cards is 0`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.totalCards.value shouldBe 0
}
@Test
fun `startSession without due cards sets NoCardsDue state`() = runTest {
coEvery { flashcardService.getDueCards(maxCount = 20L) } returns emptyList()
coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
totalFlashcards = 0,
totalPositions = 0,
dueToday = 0,
totalReviews = 0,
averageEase = null,
typeBreakdown = emptyMap()
)
val viewModel = createViewModel()
viewModel.startSession()
advanceUntilIdle()
viewModel.sessionState.value shouldBe QuizSessionState.NoCardsDue
verify { notificationManager.updateBadgeCount(0) }
}
@Test
fun `startSession with due cards sets Active state and loads first card`() = runTest {
val duePosition = FlashcardPosition(
nodeId = "node-1",
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.fromEpochSeconds(1640995200),
reviewCount = 1
)
val flashcard = Flashcard(
nodeId = "node-1",
cardType = computer.whatthefuck.arcology.domain.FlashcardType.NORMAL,
clozeType = null,
createdAt = Instant.fromEpochSeconds(1640995200),
isSuspended = false,
reviewData = emptyMap()
)
val orgNode = OrgNode(
id = "node-1",
file = "/test/file.org",
level = 1,
position = 1,
title = "Test Node",
properties = mapOf("FC_TYPE" to "normal"),
outlinePath = listOf("Test Node")
)
coEvery { flashcardService.getDueCards(maxCount = 20L) } returns listOf(duePosition)
coEvery { flashcardService.getAllFlashcards() } returns listOf(flashcard)
coEvery { flashcardService.countDueCards() } returns 1
coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
totalFlashcards = 1,
totalPositions = 2,
dueToday = 1,
totalReviews = 5,
averageEase = 2.5,
typeBreakdown = mapOf(computer.whatthefuck.arcology.domain.FlashcardType.NORMAL to 1)
)
coEvery { repository.getNodeById("node-1") } returns orgNode
coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit
val viewModel = createViewModel()
viewModel.startSession()
advanceUntilIdle()
viewModel.sessionState.value shouldBe QuizSessionState.Active
viewModel.currentCard.value shouldNotBe null
viewModel.currentCard.value!!.nodeId shouldBe "node-1"
viewModel.currentCard.value!!.title shouldBe "Test Node"
viewModel.currentCard.value!!.questionContent shouldNotBe ""
viewModel.totalCards.value shouldBe 1
verify { notificationManager.showDueCardsNotification(1) }
}
@Test
fun `startSession loads first front position for normal card`() = runTest {
val duePosition = FlashcardPosition(
nodeId = "node-1",
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.fromEpochSeconds(1640995200),
reviewCount = 1
)
val flashcard = Flashcard(
nodeId = "node-1",
cardType = computer.whatthefuck.arcology.domain.FlashcardType.NORMAL,
clozeType = null,
createdAt = Instant.fromEpochSeconds(1640995200),
isSuspended = false,
reviewData = emptyMap()
)
val orgNode = OrgNode(
id = "node-1",
file = "/test/file.org",
level = 1,
position = 1,
title = "Test Node",
properties = mapOf("FC_TYPE" to "normal"),
outlinePath = listOf("Test Node")
)
coEvery { flashcardService.getDueCards(maxCount = 20L) } returns listOf(duePosition)
coEvery { flashcardService.getAllFlashcards() } returns listOf(flashcard)
coEvery { flashcardService.countDueCards() } returns 1
coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
totalFlashcards = 1,
totalPositions = 2,
dueToday = 1,
totalReviews = 5,
averageEase = 2.5,
typeBreakdown = mapOf(computer.whatthefuck.arcology.domain.FlashcardType.NORMAL to 1)
)
coEvery { repository.getNodeById("node-1") } returns orgNode
coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit
val viewModel = createViewModel()
viewModel.startSession()
advanceUntilIdle()
viewModel.currentCard.value shouldNotBe null
viewModel.currentCard.value!!.positionName shouldBe "front"
viewModel.currentCard.value!!.title shouldBe "Test Node"
// For NORMAL card, questionContent (front) shows title
viewModel.currentCard.value!!.questionContent shouldBe "Test Node"
}
@Test
fun `startSession with cloze card shows front with blanks`() = runTest {
val duePosition = FlashcardPosition(
nodeId = "node-1",
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.fromEpochSeconds(1640995200),
reviewCount = 1
)
val flashcard = Flashcard(
nodeId = "node-1",
cardType = computer.whatthefuck.arcology.domain.FlashcardType.CLOZE,
clozeType = computer.whatthefuck.arcology.domain.ClozeType.DELETION,
createdAt = Instant.fromEpochSeconds(1640995200),
isSuspended = false,
reviewData = emptyMap()
)
val orgNode = OrgNode(
id = "node-1",
file = "/test/file.org",
level = 1,
position = 1,
title = "Cloze Test",
properties = mapOf("FC_TYPE" to "cloze"),
outlinePath = listOf("Cloze Test"),
drawers = mapOf(
"PROPERTIES" to listOf("test property drawer content")
)
)
coEvery { flashcardService.getDueCards(maxCount = 20L) } returns listOf(duePosition)
coEvery { flashcardService.getAllFlashcards() } returns listOf(flashcard)
coEvery { flashcardService.countDueCards() } returns 1
coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
totalFlashcards = 1,
totalPositions = 1,
dueToday = 1,
totalReviews = 0,
averageEase = 2.5,
typeBreakdown = mapOf(computer.whatthefuck.arcology.domain.FlashcardType.CLOZE to 1)
)
coEvery { repository.getNodeById("node-1") } returns orgNode
coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit
val viewModel = createViewModel()
viewModel.startSession()
advanceUntilIdle()
viewModel.currentCard.value shouldNotBe null
viewModel.currentCard.value!!.positionName shouldBe "front"
viewModel.currentCard.value!!.cardType shouldBe "CLOZE"
}
@Test
fun `startSession error sets Error state`() = runTest {
coEvery { flashcardService.getDueCards(maxCount = 20L) } throws Exception("Database error")
val viewModel = createViewModel()
viewModel.startSession()
advanceUntilIdle()
viewModel.sessionState.value shouldBe QuizSessionState.Error
viewModel.errorMessage.value shouldNotBe null
viewModel.errorMessage.value!! shouldContain "Database error"
}
@Test
fun `loadNextCard without session loads first due card`() = runTest {
val duePosition = FlashcardPosition(
nodeId = "node-1",
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.fromEpochSeconds(1640995200),
reviewCount = 1
)
val flashcard = Flashcard(
nodeId = "node-1",
cardType = computer.whatthefuck.arcology.domain.FlashcardType.NORMAL,
clozeType = null,
createdAt = Instant.fromEpochSeconds(1640995200),
isSuspended = false,
reviewData = emptyMap()
)
val orgNode = OrgNode(
id = "node-1",
file = "/test/file.org",
level = 1,
position = 1,
title = "Test Node",
properties = mapOf("FC_TYPE" to "normal"),
outlinePath = listOf("Test Node")
)
// Use 20L to match the default maxCardsPerSession from AppPreferencesTestDouble
coEvery { flashcardService.getDueCards(maxCount = 20L) } returns listOf(duePosition)
coEvery { flashcardService.getAllFlashcards() } returns listOf(flashcard)
coEvery { flashcardService.countDueCards() } returns 1
coEvery { repository.getNodeById("node-1") } returns orgNode
coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
totalFlashcards = 1,
totalPositions = 2,
dueToday = 1,
totalReviews = 5,
averageEase = 2.5,
typeBreakdown = mapOf(computer.whatthefuck.arcology.domain.FlashcardType.NORMAL to 1)
)
coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit
val viewModel = createViewModel()
viewModel.loadNextCard()
advanceUntilIdle()
viewModel.currentCard.value shouldNotBe null
viewModel.currentCard.value!!.nodeId shouldBe "node-1"
}
@Test
fun `rateCard with AGAIN rating updates position and loads next card`() = runTest {
val currentPosition = FlashcardPosition(
nodeId = "node-1",
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.fromEpochSeconds(1640995200),
reviewCount = 1
)
val flashcard = Flashcard(
nodeId = "node-1",
cardType = computer.whatthefuck.arcology.domain.FlashcardType.NORMAL,
clozeType = null,
createdAt = Instant.fromEpochSeconds(1640995200),
isSuspended = false,
reviewData = emptyMap()
)
coEvery { reviewService.recordReview(any(), any(), any(), any(), any()) } returns mockk()
coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
totalFlashcards = 1,
totalPositions = 2,
dueToday = 1,
totalReviews = 6,
averageEase = 2.3,
typeBreakdown = mapOf(computer.whatthefuck.arcology.domain.FlashcardType.NORMAL to 1)
)
coEvery { flashcardService.getAllFlashcards() } returns listOf(flashcard)
coEvery { flashcardService.countDueCards() } returns 1
coEvery { flashcardService.getDueCards(maxCount = 100L) } returns emptyList()
coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit
val viewModel = createViewModel()
// Set up by starting a session
coEvery { flashcardService.getDueCards(maxCount = 20L) } returns listOf(currentPosition)
coEvery { flashcardService.getAllFlashcards() } returns listOf(flashcard)
coEvery { flashcardService.countDueCards() } returns 1
coEvery { repository.getNodeById("node-1") } returns OrgNode(
id = "node-1",
file = "/test/file.org",
level = 1,
position = 1,
title = "Test Node",
properties = mapOf("FC_TYPE" to "normal"),
outlinePath = listOf("Test Node")
)
coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit
viewModel.startSession()
advanceUntilIdle()
// Now rate the card
viewModel.rateCard(ReviewRating.AGAIN)
advanceUntilIdle()
// State should have changed appropriately
// Note: AFTER rating AGAIN, the card is added back to the queue
// So sessionQueue.size would be 1, not 0
verify(exactly = 1) { notificationManager.showDueCardsNotification(any()) }
}
@Test
fun `rateCard with no current card does nothing`() = runTest {
val viewModel = createViewModel()
viewModel.rateCard(ReviewRating.GOOD)
advanceUntilIdle()
// No exception, just returns
}
@Test
fun `skipCard updates position and loads next card`() = runTest {
val currentPosition = FlashcardPosition(
nodeId = "node-1",
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.fromEpochSeconds(1640995200),
reviewCount = 1
)
// This will be mocked later in the session setup
coEvery { quizRepository.updateFlashcardPosition(any()) } returns Unit
// coEvery { flashcardService.getDueCards(maxCount = 100L) } returns emptyList()
// coEvery { flashcardService.getAllFlashcards() } returns emptyList()
val viewModel = createViewModel()
// Set up by starting a session
coEvery { flashcardService.getDueCards(maxCount = 20L) } returns listOf(currentPosition)
coEvery { flashcardService.getAllFlashcards() } returns listOf(
Flashcard(
nodeId = "node-1",
cardType = computer.whatthefuck.arcology.domain.FlashcardType.NORMAL,
clozeType = null,
createdAt = Instant.fromEpochSeconds(1640995200),
isSuspended = false,
reviewData = emptyMap()
)
)
coEvery { flashcardService.countDueCards() } returns 1
coEvery { repository.getNodeById("node-1") } returns OrgNode(
id = "node-1",
file = "/test/file.org",
level = 1,
position = 1,
title = "Test Node",
properties = mapOf("FC_TYPE" to "normal"),
outlinePath = listOf("Test Node")
)
coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit
viewModel.startSession()
advanceUntilIdle()
// Now skip the card
viewModel.skipCard()
advanceUntilIdle()
// The card was updated and next card was attempted to be loaded
// (may be Completed if no more cards)
viewModel.sessionState.value shouldNotBe QuizSessionState.Error
}
@Test
fun `skipCard with no current card does nothing`() = runTest {
val viewModel = createViewModel()
viewModel.skipCard()
advanceUntilIdle()
// No exception, just returns
}
@Test
fun `suspendCard suspends flashcard and loads next card`() = runTest {
val currentPosition = FlashcardPosition(
nodeId = "node-1",
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.fromEpochSeconds(1640995200),
reviewCount = 1
)
val flashcard = Flashcard(
nodeId = "node-1",
cardType = computer.whatthefuck.arcology.domain.FlashcardType.NORMAL,
clozeType = null,
createdAt = Instant.fromEpochSeconds(1640995200),
isSuspended = false,
reviewData = emptyMap()
)
coEvery { flashcardService.suspendFlashcard("node-1") } returns Unit
coEvery { flashcardService.getDueCards(maxCount = 100L) } returns emptyList()
coEvery { notificationManager.updateBadgeCount(any()) } returns Unit
val viewModel = createViewModel()
// Set up by starting a session
coEvery { flashcardService.getDueCards(maxCount = 20L) } returns listOf(currentPosition)
coEvery { flashcardService.getAllFlashcards() } returns listOf(flashcard)
coEvery { flashcardService.countDueCards() } returns 1
coEvery { repository.getNodeById("node-1") } returns OrgNode(
id = "node-1",
file = "/test/file.org",
level = 1,
position = 1,
title = "Test Node",
properties = mapOf("FC_TYPE" to "normal"),
outlinePath = listOf("Test Node")
)
coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit
viewModel.startSession()
advanceUntilIdle()
// Now suspend the card
viewModel.suspendCard()
advanceUntilIdle()
// State should be Completed after suspending
viewModel.sessionState.value shouldBe QuizSessionState.Completed
}
@Test
fun `suspendCard with no current card does nothing`() = runTest {
val viewModel = createViewModel()
viewModel.suspendCard()
advanceUntilIdle()
// No exception, just returns
}
@Test
fun `loadStatistics loads current statistics`() = runTest {
val stats = FlashcardStatistics(
totalFlashcards = 10,
totalPositions = 20,
dueToday = 5,
totalReviews = 50,
averageEase = 2.4,
typeBreakdown = mapOf(
computer.whatthefuck.arcology.domain.FlashcardType.NORMAL to 5,
computer.whatthefuck.arcology.domain.FlashcardType.CLOZE to 3,
computer.whatthefuck.arcology.domain.FlashcardType.VOCAB to 2
)
)
coEvery { flashcardService.getStatistics() } returns stats
val viewModel = createViewModel()
viewModel.loadStatistics()
advanceUntilIdle()
viewModel.statistics.value shouldNotBe null
viewModel.statistics.value!!.totalFlashcards shouldBe 10
viewModel.statistics.value!!.dueToday shouldBe 5
viewModel.statistics.value!!.averageEase shouldBe 2.4
}
@Test
fun `hasDueCards returns true when cards are due`() = runTest {
val duePosition = FlashcardPosition(
nodeId = "node-1",
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.fromEpochSeconds(1640995200),
reviewCount = 1
)
coEvery { flashcardService.getDueCards(maxCount = 1L) } returns listOf(duePosition)
val viewModel = createViewModel()
val result = viewModel.hasDueCards()
advanceUntilIdle()
result shouldBe true
}
@Test
fun `hasDueCards returns false when no cards are due`() = runTest {
coEvery { flashcardService.getDueCards(maxCount = 1L) } returns emptyList()
val viewModel = createViewModel()
val result = viewModel.hasDueCards()
advanceUntilIdle()
result shouldBe false
}
@Test
fun `resetSession clears all state`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
// Start a session first to set some state
coEvery { flashcardService.getDueCards(maxCount = 20L) } returns emptyList()
coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
totalFlashcards = 0,
totalPositions = 0,
dueToday = 0,
totalReviews = 0,
averageEase = null,
typeBreakdown = emptyMap()
)
viewModel.startSession()
advanceUntilIdle()
// Session should be NoCardsDue now
viewModel.resetSession()
advanceUntilIdle()
viewModel.sessionState.value shouldBe QuizSessionState.Idle
viewModel.currentCard.value shouldBe null
viewModel.progress.value shouldBe 0
viewModel.totalCards.value shouldBe 0
}
@Test
fun `refreshSession calls startSession`() = runTest {
coEvery { flashcardService.getDueCards(maxCount = 20L) } returns emptyList()
coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
totalFlashcards = 0,
totalPositions = 0,
dueToday = 0,
totalReviews = 0,
averageEase = null,
typeBreakdown = emptyMap()
)
val viewModel = createViewModel()
viewModel.refreshSession()
advanceUntilIdle()
viewModel.sessionState.value shouldBe QuizSessionState.NoCardsDue
verify { notificationManager.updateBadgeCount(0) }
}
@Test
fun `getDueCardsCount returns current total`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
// Set total cards by starting a session with cards
val duePosition = FlashcardPosition(
nodeId = "node-1",
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.fromEpochSeconds(1640995200),
reviewCount = 1
)
val flashcard = Flashcard(
nodeId = "node-1",
cardType = computer.whatthefuck.arcology.domain.FlashcardType.NORMAL,
clozeType = null,
createdAt = Instant.fromEpochSeconds(1640995200),
isSuspended = false,
reviewData = emptyMap()
)
val orgNode = OrgNode(
id = "node-1",
file = "/test/file.org",
level = 1,
position = 1,
title = "Test Node",
properties = mapOf("FC_TYPE" to "normal"),
outlinePath = listOf("Test Node")
)
coEvery { flashcardService.getDueCards(maxCount = 20L) } returns listOf(duePosition)
coEvery { flashcardService.getAllFlashcards() } returns listOf(flashcard)
coEvery { flashcardService.countDueCards() } returns 1
coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
totalFlashcards = 1,
totalPositions = 2,
dueToday = 1,
totalReviews = 5,
averageEase = 2.5,
typeBreakdown = mapOf(computer.whatthefuck.arcology.domain.FlashcardType.NORMAL to 1)
)
coEvery { repository.getNodeById("node-1") } returns orgNode
coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit
viewModel.startSession()
advanceUntilIdle()
viewModel.getDueCardsCount() shouldBe 1
}
@Test
fun `startSession respects maxCardsPerSession preference`() = runTest {
val duePosition = FlashcardPosition(
nodeId = "node-1",
positionName = "front",
easeFactor = 2.5,
box = 1,
intervalDays = 1.0,
dueDate = Instant.fromEpochSeconds(1640995200),
reviewCount = 1
)
val flashcard = Flashcard(
nodeId = "node-1",
cardType = computer.whatthefuck.arcology.domain.FlashcardType.NORMAL,
clozeType = null,
createdAt = Instant.fromEpochSeconds(1640995200),
isSuspended = false,
reviewData = emptyMap()
)
val orgNode = OrgNode(
id = "node-1",
file = "/test/file.org",
level = 1,
position = 1,
title = "Test Node",
properties = mapOf("FC_TYPE" to "normal"),
outlinePath = listOf("Test Node")
)
val testPrefs = computer.whatthefuck.arcology.app.testutils.AppPreferencesTestDouble(
selectedDirectoryUri = mockUri,
todoStates = listOf("TODO", "DONE"),
captureTemplates = emptyList(),
captureLocationEnabled = false,
onboardingCompleted = true,
lastIndexingTimestamp = 0L,
mapCenterLat = null,
mapCenterLon = null,
mapZoomScale = null
)
coEvery { flashcardService.getDueCards(maxCount = 20L) } returns listOf(duePosition)
coEvery { flashcardService.getAllFlashcards() } returns listOf(flashcard)
coEvery { flashcardService.countDueCards() } returns 1
coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
totalFlashcards = 1,
totalPositions = 2,
dueToday = 1,
totalReviews = 5,
averageEase = 2.5,
typeBreakdown = mapOf(computer.whatthefuck.arcology.domain.FlashcardType.NORMAL to 1)
)
coEvery { repository.getNodeById("node-1") } returns orgNode
coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit
val documentEditor = mockk<OrgDocumentEditor>()
coEvery { documentEditor.findHeadingBoundaries(any(), any(), any()) } returns null
val nodeContentParser = mockk<computer.whatthefuck.arcology.editor.NodeContentParser>()
coEvery { nodeContentParser.getParentNodes(any()) } returns emptyList()
val viewModel = QuizViewModel(
flashcardService = flashcardService,
reviewService = reviewService,
notificationManager = notificationManager,
repository = repository,
quizRepository = quizRepository,
appPreferences = testPrefs,
fileSystemFactory = { fileSystem },
documentEditorFactory = { documentEditor },
nodeContentParser = nodeContentParser,
backgroundDispatcher = backgroundDispatcher
)
viewModel.startSession()
advanceUntilIdle()
viewModel.sessionState.value shouldBe QuizSessionState.Active
}
}Related Modules
quiz/flashcard.org — FlashcardService, ReviewService (primary dependencies)
roam/models.org — RoamRepository (node lookup, position update)
roam/editor.org — OrgDocumentEditor, HeadingTextUtils, NodeContentParser (content extraction)
app/bootstrap.org — Koin module (provides QuizViewModel dependencies)
quiz/screen.org — QuizScreen (consumes QuizViewModel state)
quiz/notifications.org — QuizNotificationManager, QuizReminderWorker