tastty-core 0.1.0

Sans-IO core of the tastty terminal session library: VT parser, screen buffer, and byte encoders.
use super::*;

#[test]
fn contents_between_range() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 5, cols: 20 }, 0);
    parser.process(b"line one\r\n");
    parser.process(b"line two\r\n");
    parser.process(b"line three\r\n");
    let text = parser
        .screen()
        .contents_between(Position { row: 0, col: 0 }, Position { row: 1, col: 7 });
    // Non-end rows preserve their per-row padding to the column count;
    // the end row stops at end.col with no rstrip.
    let expected = format!("{:<20}\nline two", "line one");
    assert_eq!(text, expected);
}

#[test]
fn contents_between_single_line() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 5, cols: 20 }, 0);
    parser.process(b"hello world");
    let text = parser
        .screen()
        .contents_between(Position { row: 0, col: 6 }, Position { row: 0, col: 10 });
    assert_eq!(text, "world");
}

#[test]
fn contents_between_clamps_to_bounds() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 3, cols: 10 }, 0);
    parser.process(b"abc");
    let text = parser
        .screen()
        .contents_between(Position { row: 0, col: 0 }, Position { row: 99, col: 99 });
    assert!(text.contains("abc"));
}

#[test]
fn contents_between_empty_range() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 5, cols: 20 }, 0);
    parser.process(b"hello");
    let text = parser
        .screen()
        .contents_between(Position { row: 2, col: 0 }, Position { row: 2, col: 19 });
    // 20 cells of empty cell-grid render as 20 spaces; trim at the
    // call site if the caller wants the conceptually-empty shape.
    assert_eq!(text.trim_end(), "");
    assert_eq!(text.len(), 20);
}

#[test]
fn contents_between_respects_wrapped() {
    // 5 cols: "hello" fills row 0, "world" wraps to row 1
    let mut parser = crate::Parser::new(TerminalSize { rows: 5, cols: 5 }, 0);
    parser.process(b"helloworld");
    let text = parser
        .screen()
        .contents_between(Position { row: 0, col: 0 }, Position { row: 1, col: 4 });
    assert_eq!(text, "helloworld");
}

#[test]
fn contents_formatted_roundtrip() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 5, cols: 20 }, 0);
    parser.process(b"\x1b[31mred text\x1b[0m normal");
    let formatted = parser
        .screen()
        .contents_formatted(FormattedOptions::default());
    assert!(!formatted.is_empty());
    let mut parser2 = crate::Parser::new(TerminalSize { rows: 5, cols: 20 }, 0);
    parser2.process(&formatted);
    // Verify text content matches
    assert_eq!(
        parser.screen().contents(),
        parser2.screen().contents(),
        "round-tripping contents_formatted must reproduce the original plain text",
    );
    // Verify attributes are preserved on cells that have content
    let cell_r = parser2.screen().cell(0, 0).unwrap();
    assert_eq!(
        cell_r.attrs().fg_color,
        crate::attrs::Color::Index(1),
        "red SGR fg must survive the contents_formatted round-trip",
    );
    assert_eq!(cell_r.contents(), "r");
    // "normal" text (after reset) should have default fg
    let cell_n = parser2.screen().cell(0, 9).unwrap();
    assert_eq!(
        cell_n.attrs().fg_color,
        crate::attrs::Color::Default,
        "post-reset fg must round-trip as Default, not stay red from the previous span",
    );
    assert_eq!(cell_n.contents(), "n");
}

#[test]
fn contents_formatted_preserves_bold_italic() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 3, cols: 20 }, 0);
    parser.process(b"\x1b[1;3mbold italic\x1b[0m");
    let formatted = parser
        .screen()
        .contents_formatted(FormattedOptions::default());
    let mut parser2 = crate::Parser::new(TerminalSize { rows: 3, cols: 20 }, 0);
    parser2.process(&formatted);
    let cell = parser2.screen().cell(0, 0).unwrap();
    assert!(
        cell.attrs().bold(),
        "bold attribute lost across the formatted round-trip"
    );
    assert!(
        cell.attrs().italic(),
        "italic attribute lost across the formatted round-trip",
    );
}

#[test]
fn contents_between_wide_chars() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 3, cols: 10 }, 0);
    // A(0) B(1) 世(2-3) C(4) D(5)
    parser.process("AB\u{4e16}CD".as_bytes());

    // end col is inclusive, so 0..=5 covers all five logical characters
    let text = parser
        .screen()
        .contents_between(Position { row: 0, col: 0 }, Position { row: 0, col: 5 });
    assert_eq!(text, "AB\u{4e16}CD");

    // Starting at col 3 (wide continuation, skipped) through col 5
    let text = parser
        .screen()
        .contents_between(Position { row: 0, col: 3 }, Position { row: 0, col: 5 });
    assert_eq!(text, "CD");
}

#[test]
fn contents_formatted_wide_chars() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 3, cols: 10 }, 0);
    parser.process("A\u{4e16}B".as_bytes());

    let formatted = parser
        .screen()
        .contents_formatted(FormattedOptions::default());
    let mut parser2 = crate::Parser::new(TerminalSize { rows: 3, cols: 10 }, 0);
    parser2.process(&formatted);

    assert_eq!(parser.screen().contents(), parser2.screen().contents());
    let cell = parser2.screen().cell(0, 1).unwrap();
    assert!(
        cell.is_wide(),
        "wide-char head lost its width flag across the formatted round-trip",
    );
    let cell_cont = parser2.screen().cell(0, 2).unwrap();
    assert!(
        cell_cont.is_wide_continuation(),
        "wide-char continuation cell lost its flag across the formatted round-trip",
    );
}

#[test]
fn contents_formatted_with_scrollback_offset() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 3, cols: 10 }, 100);
    for i in 0..6 {
        parser.process(format!("line{i}\r\n").as_bytes());
    }

    let available = parser.screen().scrollback_available();
    assert!(available > 0);
    parser.screen_mut().scroll_to(available);

    let formatted = parser
        .screen()
        .contents_formatted(FormattedOptions::default());

    let mut parser2 = crate::Parser::new(TerminalSize { rows: 3, cols: 10 }, 0);
    parser2.process(&formatted);

    assert_eq!(parser.screen().contents(), parser2.screen().contents(),);
}

#[test]
fn contents_plain_drops_sgr() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 3, cols: 20 }, 0);
    parser.process(b"\x1b[31mred\x1b[0m \x1b[1;3mbold\x1b[0m");
    let plain = parser.screen().contents_plain(PlainOptions::default());
    assert!(!plain.contains('\x1b'));
    assert!(!plain.contains('['));
    assert_eq!(plain, "red bold");
}

#[test]
fn contents_plain_wide_chars() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 3, cols: 10 }, 0);
    parser.process("A\u{4e16}B".as_bytes());
    let plain = parser.screen().contents_plain(PlainOptions::default());
    assert_eq!(plain, "A\u{4e16}B");
}

#[test]
fn contents_plain_with_scrollback_offset() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 3, cols: 10 }, 100);
    for i in 0..6 {
        parser.process(format!("line{i}\r\n").as_bytes());
    }

    let available = parser.screen().scrollback_available();
    assert!(available > 0);
    parser.screen_mut().scroll_to(available);

    let plain = parser.screen().contents_plain(PlainOptions::default());
    assert!(
        plain.contains("line0"),
        "expected oldest history row in scrollback view, got: {plain:?}"
    );
}

#[test]
fn contents_plain_trims_trailing_blanks() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 5, cols: 10 }, 0);
    parser.process(b"hello");
    let plain = parser.screen().contents_plain(PlainOptions::default());
    assert_eq!(plain, "hello");
}

#[test]
fn contents_plain_empty_screen() {
    let parser = crate::Parser::new(TerminalSize { rows: 5, cols: 10 }, 0);
    assert_eq!(parser.screen().contents_plain(PlainOptions::default()), "");
}

#[test]
fn contents_plain_default_keeps_physical_layout() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 4, cols: 8 }, 0);
    parser.process(b"abcdefghIJ");
    let plain = parser.screen().contents_plain(PlainOptions::default());
    assert_eq!(plain, "abcdefgh\nIJ");
}

#[test]
fn contents_plain_join_wrapped_reconstructs_logical_line() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 4, cols: 8 }, 0);
    parser.process(b"abcdefghIJ");
    let plain = parser.screen().contents_plain(PlainOptions {
        join_wrapped: true,
        ..Default::default()
    });
    assert_eq!(plain, "abcdefghIJ");
}

#[test]
fn contents_plain_join_wrapped_preserves_explicit_newlines() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 6, cols: 8 }, 0);
    parser.process(b"abcdefghIJ\r\nnext");
    let plain = parser.screen().contents_plain(PlainOptions {
        join_wrapped: true,
        ..Default::default()
    });
    assert_eq!(plain, "abcdefghIJ\nnext");
}

#[test]
fn contents_formatted_default_keeps_physical_layout() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 4, cols: 8 }, 0);
    parser.process(b"abcdefghIJ");
    let formatted = parser
        .screen()
        .contents_formatted(FormattedOptions::default());
    assert_eq!(formatted, b"abcdefgh\r\nIJ");
}

#[test]
fn contents_formatted_join_wrapped_reconstructs_logical_line() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 4, cols: 8 }, 0);
    parser.process(b"\x1b[31mabcdefghIJ\x1b[0m");
    let formatted = parser.screen().contents_formatted(FormattedOptions {
        join_wrapped: true,
        ..Default::default()
    });
    assert_eq!(formatted, b"\x1b[31mabcdefghIJ\x1b[0m");

    let mut replay = crate::Parser::new(TerminalSize { rows: 2, cols: 16 }, 0);
    replay.process(&formatted);
    for col in 0..10 {
        let cell = replay.screen().cell(0, col).unwrap();
        assert_eq!(
            cell.attrs().fg_color,
            crate::attrs::Color::Index(1),
            "col {col} lost red fg across the wrap join",
        );
    }
}

#[test]
fn contents_formatted_join_wrapped_preserves_explicit_newlines() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 6, cols: 8 }, 0);
    parser.process(b"abcdefghIJ\r\nnext");
    let formatted = parser.screen().contents_formatted(FormattedOptions {
        join_wrapped: true,
        ..Default::default()
    });
    assert_eq!(formatted, b"abcdefghIJ\r\nnext");
}

#[test]
fn contents_plain_include_scrollback_emits_history_then_viewport() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 3, cols: 10 }, 100);
    for i in 0..6 {
        parser.process(format!("line{i}\r\n").as_bytes());
    }
    assert!(
        parser.screen().scrollback_available() >= 4,
        "fixture must push at least 4 lines into scrollback to exercise the history walk",
    );

    let viewport_only = parser.screen().contents_plain(PlainOptions::default());
    assert_eq!(
        viewport_only, "line4\nline5",
        "default PlainOptions must not include retained scrollback history",
    );

    let with_history = parser.screen().contents_plain(PlainOptions {
        include_scrollback: true,
        ..Default::default()
    });
    assert_eq!(
        with_history, "line0\nline1\nline2\nline3\nline4\nline5",
        "include_scrollback must prepend retained history (oldest first) to the live region",
    );
}

#[test]
fn contents_plain_no_trim_emits_blank_rows_to_full_height() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 4, cols: 8 }, 0);
    parser.process(b"hello");

    let untrimmed = parser.screen().contents_plain(PlainOptions {
        trim_trailing_blanks: false,
        ..Default::default()
    });
    assert_eq!(
        untrimmed, "hello\n\n\n",
        "trim_trailing_blanks=false must emit one blank row per remaining row of the screen",
    );
}

#[test]
fn contents_formatted_include_scrollback_emits_history_then_viewport() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 3, cols: 10 }, 100);
    for i in 0..6 {
        parser.process(format!("line{i}\r\n").as_bytes());
    }
    assert!(
        parser.screen().scrollback_available() >= 4,
        "fixture must push at least 4 lines into scrollback to exercise the history walk",
    );

    let viewport_only = parser
        .screen()
        .contents_formatted(FormattedOptions::default());
    assert_eq!(
        viewport_only, b"line4\r\nline5",
        "default FormattedOptions must not include retained scrollback history",
    );

    let with_history = parser.screen().contents_formatted(FormattedOptions {
        include_scrollback: true,
        ..Default::default()
    });
    assert_eq!(
        with_history, b"line0\r\nline1\r\nline2\r\nline3\r\nline4\r\nline5",
        "include_scrollback must prepend retained history (oldest first) to the live region",
    );
}

#[test]
fn contents_formatted_no_trim_emits_blank_rows_to_full_height() {
    let mut parser = crate::Parser::new(TerminalSize { rows: 4, cols: 8 }, 0);
    parser.process(b"hello");

    let untrimmed = parser.screen().contents_formatted(FormattedOptions {
        trim_trailing_blanks: false,
        ..Default::default()
    });
    assert_eq!(
        untrimmed, b"hello\r\n\r\n\r\n",
        "trim_trailing_blanks=false must emit a CRLF separator per remaining row of the screen",
    );
}