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.PendingReviewEdit
import computer.whatthefuck.arcology.flashcard.ReviewFlushQueue
import computer.whatthefuck.arcology.flashcard.ReviewService
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import computer.whatthefuck.arcology.app.cache.OrgDocumentCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
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, a NodeContentParser, a background dispatcher, and a flushQueueFactory that builds the session's ReviewFlushQueue (see quiz/flashcard.org). It maintains seven StateFlow fields (sessionState, currentCard, statistics, errorMessage, progress, totalCards, parentNodes, contextFilter, lastReview), an in-memory sessionQueue, a per-session filesystem/editor pair (they depend only on the selected tree URI, not the card), and a prefetch job that loads the next card while the current one is displayed.

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.
 *
 * Performance model (see quiz/flashcard.org ReviewFlushQueue): a rating tap
 * only performs DB row updates + the review-history TSV append. REVIEW_DATA
 * drawer writes are deferred into a [ReviewFlushQueue] and flushed
 * per-file-coalesced on file switch (drain-before-same-file rule), session
 * end, reset, and onCleared. The next card is prefetched while the current
 * one is displayed so card transitions are near-instant.
 */
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,
    /** Builds the deferred-review flush queue; injectable for tests. */
    private val flushQueueFactory: () -> ReviewFlushQueue = {
        error("flushQueueFactory is required for session rating; provide via DI")
    }
) : 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()

    // Session-scoped filesystem/editor: built once per session from the
    // stored tree URI, reused across card loads instead of re-created per
    // card (readFileContent/extractBodyContent previously constructed a new
    // AndroidFileSystem + OrgDocumentEditor per card).
    private var cachedFileSystem: AndroidFileSystem? = null
    private var cachedEditor: OrgDocumentEditor? = null

    // Deferred REVIEW_DATA writes for this session. recordReview hands its
    // computed ReviewData to this queue's sink; flushes happen on file
    // switch (drain-before-same-file rule), session end, and onCleared.
    private var flushQueue: ReviewFlushQueue? = null

    // Node's file path per node id, populated as cards are loaded — lets the
    // rating path enqueue into the flush queue without a DB lookup, and lets
    // the drain rule find the file for the next card.
    private val filePathByNode = mutableMapOf<String, String>()

    // Prefetch state: while the current card is displayed, the next queue
    // head's heavy work (file read, body extraction, parses, breadcrumbs)
    // runs in the background so the rating tap only swaps state.
    private var prefetchJob: Job? = null
    private var prefetchedState: QuizCardState? = null
    private val prefetchScope = CoroutineScope(SupervisorJob() + backgroundDispatcher)

    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, round-robin across files via FlashcardService.interleaveByFile), initializes sessionQueue plus the session scope (flush queue, filesystem/editor cache, prefetch slot), loads the first card via loadNextCardFromQueue, and sets session state to Active. Statistics and the due badge load asynchronously — the start path doesn't wait for aggregate queries. If no cards are due, it transitions to NoCardsDue.

loadNextCardFromQueue pops the first position, runs the drain-before-same-file rule (flushes the file's deferred REVIEW_DATA edits synchronously when pending), consumes a prefetched card state if one matches, otherwise builds one on the background dispatcher, publishes it, and kicks off a prefetch for the new queue head. When the queue empties, it transitions to Completed.

buildCardState is the heavy lift for one card: card type via a single indexed FlashcardService.getFlashcard(nodeId) query (replacing the double getAllFlashcards() full-table scans), node lookup, file read through the session-cached filesystem, body extraction with the session-cached editor, and both parses via OrgDocumentCache. Because it's a pure (position → state) function, it runs identically in the prefetch job and in the synchronous fallback.

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

            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
                }

                // Set up session: fresh flush queue and caches. Statistics
                // load lazily in the background (not on the start path).
                startNewSessionScope()
                sessionQueue = duePositions.toMutableList()
                _totalCards.value = duePositions.size
                _progress.value = 0

                // Session census: due count, per-file distribution of the
                // queue, totalCards. 
                run {
                    val byFile = sessionQueue.groupBy { pos ->
                        filePathByNode[pos.nodeId] ?: repository.getNodeById(pos.nodeId)?.file ?: "?"
                    }
                    android.util.Log.i(
                        "QuizViewModel",
                        "session start: due=${sessionQueue.size} totalCards=${_totalCards.value} " +
                            "byFile=${byFile.entries.joinToString { (f, ps) -> "$f=${ps.size}" }}"
                    )
                }

                // Load first card
                loadNextCardFromQueue()

                // Update notification with total due count (not just session
                // size). This is once per session so the aggregate countDueCards
                // query is not on any per-card path; the badge refreshes
                // asynchronously alongside it.
                val totalDue = if (context != null) {
                    flashcardService.countDueCardsByContext(context).toInt()
                } else {
                    flashcardService.countDueCards().toInt()
                }
                notificationManager.showDueCardsNotification(totalDue)
                updateBadgeAsync()
                updateStatisticsAsync()

                _sessionState.value = QuizSessionState.Active
            } catch (e: Exception) {
                _errorMessage.value = "Failed to start session: ${e.message}"
                _sessionState.value = QuizSessionState.Error
            }
        }
    }

    /**
     * Fresh per-session state: flush queue, filesystem/editor cache, node
     * file-path cache, prefetch slot.
     */
    private fun startNewSessionScope() {
        flushQueue = flushQueueFactory()
        cachedFileSystem = null
        cachedEditor = null
        filePathByNode.clear()
        prefetchedState = null
        prefetchJob?.cancel()
        prefetchJob = null
    }

    /**
     * Lazily build (or return) the session filesystem/editor pair. Throws
     * when no directory is selected — same contract as ReviewService's
     * editor factory.
     */
    private suspend fun sessionEditor(): OrgDocumentEditor {
        cachedEditor?.let { return it }
        val treeUri = appPreferences.getSelectedDirectoryUri()
            ?: throw IllegalStateException("No directory selected")
        val fs = fileSystemFactory(treeUri)
        cachedFileSystem = fs
        val editor = documentEditorFactory(fs)
        cachedEditor = editor
        return editor
    }

    /**
     * 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()
        }
    }

    /**
     * Drain-before-same-file rule: when the next card comes from a file with
     * pending REVIEW_DATA edits, flush that file synchronously before
     * showing the card. This guarantees the card's position state in the DB
     * reflects every earlier rating in the session — the reindex re-derives
     * positions from the flushed drawer. Interleaving (FlashcardService.
     * interleaveByFile) makes same-file adjacency rare, so this usually
     * costs nothing; when it does trigger, it replaces the old per-rating
     * synchronous write+reindex with one batched write+reindex.
     *
     * A failed flush must NOT abort the card transition: the DB and TSV
     * already hold the review, and F1's failure-safe queue keeps the pending
     * drawer edit queued for the next flush point (session end, onCleared).
     * The failure line lists the exact (nodeId, positionName) pairs at risk
     * — this is the smoking-gun log for lost reviews. A stale due-count
     * delta after a SUCCESSFUL flush is the reset-resurfacing mechanism
     * observed live (positions re-derived from a pre-review drawer).
     */
    private suspend fun drainForFile(filePath: String) {
        val queue = flushQueue ?: return
        if (queue.hasPending(filePath)) {
            val before = queue.pendingEntries(filePath)
            android.util.Log.d(
                "QuizViewModel",
                "flush(file) entry: file=$filePath edits=${before.size} " +
                    "pairs=${before.joinToString { "${it.nodeId}/${it.positionName}" }}"
            )
            val flushed = try {
                queue.flushFile(filePath)
            } catch (e: Exception) {
                android.util.Log.w(
                    "QuizViewModel",
                    "flush(file) FAILED: file=$filePath lost pairs=${lostPairList(before)} error=${e.message}"
                )
                false
            }
            if (flushed) {
                // Drawer state just changed on disk; refresh stats/badge lazily
                updateStatisticsAsync()
                logDueCountDelta("flush(file) success", filePath)
            } else {
                android.util.Log.w(
                    "QuizViewModel",
                    "flush(file) FAILED: file=$filePath lost pairs=${lostPairList(before)} (EditResult error; edits stay queued)"
                )
            }
        }
    }

    /**
     * Format the (nodeId, positionName) pairs a failed flush put at risk.
     */
    private fun lostPairList(edits: List<computer.whatthefuck.arcology.flashcard.PendingReviewEdit>): String =
        edits.joinToString { "${it.nodeId}/${it.positionName}" }

    /**
     * Due-count delta after a flush: a JUMP means the reindex re-derived
     * positions from an unflushed/pre-review drawer and reset schedules —
     * the reset-resurfacing mechanism observed live. Logged best-effort.
     */
    private fun logDueCountDelta(context: String, filePath: String) {
        viewModelScope.launch {
            try {
                val due = flashcardService.countDueCards().toInt()
                android.util.Log.d("QuizViewModel", "due-count after $context ($filePath): due=$due")
            } catch (e: Exception) {
                android.util.Log.w("QuizViewModel", "due-count refresh failed: ${e.message}")
            }
        }
    }

    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)

        // Duplicate-presentation check: every position that enters the
        // session queue is unique up front, so the only legitimate
        // second presentation is the AGAIN re-entry path in rateCard
        // (which removes before re-adding). Seeing a position here that
        // was already presented is double-picking or queue duplication.
        android.util.Log.d(
            "QuizViewModel",
            "present: node=${position.nodeId} position=${position.positionName}"
        )

        // Drain-before-same-file: ensure this card's file has no pending
        // deferred writes before presenting it.
        val nextFilePath = filePathByNode[position.nodeId]
            ?: repository.getNodeById(position.nodeId)?.file ?: ""
        if (nextFilePath.isNotEmpty()) {
            filePathByNode[position.nodeId] = nextFilePath
            drainForFile(nextFilePath)
        }

        val cardState = takePrefetched(position) ?: buildCardState(position)
        publishCard(cardState, position)
    }

    /**
     * Publish a card: set currentCard, progress, breadcrumbs, session state,
     * then kick off the prefetch for the new queue head.
     */
    private suspend fun publishCard(cardState: QuizCardState, position: FlashcardPosition) {
        _currentCard.value = cardState

        // Load parent nodes for breadcrumbs
        loadParentNodes(position.nodeId)

        _progress.value = (_progress.value + 1).coerceAtMost(_totalCards.value)
        _sessionState.value = QuizSessionState.Active

        startPrefetch()
    }

    /**
     * Prefetch machinery: builds the QuizCardState for the queue head in the
     * background while the current card is displayed. buildCardState for the
     * prefetched position is memoized in prefetchedState; takePrefetched
     * consumes it when the queue head matches.
     */
    private fun startPrefetch() {
        prefetchJob?.cancel()
        val next = sessionQueue.firstOrNull() ?: return
        val queue = flushQueue
        prefetchJob = prefetchScope.launch {
            try {
                // The prefetch must not run while a drain-for-file is
                // mid-flush for the same file; the drain happens on the
                // session scope and the check inside buildCardState's caller
                // guards staleness by identity match in takePrefetched.
                if (sessionQueue.firstOrNull() != next) return@launch
                drainForFileForPrefetch(queue, next)
                val state = buildCardState(next)
                prefetchedState = state
            } catch (e: kotlinx.coroutines.CancellationException) {
                throw e
            } catch (e: Exception) {
                // Prefetch failures are non-fatal: loadNextCardFromQueue
                // falls back to building the card synchronously.
                android.util.Log.w("QuizViewModel", "prefetch failed: ${e.message}")
                prefetchedState = null
            }
        }
    }

    private suspend fun drainForFileForPrefetch(queue: ReviewFlushQueue?, position: FlashcardPosition) {
        if (queue == null) return
        val filePath = filePathByNode[position.nodeId]
            ?: repository.getNodeById(position.nodeId)?.file ?: ""
        if (filePath.isNotEmpty()) {
            filePathByNode[position.nodeId] = filePath
            if (queue.hasPending(filePath)) {
                val before = queue.pendingEntries(filePath)
                android.util.Log.d(
                    "QuizViewModel",
                    "flush(prefetch) entry: file=$filePath edits=${before.size} " +
                        "pairs=${before.joinToString { "${it.nodeId}/${it.positionName}" }}"
                )
                val flushed = try {
                    queue.flushFile(filePath)
                } catch (e: Exception) {
                    android.util.Log.w(
                        "QuizViewModel",
                        "flush(prefetch) FAILED: file=$filePath lost pairs=${lostPairList(before)} error=${e.message}"
                    )
                    false
                }
                if (flushed) {
                    updateStatisticsAsync()
                    logDueCountDelta("flush(prefetch) success", filePath)
                } else {
                    android.util.Log.w(
                        "QuizViewModel",
                        "flush(prefetch) FAILED: file=$filePath lost pairs=${lostPairList(before)} (EditResult error; edits stay queued)"
                    )
                }
            }
        }
    }

    private fun takePrefetched(position: FlashcardPosition): QuizCardState? {
        val state = prefetchedState
        return if (state != null && state.position == position) {
            prefetchedState = null
            state
        } else {
            null
        }
    }

    /**
     * The heavy lift for one card: card type lookup (single indexed query),
     * node lookup, file read, body extraction, both parses, state build.
     * Runs on the background dispatcher.
     */
    private suspend fun buildCardState(position: FlashcardPosition): QuizCardState = withContext(backgroundDispatcher) {
        val flashcard = flashcardService.getFlashcard(position.nodeId)
        val cardType = flashcard?.cardType?.name ?: "unknown"
        val clozeType = flashcard?.clozeType

        val node = repository.getNodeById(position.nodeId)
        node?.let { filePathByNode[position.nodeId] = it.file }

        var questionContent: String
        var answerContent: String
        var questionParse: OrgParseResult?
        var answerParse: OrgParseResult?

        if (node != null) {
            val fileContent = readFileContent(node.file)
            if (fileContent != null) {
                val bodyContent = extractBodyContent(node, fileContent, position.positionName)
                questionContent = extractContentForCardType(node, cardType, position.positionName, fileContent, bodyContent)
                answerContent = extractContentForCardType(node, cardType, if (position.positionName.lowercase() == "front") "back" else "front", fileContent, bodyContent)
                val qp = OrgDocumentCache.parse(questionContent)
                val ap = OrgDocumentCache.parse(answerContent)
                questionParse = qp
                answerParse = ap
            } else {
                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 -> ""
                }
                questionParse = OrgDocumentCache.parse(questionContent)
                answerParse = OrgDocumentCache.parse(answerContent)
            }
        } else {
            questionContent = ""
            answerContent = ""
            questionParse = null
            answerParse = null
        }

        QuizCardState(
            position = position,
            nodeId = position.nodeId,
            cardType = cardType,
            positionName = position.positionName,
            title = node?.title ?: "Untitled",
            questionContent = questionContent,
            answerContent = answerContent,
            filePath = node?.file ?: "",
            clozeType = clozeType,
            questionParseResult = questionParse,
            answerParseResult = answerParse
        )
    }

    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 reads through the session-cached AndroidFileSystem (built once per session from the stored tree URI). extractBodyContent uses the session-cached OrgDocumentEditor.findHeadingBoundaries and HeadingTextUtils.extractHeadingBody to slice out the heading's body text from the file content. 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. The filesystem and editor depend only on the selected tree URI, so caching them per session removes two factory constructions per card.

kotlin#+name: quiz-vm-content
    private suspend fun readFileContent(filePath: String): String? {
        return try {
            // Session-cached file system (built from the stored tree URI)
            val fileSystem = sessionFileSystem()

            // 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 the session-cached OrgDocumentEditor's findHeadingBoundaries and
     * HeadingTextUtils's extractHeadingBody.
     */
    private suspend fun extractBodyContent(node: OrgNode, fileContent: String, positionName: String): String {
        val boundaries = try {
            sessionEditor().findHeadingBoundaries(fileContent, node.id, node.level)
        } catch (e: Exception) {
            android.util.Log.w("QuizViewModel", "Failed to find heading boundaries: ${e.message}")
            null
        }

        return if (boundaries != null) {
            val (headingPos, nextHeadingPos) = boundaries
            HeadingTextUtils.extractHeadingBody(fileContent, headingPos, nextHeadingPos)
        } else {
            ""
        }
    }
kotlin#+name: quiz-vm-actions
    /**
     * Lazily build (or return) the session filesystem. Unlike [sessionEditor]
     * this tolerates a missing directory (returns null) because card content
     * extraction degrades gracefully to title-only cards.
     */
    private suspend fun sessionFileSystem(): AndroidFileSystem {
        cachedFileSystem?.let { return it }
        val treeUri = appPreferences.getSelectedDirectoryUri()
            ?: throw IllegalStateException("No directory selected")
        val fs = fileSystemFactory(treeUri)
        cachedFileSystem = fs
        return fs
    }

    /**
     * Rate the current card with the given rating.
     * AGAIN rating moves the card to the end of the session queue instead of scheduling.
     *
     * Critical path per tap: DB position update + review insert + TSV append
     * (inside ReviewService) + enqueueing the computed ReviewData into the
     * flush queue. The REVIEW_DATA drawer write + reindex is deferred to the
     * flush queue (file switch / session end / onCleared).
     *
     * The current card is cleared IMMEDIATELY on entry: the UI's spinner
     * branch renders while the next card loads (visible during the
     * drain-before-same-file flush), and a second tap during the transition
     * is a no-op instead of double-rating the same card and double-queuing
     * its AGAIN re-entry.
     */
    fun rateCard(rating: ReviewRating) {
        val cardState = _currentCard.value ?: return
        _currentCard.value = null

        viewModelScope.launch {
            try {
                val queue = flushQueue
                    ?: throw IllegalStateException("Session not initialized")

                // Build the sink that defers the drawer write into the
                // flush queue, coalesced per file. enqueue is a plain
                // in-memory mutation under a monitor lock — no I/O, safe to
                // call from this non-suspending lambda. The returned flag
                // says this edit REPLACED a still-pending older edit: the
                // normal coalescing case, but also the fingerprint of a
                // position being re-rated after a flush.
                val filePath = cardState.filePath
                val drawerSink: ((computer.whatthefuck.arcology.flashcard.ReviewData) -> Unit) = { reviewData ->
                    val replaced = queue.enqueue(
                        filePath,
                        PendingReviewEdit(
                            nodeId = cardState.nodeId,
                            positionName = cardState.positionName,
                            reviewData = reviewData
                        )
                    )
                    android.util.Log.d(
                        "QuizViewModel",
                        "enqueue: node=${cardState.nodeId} position=${cardState.positionName} file=$filePath replaced=$replaced"
                    )
                }

                val review = withContext(backgroundDispatcher) {
                    reviewService.recordReview(
                        nodeId = cardState.nodeId,
                        positionName = cardState.positionName,
                        rating = rating,
                        filePath = filePath.ifEmpty { null },
                        drawerSink = drawerSink
                    )
                }

                _lastReview.value = review

                if (rating == ReviewRating.AGAIN) {
                    // Move card to end of session queue for later review.
                    // Marker log: separates "user rated AGAIN" repeats from
                    // schedule-reset repeats when reading logs later.
                    sessionQueue.add(cardState.position)
                    android.util.Log.d(
                        "QuizViewModel",
                        "again re-entry: node=${cardState.nodeId} position=${cardState.positionName} queueSize=${sessionQueue.size}"
                    )
                }

                // Load next card (drain-before-same-file handles the rare
                // case where the next card shares the file we just wrote to)
                loadNextCardFromQueue()

                // Notification shows the remaining queue size; the badge
                // count refreshes lazily in the background.
                val remaining = sessionQueue.size + if (_currentCard.value != null) 1 else 0
                notificationManager.showDueCardsNotification(remaining)
                updateBadgeAsync()

                _sessionState.value = if (sessionQueue.isEmpty() && _currentCard.value == null) {
                    QuizSessionState.Completed
                } else {
                    QuizSessionState.Active
                }
            } catch (e: Exception) {
                _errorMessage.value = "Failed to rate card: ${e.message}"
                // The failed card was already cleared; restore it so the user
                // can retry the rating instead of staring at a spinner.
                _currentCard.value = cardState
            }
        }
    }

    /**
     * Skip the current card and move to the next one.
     */
    @OptIn(ExperimentalTime::class)
    fun skipCard() {
        val currentCard = _currentCard.value ?: return
        _currentCard.value = null

        viewModelScope.launch {
            try {
                val algorithm = computer.whatthefuck.arcology.flashcard.SM2Algorithm()

                // Re-read the position from the database before applying the
                // SM-2 reset: a flush/reindex mid-session may have replaced
                // the rows, and skip must not resurrect stale SM-2 state
                // from the in-memory card.
                val position = quizRepository.getFlashcardPosition(
                    currentCard.nodeId,
                    currentCard.positionName
                ) ?: currentCard.position

                // 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}"
                _currentCard.value = currentCard
            }
        }
    }

    /**
     * Mark the current card as suspended.
     */
    fun suspendCard() {
        val cardState = _currentCard.value ?: return
        _currentCard.value = null

        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}"
                _currentCard.value = cardState
            }
        }
    }

    /**
     * Alias for suspendCard() to maintain consistency with rateCard() naming.
     */
    fun suspendCurrentCard() = suspendCard()

    /**
     * End the current quiz session: flush all pending REVIEW_DATA drawer
     * edits (one batched write + reindex per touched file), then transition
     * to Completed state. Failed files are logged with the exact
     * (nodeId, positionName) pairs that did not reach the org file — F1
     * keeps them queued for the next flush point (next session end, or the
     * ON_STOP lifecycle flush).
     */
    fun endSession() {
        viewModelScope.launch {
            try {
                logFlushAllFailures("endSession", flushQueue?.flushAll() ?: emptyList())
                updateStatisticsAsync()
            } catch (e: Exception) {
                android.util.Log.w("QuizViewModel", "endSession flush failed: ${e.message}")
            }
            _sessionState.value = QuizSessionState.Completed
        }
    }

    /**
     * Shared failure logger for flushAll call sites: THE smoking-gun line
     * for lost reviews — file, edit counts, and every (nodeId, positionName)
     * pair that failed to reach the org file.
     */
    internal fun logFlushAllFailures(context: String, failedFiles: List<String>) {
        val queue = flushQueue ?: return
        for (file in failedFiles) {
            android.util.Log.w(
                "QuizViewModel",
                "flushAll($context) FAILED: file=$file lost pairs=${lostPairList(queue.pendingEntries(file))}"
            )
        }
    }

    /**
     * Public lifecycle hook for the ON_STOP flush (F2): app going to
     * background. Flushes every pending drawer edit so a process kill
     * while backgrounded cannot lose reviews. Uses the same failure-safe
     * path: failed files stay queued (a later session end still retries).
     */
    fun flushPendingReviewData() {
        viewModelScope.launch {
            try {
                logFlushAllFailures("onStop", flushQueue?.flushAll() ?: emptyList())
            } catch (e: Exception) {
                android.util.Log.w("QuizViewModel", "onStop flush failed: ${e.message}")
            }
        }
    }

Statistics & Utilities

loadStatistics fetches from FlashcardService.getStatistics(). updateStatisticsAsync and updateBadgeAsync are the off-critical-path refreshes used after ratings and flushes — they run on the ViewModel scope and never block card transitions. hasDueCards is a lightweight check used for notification logic. refreshSession and resetSession control the lifecycle. onCleared flushes the queue so a destroyed ViewModel (navigating away mid-session) still persists its deferred drawer writes. 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()
        }
    }

    /**
     * Refresh statistics in the background. Never blocks card transitions:
     * failures are logged and the previous statistics stay visible.
     */
    private fun updateStatisticsAsync() {
        viewModelScope.launch {
            try {
                _statistics.value = flashcardService.getStatistics()
            } catch (e: Exception) {
                android.util.Log.w("QuizViewModel", "statistics refresh failed: ${e.message}")
            }
        }
    }

    /**
     * Refresh the notification badge with the current total due count in
     * the background.
     */
    private fun updateBadgeAsync() {
        viewModelScope.launch {
            try {
                val quizContext = _contextFilter.value
                val totalDue = if (quizContext != null) {
                    flashcardService.countDueCardsByContext(quizContext).toInt()
                } else {
                    flashcardService.countDueCards().toInt()
                }
                notificationManager.updateBadgeCount(totalDue)
            } catch (e: Exception) {
                android.util.Log.w("QuizViewModel", "badge refresh failed: ${e.message}")
            }
        }
    }

    /**
     * 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.
     * The existing flush queue is drained first so pending drawer writes
     * from the old session are not lost; failures are logged (F1 keeps
     * the edits queued on the old queue object, which refreshSession
     * replaces via startNewSessionScope — so a failed refresh-time flush
     * IS a real loss and must be loud).
     */
    fun refreshSession() {
        viewModelScope.launch {
            val oldQueue = flushQueue
            if (oldQueue != null) {
                try {
                    logFlushAllFailures("refreshSession", oldQueue.flushAll())
                } catch (e: Exception) {
                    android.util.Log.w("QuizViewModel", "refreshSession flush failed: ${e.message}")
                }
            }
            startSession()
        }
    }

    /**
     * Reset the session to idle state, flushing all pending drawer edits
     * first so no deferred review data is lost. Failures keep the edits
     * on the queue object (F1) but resetSession then drops the whole
     * session scope — so a failed reset-time flush must be logged loudly.
     */
    fun resetSession() {
        viewModelScope.launch {
            try {
                logFlushAllFailures("resetSession", flushQueue?.flushAll() ?: emptyList())
            } catch (e: Exception) {
                android.util.Log.w("QuizViewModel", "resetSession flush failed: ${e.message}")
            }
            _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
    }

    /**
     * ViewModel destroyed (navigating away mid-session): persist any
     * pending deferred REVIEW_DATA writes before the scope dies. Failures
     * are logged with the exact lost pairs (F1 keeps them queued, but the
     * scope dies here — so this is the last chance and must be loud).
     */
    override fun onCleared() {
        val queue = flushQueue
        if (queue != null) {
            val scope = CoroutineScope(SupervisorJob() + backgroundDispatcher)
            scope.launch {
                try {
                    val failed = queue.flushAll()
                    for (file in failed) {
                        android.util.Log.w(
                            "QuizViewModel",
                            "flushAll(onCleared) FAILED: file=$file lost pairs=${
                                queue.pendingEntries(file).joinToString { "${it.nodeId}/${it.positionName}" }
                            }"
                        )
                    }
                } catch (e: Exception) {
                    android.util.Log.w("QuizViewModel", "onCleared flush failed: ${e.message}")
                }
            }
        }
        super.onCleared()
    }
}

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.PendingReviewEdit
import computer.whatthefuck.arcology.flashcard.ReviewData
import computer.whatthefuck.arcology.flashcard.ReviewFlushQueue
import computer.whatthefuck.arcology.flashcard.ReviewService
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import io.kotest.matchers.collections.shouldContain
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(relaxed = true)
        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.i(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(
        testPrefs: AppPreferencesInterface = appPreferences,
        flushedFiles: MutableList<String> = mutableListOf(),
        /** When non-null, setReviewDataBatch answers with EditResult.Error instead of success. */
        failFlushEditor: MutableList<Boolean> = mutableListOf(false)
    ): 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 = testPrefs,
            fileSystemFactory = { fileSystem },
            documentEditorFactory = { documentEditor },
            nodeContentParser = nodeContentParser,
            backgroundDispatcher = backgroundDispatcher,
            flushQueueFactory = {
                val flushEditor = mockk<OrgDocumentEditor>()
                coEvery {
                    flushEditor.setReviewDataBatch(any(), any())
                } answers {
                    if (failFlushEditor.isNotEmpty() && failFlushEditor[0]) {
                        computer.whatthefuck.arcology.editor.EditResult.Error("simulated flush failure")
                    } else {
                        flushedFiles.add(firstArg())
                        computer.whatthefuck.arcology.editor.EditResult.Success("")
                    }
                }
                ReviewFlushQueue { flushEditor }
            }
        )
    }

    @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.getFlashcard(any()) } returns 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.getFlashcard(any()) } returns 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.getFlashcard(any()) } returns 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.getFlashcard(any()) } returns 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(), 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.getFlashcard(any()) } returns 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.getFlashcard(any()) } returns 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()

        // startSession showed the total due count (1); after rating AGAIN the
        // card is re-queued so rateCard's remaining-count notification shows
        // 1 again. Two notifications total, both showing 1.
        verify(exactly = 2) { notificationManager.showDueCardsNotification(1) }
    }

    @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.getFlashcard(any()) } returns 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.getFlashcard(any()) } returns 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.getFlashcard(any()) } returns 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.getFlashcard(any()) } returns 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(testPrefs = testPrefs)

        viewModel.startSession()
        advanceUntilIdle()

        viewModel.sessionState.value shouldBe QuizSessionState.Active
    }

    @Test
    fun `rateCard defers drawer write into flush queue and flushes on file switch`() = runTest {
        val positionA = FlashcardPosition(
            nodeId = "node-a",
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.fromEpochSeconds(1640995200),
            reviewCount = 1
        )
        val positionB = FlashcardPosition(
            nodeId = "node-b",
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.fromEpochSeconds(1640995200),
            reviewCount = 1
        )
        val flashcard = Flashcard(
            nodeId = "node-a",
            cardType = computer.whatthefuck.arcology.domain.FlashcardType.NORMAL,
            clozeType = null,
            createdAt = Instant.fromEpochSeconds(1640995200),
            isSuspended = false,
            reviewData = emptyMap()
        )
        val nodeA = OrgNode(
            id = "node-a",
            file = "deck/a.org",
            level = 1,
            position = 1,
            title = "Card A",
            properties = mapOf("FC_TYPE" to "normal"),
            outlinePath = listOf("Card A")
        )
        val nodeB = OrgNode(
            id = "node-b",
            file = "deck/b.org",
            level = 1,
            position = 1,
            title = "Card B",
            properties = mapOf("FC_TYPE" to "normal"),
            outlinePath = listOf("Card B")
        )

        coEvery { flashcardService.getDueCards(maxCount = 20L) } returns listOf(positionA, positionB)
        coEvery { flashcardService.getFlashcard(any()) } returns flashcard
        coEvery { flashcardService.countDueCards() } returns 2
        coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
            totalFlashcards = 2,
            totalPositions = 2,
            dueToday = 2,
            totalReviews = 0,
            averageEase = 2.5,
            typeBreakdown = emptyMap()
        )
        coEvery { repository.getNodeById("node-a") } returns nodeA
        coEvery { repository.getNodeById("node-b") } returns nodeB
        coEvery { reviewService.recordReview(any(), any(), any(), any(), any(), any()) } answers {
            // Simulate ReviewService handing its computed ReviewData to the
            // session's drawer sink so the flush queue actually gets an edit.
            val sink = arg<((computer.whatthefuck.arcology.flashcard.ReviewData) -> Unit)?>(5)
            sink?.invoke(
                computer.whatthefuck.arcology.flashcard.ReviewData(
                    positionName = secondArg(),
                    easeFactor = 2.5,
                    box = 1,
                    intervalDays = 1.0,
                    dueDate = Instant.fromEpochSeconds(1700000000),
                    reviewCount = 2
                )
            )
            mockk()
        }
        coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit

        val flushedFiles = mutableListOf<String>()
        val viewModel = createViewModel(
            flushedFiles = flushedFiles
        )

        viewModel.startSession()
        advanceUntilIdle()

        // Rate the first card (from deck/a.org) — the drawer write is
        // deferred, no flush yet since the next card (b.org) is a different file
        viewModel.rateCard(ReviewRating.GOOD)
        advanceUntilIdle()

        flushedFiles shouldBe emptyList()

        // Rate the second card (deck/b.org): the drain-before-same-file rule
        // doesn't trigger for b, but the queue still holds a's edit
        viewModel.rateCard(ReviewRating.GOOD)
        advanceUntilIdle()

        // Loading the next card from the empty queue triggers Completed; the
        // session's flush should have been applied for a.org when its card
        // was loaded... actually only when a card from a.org is presented
        // again or the session ends. endSession flushes everything:
        viewModel.endSession()
        advanceUntilIdle()

        flushedFiles shouldBe listOf("deck/a.org", "deck/b.org")
    }

    @Test
    fun `rateCard with AGAIN requeues and drains file before same-file card shows`() = runTest {
        val positionA1 = FlashcardPosition(
            nodeId = "node-a",
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.fromEpochSeconds(1640995200),
            reviewCount = 1
        )
        val positionB = FlashcardPosition(
            nodeId = "node-b",
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.fromEpochSeconds(1640995200),
            reviewCount = 1
        )
        val flashcard = Flashcard(
            nodeId = "node-a",
            cardType = computer.whatthefuck.arcology.domain.FlashcardType.NORMAL,
            clozeType = null,
            createdAt = Instant.fromEpochSeconds(1640995200),
            isSuspended = false,
            reviewData = emptyMap()
        )
        val nodeA = OrgNode(
            id = "node-a",
            file = "deck/a.org",
            level = 1,
            position = 1,
            title = "Card A",
            properties = mapOf("FC_TYPE" to "normal"),
            outlinePath = listOf("Card A")
        )
        val nodeB = OrgNode(
            id = "node-b",
            file = "deck/b.org",
            level = 1,
            position = 1,
            title = "Card B",
            properties = mapOf("FC_TYPE" to "normal"),
            outlinePath = listOf("Card B")
        )

        coEvery { flashcardService.getDueCards(maxCount = 20L) } returns listOf(positionA1, positionB)
        coEvery { flashcardService.getFlashcard(any()) } returns flashcard
        coEvery { flashcardService.countDueCards() } returns 2
        coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
            totalFlashcards = 2,
            totalPositions = 2,
            dueToday = 2,
            totalReviews = 0,
            averageEase = 2.5,
            typeBreakdown = emptyMap()
        )
        coEvery { repository.getNodeById("node-a") } returns nodeA
        coEvery { repository.getNodeById("node-b") } returns nodeB
        coEvery { reviewService.recordReview(any(), any(), any(), any(), any(), any()) } answers {
            // Simulate ReviewService handing its computed ReviewData to the
            // session's drawer sink so the flush queue actually gets an edit.
            val sink = arg<((computer.whatthefuck.arcology.flashcard.ReviewData) -> Unit)?>(5)
            sink?.invoke(
                computer.whatthefuck.arcology.flashcard.ReviewData(
                    positionName = secondArg(),
                    easeFactor = 2.5,
                    box = 1,
                    intervalDays = 1.0,
                    dueDate = Instant.fromEpochSeconds(1700000000),
                    reviewCount = 2
                )
            )
            mockk()
        }
        coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit

        val flushedFiles = mutableListOf<String>()
        val viewModel = createViewModel(
            flushedFiles = flushedFiles
        )

        viewModel.startSession()
        advanceUntilIdle()

        // AGAIN on card A: re-queued at the end of the session queue
        viewModel.rateCard(ReviewRating.AGAIN)
        advanceUntilIdle()

        // Advance past B to the re-queued A card. When A is presented again,
        // the drain rule must flush deck/a.org first.
        viewModel.rateCard(ReviewRating.GOOD)
        advanceUntilIdle()

        viewModel.rateCard(ReviewRating.GOOD)
        advanceUntilIdle()

        // a.org was flushed before its re-queued card was shown
        flushedFiles shouldContain "deck/a.org"
    }

    @Test
    fun `rateCard AGAIN presents other cards before the re-queued card`() = runTest {
        val positionA = FlashcardPosition(
            nodeId = "node-a",
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.fromEpochSeconds(1640995200),
            reviewCount = 1
        )
        val positionB = FlashcardPosition(
            nodeId = "node-b",
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.fromEpochSeconds(1640995200),
            reviewCount = 1
        )
        val flashcard = Flashcard(
            nodeId = "node-a",
            cardType = computer.whatthefuck.arcology.domain.FlashcardType.NORMAL,
            clozeType = null,
            createdAt = Instant.fromEpochSeconds(1640995200),
            isSuspended = false,
            reviewData = emptyMap()
        )
        val nodeA = OrgNode(
            id = "node-a",
            file = "deck/a.org",
            level = 1,
            position = 1,
            title = "Card A",
            properties = mapOf("FC_TYPE" to "normal"),
            outlinePath = listOf("Card A")
        )
        val nodeB = OrgNode(
            id = "node-b",
            file = "deck/b.org",
            level = 1,
            position = 1,
            title = "Card B",
            properties = mapOf("FC_TYPE" to "normal"),
            outlinePath = listOf("Card B")
        )

        coEvery { flashcardService.getDueCards(maxCount = 20L) } returns listOf(positionA, positionB)
        coEvery { flashcardService.getFlashcard(any()) } returns flashcard
        coEvery { flashcardService.countDueCards() } returns 2
        coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
            totalFlashcards = 2,
            totalPositions = 2,
            dueToday = 2,
            totalReviews = 0,
            averageEase = 2.5,
            typeBreakdown = emptyMap()
        )
        coEvery { repository.getNodeById("node-a") } returns nodeA
        coEvery { repository.getNodeById("node-b") } returns nodeB
        coEvery { reviewService.recordReview(any(), any(), any(), any(), any(), any()) } returns mockk()
        coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit

        val viewModel = createViewModel()
        viewModel.startSession()
        advanceUntilIdle()

        // Rating A with AGAIN re-queues it at the END: the very next card
        // must be B (the other card), never A again immediately.
        viewModel.rateCard(ReviewRating.AGAIN)
        advanceUntilIdle()

        viewModel.currentCard.value!!.nodeId shouldBe "node-b"

        // After B is rated, the re-queued A card comes up
        viewModel.rateCard(ReviewRating.GOOD)
        advanceUntilIdle()

        viewModel.currentCard.value!!.nodeId shouldBe "node-a"
    }

    @Test
    fun `rateCard clears the current card during transition and restores it on failure`() = runTest {
        val positionA = FlashcardPosition(
            nodeId = "node-a",
            positionName = "front",
            easeFactor = 2.5,
            box = 1,
            intervalDays = 1.0,
            dueDate = Instant.fromEpochSeconds(1640995200),
            reviewCount = 1
        )
        val flashcard = Flashcard(
            nodeId = "node-a",
            cardType = computer.whatthefuck.arcology.domain.FlashcardType.NORMAL,
            clozeType = null,
            createdAt = Instant.fromEpochSeconds(1640995200),
            isSuspended = false,
            reviewData = emptyMap()
        )
        val nodeA = OrgNode(
            id = "node-a",
            file = "deck/a.org",
            level = 1,
            position = 1,
            title = "Card A",
            properties = mapOf("FC_TYPE" to "normal"),
            outlinePath = listOf("Card A")
        )

        coEvery { flashcardService.getDueCards(maxCount = 20L) } returns listOf(positionA)
        coEvery { flashcardService.getFlashcard(any()) } returns flashcard
        coEvery { flashcardService.countDueCards() } returns 1
        coEvery { flashcardService.getStatistics() } returns FlashcardStatistics(
            totalFlashcards = 1,
            totalPositions = 1,
            dueToday = 1,
            totalReviews = 0,
            averageEase = 2.5,
            typeBreakdown = emptyMap()
        )
        coEvery { repository.getNodeById("node-a") } returns nodeA
        coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit

        // recordReview throws AFTER being invoked: the tap cleared the card
        // and the catch must restore it so the user can retry.
        coEvery { reviewService.recordReview(any(), any(), any(), any(), any(), any()) } throws RuntimeException("boom")

        val viewModel = createViewModel()
        viewModel.startSession()
        advanceUntilIdle()

        val shownCard = viewModel.currentCard.value!!
        viewModel.rateCard(ReviewRating.GOOD)
        // Cleared synchronously on entry — no double-tap can re-rate the
        // same card, and the UI's spinner branch renders.
        viewModel.currentCard.value shouldBe null
        advanceUntilIdle()

        // Failure restored the card for retry
        viewModel.currentCard.value shouldBe shownCard
        viewModel.errorMessage.value shouldNotBe null
    }

    @Test
    fun `failed flush keeps pending edit and later flush persists it`() = runTest {
        // F1 regression: with the pre-rewrite remove-then-write queue, an
        // EditResult error discarded the pending drawer edits silently.
        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 = "deck/a.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.getDueCards(maxCount = 100L) } returns emptyList()
        coEvery { flashcardService.getFlashcard(any()) } returns 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 { reviewService.recordReview(any(), any(), any(), any(), any(), any()) } answers {
            val sink = arg<((computer.whatthefuck.arcology.flashcard.ReviewData) -> Unit)?>(5)
            sink?.invoke(
                computer.whatthefuck.arcology.flashcard.ReviewData(
                    positionName = secondArg(),
                    easeFactor = 2.5,
                    box = 1,
                    intervalDays = 1.0,
                    dueDate = Instant.fromEpochSeconds(1700000000),
                    reviewCount = 2
                )
            )
            mockk()
        }
        coEvery { notificationManager.showDueCardsNotification(any()) } returns Unit

        val flushedFiles = mutableListOf<String>()
        val failFlushEditor = mutableListOf(true)
        val viewModel = createViewModel(
            flushedFiles = flushedFiles,
            failFlushEditor = failFlushEditor
        )
        viewModel.startSession()
        advanceUntilIdle()

        // Rate the card; the flush happens only at session end
        viewModel.rateCard(ReviewRating.GOOD)
        advanceUntilIdle()

        // endSession with a failing editor: nothing flushed yet
        viewModel.endSession()
        advanceUntilIdle()
        flushedFiles shouldBe emptyList()

        // Editor recovers; the STILL-PENDING edit is flushed by the
        // lifecycle-stop hook (F2) without re-rating the card.
        failFlushEditor[0] = false
        viewModel.flushPendingReviewData()
        advanceUntilIdle()

        flushedFiles shouldBe listOf("deck/a.org")
    }

    @Test
    fun `flushPendingReviewData is a no-op without pending edits`() = runTest {
        val flushedFiles = mutableListOf<String>()
        val viewModel = createViewModel(flushedFiles = flushedFiles)
        advanceUntilIdle()

        viewModel.flushPendingReviewData()
        advanceUntilIdle()

        flushedFiles shouldBe emptyList()
    }
}

Related Modules