strop-terminal 0.34.0

Owned local terminal sessions and bounded VT snapshots for strop
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 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() {
    // 0055 §12: title, cwd, clipboard, window and hyperlink attempts from
    // the child become scoped editor metadata or an explicit denial —
    // never host window control, filesystem navigation or a reply that
    // probes the outer terminal.
    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]);
    // Window size queries and title-stack pushes answer nothing: the child
    // reads only the admitted geometry through normal mode reports.
    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());
    }
    // Hyperlink targets do not reach the host; the link text stays text.
    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() {
    // 0065 D3: the embedder-owned palette lands at creation; indexed and
    // default colors resolve through it, and one Arc serves every frame
    // until the child overrides a color itself.
    let palette = Palette::strop();
    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"
    );
    // A child's OSC 4 override of an index still wins over the embedder
    // default for that index (native palette-set semantics).
    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,
        }
    );
}