use super::*;
use facett_core::Facet;
#[test]
fn parse_ansi_splits_coloured_runs() {
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");
assert!(c.plain_text().contains("line 9"));
assert!(!c.plain_text().contains("line 0"));
}
#[test]
fn line_ids_are_stable_across_ring_eviction() {
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]);
c.select(Some(0));
assert_eq!(c.state().selected, Some(0));
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() {
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);
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() {
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");
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");
let p = c.copy_payload().expect("populated console copies");
assert_eq!(p.kind(), ClipKind::Text);
assert_eq!(p.as_text(), "first\nsecond");
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));
assert!(Console::new("empty").copy_payload().is_none());
}
#[test]
fn harness_snapshot_drives_command_entry_and_submit() {
use facett_core::harness;
let mut c = Console::new("term").with_prompt("$ ");
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);
}
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");
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();
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]);
let snap = harness::snapshot(&mut c, [Msg::SelectLine(Some(9999))]);
assert_eq!(snap.selected, Some(ids[1]), "unknown id ignored");
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");
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);
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");
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");
let eff = harness::drive(&mut c, [Msg::Submit]);
assert_eq!(eff, vec![Effect::RunCommand("echo hi".into())]);
let eff = harness::drive(&mut c, [Msg::Submit]);
assert!(eff.is_empty(), "empty submit → no RunCommand");
}
#[test]
fn model_serde_round_trips() {
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");
}