caretline

Testing

Because update and view are pure, a caretline test is just data: a starting state, some keys or messages, and the expected result. No terminal, no timing, no mocks.

Where the tests live

FileWhat it checks
caretline-next/tests/goldens.rsBehaviour goldens: text and selection before, keys, text and selection after (and frames where it matters). The baseline is a macOS text field
caretline-next/tests/rehydrate.rsA state survives JSON exactly; a recorded session replays to the live result
caretline-next/tests/fuzz.rsProperty tests: seeded random documents and messages, with invariants checked after every step
caretline-next/tests/marks.rsBlock marks: each mapping rule, undo and redo restoring marks exactly, cut and paste keeping ids, serialization; random sessions with marks at line starts and unique ids after every step, every undo restoring the marks
caretline-next/tests/outline.rsOutline goldens in block notation (‖ blocks with a blank row between, ¦ without, ⏎ a soft break), with block ids: Enter, Backspace and Delete at block edges, Tab, the task cycle, moves, atomic images, copy and paste, blank rows, host messages and effects
caretline-next/tests/outline_fuzz.rsOutline properties over random outlines and messages: blocks and marks agree, ids unique, no caret in a marker or an image, undo and redo exact (marks included), kind changes move no other block, cut and paste in place keeps ids, Markdown files round-trip. CARETLINE_OUTLINE_SEEDS runs more seeds
caretline-next/tests/keymap.rsKey bindings and the key-script parser
caretline-next/tests/common/mod.rsShared helpers: caret notation, golden, keys, send, frame, random generators
caretline-next/src/helix/**Helix’s own unit tests, vendored with the code
caretline-app/tests/cli.rsThe binary: fixtures render to their saved snapshots, traces replay, --keys and --dump-state round-trip, effects are reported and never performed

Run them:

cargo test -p caretline-next
cargo test -p caretline-app

Caret notation

Goldens write the text and the selection as one string:

NotationMeans
▮The caret (the selection’s head)
⟦abc▮⟧abc selected left to right: the anchor before a, the caret after c
⟦▮abc⟧abc selected right to left: the caret before a

state("Hello ⟦wor▮⟧ld") builds a state at 80x24 from notation; state_wh(…, w, h) picks the size. show(&state) prints the primary selection back in notation.

Goldens

A golden is a before, a key script and an after:

#[test]
fn e01_left_collapses_to_start() {
    golden("Hello ⟦wor▮⟧ld", "<left>", "Hello ▮world");
}

When a case needs more than text and selection, drive the state and assert on what you need: effects, the clipboard, the history, the frame or the caret’s cell.

#[test]
fn e37_cut_is_one_undo_step() {
    let mut s = state("Hello ⟦wor▮⟧ld");
    let fx = keys(&mut s, "<c-x>");
    assert_eq!(fx, vec![Effect::ClipboardSet { text: "wor".into() }]);
    assert_eq!(show(&s), "Hello ▮ld");
    keys(&mut s, "<c-z>");
    assert_eq!(show(&s), "Hello ⟦wor▮⟧ld");
}
HelperDoes
golden(before, script, after)Builds, runs the keys, compares the notation
keys(&mut state, script)Runs a key script through the keymap; returns the effects
send(&mut state, msgs)Sends messages; returns the effects
frame(&state)The rendered frame as text
cursor(&state)The caret’s screen cell

Use <wait:MS> in a script when undo grouping matters: "one<wait:2000> two<c-z>" undoes only " two".

Rehydration and replay tests

rehydrate.rs checks two promises.

The property fuzzer

fuzz.rs runs 24 seeds of 500 random messages each (SEEDS, STEPS). Documents mix ASCII, tabs, CRLF, emoji with skin tones, ZWJ families, flags, combining accents, CJK, zero-width and control characters. Viewports range from 1x1 to 200 columns. Now and then a step starts from a random multi-range selection.

After every step it checks:

Invariant
PositionsEvery anchor and head is within the text and on a grapheme boundary
EI1A motion without Shift leaves no selection
EI2A motion with Shift never moves the anchor
EI4Copy changes nothing but the clipboard
EI5An edit that made a new revision undoes to exactly the state before it and redoes to exactly the state after
EI6, EI7Typing over a selection replaces exactly it; a delete with a selection removes exactly it
EffectsEffects are plain values that match the message
viewNever panics, at the state’s size and at extreme sizes
SerializationAt random points, the state round-trips to the same state and frame
Undo allUndoing everything gives back the original text

Separate tests check EI3 and EI8 (cut-then-paste and copy-then-paste in place are identities) and EI12 (select all, delete, one undo restores everything).

A failure names its seed and step (seed 7 step 312: …). The generator is seeded, so the same seed fails the same way every time. To focus on it, temporarily run only that seed in random_sessions_keep_every_invariant.

Snapshot fixtures

crates/caretline-app/fixtures holds NAME.state.json files with NAME.snapshot.txt and NAME.snapshot.ansi beside them. fixtures_render_their_snapshots finds every *.state.json, renders it at its own viewport in both formats, and compares. Adding a fixture needs no code:

cd crates/caretline-app/fixtures
printf 'First line\nA second line that is long enough to wrap.\n' > my-case.md
caretline --new-state my-case.md --size 30x6 > my-case.state.json && rm my-case.md
caretline --state my-case.state.json --keys '<down><s-end>' --dump-state my-case.state.json
caretline --state my-case.state.json --snapshot 30x6 > my-case.snapshot.txt
caretline --state my-case.state.json --snapshot 30x6 --format ansi > my-case.snapshot.ansi

The snapshot size must be the state’s viewport, because the test renders each fixture at its own viewport.

--new-state records the path you give it, so run it from the fixtures directory (or edit path afterwards) to keep machine paths out of the fixture. Review the snapshot by eye before committing it: from then on, the test holds the engine to it.

From a recorded trace to a test

When something goes wrong in a real session, record it and turn it into a test.

1. Record it.

caretline notes.md --trace bug.jsonl

2. Check that it replays. The replay is exact, so the bug shows up in the final frame or state.

caretline --replay bug.jsonl --snapshot 80x24
caretline --replay bug.jsonl --dump-state -

3. Split it into a starting state and a message file. Then you can trim messages until only the ones that matter remain:

head -1 bug.jsonl | jq .state > bug.state.json
tail -n +2 bug.jsonl | jq -c .msg > bug.msgs.jsonl
caretline --state bug.state.json --msgs bug.msgs.jsonl --snapshot 80x24   # same frame as the replay

--msgs and --replay agree as long as the trace has a single state line. A trace that was appended to by several sessions has one state line per session; split at the last one.

4. Write the test. Pick one:

use caretline::trace::replay_trace;
use caretline::view;

#[test]
fn recorded_session_replays_to_the_saved_frame() {
    let trace = include_str!("../../caretline-app/fixtures/session.trace.jsonl");
    let (state, _) = replay_trace(trace).unwrap();
    // The trace ends with a resize to 36x8, the snapshot's size.
    assert_eq!(
        view(&state).to_text(),
        include_str!("../../caretline-app/fixtures/session.snapshot.txt")
    );
}

Run it before the fix to see it fail, then fix the engine and keep the test.

This page on GitHub: docs/caretline/testing.md