Arcology Engine

Quiz ViewModel — Session State Machine & Card Loading

Contents

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.

kotlin#+name: quiz-vm-imports-state
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.

kotlin#+name: quiz-vm-constructor
/**
 * 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.

kotlin#+name: quiz-vm-session
    /**
     * 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.

kotlin#+name: quiz-vm-content
    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.

kotlin#+name: quiz-vm-actions
    /**
     * 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.

kotlin#+name: quiz-vm-utilities
    /**
     * 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

kotlin#+name: quiz-viewmodel-file:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/viewmodel/QuizViewModel.kt:noweb yes
<<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.

kotlin#+name: quiz-viewmodel-test:tangle ../app/src/test/kotlin/computer/whatthefuck/arcology/app/viewmodel/QuizViewModelTest.kt
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