facett-console/src/tests.rs
//! 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());
// `drew()` cannot see colour: with `set_theme` deleted from `render_themed` this
// test still passed. Compare the ink the preset painted against the default
// palette's — a look preset that never reaches the paint produces the same one.
let mut d = Console::new("term");
d.push_line("ready");
let plain = facett_core::harness::headless_render(&mut d);
assert_ne!(
r.ink_signature(),
plain.ink_signature(),
"the macos_dark look preset painted the console exactly like the default \
palette ({:?} vs {:?}) — the preset never reached the paint",
r.ink_colors,
plain.ink_colors,
);
}
#[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");
}
/// **RED-when-broken: the uniform scale knob reaches the PIXELS.**
///
/// Everything else here is a state round-trip — set a value, read it back, see it in
/// `state_json`. Measured: deleting the scale application from the painter outright
/// left this whole crate's suite green, and `RenderReport::drew()` cannot see it
/// either (a glyph is four vertices at 6pt and at 60pt). So this asserts APPLIED
/// geometry: the area of the glyph triangles the painter really emitted, which must
/// grow roughly 4x when the scale doubles.
#[test]
fn scale_is_applied_to_the_drawn_text_not_just_stored() {
use facett_core::harness;
let ink = |s: f32| {
let mut v = { let mut c = Console::new("term").with_prompt("$$ "); for i in 0..12 { c.push_line(format!("line {i} some text")); } c };
<_ as facett_core::Facet>::set_scale(&mut v, s);
harness::headless_render(&mut v).textured_ink_area
};
let (small, big) = (ink(1.0), ink(2.0));
assert!(small > 0.0, "the facet draws text at 1.0x ({small})");
assert!(
big > small * 2.0,
"doubling the scale must roughly QUADRUPLE the glyph ink, got {small:.0} -> \
{big:.0} pt^2 ({:.2}x). A ratio near 1.0 means the knob is stored, clamped and \
serialised but never applied to anything drawn.",
big / small,
);
}