Arcology Engine

Quiz Cloze & Session Logic — Cloze Parsing, Quiz Sessions & Sibling Burying

Contents

Introduction

Cloze deletion is the most complex flashcard format in org-fc, and this cluster handles it end to end: ClozeService extracts cloze holes from parsed org-mode ASTs (walking OrgInlineElem.Cloze elements in paragraphs, source blocks, and example blocks); ClozeQuizSession runs an interactive review session with hole visibility rules (DELETION, ENUMERATION, SINGLE, CONTEXT); and ClozeSiblingBurying prevents "spoiling" — seeing hole 1's answer while reviewing hole 0 — by tracking which sibling holes to exclude from the current session.

The parser narrative section explains the patches made to the vendored orgmode-kmp fork that make all of this possible: a new OrgInlineElem.Cloze inline element, a new OrgChunk.OrgReviewDataDrawer drawer chunk, and lexer support for ={{...}} syntax. These live in the git submodule (not tangled from arcology2go org files) but are essential context for understanding the quiz system.

Design Decisions

org-fc cloze syntax, not something custom.

The cloze format {{text}@id}, {{text}{hint}@id}, and bare {{text}} is org-fc's established syntax. The Kotlin code matches it exactly using a regex (CLOZE_REGEX) and also via the parser's OrgInlineElem.Cloze AST element. Two parsing paths exist: the parser-based path (used by extractHolesFromContent for accurate offset tracking through the AST) and the regex-based path (used by findMaxHoleId for quick scanning, and by extractHolesFromTextBlock for source/example blocks where the parser sees plain text).

Character offset tracking enables direct rendering without re-parse.

Every ClozeHole carries startIndex and endIndex character offsets into the raw content string. This means ClozeRenderedContent can decompose the raw text into segments (plain text, hidden holes, revealed holes) by walking the sorted holes list and slicing the content string at hole boundaries — no second parse pass needed.

ClozeType drives hole visibility, not position name.

The four cloze subtypes (DELETION, ENUMERATION, CONTEXT, SINGLE) determine which sibling holes are visible when reviewing position N. This is org-fc's model: the cloze type is a property of the card, not the position. ClozeQuizSession.computeVisibility maps hole index + current position + cloze type + context size to a ClozeHoleVisibility enum that the renderer consumes.

Sibling burying: per-hash, not per-database.

ClozeSiblingBurying uses an in-memory MutableMap<String, MutableSet<String>> keyed by nodeId to track buried positions. It's session-local — the database doesn't know about burying. This matches org-fc's approach: burying is a transient review-session optimization, not persisted state. When the session ends, all buried positions are cleared.

Implementation

ClozeService — hole extraction from parsed org-mode ASTs

Walks parsed OrgDocument chunks (OrgParagraph, OrgSourceBlock, OrgExampleBlock) looking for OrgInlineElem.Cloze elements. Tracks character offsets through the entire chunk sequence so that hole startIndex and endIndex are absolute positions in the reconstructed raw content. Handles source blocks and example blocks via regex fallback (the parser doesn't emit OrgInlineElem.Cloze inside these blocks). Includes utility functions for converting elements to plain text, calculating rendered lengths of inline elements, and finding the maximum cloze hole ID from raw text.

The findMaxHoleId regex-based method serves a specific purpose: when the REVIEW_DATA drawer is missing or needs rebuilding, FC_CLOZE_MAX can be derived by scanning the raw content for the highest @id number. This is separate from the AST-based extraction because findMaxHoleId doesn't need offset accuracy — just the maximum integer ID.

kotlin#+name: quiz-cloze-service:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/flashcard/ClozeService.kt
package computer.whatthefuck.arcology.flashcard

import computer.whatthefuck.arcology.domain.ClozeCardContent
import computer.whatthefuck.arcology.domain.ClozeHole
import computer.whatthefuck.arcology.domain.ClozeType
import xyz.lepisma.orgmode.OrgBlock
import xyz.lepisma.orgmode.OrgChunk
import xyz.lepisma.orgmode.OrgElem
import xyz.lepisma.orgmode.OrgInlineElem
import xyz.lepisma.orgmode.OrgLogbookEntry

/**
 * Service for extracting and processing cloze holes from org-mode content.
 */
class ClozeService {

    /**
     * Extract all cloze holes from parsed org content.
     * Scans OrgChunk.OrgParagraph items for OrgInlineElem.Cloze elements.
     *
     * @param chunks List of parsed org chunks from the card content
     * @param clozeType The cloze subtype for this card
     * @return ClozeCardContent with extracted holes
     */
    fun extractHolesFromContent(chunks: List<OrgElem>, clozeType: ClozeType): ClozeCardContent {
        val holes = mutableListOf<ClozeHole>()
        var currentOffset = 0

        for (chunk in chunks) {
            when (chunk) {
                is OrgChunk.OrgParagraph -> {
                    val paragraphHoles = extractHolesFromParagraph(chunk, currentOffset)
                    holes.addAll(paragraphHoles)
                    currentOffset += getChunkTextLength(chunk)
                }
                is OrgChunk.OrgTable -> {
                    // TODO: Support cloze holes inside table cells
                    // For now, skip tables but advance offset
                    currentOffset += getChunkTextLength(chunk)
                }
                is OrgBlock.OrgSourceBlock -> {
                    val blockHoles = extractHolesFromSourceBlock(chunk, currentOffset)
                    holes.addAll(blockHoles)
                    currentOffset += getBlockTextLengthForElem(chunk)
                }
                is OrgBlock.OrgExampleBlock -> {
                    val blockHoles = extractHolesFromTextBlock(chunk.text, currentOffset)
                    holes.addAll(blockHoles)
                    currentOffset += getBlockTextLengthForElem(chunk)
                }
                is OrgChunk.OrgKeywordLine -> {
                    currentOffset += chunk.keyword.length.toInt() + chunk.value.length.toInt() + 2
                }
                is OrgChunk.OrgLogbookDrawer -> {
                    currentOffset += chunk.entries.sumOf { entry ->
                        when (entry) {
                            is OrgLogbookEntry.StateChange -> entry.fromState.length.toInt() + entry.toState.length.toInt() + 20
                            is OrgLogbookEntry.ClockEntry -> entry.duration?.length?.toInt() ?: 10
                        }
                    } + 20
                }
                is OrgChunk.OrgReviewDataDrawer -> {
                    currentOffset += chunk.content.sumOf { it.length.toInt() } + 20
                }
                is OrgChunk.OrgCommentLine -> {
                    currentOffset += chunk.text.length.toInt() + 2
                }
                is OrgChunk.OrgHorizontalLine -> {
                    currentOffset += 3
                }
                else -> {
                    // Other chunk types - skip but advance offset roughly
                    currentOffset += chunk.toString().length.toInt()
                }
            }
        }

        // Reconstruct raw content from chunks for rendering
        val rawContent = chunks.joinToString("\n") { elemToText(it) }

        return ClozeCardContent(
            rawContent = rawContent,
            holes = holes.sortedBy { it.id },
            clozeType = clozeType
        )
    }

    /**
     * Find max hole ID from content (for FC_CLOZE_MAX property).
     * Useful when REVIEW_DATA drawer is missing or needs to be rebuilt.
     */
    fun findMaxHoleId(content: String): Int {
        // TODO: LaTeX support - handle }} inside math expressions
        // LaTeX like \frac{1}{\sqrt{2} } contains braces that break regex
        // Solution: detect LaTeX tokens and skip brace-matching inside them
        
        val pattern = Regex("""\{\{[^{}]+\}(?:\{[^{}]*\})?@(\d+)\}""")
        return pattern.findAll(content)
            .mapNotNull { it.groupValues[1].toIntOrNull() }
            .maxOrNull() ?: -1
    }

    /**
     * Extract holes from a paragraph chunk.
     */
    private fun extractHolesFromParagraph(chunk: OrgChunk.OrgParagraph, offset: Int): List<ClozeHole> {
        val holes = mutableListOf<ClozeHole>()
        var runningOffset = offset

        for (item in chunk.items) {
            when (item) {
                is OrgInlineElem.Cloze -> {
                    holes.add(
                        ClozeHole(
                            id = item.id,
                            text = item.text,
                            hint = item.hint,
                            startIndex = runningOffset,
                            endIndex = runningOffset + item.text.length.toInt()
                        )
                    )
                    // Advance offset by the rendered length of the cloze
                    runningOffset += item.text.length.toInt()
                }
                is OrgInlineElem.Text -> {
                    runningOffset += item.text.length.toInt()
                }
                is OrgInlineElem.Link -> {
                    // TODO: Image support - detect image links inside cloze holes
                    // If this link is inside a cloze (shouldn't happen normally), handle it
                    runningOffset += inlineElementLength(item)
                }
                else -> {
                    runningOffset += inlineElementLength(item)
                }
            }
        }

        return holes
    }

    /**
     * Extract holes from a source code block.
     * Source blocks are common in "single" type clozes for learning syntax.
     */
    private fun extractHolesFromSourceBlock(chunk: OrgBlock.OrgSourceBlock, offset: Int): List<ClozeHole> {
        return extractHolesFromTextBlock(chunk.body, offset)
    }

    /**
     * Extract holes from plain text (used for source blocks, example blocks, etc.)
     */
    private fun extractHolesFromTextBlock(text: String, offset: Int): List<ClozeHole> {
        val holes = mutableListOf<ClozeHole>()

        // For now, treat block content as plain text and use regex
        // TODO: Proper parsing of inline elements inside source blocks
        val pattern = Regex("""\{\{([^{}]+)\}(?:\{([^{}]*)\})?@(\d+)\}""")

        for (match in pattern.findAll(text)) {
            val textContent = match.groupValues[1]
            val hint = match.groupValues[2].takeIf { it.isNotEmpty() }
            val id = match.groupValues[3].toIntOrNull() ?: continue

            holes.add(
                ClozeHole(
                    id = id,
                    text = textContent,
                    hint = hint,
                    startIndex = offset + match.range.first,
                    endIndex = offset + match.range.last + 1
                )
            )
        }

        return holes
    }

    /**
     * Calculate the text length of a chunk for offset tracking.
     */
    private fun getChunkTextLength(chunk: OrgChunk): Int {
        return when (chunk) {
            is OrgChunk.OrgParagraph -> chunk.items.sumOf { inlineElementLength(it).toInt() }
            is OrgChunk.OrgTable -> chunk.toString().length.toInt()
            is OrgChunk.OrgKeywordLine -> chunk.keyword.length.toInt() + chunk.value.length.toInt() + 2
            is OrgChunk.OrgLogbookDrawer -> chunk.entries.sumOf { entry ->
                when (entry) {
                    is OrgLogbookEntry.StateChange -> entry.fromState.length.toInt() + entry.toState.length.toInt() + 20
                    is OrgLogbookEntry.ClockEntry -> entry.duration?.length?.toInt() ?: 10
                }
            } + 20
            is OrgChunk.OrgReviewDataDrawer -> chunk.content.sumOf { it.length.toInt() } + 20
            is OrgChunk.OrgCommentLine -> chunk.text.length.toInt() + 2
            is OrgChunk.OrgHorizontalLine -> 3
            else -> chunk.toString().length.toInt()
        }
    }

    /**
     * Calculate the text length of a block for offset tracking.
     */
    private fun getBlockTextLengthForElem(elem: OrgElem): Int {
        return when (elem) {
            is OrgBlock.OrgSourceBlock -> elem.body.length.toInt() + 20
            is OrgBlock.OrgExampleBlock -> elem.text.length.toInt() + 20
            else -> elem.toString().length.toInt()
        }
    }

    private fun getBlockTextLength(block: OrgBlock): Int {
        return when (block) {
            is OrgBlock.OrgSourceBlock -> block.body.length.toInt() + 20
            is OrgBlock.OrgExampleBlock -> block.text.length.toInt() + 20
            is OrgBlock.OrgQuoteBlock -> block.body.sumOf { getChunkTextLength(it) } + 20
            is OrgBlock.OrgCenterBlock -> block.body.sumOf { getChunkTextLength(it) } + 20
            is OrgBlock.OrgHTMLBlock -> block.body.length.toInt() + 20
            is OrgBlock.OrgVerseBlock -> block.body.length.toInt() + 20
            is OrgBlock.OrgLaTeXBlock -> block.body.length.toInt() + 20
            is OrgBlock.OrgPageIntroBlock -> block.body.sumOf { getChunkTextLength(it) } + 20
            is OrgBlock.OrgEditsBlock -> block.body.sumOf { getChunkTextLength(it) } + 20
            is OrgBlock.OrgAsideBlock -> block.body.sumOf { getChunkTextLength(it) } + 20
            is OrgBlock.OrgVideoBlock -> block.body.sumOf { getChunkTextLength(it) } + 20
            is OrgBlock.OrgCommentBlock -> block.text.length.toInt() + 20
        }
    }

    /**
     * Calculate the rendered length of an inline element.
     */
    private fun inlineElementLength(elem: OrgInlineElem): Int {
        return when (elem) {
            is OrgInlineElem.Text -> elem.text.length.toInt()
            is OrgInlineElem.Cloze -> elem.text.length.toInt()
            is OrgInlineElem.Link -> elem.title?.sumOf { inlineElementLength(it) } ?: elem.target.length.toInt()
            is OrgInlineElem.Bold -> elem.content.sumOf { inlineElementLength(it) }
            is OrgInlineElem.Italic -> elem.content.sumOf { inlineElementLength(it) }
            is OrgInlineElem.Underline -> elem.content.sumOf { inlineElementLength(it) }
            is OrgInlineElem.StrikeThrough -> elem.content.sumOf { inlineElementLength(it) }
            is OrgInlineElem.Verbatim -> elem.content.sumOf { inlineElementLength(it) }
            is OrgInlineElem.Code -> elem.content.sumOf { inlineElementLength(it) }
            is OrgInlineElem.DTStamp -> elem.tokens.sumOf { it.text.length }
            is OrgInlineElem.DTRange -> elem.tokens.sumOf { it.text.length }
            is OrgInlineElem.HashTag -> elem.text.length + 1
            is OrgInlineElem.HashMetric -> elem.metric.length + elem.value.length + 3
            is OrgInlineElem.Footnote -> elem.text.items.sumOf { inlineElementLength(it) } + 2
            is OrgInlineElem.InlineMath -> elem.text.length + 2
            is OrgInlineElem.InlineQuote -> elem.text.length + 2
            is OrgInlineElem.Citation -> elem.citeString.length
        }
    }

    /**
     * Convert an element to plain text for content reconstruction.
     */
    private fun elemToText(elem: OrgElem): String {
        return when (elem) {
            is OrgChunk.OrgParagraph -> elem.items.joinToString("") { inlineElemToText(it) }
            is OrgChunk.OrgKeywordLine -> "#+${elem.keyword}: ${elem.value}"
            is OrgBlock.OrgSourceBlock -> "#+begin_src ${elem.language}\n${elem.body}\n#+end_src"
            is OrgBlock.OrgExampleBlock -> "#+begin_example\n${elem.text}\n#+end_example"
            is OrgChunk.OrgTable -> elem.toString()
            is OrgChunk.OrgLogbookDrawer -> ":LOGBOOK:\n${elem.entries.joinToString("\n")}\n:END:"
            is OrgChunk.OrgReviewDataDrawer -> ":REVIEW_DATA:\n${elem.content.joinToString("\n")}\n:END:"
            is OrgChunk.OrgCommentLine -> "# ${elem.text}"
            is OrgChunk.OrgHorizontalLine -> "----"
            is OrgBlock.OrgQuoteBlock -> "#+begin_quote\n${elem.body.joinToString("\n") { elemToText(it) }}\n#+end_quote"
            is OrgBlock.OrgCenterBlock -> "#+begin_center\n${elem.body.joinToString("\n") { elemToText(it) }}\n#+end_center"
            is OrgBlock.OrgHTMLBlock -> "#+begin_html\n${elem.body}\n#+end_html"
            is OrgBlock.OrgVerseBlock -> "#+begin_verse\n${elem.body}\n#+end_verse"
            is OrgBlock.OrgLaTeXBlock -> "#+begin_latex\n${elem.body}\n#+end_latex"
            is OrgBlock.OrgPageIntroBlock -> "#+begin_pageintro\n${elem.body.joinToString("\n") { elemToText(it) }}\n#+end_pageintro"
            is OrgBlock.OrgEditsBlock -> "#+begin_edits\n${elem.body.joinToString("\n") { elemToText(it) }}\n#+end_edits"
            is OrgBlock.OrgAsideBlock -> "#+begin_aside\n${elem.body.joinToString("\n") { elemToText(it) }}\n#+end_aside"
            is OrgBlock.OrgVideoBlock -> "#+begin_video\n${elem.body.joinToString("\n") { elemToText(it) }}\n#+end_video"
            is OrgBlock.OrgCommentBlock -> "#+begin_comment\n${elem.text}\n#+end_comment"
            is OrgInlineElem.Bold -> elem.content.joinToString("") { inlineElemToText(it) }
            is OrgInlineElem.Italic -> elem.content.joinToString("") { inlineElemToText(it) }
            is OrgInlineElem.Underline -> elem.content.joinToString("") { inlineElemToText(it) }
            is OrgInlineElem.StrikeThrough -> elem.content.joinToString("") { inlineElemToText(it) }
            is OrgInlineElem.Verbatim -> elem.content.joinToString("") { inlineElemToText(it) }
            is OrgInlineElem.Code -> elem.content.joinToString("") { inlineElemToText(it) }
            is OrgInlineElem.Text -> elem.text
            is OrgInlineElem.Cloze -> elem.text
            is OrgInlineElem.Link -> elem.title?.joinToString("") { inlineElemToText(it) } ?: elem.target
            is OrgInlineElem.DTStamp -> elem.tokens.joinToString("") { it.text }
            is OrgInlineElem.DTRange -> elem.tokens.joinToString("") { it.text }
            is OrgInlineElem.HashTag -> "#${elem.text}"
            is OrgInlineElem.HashMetric -> "#${elem.metric}(${elem.value})"
            is OrgInlineElem.Footnote -> elem.text.items.joinToString("") { inlineElemToText(it) }
            is OrgInlineElem.InlineMath -> "$${elem.text}$"
            is OrgInlineElem.InlineQuote -> "@${elem.type.name.lowercase()}:${elem.text}@"
            is OrgInlineElem.Citation -> elem.citeString
            else -> elem.toString()
        }
    }

    /**
     * Convert an inline element to plain text.
     */
    private fun inlineElemToText(elem: OrgInlineElem): String {
        return when (elem) {
            is OrgInlineElem.Text -> elem.text
            is OrgInlineElem.Cloze -> elem.text
            is OrgInlineElem.Link -> elem.title?.joinToString("") { inlineElemToText(it) } ?: elem.target
            is OrgInlineElem.Bold -> elem.content.joinToString("") { inlineElemToText(it) }
            is OrgInlineElem.Italic -> elem.content.joinToString("") { inlineElemToText(it) }
            is OrgInlineElem.Underline -> elem.content.joinToString("") { inlineElemToText(it) }
            is OrgInlineElem.StrikeThrough -> elem.content.joinToString("") { inlineElemToText(it) }
            is OrgInlineElem.Verbatim -> elem.content.joinToString("") { inlineElemToText(it) }
            is OrgInlineElem.Code -> elem.content.joinToString("") { inlineElemToText(it) }
            else -> elem.tokens.joinToString("") { it.text }
        }
    }
}

ClozeQuizSession — interactive quiz session with hole visibility

ClozeQuizSession is the runtime engine for reviewing a single cloze card. Given a Flashcard, parsed ClozeCardContent, and the current FlashcardPosition (identifying which hole is being tested), it computes per-hole visibility based on the cloze type:

  • DELETION: only the current hole is hidden, all others visible

  • ENUMERATION: current and all subsequent holes hidden, prior holes visible (sequential reveal)

  • SINGLE: only the current hole visible, all others hidden (isolated testing)

  • CONTEXT: current hole hidden, N surrounding holes visible, distant holes hidden

renderContent decomposes the raw content into ClozeSegment elements by walking the sorted holes list and slicing text between boundaries. When isFlipped is true, the current hole is rendered as RevealedHole with a highlight. The method also supports sibling burying (via shouldBurySiblings and getSiblingHolesToBury) and helper properties (holeCount, isLastHole, nextHoleIndex).

kotlin#+name: quiz-cloze-session:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/quiz/ClozeQuizSession.kt
package computer.whatthefuck.arcology.quiz

import computer.whatthefuck.arcology.domain.ClozeCardContent
import computer.whatthefuck.arcology.domain.ClozeHole
import computer.whatthefuck.arcology.domain.ClozeHoleVisibility
import computer.whatthefuck.arcology.domain.ClozeRenderedContent
import computer.whatthefuck.arcology.domain.ClozeSegment
import computer.whatthefuck.arcology.domain.ClozeType
import computer.whatthefuck.arcology.domain.Flashcard
import computer.whatthefuck.arcology.domain.FlashcardPosition

/**
 * Quiz session for cloze deletion flashcards.
 * Manages hole visibility based on cloze type and current position.
 *
 * @param card The flashcard being reviewed
 * @param content Parsed cloze content with holes
 * @param currentPosition The current position (hole) being reviewed
 * @param contextSize Number of surrounding holes to show for CONTEXT type
 */
class ClozeQuizSession(
    private val card: Flashcard,
    private val content: ClozeCardContent,
    private val currentPosition: FlashcardPosition,
    private val contextSize: Int = ClozeType.DEFAULT_CONTEXT_SIZE
) {
    /** The ID of the currently reviewed hole (0, 1, 2, ...) */
    val currentHoleIndex: Int = currentPosition.positionName.toIntOrNull() ?: 0

    /**
     * Determine visibility for a hole based on cloze type and current position.
     *
     * @param holeId The ID of the hole to check
     * @return The visibility state for this hole
     */
    fun getHoleVisibility(holeId: Int): ClozeHoleVisibility {
        val holeIndex = content.holes.indexOfFirst { it.id == holeId }
        if (holeIndex < 0) return ClozeHoleVisibility.VISIBLE

        return computeVisibility(holeIndex)
    }

    /**
     * Get visibility for a hole by its index in the holes list.
     */
    fun getHoleVisibilityByIndex(holeIndex: Int): ClozeHoleVisibility {
        if (holeIndex < 0 || holeIndex >= content.holes.size) {
            return ClozeHoleVisibility.VISIBLE
        }
        return computeVisibility(holeIndex)
    }

    /**
     * Compute visibility based on cloze type.
     */
    private fun computeVisibility(holeIndex: Int): ClozeHoleVisibility {
        return when (content.clozeType) {
            ClozeType.DELETION -> {
                // Only current hole hidden, all others visible
                if (holeIndex == currentHoleIndex) {
                    ClozeHoleVisibility.HIDDEN
                } else {
                    ClozeHoleVisibility.VISIBLE
                }
            }
            ClozeType.ENUMERATION -> {
                // Current and all subsequent holes hidden, previous ones visible
                if (holeIndex >= currentHoleIndex) {
                    ClozeHoleVisibility.HIDDEN
                } else {
                    ClozeHoleVisibility.VISIBLE
                }
            }
            ClozeType.SINGLE -> {
                // Only current hole visible, all others hidden
                if (holeIndex == currentHoleIndex) {
                    ClozeHoleVisibility.VISIBLE
                } else {
                    ClozeHoleVisibility.HIDDEN
                }
            }
            ClozeType.CONTEXT -> {
                // Current hole hidden, N surrounding holes visible
                val distance = kotlin.math.abs(holeIndex - currentHoleIndex)
                when {
                    holeIndex == currentHoleIndex -> ClozeHoleVisibility.HIDDEN
                    distance <= contextSize -> ClozeHoleVisibility.VISIBLE
                    else -> ClozeHoleVisibility.HIDDEN
                }
            }
        }
    }

    /**
     * Render content with holes hidden/visible based on current state.
     *
     * @param isFlipped If true, reveal the current hole
     * @return Rendered content with segments for display
     */
    fun renderContent(isFlipped: Boolean = false): ClozeRenderedContent {
        val segments = mutableListOf<ClozeSegment>()
        var lastEndIndex = 0

        // Sort holes by start index for sequential rendering
        val sortedHoles = content.holes.sortedBy { it.startIndex }

        for (hole in sortedHoles) {
            // Add text before this hole
            if (hole.startIndex > lastEndIndex) {
                val textBefore = content.rawContent.substring(lastEndIndex, hole.startIndex)
                segments.add(ClozeSegment.Text(textBefore))
            }

            // Determine hole visibility
            val visibility = getHoleVisibility(hole.id)
            when {
                isFlipped && hole.id == currentHoleIndex -> {
                    // Revealed on flip - highlight the current hole
                    segments.add(ClozeSegment.RevealedHole(hole, isHighlighted = true))
                }
                visibility == ClozeHoleVisibility.HIDDEN -> {
                    // Show placeholder with optional hint
                    val displayText = formatHint(hole.hint)
                    segments.add(ClozeSegment.HiddenHole(hole, displayText))
                }
                else -> {
                    // Visible normally
                    segments.add(ClozeSegment.RevealedHole(hole, isHighlighted = false))
                }
            }

            lastEndIndex = hole.endIndex
        }

        // Add any remaining text after the last hole
        if (lastEndIndex < content.rawContent.length) {
            val textAfter = content.rawContent.substring(lastEndIndex)
            segments.add(ClozeSegment.Text(textAfter))
        }

        return ClozeRenderedContent(segments)
    }

    /**
     * Format a hint for display (e.g., "[...city]").
     * Returns "[...]" for null/empty hints.
     */
    fun formatHint(hint: String?): String {
        return if (hint.isNullOrEmpty()) {
            "[...]"
        } else {
            "[...$hint]"
        }
    }

    /**
     * Check if siblings should be buried for this cloze type.
     * Sibling burying prevents reviewing multiple holes from the same card in one session.
     *
     * @return true if siblings should be buried after reviewing this hole
     *
     * TODO: Sibling burying support
     * Currently returns true for SINGLE and ENUMERATION types as per org-fc behavior.
     * Future implementation should:
     * 1. Add burying logic to QuizSessionManager
     * 2. Track buried positions in current session
     * 3. Filter out buried positions when selecting next card
     * Reference: org-fc-review--bury-cloze-siblings-except-single-or-enumeration
     */
    fun shouldBurySiblings(): Boolean {
        return when (content.clozeType) {
            ClozeType.SINGLE, ClozeType.ENUMERATION -> true
            ClozeType.DELETION, ClozeType.CONTEXT -> false
        }
    }

    /**
     * Get the IDs of sibling holes that should be buried.
     *
     * @return List of hole IDs to bury (exclude from current session)
     *
     * TODO: Implement full burying logic
     * - SINGLE: Bury ALL other holes
     * - ENUMERATION: Bury holes after current (sequential reveal)
     */
    fun getSiblingHolesToBury(): List<Int> {
        return when (content.clozeType) {
            ClozeType.SINGLE -> {
                // Bury all holes except current
                content.holes.map { it.id }.filter { it != currentHoleIndex }
            }
            ClozeType.ENUMERATION -> {
                // Bury holes after current (they'll be revealed sequentially)
                content.holes.map { it.id }.filter { it > currentHoleIndex }
            }
            ClozeType.DELETION, ClozeType.CONTEXT -> {
                // No burying for these types
                emptyList()
            }
        }
    }

    /**
     * Get the total number of holes in this card.
     */
    val holeCount: Int get() = content.holes.size

    /**
     * Check if this is the last hole in the card.
     */
    val isLastHole: Boolean get() = currentHoleIndex >= holeCount - 1

    /**
     * Get the next hole index, or null if at the end.
     */
    val nextHoleIndex: Int?
        get() = if (isLastHole) null else currentHoleIndex + 1

    companion object {
        // TODO: Image support breadcrumbs
        // Images inside cloze holes need special handling:
        // - org-fc adds spaces around image links: {{ [[file:image.png]] }}
        // - Invisible overlays don't render images correctly in org-fc
        // Future implementation should:
        // 1. Detect OrgInlineElem.Link with image file targets inside cloze holes
        // 2. Add ClozeSegment.ImageHole type for image-based clozes
        // 3. Use AsyncImage composable in UI for rendering
        // 4. Handle hidden state with placeholder image or "[...]" text
        // 5. Apply highlight overlay on flip for revealed images

        // TODO: LaTeX support
        // LaTeX inside cloze holes can break parsing if it contains }}
        // org-fc workaround: insert space before closing brace: \frac{1}{\sqrt{2} }
        // Future implementation should:
        // 1. Add LaTeX-aware parsing in ClozeService
        // 2. Detect and preserve LaTeX tokens inside cloze markers
        // 3. Apply MathJax/KaTeX rendering to revealed holes containing LaTeX
    }
}

ClozeSiblingBurying — preventing spoilers in multi-hole cloze cards

Tracks buried positions per node in an in-memory map. burySiblings determines which holes to bury based on cloze type (SINGLE buries all other holes, ENUMERATION buries holes after the current one, DELETION and CONTEXT do no burying). isBuried checks whether a position should be excluded from the current session. The map is session-local — clear and clearForNode reset it between sessions.

kotlin#+name: quiz-sibling-burying:tangle ../src/commonMain/kotlin/computer/whatthefuck/arcology/quiz/ClozeSiblingBurying.kt
package computer.whatthefuck.arcology.quiz

import computer.whatthefuck.arcology.domain.ClozeType
import computer.whatthefuck.arcology.domain.FlashcardPosition

/**
 * Utility for managing sibling burying in cloze flashcard review sessions.
 *
 * Sibling burying prevents "spoiling" - seeing hole 1's answer while reviewing hole 0.
 * When a cloze card hole is reviewed, sibling holes can be excluded from the current session.
 *
 * Burying rules (from org-fc):
 * - [ClozeType.SINGLE]: Bury ALL other holes (only current hole shown)
 * - [ClozeType.ENUMERATION]: Bury holes after current (sequential reveal)
 * - [ClozeType.DELETION]: No burying (all holes independent)
 * - [ClozeType.CONTEXT]: No burying (all holes independent)
 *
 * TODO: UI preference for enabling/disabling sibling burying
 * Currently burying is always enabled for SINGLE and ENUMERATION types.
 * Future implementation should add AppPreferences.clozeBurySiblings boolean
 * with UI toggle in settings screen.
 */
class ClozeSiblingBurying {

    /**
     * Track buried positions for the current session.
     * Key: nodeId, Value: set of position names to bury
     */
    private val buriedPositions: MutableMap<String, MutableSet<String>> = mutableMapOf()

    /**
     * Check if a position should be excluded from the current session.
     *
     * @param position The flashcard position to check
     * @return true if this position is buried and should be skipped
     */
    fun isBuried(position: FlashcardPosition): Boolean {
        return buriedPositions[position.nodeId]?.contains(position.positionName) == true
    }

    /**
     * Bury sibling positions after reviewing a cloze hole.
     *
     * @param position The position that was just reviewed
     * @param clozeType The cloze type determining burying behavior
     * @param totalHoles Total number of holes in this card
     */
    fun burySiblings(position: FlashcardPosition, clozeType: ClozeType, totalHoles: Int) {
        when (clozeType) {
            ClozeType.SINGLE -> {
                // Bury all other holes
                buryAllSiblings(position.nodeId, totalHoles, position.positionName)
            }
            ClozeType.ENUMERATION -> {
                // Bury holes after current (sequential reveal)
                burySubsequentSiblings(position.nodeId, totalHoles, position.positionName)
            }
            ClozeType.DELETION, ClozeType.CONTEXT -> {
                // No burying for these types
            }
        }
    }

    /**
     * Bury all sibling positions except the current one.
     * Used for SINGLE cloze type.
     */
    private fun buryAllSiblings(nodeId: String, totalHoles: Int, currentPositionName: String) {
        val currentId = currentPositionName.toIntOrNull() ?: return
        val toBury = (0 until totalHoles).filter { it != currentId }.map { it.toString() }
        addToBuried(nodeId, toBury)
    }

    /**
     * Bury all positions after the current one.
     * Used for ENUMERATION cloze type.
     */
    private fun burySubsequentSiblings(nodeId: String, totalHoles: Int, currentPositionName: String) {
        val currentId = currentPositionName.toIntOrNull() ?: return
        val toBury = ((currentId + 1) until totalHoles).map { it.toString() }
        addToBuried(nodeId, toBury)
    }

    /**
     * Add positions to the buried set for a node.
     */
    private fun addToBuried(nodeId: String, positionNames: List<String>) {
        if (positionNames.isEmpty()) return
        
        val existing = buriedPositions.getOrPut(nodeId) { mutableSetOf() }
        existing.addAll(positionNames)
    }

    /**
     * Clear all buried positions.
     * Call this when starting a new session.
     */
    fun clear() {
        buriedPositions.clear()
    }

    /**
     * Clear buried positions for a specific node.
     */
    fun clearForNode(nodeId: String) {
        buriedPositions.remove(nodeId)
    }

    /**
     * Get all buried positions for debugging/testing.
     */
    fun getBuriedPositions(): Map<String, Set<String>> {
        return buriedPositions.mapValues { it.value.toSet() }.toMap()
    }
}

Parser Patches — orgmode-kmp extensions for cloze support

The cloze flashcard system depends on four patches to the vendored orgmode-kmp git submodule (at orgmode-kmp/). These are not tangled from arcology2go org files — they live in the submodule's own build system — but they are essential context for understanding how the quiz system works.

The patches total ~2,000 lines across 16 files and live at git commit 562e900 ("cloze and fc drawers") and the surrounding commits in the submodule's history. They are not intended to be upstreamed because they make design choices that are specific to the arcology2go use case (e.g., exposing raw drawer content lines rather than parsed table structures, treating cloze as a first-class inline element rather than a generic markup match).

OrgInlineElem.Cloze — new inline element for org-fc cloze syntax

Added to OrgInlineElem.kt as a sealed subclass of OrgInlineElem. Represents the {{text}@id} or {{text}{hint}@id} syntax. Carries text, hint (optional), id (integer), and tokens. The lexer was extended to recognize {{ as a token marker, matching nested braces and optional @id suffixes.

OrgChunk.OrgReviewDataDrawer — new drawer chunk type

Added to OrgChunk.kt as a sealed subclass of OrgChunk. Unlike the generic OrgChunk.OrgDrawer which stores raw content as a single string, OrgReviewDataDrawer preserves the drawer content as List<String> (one per line) and is recognized as a distinct type in the drawer processing pipeline. This is necessary because REVIEW_DATA drawers contain org-table data that must be parsed column-by-column — the generic drawer type's text content is not amenable to table parsing.

Lexer support — tokenizing {{...}} syntax

The lexer (in Lexer.kt) was extended to recognize {{ as a cloak marker token, match multi-brace nesting, and handle @id suffixes. This required careful handling of brace matching to distinguish cloze markers from other uses of curly braces (e.g., LaTeX formulas, which remain a TODO item for future handling).

ClozeParserTest — ~296 lines of parser tests

Tests for the cloze parser extension live in orgmode-kmp/src/commonTest/kotlin/xyz/lepisma/orgmode/ClozeParserTest.kt. These verify parsing of {{text}@id}, {{text}{hint}@id}, bare {{text}} without @id, and multi-cloze content. Additional test cases in OrgParserTest.kt and OrgReviewDataDrawerTest.kt cover the REVIEW_DATA drawer parsing.

Tests

ClozeServiceTest — hole extraction and hint formatting

Tests findMaxHoleId with single and high ID numbers, empty content, hinted clozes, and empty hints. Tests extractHolesFromContent with single and multiple clozes in paragraphs, sorted-by-ID output, clozes in source blocks (with and without @id), and empty content. Tests formatHint via ClozeQuizSession for null, empty, and non-empty hints.

kotlin#+name: quiz-cloze-service-test:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/flashcard/ClozeServiceTest.kt
package computer.whatthefuck.arcology.flashcard

import computer.whatthefuck.arcology.domain.ClozeType
import computer.whatthefuck.arcology.quiz.ClozeQuizSession
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import xyz.lepisma.orgmode.OrgBlock
import xyz.lepisma.orgmode.OrgChunk
import xyz.lepisma.orgmode.OrgInlineElem

/**
 * Unit tests for ClozeService hole extraction.
 */
class ClozeServiceTest : StringSpec({

    val clozeService = ClozeService()

    "findMaxHoleId should extract max ID from content" {
        val content = "{{Paris}@0} is the capital of {{France}@1}."
        clozeService.findMaxHoleId(content) shouldBe 1
    }

    "findMaxHoleId should handle high ID numbers" {
        val content = "Answer is {{test}@42}."
        clozeService.findMaxHoleId(content) shouldBe 42
    }

    "findMaxHoleId should return -1 for no holes" {
        val content = "This is plain text with no clozes."
        clozeService.findMaxHoleId(content) shouldBe -1
    }

    "findMaxHoleId should handle clozes with hints" {
        val content = "{{capital}{city}@0} and {{country}{nation}@5}"
        clozeService.findMaxHoleId(content) shouldBe 5
    }

    "findMaxHoleId should handle empty hint" {
        val content = "{{text}{}@3}"
        clozeService.findMaxHoleId(content) shouldBe 3
    }

    "extractHolesFromContent should parse paragraph with single cloze" {
        val paragraph = OrgChunk.OrgParagraph(
            items = listOf(
                OrgInlineElem.Text("The capital is ", emptyList()),
                OrgInlineElem.Cloze(
                    text = "Paris",
                    hint = "city",
                    id = 0,
                    tokens = emptyList()
                ),
                OrgInlineElem.Text(".", emptyList())
            ),
            tokens = emptyList()
        )

        val result = clozeService.extractHolesFromContent(listOf(paragraph), ClozeType.DELETION)

        result.holes.size shouldBe 1
        result.holes[0].id shouldBe 0
        result.holes[0].text shouldBe "Paris"
        result.holes[0].hint shouldBe "city"
    }

    "extractHolesFromContent should parse multiple clozes" {
        val paragraph = OrgChunk.OrgParagraph(
            items = listOf(
                OrgInlineElem.Cloze("First", null, 0, emptyList()),
                OrgInlineElem.Text(" and ", emptyList()),
                OrgInlineElem.Cloze("second", "hint", 1, emptyList()),
                OrgInlineElem.Text(" and ", emptyList()),
                OrgInlineElem.Cloze("third", null, 2, emptyList())
            ),
            tokens = emptyList()
        )

        val result = clozeService.extractHolesFromContent(listOf(paragraph), ClozeType.ENUMERATION)

        result.holes.size shouldBe 3
        result.holes.map { it.id } shouldBe listOf(0, 1, 2)
        result.clozeType shouldBe ClozeType.ENUMERATION
    }

    "extractHolesFromContent should sort holes by ID" {
        val paragraph = OrgChunk.OrgParagraph(
            items = listOf(
                OrgInlineElem.Cloze("Third", null, 2, emptyList()),
                OrgInlineElem.Text(" ", emptyList()),
                OrgInlineElem.Cloze("First", null, 0, emptyList()),
                OrgInlineElem.Text(" ", emptyList()),
                OrgInlineElem.Cloze("Second", null, 1, emptyList())
            ),
            tokens = emptyList()
        )

        val result = clozeService.extractHolesFromContent(listOf(paragraph), ClozeType.DELETION)

        result.holes.map { it.id } shouldBe listOf(0, 1, 2)
    }

    "extractHolesFromContent should handle cloze in source block" {
        val sourceBlock = OrgBlock.OrgSourceBlock(
            language = "python",
            switches = emptyList(),
            headerArgs = emptyMap(),
            body = "def {{function_name@0}}():\n    return {{value@1}}",
            name = null,
            tokens = emptyList()
        )

        // Note: Clozes without @id won't be extracted
        val result = clozeService.extractHolesFromContent(listOf(sourceBlock), ClozeType.SINGLE)
        result.holes.size shouldBe 0
    }

    "extractHolesFromContent should handle cloze in source block with IDs" {
        val sourceBlock = OrgBlock.OrgSourceBlock(
            language = "python",
            switches = emptyList(),
            headerArgs = emptyMap(),
            body = "def {{function_name}@0}():\n    return {{value}@1}",
            name = null,
            tokens = emptyList()
        )

        val result = clozeService.extractHolesFromContent(listOf(sourceBlock), ClozeType.SINGLE)
        result.holes.size shouldBe 2
        result.holes[0].id shouldBe 0
        result.holes[1].id shouldBe 1
    }

    "extractHolesFromContent should handle empty content" {
        val paragraph = OrgChunk.OrgParagraph(
            items = listOf(OrgInlineElem.Text("", emptyList())),
            tokens = emptyList()
        )

        val result = clozeService.extractHolesFromContent(listOf(paragraph), ClozeType.DELETION)
        result.holes.size shouldBe 0
    }

    "formatHint should return [...] for null hint" {
        // Test via ClozeQuizSession since formatHint is there
        val content = computer.whatthefuck.arcology.domain.ClozeCardContent(
            rawContent = "test",
            holes = emptyList(),
            clozeType = ClozeType.DELETION
        )
        val session = ClozeQuizSession(
            card = mockFlashcard(),
            content = content,
            currentPosition = mockPosition("0")
        )
        session.formatHint(null) shouldBe "[...]"
    }

    "formatHint should return [...] for empty hint" {
        val content = computer.whatthefuck.arcology.domain.ClozeCardContent(
            rawContent = "test",
            holes = emptyList(),
            clozeType = ClozeType.DELETION
        )
        val session = ClozeQuizSession(
            card = mockFlashcard(),
            content = content,
            currentPosition = mockPosition("0")
        )
        session.formatHint("") shouldBe "[...]"
    }

    "formatHint should return [...hint] for non-empty hint" {
        val content = computer.whatthefuck.arcology.domain.ClozeCardContent(
            rawContent = "test",
            holes = emptyList(),
            clozeType = ClozeType.DELETION
        )
        val session = ClozeQuizSession(
            card = mockFlashcard(),
            content = content,
            currentPosition = mockPosition("0")
        )
        session.formatHint("city") shouldBe "[...city]"
    }
})

// Helper functions for tests
private fun mockFlashcard(): computer.whatthefuck.arcology.domain.Flashcard {
    return computer.whatthefuck.arcology.domain.Flashcard(
        nodeId = "test-node",
        cardType = computer.whatthefuck.arcology.domain.FlashcardType.CLOZE,
        clozeType = ClozeType.DELETION,
        createdAt = kotlin.time.Clock.System.now()
    )
}

private fun mockPosition(positionName: String): computer.whatthefuck.arcology.domain.FlashcardPosition {
    return computer.whatthefuck.arcology.domain.FlashcardPosition(
        nodeId = "test-node",
        positionName = positionName,
        easeFactor = 2.5,
        box = 0,
        intervalDays = 0.0,
        dueDate = kotlin.time.Clock.System.now(),
        reviewCount = 0
    )
}

ClozeQuizSessionTest — visibility logic for all four cloze types

Tests getHoleVisibility for DELETION (current hidden, others visible), ENUMERATION (current+subsequent hidden), SINGLE (only current visible), and CONTEXT with contextSize=1 and contextSize=2. Tests shouldBurySiblings returning true for SINGLE/ENUMERATION and false for DELETION/CONTEXT. Tests getSiblingHolesToBury returning correct hole ID lists. Tests holeCount, isLastHole, nextHoleIndex, and renderContent segment creation with and without flip state.

kotlin#+name: quiz-cloze-session-test:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/quiz/ClozeQuizSessionTest.kt
package computer.whatthefuck.arcology.quiz

import computer.whatthefuck.arcology.domain.ClozeHole
import computer.whatthefuck.arcology.domain.ClozeHoleVisibility
import computer.whatthefuck.arcology.domain.ClozeType
import computer.whatthefuck.arcology.domain.Flashcard
import computer.whatthefuck.arcology.domain.FlashcardPosition
import computer.whatthefuck.arcology.domain.FlashcardType
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import kotlin.time.Clock

/**
 * Unit tests for ClozeQuizSession visibility logic.
 */
class ClozeQuizSessionTest : StringSpec({

    fun createFlashcard(clozeType: ClozeType): Flashcard {
        return Flashcard(
            nodeId = "test-node",
            cardType = FlashcardType.CLOZE,
            clozeType = clozeType,
            createdAt = Clock.System.now()
        )
    }

    fun createPosition(nodeId: String, positionName: String): FlashcardPosition {
        return FlashcardPosition(
            nodeId = nodeId,
            positionName = positionName,
            easeFactor = 2.5,
            box = 0,
            intervalDays = 0.0,
            dueDate = Clock.System.now(),
            reviewCount = 0
        )
    }

    fun createClozeContent(holeCount: Int, clozeType: ClozeType): computer.whatthefuck.arcology.domain.ClozeCardContent {
        val holes = (0 until holeCount).map { i ->
            ClozeHole(
                id = i,
                text = "hole$i",
                hint = if (i % 2 == 0) "hint$i" else null,
                startIndex = i * 10,
                endIndex = i * 10 + 5
            )
        }
        return computer.whatthefuck.arcology.domain.ClozeCardContent(
            rawContent = "test content with holes",
            holes = holes,
            clozeType = clozeType
        )
    }

    "DELETION type should hide only current hole" {
        val content = createClozeContent(3, ClozeType.DELETION)
        val card = createFlashcard(ClozeType.DELETION)
        val position = createPosition("test-node", "1")
        val session = ClozeQuizSession(card, content, position)

        session.getHoleVisibility(0) shouldBe ClozeHoleVisibility.VISIBLE
        session.getHoleVisibility(1) shouldBe ClozeHoleVisibility.HIDDEN
        session.getHoleVisibility(2) shouldBe ClozeHoleVisibility.VISIBLE
    }

    "ENUMERATION type should hide current and subsequent holes" {
        val content = createClozeContent(4, ClozeType.ENUMERATION)
        val card = createFlashcard(ClozeType.ENUMERATION)
        val position = createPosition("test-node", "2")
        val session = ClozeQuizSession(card, content, position)

        session.getHoleVisibility(0) shouldBe ClozeHoleVisibility.VISIBLE
        session.getHoleVisibility(1) shouldBe ClozeHoleVisibility.VISIBLE
        session.getHoleVisibility(2) shouldBe ClozeHoleVisibility.HIDDEN
        session.getHoleVisibility(3) shouldBe ClozeHoleVisibility.HIDDEN
    }

    "SINGLE type should show only current hole" {
        val content = createClozeContent(3, ClozeType.SINGLE)
        val card = createFlashcard(ClozeType.SINGLE)
        val position = createPosition("test-node", "1")
        val session = ClozeQuizSession(card, content, position)

        session.getHoleVisibility(0) shouldBe ClozeHoleVisibility.HIDDEN
        session.getHoleVisibility(1) shouldBe ClozeHoleVisibility.VISIBLE
        session.getHoleVisibility(2) shouldBe ClozeHoleVisibility.HIDDEN
    }

    "CONTEXT type should show N surrounding holes" {
        val content = createClozeContent(5, ClozeType.CONTEXT)
        val card = createFlashcard(ClozeType.CONTEXT)
        val position = createPosition("test-node", "2")
        val session = ClozeQuizSession(card, content, position, contextSize = 1)

        session.getHoleVisibility(0) shouldBe ClozeHoleVisibility.HIDDEN    // distance=2
        session.getHoleVisibility(1) shouldBe ClozeHoleVisibility.VISIBLE   // distance=1
        session.getHoleVisibility(2) shouldBe ClozeHoleVisibility.HIDDEN    // current
        session.getHoleVisibility(3) shouldBe ClozeHoleVisibility.VISIBLE   // distance=1
        session.getHoleVisibility(4) shouldBe ClozeHoleVisibility.HIDDEN    // distance=2
    }

    "CONTEXT type with contextSize=2 should show more holes" {
        val content = createClozeContent(5, ClozeType.CONTEXT)
        val card = createFlashcard(ClozeType.CONTEXT)
        val position = createPosition("test-node", "2")
        val session = ClozeQuizSession(card, content, position, contextSize = 2)

        session.getHoleVisibility(0) shouldBe ClozeHoleVisibility.VISIBLE   // distance=2
        session.getHoleVisibility(1) shouldBe ClozeHoleVisibility.VISIBLE   // distance=1
        session.getHoleVisibility(2) shouldBe ClozeHoleVisibility.HIDDEN    // current
        session.getHoleVisibility(3) shouldBe ClozeHoleVisibility.VISIBLE   // distance=1
        session.getHoleVisibility(4) shouldBe ClozeHoleVisibility.VISIBLE   // distance=2
    }

    "shouldBurySiblings should return true for SINGLE" {
        val content = createClozeContent(3, ClozeType.SINGLE)
        val card = createFlashcard(ClozeType.SINGLE)
        val position = createPosition("test-node", "0")
        val session = ClozeQuizSession(card, content, position)

        session.shouldBurySiblings() shouldBe true
    }

    "shouldBurySiblings should return true for ENUMERATION" {
        val content = createClozeContent(3, ClozeType.ENUMERATION)
        val card = createFlashcard(ClozeType.ENUMERATION)
        val position = createPosition("test-node", "0")
        val session = ClozeQuizSession(card, content, position)

        session.shouldBurySiblings() shouldBe true
    }

    "shouldBurySiblings should return false for DELETION" {
        val content = createClozeContent(3, ClozeType.DELETION)
        val card = createFlashcard(ClozeType.DELETION)
        val position = createPosition("test-node", "0")
        val session = ClozeQuizSession(card, content, position)

        session.shouldBurySiblings() shouldBe false
    }

    "shouldBurySiblings should return false for CONTEXT" {
        val content = createClozeContent(3, ClozeType.CONTEXT)
        val card = createFlashcard(ClozeType.CONTEXT)
        val position = createPosition("test-node", "0")
        val session = ClozeQuizSession(card, content, position)

        session.shouldBurySiblings() shouldBe false
    }

    "getSiblingHolesToBury for SINGLE should return all except current" {
        val content = createClozeContent(3, ClozeType.SINGLE)
        val card = createFlashcard(ClozeType.SINGLE)
        val position = createPosition("test-node", "1")
        val session = ClozeQuizSession(card, content, position)

        session.getSiblingHolesToBury() shouldBe listOf(0, 2)
    }

    "getSiblingHolesToBury for ENUMERATION should return subsequent holes" {
        val content = createClozeContent(4, ClozeType.ENUMERATION)
        val card = createFlashcard(ClozeType.ENUMERATION)
        val position = createPosition("test-node", "1")
        val session = ClozeQuizSession(card, content, position)

        session.getSiblingHolesToBury() shouldBe listOf(2, 3)
    }

    "getSiblingHolesToBury for DELETION should return empty list" {
        val content = createClozeContent(3, ClozeType.DELETION)
        val card = createFlashcard(ClozeType.DELETION)
        val position = createPosition("test-node", "0")
        val session = ClozeQuizSession(card, content, position)

        session.getSiblingHolesToBury() shouldBe emptyList()
    }

    "holeCount should return total holes" {
        val content = createClozeContent(5, ClozeType.DELETION)
        val card = createFlashcard(ClozeType.DELETION)
        val position = createPosition("test-node", "0")
        val session = ClozeQuizSession(card, content, position)

        session.holeCount shouldBe 5
    }

    "isLastHole should be true for last position" {
        val content = createClozeContent(3, ClozeType.DELETION)
        val card = createFlashcard(ClozeType.DELETION)
        val position = createPosition("test-node", "2")
        val session = ClozeQuizSession(card, content, position)

        session.isLastHole shouldBe true
    }

    "isLastHole should be false for non-last position" {
        val content = createClozeContent(3, ClozeType.DELETION)
        val card = createFlashcard(ClozeType.DELETION)
        val position = createPosition("test-node", "1")
        val session = ClozeQuizSession(card, content, position)

        session.isLastHole shouldBe false
    }

    "nextHoleIndex should return next index or null" {
        val content = createClozeContent(3, ClozeType.DELETION)
        val card = createFlashcard(ClozeType.DELETION)
        
        ClozeQuizSession(card, content, createPosition("test-node", "0")).nextHoleIndex shouldBe 1
        ClozeQuizSession(card, content, createPosition("test-node", "1")).nextHoleIndex shouldBe 2
        ClozeQuizSession(card, content, createPosition("test-node", "2")).nextHoleIndex shouldBe null
    }

    "renderContent should create correct segments" {
        val content = computer.whatthefuck.arcology.domain.ClozeCardContent(
            rawContent = "The {{capital}{city}@0} of {{France}@1} is famous.",
            holes = listOf(
                ClozeHole(id = 0, text = "capital", hint = "city", startIndex = 4, endIndex = 11),
                ClozeHole(id = 1, text = "France", hint = null, startIndex = 20, endIndex = 26)
            ),
            clozeType = ClozeType.DELETION
        )
        val card = createFlashcard(ClozeType.DELETION)
        val position = createPosition("test-node", "0")
        val session = ClozeQuizSession(card, content, position)

        val rendered = session.renderContent(isFlipped = false)

        // Should have: "The ", hidden hole 0, " of ", hidden hole 1, " is famous."
        rendered.segments.size shouldBe 5
    }

    "renderContent with isFlipped=true should reveal current hole" {
        val content = computer.whatthefuck.arcology.domain.ClozeCardContent(
            rawContent = "{{Paris}@0}",
            holes = listOf(
                ClozeHole(id = 0, text = "Paris", hint = null, startIndex = 0, endIndex = 5)
            ),
            clozeType = ClozeType.DELETION
        )
        val card = createFlashcard(ClozeType.DELETION)
        val position = createPosition("test-node", "0")
        val session = ClozeQuizSession(card, content, position)

        val notFlipped = session.renderContent(isFlipped = false)
        val flipped = session.renderContent(isFlipped = true)

        // Not flipped: HiddenHole
        // Flipped: RevealedHole with isHighlighted=true
        notFlipped.segments.first()::class shouldBe computer.whatthefuck.arcology.domain.ClozeSegment.HiddenHole::class
        flipped.segments.first()::class shouldBe computer.whatthefuck.arcology.domain.ClozeSegment.RevealedHole::class
    }
})

ClozeSiblingBuryingTest — burying edge cases

Tests burySiblings for SINGLE (buries all except current), ENUMERATION (buries subsequent), DELETION and CONTEXT (no burying). Tests isBuried for different nodes, clear removes all, clearForNode removes one node, multiple bury calls accumulate, and edge cases: last hole (ENUMERATION buries nothing), single-hole card (SINGLE buries nothing), and invalid position name (no crash).

kotlin#+name: quiz-sibling-burying-test:tangle ../src/commonTest/kotlin/computer/whatthefuck/arcology/quiz/ClozeSiblingBuryingTest.kt
package computer.whatthefuck.arcology.quiz

import computer.whatthefuck.arcology.domain.ClozeType
import computer.whatthefuck.arcology.domain.FlashcardPosition
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import kotlin.time.Clock

/**
 * Unit tests for ClozeSiblingBurying utility.
 */
class ClozeSiblingBuryingTest : StringSpec({

    fun createPosition(nodeId: String, positionName: String): FlashcardPosition {
        return FlashcardPosition(
            nodeId = nodeId,
            positionName = positionName,
            easeFactor = 2.5,
            box = 0,
            intervalDays = 0.0,
            dueDate = Clock.System.now(),
            reviewCount = 0
        )
    }

    "burySiblings for SINGLE should bury all other holes" {
        val burying = ClozeSiblingBurying()
        val position = createPosition("node1", "1")
        
        burying.burySiblings(position, ClozeType.SINGLE, totalHoles = 3)
        
        val buried = burying.getBuriedPositions()
        buried["node1"] shouldBe setOf("0", "2")
    }

    "burySiblings for ENUMERATION should bury subsequent holes" {
        val burying = ClozeSiblingBurying()
        val position = createPosition("node1", "1")
        
        burying.burySiblings(position, ClozeType.ENUMERATION, totalHoles = 4)
        
        val buried = burying.getBuriedPositions()
        buried["node1"] shouldBe setOf("2", "3")
    }

    "burySiblings for DELETION should not bury any holes" {
        val burying = ClozeSiblingBurying()
        val position = createPosition("node1", "0")
        
        burying.burySiblings(position, ClozeType.DELETION, totalHoles = 3)
        
        val buried = burying.getBuriedPositions()
        buried shouldBe emptyMap()
    }

    "burySiblings for CONTEXT should not bury any holes" {
        val burying = ClozeSiblingBurying()
        val position = createPosition("node1", "0")
        
        burying.burySiblings(position, ClozeType.CONTEXT, totalHoles = 3)
        
        val buried = burying.getBuriedPositions()
        buried shouldBe emptyMap()
    }

    "isBuried should return true for buried positions" {
        val burying = ClozeSiblingBurying()
        val position = createPosition("node1", "0")
        
        burying.burySiblings(position, ClozeType.SINGLE, totalHoles = 3)
        
        burying.isBuried(createPosition("node1", "1")) shouldBe true
        burying.isBuried(createPosition("node1", "2")) shouldBe true
        burying.isBuried(createPosition("node1", "0")) shouldBe false
    }

    "isBuried should return false for non-buried nodes" {
        val burying = ClozeSiblingBurying()
        val position = createPosition("node1", "0")
        
        burying.burySiblings(position, ClozeType.SINGLE, totalHoles = 3)
        
        burying.isBuried(createPosition("node2", "0")) shouldBe false
        burying.isBuried(createPosition("node2", "1")) shouldBe false
    }

    "clear should remove all buried positions" {
        val burying = ClozeSiblingBurying()
        burying.burySiblings(createPosition("node1", "0"), ClozeType.SINGLE, 3)
        burying.burySiblings(createPosition("node2", "0"), ClozeType.SINGLE, 3)
        
        burying.clear()
        
        burying.getBuriedPositions() shouldBe emptyMap()
    }

    "clearForNode should remove only specified node" {
        val burying = ClozeSiblingBurying()
        burying.burySiblings(createPosition("node1", "0"), ClozeType.SINGLE, 3)
        burying.burySiblings(createPosition("node2", "0"), ClozeType.SINGLE, 3)
        
        burying.clearForNode("node1")
        
        val buried = burying.getBuriedPositions()
        buried.containsKey("node1") shouldBe false
        buried.containsKey("node2") shouldBe true
    }

    "multiple bury calls should accumulate" {
        val burying = ClozeSiblingBurying()
        
        // First burial
        burying.burySiblings(createPosition("node1", "0"), ClozeType.ENUMERATION, 4)
        // Second burial (simulating reviewing hole 1)
        burying.burySiblings(createPosition("node1", "1"), ClozeType.ENUMERATION, 4)
        
        val buried = burying.getBuriedPositions()
        // After hole 0: buries 1, 2, 3
        // After hole 1: buries 2, 3
        // Combined: 1, 2, 3
        buried["node1"] shouldBe setOf("1", "2", "3")
    }

    "burySiblings should handle edge case: last hole" {
        val burying = ClozeSiblingBurying()
        val position = createPosition("node1", "2")
        
        burying.burySiblings(position, ClozeType.ENUMERATION, totalHoles = 3)
        
        val buried = burying.getBuriedPositions()
        // No holes after the last one, so nothing to bury
        buried.containsKey("node1") shouldBe false
    }

    "burySiblings should handle edge case: single hole card" {
        val burying = ClozeSiblingBurying()
        val position = createPosition("node1", "0")
        
        burying.burySiblings(position, ClozeType.SINGLE, totalHoles = 1)
        
        val buried = burying.getBuriedPositions()
        // No other holes to bury
        buried.containsKey("node1") shouldBe false
    }

    "burySiblings should handle invalid position name" {
        val burying = ClozeSiblingBurying()
        val position = createPosition("node1", "invalid")
        
        // Should not crash
        burying.burySiblings(position, ClozeType.SINGLE, totalHoles = 3)
        
        val buried = burying.getBuriedPositions()
        buried shouldBe emptyMap()
    }
})

Related Modules

  • quiz/models.org — ClozeHole, ClozeCardContent, ClozeSegment (domain models consumed here)

  • quiz/flashcard.org — FlashcardService (calls ClozeService for hole extraction)

  • editor/renderer.org — ClozeState, OrgDocumentRenderer (cloze rendering in Compose)

  • orgmode-kmp/ (vendored submodule, commit 562e900) — OrgInlineElem.Cloze, OrgChunk.OrgReviewDataDrawer, lexer cloze support