text-document 1.6.3

Rich text document editing library
Documentation
//! Property-style invariant test for the blockquote use cases.
//!
//! Drives a small document through a randomised sequence of edits
//! (wrap, unwrap, depth+, depth-, toggle, insert text, delete text,
//! insert block, undo) and after every step asserts a small set of
//! structural invariants:
//!
//! 1. `to_plain_text()` succeeds (no panic, no error).
//! 2. `to_markdown()` succeeds AND the markdown round-trips through
//!    `set_markdown` without errors.
//! 3. The character count reported by the document matches the length
//!    of the rendered plain text.
//! 4. After any sequence of blockquote ops, `is_in_blockquote()` agrees
//!    with `blockquote_depth_at_cursor() > 0`.
//!
//! This is the structural safety net for blockquote editing: any change
//! that produces orphan frames, dangling `child_order` entries, or rope
//! offsets out of sync with the entity tree will eventually trip one of
//! these checks under a long enough random sequence.

use text_document::{MoveMode, TextDocument};

/// Tiny deterministic LCG — proptest is overkill for a coverage net.
struct Rng {
    state: u64,
}

impl Rng {
    fn new(seed: u64) -> Self {
        Self {
            state: seed.wrapping_add(1),
        }
    }
    fn next(&mut self) -> u64 {
        // Numerical Recipes LCG constants.
        self.state = self
            .state
            .wrapping_mul(6364136223846793005)
            .wrapping_add(1442695040888963407);
        self.state
    }
    fn range(&mut self, bound: u64) -> u64 {
        self.next() % bound.max(1)
    }
}

#[derive(Debug, Clone, Copy)]
enum Op {
    WrapSelection,
    UnwrapCurrentFrame,
    Toggle,
    IncreaseDepth,
    DecreaseDepth,
    InsertChar,
    DeletePrev,
    InsertBlock,
    Undo,
    MoveLeft,
    MoveRight,
}

fn pick_op(rng: &mut Rng) -> Op {
    match rng.range(11) {
        0 => Op::WrapSelection,
        1 => Op::UnwrapCurrentFrame,
        2 => Op::Toggle,
        3 => Op::IncreaseDepth,
        4 => Op::DecreaseDepth,
        5 => Op::InsertChar,
        6 => Op::DeletePrev,
        7 => Op::InsertBlock,
        8 => Op::Undo,
        9 => Op::MoveLeft,
        _ => Op::MoveRight,
    }
}

fn apply_op(doc: &TextDocument, op: Op) {
    let pos = {
        let c = doc.cursor_at(0);
        c.position()
    };
    let len = doc.character_count();
    let cursor = doc.cursor_at(pos.min(len));
    match op {
        Op::WrapSelection => {
            let _ = cursor.wrap_selection_in_blockquote();
        }
        Op::UnwrapCurrentFrame => {
            let _ = cursor.unwrap_current_frame();
        }
        Op::Toggle => {
            let _ = cursor.toggle_blockquote();
        }
        Op::IncreaseDepth => {
            let _ = cursor.increase_blockquote_depth();
        }
        Op::DecreaseDepth => {
            let _ = cursor.decrease_blockquote_depth();
        }
        Op::InsertChar => {
            let _ = cursor.insert_text("x");
        }
        Op::DeletePrev => {
            let _ = cursor.delete_previous_char();
        }
        Op::InsertBlock => {
            let _ = cursor.insert_block();
        }
        Op::Undo => {
            let _ = doc.undo();
        }
        Op::MoveLeft => {
            cursor.move_position(text_document::MoveOperation::Left, MoveMode::MoveAnchor, 1);
        }
        Op::MoveRight => {
            cursor.move_position(text_document::MoveOperation::Right, MoveMode::MoveAnchor, 1);
        }
    }
}

fn check_invariants(doc: &TextDocument, step: usize, op: Op, seed: u64) {
    // (1) plain text export must not panic / error
    let plain = doc
        .to_plain_text()
        .unwrap_or_else(|e| panic!("seed={seed} step={step} op={op:?}: to_plain_text failed: {e}"));

    // (2) markdown export must not panic / error
    let md = doc
        .to_markdown()
        .unwrap_or_else(|e| panic!("seed={seed} step={step} op={op:?}: to_markdown failed: {e}"));

    // (3) markdown must round-trip back through set_markdown without crashing
    let round = TextDocument::new();
    round.set_markdown(&md).unwrap().wait().unwrap_or_else(|e| {
        panic!(
            "seed={seed} step={step} op={op:?}: re-import of own markdown failed: {e}\n\
             markdown was: {md:?}"
        )
    });

    // (4) is_in_blockquote() must agree with depth > 0
    let cursor = doc.cursor_at(0);
    let depth = cursor.blockquote_depth_at_cursor();
    let in_quote = cursor.is_in_blockquote();
    assert_eq!(
        in_quote,
        depth > 0,
        "seed={seed} step={step} op={op:?}: is_in_blockquote ({in_quote}) disagrees with \
         blockquote_depth_at_cursor ({depth})"
    );

    // (5) character_count must not panic; basic sanity
    let _cc = doc.character_count();

    // (6) the plain text export must be reproducible — calling it
    // twice in a row must yield the same value (catches operations
    // that leave the document in an inconsistent state where the
    // export iterates frames non-deterministically).
    let plain2 = doc.to_plain_text().expect("repeat to_plain_text");
    assert_eq!(
        plain, plain2,
        "seed={seed} step={step} op={op:?}: to_plain_text is non-deterministic"
    );
}

fn run_seed(seed: u64, steps: usize) {
    let mut rng = Rng::new(seed);
    let doc = TextDocument::new();
    doc.set_markdown("# Title\n\nFirst paragraph.\n\n> A quoted line.\n\nLast paragraph.\n")
        .unwrap()
        .wait()
        .unwrap();
    for step in 0..steps {
        let op = pick_op(&mut rng);
        apply_op(&doc, op);
        check_invariants(&doc, step, op, seed);
    }
}

#[test]
fn random_blockquote_ops_preserve_invariants_seed_1() {
    run_seed(1, 200);
}

#[test]
fn random_blockquote_ops_preserve_invariants_seed_42() {
    run_seed(42, 200);
}

#[test]
fn random_blockquote_ops_preserve_invariants_seed_2026() {
    run_seed(2026, 200);
}

/// A larger run for the "lucky" seed that historically caught the most
/// bugs while developing this feature.
#[test]
fn random_blockquote_ops_long_run() {
    run_seed(0xC0FFEE_DEADBEEF, 1000);
}