justerm-core 0.17.0

A pure terminal engine: VT byte stream to grid + scrollback + damage. No I/O, no rendering, theme-agnostic.
Documentation
//! Issue #4 — damage (line + column span) + first-class scroll op.
//! Model: incremental bounds, ack-gated reset, recorded scroll op (ADR-0003).

use justerm_core::{Engine, TermDamage};

// ScrollOp is brought in per-test where needed.

/// Writing one glyph damages only its line, with a column span of just that cell.
#[test]
fn single_cell_change_damages_its_line_span() {
    let mut term = Engine::new(10, 3);
    term.reset_damage(); // clean baseline (the "last ack")
    term.feed(b"x"); // one glyph at (0, 0)

    match term.damage() {
        TermDamage::Partial(lines) => {
            assert_eq!(lines.len(), 1);
            assert_eq!((lines[0].line, lines[0].left, lines[0].right), (0, 0, 0));
        }
        other => panic!("expected partial damage, got {other:?}"),
    }
}

/// Erasing records damage over the cleared span.
#[test]
fn erase_damages_the_cleared_span() {
    let mut term = Engine::new(10, 1);
    term.feed(b"abcde");
    term.reset_damage(); // baseline after the writes
    term.feed(b"\x1b[1;3H\x1b[K"); // cursor to col 2, erase to end of line

    match term.damage() {
        TermDamage::Partial(lines) => {
            assert_eq!(lines.len(), 1);
            assert_eq!((lines[0].line, lines[0].left, lines[0].right), (0, 2, 9));
        }
        other => panic!("{other:?}"),
    }
}

/// A pure cursor move puts the old *and* new cursor cells into the rendered
/// frame, even though no cell *content* changed — the consumer draws the caret
/// by cell-invert (beamterm has no cursor primitive), so without this the old
/// position keeps the inverted cell (a ghost) and the new one is never inverted.
/// Caret cells live in `frame()` (the render producer), not `damage()` (which
/// stays content-only for cadence); mirrors Alacritty's `last_cursor`. (#38)
#[test]
fn cursor_move_puts_old_and_new_cells_in_frame() {
    let mut term = Engine::new(80, 24);
    term.feed(b"abc"); // content on row 0; cursor now at (0, 3)
    term.reset_damage(); // ack: consumer has seen the cursor at (0, 3)
    term.feed(b"\x1b[10;20H"); // CUP to (9, 19); no glyph written

    let covers = |spans: &[justerm_core::Span], line: u16, col: u16| {
        spans
            .iter()
            .any(|s| s.line == line && s.left <= col && s.right >= col)
    };
    let f = term.frame();
    assert!(
        covers(&f.spans, 0, 3),
        "old cursor cell (0,3) must be in the frame, got {:?}",
        f.spans
    );
    assert!(
        covers(&f.spans, 9, 19),
        "new cursor cell (9,19) must be in the frame, got {:?}",
        f.spans
    );

    // `damage()` stays content-only: a pure move records no content change.
    assert!(
        matches!(term.damage(), TermDamage::Partial(ref l) if l.is_empty()),
        "damage() is content-only and must stay empty on a pure cursor move"
    );
}

/// A scroll is a first-class op, not full-screen damage.
#[test]
fn scroll_emits_first_class_op_not_full_damage() {
    let mut term = Engine::new(4, 2);
    term.feed(b"a\r\nb"); // a → row 0, b → row 1
    term.reset_damage();
    term.feed(b"\r\nc"); // CR+LF at the bottom → scroll up; c on the new bottom

    let op = term.scroll_delta().expect("expected a scroll op");
    assert_eq!((op.top, op.bottom, op.count), (0, 1, 1)); // rows [0..=1] up by 1
    assert!(!matches!(term.damage(), TermDamage::Full)); // not a full redraw
}

/// Switching to the alt screen replaces the whole screen → full damage.
#[test]
fn alt_screen_switch_is_full_damage() {
    let mut term = Engine::new(4, 2);
    term.feed(b"ab");
    term.reset_damage();
    term.feed(b"\x1b[?1049h"); // enter alt → entire screen swapped + cleared

    assert!(matches!(term.damage(), TermDamage::Full));
}

/// Resize clears any pending scroll op — a stale op points at the old rows and
/// would be out of range against the resized screen.
#[test]
fn resize_clears_stale_scroll_op() {
    let mut term = Engine::new(4, 5);
    term.feed(b"a\r\nb\r\nc\r\nd\r\ne\r\nf"); // line-feeds at the bottom record a scroll op
    assert!(term.scroll_delta().is_some());

    term.resize(4, 3);

    assert!(term.scroll_delta().is_none());
}

/// Reverse index at the top margin scrolls down → a negative-count scroll op.
#[test]
fn reverse_index_emits_down_scroll_op() {
    let mut term = Engine::new(4, 2);
    term.feed(b"\x1b[1;1H"); // cursor at the top margin
    term.reset_damage();
    term.feed(b"\x1bM"); // RI → scroll the region down

    let op = term.scroll_delta().expect("expected a scroll op");
    assert_eq!((op.top, op.bottom, op.count), (0, 1, -1));
}

/// The common path: print on the bottom row, then a line-feed scrolls it up.
/// Damage must follow the content to its new row (and the exposed bottom row is
/// new blank content), so the consumer redraws the right rows after the shift.
#[test]
fn write_then_scroll_realigns_damage_with_content() {
    let mut term = Engine::new(4, 2);
    term.feed(b"\x1b[2;1H"); // cursor to the bottom row
    term.reset_damage();
    term.feed(b"Z\n"); // write Z at the bottom, then LF scrolls it up

    let lines = match term.damage() {
        TermDamage::Partial(l) => l,
        other => panic!("{other:?}"),
    };
    // Z ended up on row 0 after the scroll — its damage must point there.
    assert!(
        lines.iter().any(|d| d.line == 0),
        "row 0 not damaged: {lines:?}"
    );
    // The newly exposed bottom row is new blank content → damaged too.
    assert!(
        lines.iter().any(|d| d.line == 1),
        "row 1 not damaged: {lines:?}"
    );
}

/// Several scrolls of the same region between acks accumulate into one op (flow
/// control: a slow consumer gets a single larger shift, never a pile-up).
#[test]
fn repeated_scroll_accumulates_count() {
    let mut term = Engine::new(4, 3);
    term.feed(b"a\r\nb\r\nc"); // fill 3 rows
    term.reset_damage();
    term.feed(b"\r\nd\r\ne"); // two line-feeds → two full-region scrolls

    let op = term.scroll_delta().expect("scroll op");
    assert_eq!((op.top, op.bottom, op.count), (0, 2, 2));
}

/// #661 — the *reported* count never exceeds the region's own height. Shifting a
/// region by more than its height moves every source row outside it, so the extra
/// magnitude carries nothing a consumer can act on — while it does overflow the
/// wire's `i16` and flip an up-scroll into a down-scroll. Both references that
/// state a quantity here clamp it the same way: alacritty
/// `term/mod.rs:773` (`cmp::min(lines, region height)`) and ghostty
/// `Terminal.zig:2703` (`@min(count, rem)`), SHAs pinned in theflow.md.
#[test]
fn reported_scroll_count_is_capped_at_the_region_height() {
    let mut term = Engine::new(4, 3);
    term.feed(b"a\r\nb\r\nc"); // fill 3 rows, cursor on the last one
    term.reset_damage();
    term.feed(&vec![b'\n'; 40_000]); // 39 998 full-region up-scrolls

    let op = term.scroll_delta().expect("scroll op");
    assert_eq!((op.top, op.bottom), (0, 2), "still one full-screen region");
    assert_eq!(op.count, 3, "capped at the 3-row region's height");
}

/// The same cap downward — a reverse-index flood is the mirror, and the sign has
/// to survive it (the wire defect *was* a sign flip, so a cap that only guarded
/// the positive direction would look right in the test above and still ship the
/// bug).
#[test]
fn the_cap_applies_to_a_down_scroll_too() {
    let mut term = Engine::new(4, 3);
    term.feed(b"a\r\nb\r\nc");
    term.reset_damage();
    let mut down = b"\x1b[H".to_vec(); // home; RI at the top row scrolls down
    down.extend(std::iter::repeat_n(*b"\x1bM", 40_000).flatten());
    term.feed(&down);

    let op = term.scroll_delta().expect("scroll op");
    assert_eq!(op.count, -3, "capped at -height, not saturated to +height");
}

/// The cap is on what is *reported*, not on what is accumulated — so a region
/// that scrolls far up and then all the way back down reports the true small net,
/// not a saturated one. Clamping the accumulator instead would answer `-3` here:
/// it would have already forgotten 39 998 of the up-scrolls by the time the
/// down-scrolls arrived, and each subsequent one would walk the saturated value
/// down through zero.
#[test]
fn a_scroll_that_returns_to_its_start_reports_no_net_shift() {
    let mut term = Engine::new(4, 3);
    term.feed(b"a\r\nb\r\nc");
    term.reset_damage();
    term.feed(&vec![b'\n'; 40_000]); // +40 000 (the fill left the cursor on the last row)
    let mut down = b"\x1b[H".to_vec();
    down.extend(std::iter::repeat_n(*b"\x1bM", 40_000).flatten()); // -40 000
    term.feed(&down);

    let op = term.scroll_delta().expect("scroll op");
    assert_eq!(op.count, 0, "the net is zero and small enough to say so");
}

/// Scrolls of *different* regions in one frame cannot be expressed as a single
/// op, so they degrade to full damage rather than silently dropping one.
#[test]
fn scrolls_of_different_regions_degrade_to_full() {
    let mut term = Engine::new(4, 4);
    term.feed(b"\x1b[4;1H"); // cursor to the bottom of the full screen
    term.reset_damage();
    term.feed(b"\n"); // scroll the full region [0..=3]

    term.feed(b"\x1b[2;3r"); // DECSTBM region rows 2..3 → [1..=2] (homes cursor)
    term.feed(b"\x1b[3;1H\n"); // cursor to the region bottom, scroll [1..=2]

    assert!(matches!(term.damage(), TermDamage::Full));
}