facett-console 0.1.11

facett — themed terminal/shell component: fixed monospace cell grid, block/beam cursor, ANSI colour mapped to the palette, scrollback on the CygnusEd smooth-scroll engine
Documentation
//! Inject-and-assert tests for the Elm-migrated console: ANSI parsing, the
//! scrollback ring, typed copy/paste, deterministic smooth-scroll under an
//! injected clock, and the FC-2 → FC-3 headless harness (feed `Vec<Msg>`, snapshot
//! `state()`; FC-8 effects-as-data on submit; FC-3 Model serde round-trip; FC-5
//! stable-id selection).

use super::*;
// `Facet` for `caps`/`state_json`/`selection_json`/`copy`; the rest are inherent.
use facett_core::Facet;

#[test]
fn parse_ansi_splits_coloured_runs() {
    // "ok " default, then green "PASS", then default " done".
    let spans = parse_ansi("ok \x1b[32mPASS\x1b[0m done");
    assert_eq!(spans.len(), 3);
    assert_eq!(spans[0].text, "ok ");
    assert_eq!(spans[0].color, None);
    assert_eq!(spans[1].text, "PASS");
    assert_eq!(spans[1].color, Some(AnsiColor::Green));
    assert_eq!(spans[2].text, " done");
    assert_eq!(spans[2].color, None);
}

#[test]
fn plain_line_is_one_default_span() {
    let spans = parse_ansi("hello world");
    assert_eq!(spans.len(), 1);
    assert_eq!(spans[0].text, "hello world");
    assert_eq!(spans[0].color, None);
}

#[test]
fn scrollback_ring_drops_oldest_past_max() {
    let mut c = Console::new("term").with_max_lines(3);
    for i in 0..10 {
        c.push_line(format!("line {i}"));
    }
    assert_eq!(c.line_count(), 3, "ring capped to max_lines");
    // The last three lines survive.
    assert!(c.plain_text().contains("line 9"));
    assert!(!c.plain_text().contains("line 0"));
}

#[test]
fn line_ids_are_stable_across_ring_eviction() {
    // FC-5: ids are monotonic and survive eviction — the ids of the surviving lines
    // never change, and a selection whose line is evicted is dropped (not re-pointed).
    let mut c = Console::new("term").with_max_lines(3);
    for i in 0..3 {
        c.push_line(format!("l{i}"));
    }
    let ids_before: Vec<LineId> = c.state().lines.iter().map(|l| l.id).collect();
    assert_eq!(ids_before, vec![0, 1, 2]);
    // Select the oldest surviving line (id 0).
    c.select(Some(0));
    assert_eq!(c.state().selected, Some(0));
    // Push one more → id 0 is evicted; ids 1,2,3 survive with the SAME ids.
    c.push_line("l3");
    let ids_after: Vec<LineId> = c.state().lines.iter().map(|l| l.id).collect();
    assert_eq!(ids_after, vec![1, 2, 3], "surviving ids are unchanged (never re-indexed)");
    assert_eq!(c.state().selected, None, "a selection on an evicted line is dropped, not re-targeted");
}

#[test]
fn copy_yields_the_plain_scrollback() {
    let mut c = Console::new("term");
    c.push_line("\x1b[31merror:\x1b[0m boom");
    c.push_line("trace ...");
    let copied = c.copy().unwrap();
    assert_eq!(copied, "error: boom\ntrace ...", "ANSI stripped in the copy text");
}

#[test]
fn scrollback_animation_is_deterministic_under_injected_clock() {
    // Same Msg sequence (SetScrollMax + ScrollBy + a fixed number of Ticks) → the
    // same offset (FC-7), driven entirely through the FC-2 update path.
    let mut a = Console::new("a");
    let mut b = Console::new("b");
    for i in 0..200 {
        a.push_line(format!("l{i}"));
        b.push_line(format!("l{i}"));
    }
    for c in [&mut a, &mut b] {
        let _ = c.update(Msg::SetScrollMax(1000.0));
        let _ = c.update(Msg::ScrollTo(0.0));
        let _ = c.update(Msg::ScrollBy(300.0));
        for _ in 0..30 {
            let _ = c.update(Msg::Tick(1.0 / 60.0));
        }
    }
    assert!(
        (a.state().scroll.offset - b.state().scroll.offset).abs() < 1e-6,
        "same input → same scroll (FC-7)"
    );
}

#[test]
fn caps_and_model_round_trip() {
    let mut c = Console::new("term").with_cursor(Cursor::Beam).with_prompt("> ");
    c.push_line("\x1b[36mhi\x1b[0m");
    let caps = c.caps();
    assert!(caps.themeable && caps.copyable && caps.searchable && caps.scalable && caps.selectable);
    // FC-3: the observable Model round-trips through serde and re-derives the same
    // `state_json` (the public observable contract).
    let json = serde_json::to_value(c.state()).unwrap();
    let back: ConsoleModel = serde_json::from_value(json).unwrap();
    let restored = Console { state: back };
    assert_eq!(Facet::state_json(&restored), Facet::state_json(&c));
}

#[test]
fn state_json_preserves_the_observable_contract() {
    // The exact keys callers read: `lines` is a COUNT, scroll offset/extent are flat,
    // plus the additive stable-id selection.
    let mut c = Console::new("term").with_prompt("$$ ");
    for i in 0..5 {
        c.push_line(format!("l{i}"));
    }
    c.set_input("cmd");
    let j = Facet::state_json(&c);
    assert_eq!(j["title"], "term");
    assert_eq!(j["lines"], 5, "`lines` is published as a count, not the raw array");
    assert_eq!(j["prompt"], "$$ ");
    assert_eq!(j["input"], "cmd");
    assert_eq!(j["cursor"], "Block");
    assert!(j["scroll_offset"].is_number());
    assert!(j["scroll_max"].is_number());
    assert_eq!(j["scale"], 1.0);
    assert_eq!(j["filter"], "");
    assert_eq!(j["idle"], true);
    assert_eq!(j["selected"], serde_json::Value::Null);
}

#[test]
fn headless_render_draws_a_populated_console() {
    use facett_core::harness::headless_render;
    let mut c = Console::new("term");
    for i in 0..40 {
        c.push_line(format!("\x1b[32m[{i:03}]\x1b[0m output line"));
    }
    let r = headless_render(&mut c);
    assert!(r.drew(), "console renders a non-empty frame headlessly");
    assert_eq!(r.state["lines"], 40);
}

#[test]
fn themed_render_works_for_a_look_preset() {
    use facett_core::harness::render_themed;
    let mut c = Console::new("term");
    c.push_line("ready");
    // Apply the look-preset's *derived* palette through the legacy bridge.
    let legacy = facett_core::look::Theme::macos_dark().to_legacy_palette();
    let r = render_themed(&mut c, legacy);
    assert!(r.drew());
}

#[test]
fn typed_copy_is_scrollback_text_and_paste_appends_to_input() {
    use facett_core::clip::{ClipKind, ClipPayload, CopySource, PasteTarget};
    let mut c = Console::new("term");
    c.push_line("first");
    c.push_line("second");
    // Copy: the whole scrollback as Text.
    let p = c.copy_payload().expect("populated console copies");
    assert_eq!(p.kind(), ClipKind::Text);
    assert_eq!(p.as_text(), "first\nsecond");
    // Paste: a payload's text view lands on the input line (newlines collapsed).
    c.set_input("cmd ");
    c.paste_payload(&ClipPayload::Rows("a\tb".into()));
    assert_eq!(c.state_json()["input"], "cmd a\tb");
    assert!(c.accepts(ClipKind::Text) && c.accepts(ClipKind::Rows));
    // Empty console has nothing to copy.
    assert!(Console::new("empty").copy_payload().is_none());
}

// ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
//    harness and assert the resulting `ConsoleModel` — no egui, no GPU. ──────────

#[test]
fn harness_snapshot_drives_command_entry_and_submit() {
    use facett_core::harness;
    let mut c = Console::new("term").with_prompt("$ ");
    // Type "ls", then submit: the input echoes into scrollback and clears.
    let snap = harness::snapshot(
        &mut c,
        [Msg::InputChar('l'), Msg::InputChar('s'), Msg::Submit],
    );
    assert_eq!(snap.input, "", "submit clears the input");
    assert_eq!(snap.lines.len(), 1, "the submitted command echoes as one scrollback line");
    assert_eq!(snap.lines[0].plain(), "$ ls");
    assert_eq!(snap.history, vec!["ls".to_string()], "the command is recorded in history");
    assert_eq!(&snap, c.state(), "the snapshot is a clone of the live state");
}

#[test]
fn harness_snapshot_drives_history_recall() {
    use facett_core::harness;
    let mut c = Console::new("term");
    for cmd in ["one", "two"] {
        let _ = c.update(Msg::SetInput(cmd.to_string()));
        let _ = c.update(Msg::Submit);
    }
    // Up recalls the newest ("two"), Up again the older ("one").
    let snap = harness::snapshot(&mut c, [Msg::HistoryPrev]);
    assert_eq!(snap.input, "two");
    let snap = harness::snapshot(&mut c, [Msg::HistoryPrev]);
    assert_eq!(snap.input, "one");
    // Down walks back toward the newest, then to an empty draft.
    let snap = harness::snapshot(&mut c, [Msg::HistoryNext]);
    assert_eq!(snap.input, "two");
    let snap = harness::snapshot(&mut c, [Msg::HistoryNext]);
    assert_eq!(snap.input, "", "past the newest → a blank draft line");
}

#[test]
fn harness_backspace_and_set_input() {
    use facett_core::harness;
    let mut c = Console::new("term");
    let snap = harness::snapshot(
        &mut c,
        [Msg::InputChar('a'), Msg::InputChar('b'), Msg::Backspace],
    );
    assert_eq!(snap.input, "a");
    let snap = harness::snapshot(&mut c, [Msg::SetInput("xyz".into())]);
    assert_eq!(snap.input, "xyz");
}

#[test]
fn harness_selects_a_line_by_stable_id_and_ignores_unknown() {
    use facett_core::harness;
    let mut c = Console::new("term");
    for i in 0..3 {
        c.push_line(format!("l{i}"));
    }
    let ids: Vec<LineId> = c.state().lines.iter().map(|l| l.id).collect();
    // Select an existing line by its stable id.
    let snap = harness::snapshot(&mut c, [Msg::SelectLine(Some(ids[1]))]);
    assert_eq!(snap.selected, Some(ids[1]));
    assert_eq!(c.selection_json()["line"], ids[1]);
    // An unknown/stale id is ignored — the prior selection stands (FC-5).
    let snap = harness::snapshot(&mut c, [Msg::SelectLine(Some(9999))]);
    assert_eq!(snap.selected, Some(ids[1]), "unknown id ignored");
    // None clears.
    let snap = harness::snapshot(&mut c, [Msg::SelectLine(None)]);
    assert_eq!(snap.selected, None);
    assert_eq!(c.selection_json(), serde_json::Value::Null);
}

#[test]
fn harness_scroll_msgs_move_the_offset() {
    use facett_core::harness;
    let mut c = Console::new("term");
    // Establish an extent, then a jump + a tick advances the eased offset toward it.
    let snap = harness::snapshot(
        &mut c,
        [Msg::SetScrollMax(500.0), Msg::ScrollTo(200.0), Msg::Tick(1.0)],
    );
    assert!(snap.scroll.offset > 0.0, "scroll offset moved off zero");
    assert_eq!(snap.scroll.max, 500.0);
    // ScrollToBottom snaps the target to the extent.
    let snap = harness::snapshot(&mut c, [Msg::ScrollToBottom, Msg::Tick(1.0)]);
    assert!(snap.scroll.offset > 100.0);
}

#[test]
fn drive_submit_emits_a_runcommand_effect_and_others_emit_none() {
    use facett_core::harness;
    let mut c = Console::new("term");
    // FC-8: non-submit messages are pure state transitions — no effects.
    let none = harness::drive(
        &mut c,
        [Msg::PushLine("out".into()), Msg::SetInput("echo hi".into()), Msg::SelectLine(None)],
    );
    assert!(none.is_empty(), "FC-8: only Submit produces an Effect");
    // Submitting a non-empty line asks the host to run it (side work as data).
    let eff = harness::drive(&mut c, [Msg::Submit]);
    assert_eq!(eff, vec![Effect::RunCommand("echo hi".into())]);
    // An empty submit runs nothing.
    let eff = harness::drive(&mut c, [Msg::Submit]);
    assert!(eff.is_empty(), "empty submit → no RunCommand");
}

#[test]
fn model_serde_round_trips() {
    // FC-3: the observable Model round-trips through serde value-for-value.
    let mut c = Console::new("term").with_prompt("> ");
    c.push_line("\x1b[36mhi\x1b[0m");
    let _ = c.update(Msg::SetInput("draft".into()));
    let json = serde_json::to_value(c.state()).unwrap();
    let back: ConsoleModel = serde_json::from_value(json).unwrap();
    assert_eq!(&back, c.state(), "serde(state) -> state round-trips");
}