use super::*;
use strop_core::frontend_input::Input;
fn terminal(columns: u16, rows: u16) -> Vt {
themed_terminal(columns, rows, None)
}
fn themed_terminal(columns: u16, rows: u16, palette: Option<&Palette>) -> Vt {
Vt::new(
SessionId::from_request(strop_core::worker::WorkerId::new(1)),
Geometry {
columns,
rows,
revision: 1,
},
palette,
)
.unwrap()
}
#[test]
fn soft_wrap_and_grapheme_cells_share_the_readonly_text_projection() {
let mut vt = terminal(5, 3);
vt.feed("\x1b[?2027hA👩\u{200d}💻BCx".as_bytes()).unwrap();
let (frame, _) = vt.snapshot().unwrap();
let first = &frame.rows[0].row;
assert!(first.wrapped);
assert_eq!(first.symbol(1), Some("👩\u{200d}💻"));
assert_eq!(first.cells[1].width, 2);
assert_eq!(first.cells[2].width, 0);
assert_eq!(first.byte_at(1), first.byte_at(2));
assert!(frame.projection.to_string().starts_with("A👩\u{200d}💻BCx"));
assert_eq!(frame.cursor.row, 1);
assert_eq!(frame.cursor.column, 1);
assert_eq!(frame.cursor_byte(), "A👩\u{200d}💻BCx".len());
}
#[test]
fn continuing_output_cannot_mutate_an_inspection_snapshot() {
let mut vt = terminal(12, 3);
vt.feed(b"one\r\ntwo\r\nthree\r\n").unwrap();
let (before, _) = vt.snapshot().unwrap();
let before_text = before.projection.to_string();
vt.feed(b"four\r\nfive\r\nlatest\r\n").unwrap();
let (after, _) = vt.snapshot().unwrap();
let after_text = after.projection.to_string();
assert_eq!(before.projection.to_string(), before_text);
assert!(!before_text.contains("latest"));
assert!(after_text.contains("one"));
assert!(after_text.contains("latest"));
assert!(after.history_rows > before.history_rows);
}
#[test]
fn erased_cells_retain_their_terminal_background_color() {
let mut vt = terminal(10, 2);
vt.feed(b"\x1b[41m\x1b[2J").unwrap();
let (frame, _) = vt.snapshot().unwrap();
assert_eq!(frame.rows[0].row.symbol(0), Some(" "));
assert_eq!(
frame.rows[0].row.cells[0].style.background,
Color::Indexed(1)
);
}
#[test]
fn clipboard_reads_are_denied_and_multiline_paste_requires_explicit_consent() {
let mut vt = terminal(20, 3);
let query = vt.feed(b"\x1b]52;c;?\x07").unwrap();
assert!(query.reply.is_empty());
let paste = Input::Paste("first\nsecond".into());
assert!(matches!(
vt.input(&paste, false),
Err(Error::PasteNeedsConfirmation)
));
let accepted = vt.input(&paste, true).unwrap();
assert!(String::from_utf8(accepted.reply)
.unwrap()
.contains("second"));
vt.feed(b"\x1b[?2004h").unwrap();
let bracketed = vt.input(&paste, false).unwrap().reply;
assert!(bracketed.starts_with(b"\x1b[200~"));
assert!(bracketed.ends_with(b"\x1b[201~"));
}
#[test]
fn nul_retains_modifiers_and_negotiated_repeat_release_semantics() {
use strop_core::frontend_input::{KeyCode, KeyEvent, KeyKind};
let mut vt = terminal(20, 3);
let mut key = KeyEvent::press(KeyCode::Null);
key.modifiers.alt = true;
assert_eq!(vt.input(&Input::Key(key), false).unwrap().reply, b"\x1b\0");
vt.feed(b"\x1b[>3u").unwrap();
assert_eq!(
vt.input(&Input::Key(key), false).unwrap().reply,
b"\x1b[32;7u"
);
key.kind = KeyKind::Repeat;
assert_eq!(
vt.input(&Input::Key(key), false).unwrap().reply,
b"\x1b[32;7:2u"
);
key.kind = KeyKind::Release;
assert_eq!(
vt.input(&Input::Key(key), false).unwrap().reply,
b"\x1b[32;7:3u"
);
}
#[test]
fn terminal_escape_precedes_the_next_literal_key() {
use strop_core::frontend_input::{KeyCode, KeyEvent};
let mut vt = terminal(20, 3);
let escape = vt
.input(&Input::Key(KeyEvent::press(KeyCode::Escape)), false)
.unwrap();
assert_eq!(escape.reply, b"\x1b");
let letter = vt
.input(&Input::Key(KeyEvent::press(KeyCode::Char('x'))), false)
.unwrap();
assert_eq!(letter.reply, b"x");
}
#[test]
fn oversized_paste_refusal_leaves_the_terminal_encoder_usable() {
let mut vt = terminal(20, 3);
vt.feed(b"\x1b[?2004h").unwrap();
let oversized = Input::Paste("x".repeat(MAX_INPUT_BYTES));
assert!(matches!(
vt.input(&oversized, false),
Err(Error::Capacity(_))
));
assert_eq!(
vt.input(&Input::Paste("safe".into()), false).unwrap().reply,
b"\x1b[200~safe\x1b[201~"
);
}
#[test]
fn replay_admission_checks_real_grapheme_and_projection_correspondence() {
let mut vt = terminal(5, 3);
vt.feed("\x1b[?2027hA👩\u{200d}💻BCx".as_bytes()).unwrap();
let (frame, _) = vt.snapshot().unwrap();
frame.validate().unwrap();
let update = Update {
session: frame.session,
phase: Phase::Running,
frame: Some(frame.clone()),
effects: Vec::new(),
acknowledged_input: 0,
warning: None,
};
let wire = serde_json::to_value(&update).unwrap();
let decoded: Update = serde_json::from_value(wire.clone()).unwrap();
let decoded = decoded.frame.unwrap();
assert_eq!(decoded.projection, frame.projection);
assert_eq!(decoded.cell_at_byte(1).unwrap().width, 2);
let mut corrupt = wire;
corrupt["frame"]["geometry"]["columns"] = serde_json::json!(4);
assert!(serde_json::from_value::<Update>(corrupt).is_err());
}
#[test]
fn focus_reports_follow_the_child_negotiated_mode() {
let mut vt = terminal(20, 3);
assert!(vt.focus(false).unwrap().reply.is_empty());
vt.feed(b"\x1b[?1004h").unwrap();
assert_eq!(vt.focus(false).unwrap().reply, b"\x1b[O");
assert_eq!(vt.focus(true).unwrap().reply, b"\x1b[I");
vt.feed(b"\x1b[?1004l").unwrap();
assert!(vt.focus(true).unwrap().reply.is_empty());
}
#[test]
fn keyboard_advertisement_matches_the_captured_frontend_profile() {
let mut vt = terminal(20, 3);
vt.keyboard_capabilities(0).unwrap();
assert_eq!(vt.feed(b"\x1b[?u\x1b[c").unwrap().reply, b"\x1b[?62;22c");
vt.feed(b"\x1b[>31u").unwrap();
assert!(vt.feed(b"\x1b[?u").unwrap().reply.is_empty());
let mut alt =
strop_core::frontend_input::KeyEvent::press(strop_core::frontend_input::KeyCode::Char('x'));
alt.modifiers.alt = true;
assert_eq!(vt.input(&Input::Key(alt), false).unwrap().reply, b"\x1bx");
vt.keyboard_capabilities(SUPPORTED_KEYBOARD_FLAGS).unwrap();
assert_eq!(vt.feed(b"\x1b[?u").unwrap().reply, b"\x1b[?27u");
}
#[test]
fn osc_and_window_attempts_stay_in_their_namespace() {
let mut vt = terminal(40, 3);
let title = vt.feed(b"\x1b]0;title-attempt\x07").unwrap();
assert_eq!(title.reply, b"");
assert!(matches!(
title.effects.as_slice(),
[Effect::Title(text)] if text == "title-attempt"
));
let title_two = vt.feed(b"\x1b]2;title-2\x07").unwrap();
assert!(matches!(
title_two.effects.as_slice(),
[Effect::Title(text)] if text == "title-2"
));
let directory = vt.feed(b"\x1b]7;file://elsewhere/home\x07").unwrap();
assert_eq!(directory.reply, b"");
assert!(matches!(
directory.effects.as_slice(),
[Effect::ReportedDirectory(bytes)] if bytes == b"file://elsewhere/home"
));
let clipboard = vt.feed(b"\x1b]52;c;aGVsbG8=\x07").unwrap();
assert_eq!(clipboard.reply, b"");
assert_eq!(clipboard.effects.as_slice(), [Effect::ClipboardWriteDenied]);
for probe in [
b"\x1b[18t".as_slice(),
b"\x1b[19t",
b"\x1b[22t",
b"\x1b[23t",
] {
let window = vt.feed(probe).unwrap();
assert_eq!(window.reply, b"", "no host probing via {probe:?}");
assert!(window.effects.is_empty());
}
let link = vt
.feed(b"\x1b]8;;scheme://example\x07link\x1b]8;;\x07")
.unwrap();
assert_eq!(link.reply, b"");
assert!(link.effects.is_empty());
assert!(vt
.snapshot()
.unwrap()
.0
.projection
.to_string()
.starts_with("link"));
}
#[test]
fn the_embedder_palette_configures_defaults_and_survives_indexed_output() {
let palette = Palette::for_theme(strop_core::theme::ThemeId::Dark);
let mut vt = themed_terminal(20, 3, Some(&palette));
vt.feed(b"\x1b[31mred \x1b[38;2;1;2;3mtrue \x1b[mplain")
.unwrap();
let (frame, _) = vt.snapshot().unwrap();
assert_eq!(frame.palette.foreground, palette.foreground);
assert_eq!(frame.palette.background, palette.background);
assert_eq!(frame.palette.colors, palette.colors);
let row = &frame.rows[0].row;
assert_eq!(row.symbol(0), Some("r"));
assert_eq!(row.cells[0].style.foreground, Color::Indexed(1));
assert_eq!(
row.cells[4].style.foreground,
Color::Rgb(Rgb {
red: 1,
green: 2,
blue: 3,
})
);
assert_eq!(row.cells[9].style.foreground, Color::Default);
let (again, _) = vt.snapshot().unwrap();
assert!(
Arc::ptr_eq(&frame.palette, &again.palette),
"an unchanged palette is one Arc, cloned per frame never per cell"
);
vt.feed(b"\x1b]4;1;rgb:12/34/56\x07\x1b[31mx").unwrap();
let (overridden, _) = vt.snapshot().unwrap();
assert_eq!(
overridden.palette.colors[1],
Rgb {
red: 0x12,
green: 0x34,
blue: 0x56,
}
);
}
#[test]
fn live_defaults_preserve_osc_overrides_and_reset_to_the_selected_theme() {
use strop_core::theme::ThemeId;
let original = Palette::for_theme(ThemeId::Dark);
let selected = Palette::for_theme(ThemeId::CatppuccinMocha);
let mut vt = themed_terminal(20, 3, Some(&original));
vt.feed(b"\x1b[31mI\x1b[38;2;1;2;3;48;2;4;5;6mT\x1b[mD")
.unwrap();
let same = original.colors[2];
vt.feed(
format!(
"\x1b]4;1;rgb:12/34/56;2;rgb:{:02x}/{:02x}/{:02x}\x07\
\x1b]10;rgb:23/45/67\x07\x1b]11;rgb:34/56/78\x07",
same.red, same.green, same.blue
)
.as_bytes(),
)
.unwrap();
let (before, _) = vt.snapshot().unwrap();
assert_eq!(
before.palette.foreground,
Rgb {
red: 0x23,
green: 0x45,
blue: 0x67
}
);
assert_eq!(
before.palette.background,
Rgb {
red: 0x34,
green: 0x56,
blue: 0x78
}
);
assert_eq!(
before.palette.colors[1],
Rgb {
red: 0x12,
green: 0x34,
blue: 0x56
}
);
vt.configure_palette(&selected).unwrap();
let (after, emission) = vt.snapshot().unwrap();
assert!(emission.reply.is_empty());
assert!(emission.effects.is_empty());
assert!(after.revision > before.revision);
assert_eq!(after.projection, before.projection);
assert_eq!(after.cursor, before.cursor);
assert_eq!(after.geometry, before.geometry);
assert_eq!(after.origin, before.origin);
assert_eq!(after.palette.foreground, before.palette.foreground);
assert_eq!(after.palette.background, before.palette.background);
assert_eq!(after.palette.colors[1], before.palette.colors[1]);
assert_eq!(after.palette.colors[2], same);
assert_eq!(after.palette.colors[3], selected.colors[3]);
let style = after.rows[0].row.cells[1].style;
assert_eq!(
style.foreground,
Color::Rgb(Rgb {
red: 1,
green: 2,
blue: 3
})
);
assert_eq!(
style.background,
Color::Rgb(Rgb {
red: 4,
green: 5,
blue: 6
})
);
assert_eq!(
after.rows[0].row.cells[0].style.foreground,
Color::Indexed(1)
);
assert_eq!(after.rows[0].row.cells[2].style.foreground, Color::Default);
vt.feed(b"\x1b]104;1\x07\x1b]110\x07\x1b]111\x07").unwrap();
let (reset, _) = vt.snapshot().unwrap();
assert_eq!(reset.palette.foreground, selected.foreground);
assert_eq!(reset.palette.background, selected.background);
assert_eq!(reset.palette.colors[1], selected.colors[1]);
assert_eq!(reset.palette.colors[2], same);
vt.feed(b"\x1b]104\x07").unwrap();
let (reset_all, _) = vt.snapshot().unwrap();
assert_eq!(*reset_all.palette, selected);
assert_eq!(reset_all.rows[0].row.cells[1].style, style);
let (unchanged, _) = vt.snapshot().unwrap();
assert!(Arc::ptr_eq(&reset_all.palette, &unchanged.palette));
}
#[test]
fn every_registered_theme_initializes_and_rethemes_an_existing_terminal() {
use strop_core::theme::ThemeId;
let mut existing = terminal(10, 2);
existing.feed(b"unchanged").unwrap();
for theme in ThemeId::ALL {
let palette = Palette::for_theme(theme);
let mut fresh = themed_terminal(10, 2, Some(&palette));
assert_eq!(*fresh.snapshot().unwrap().0.palette, palette);
existing.configure_palette(&palette).unwrap();
let (frame, _) = existing.snapshot().unwrap();
assert_eq!(*frame.palette, palette);
assert!(frame.projection.to_string().starts_with("unchanged"));
}
}