use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use alacritty_terminal::event::{Event, EventListener, WindowSize};
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line};
use alacritty_terminal::term::cell::{Cell, Flags};
use alacritty_terminal::term::{Config as TermConfig, Osc52, Term, TermMode};
use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Processor, Rgb};
use tracing::warn;
use super::frame::{CellFrame, CursorState, DiffEngine, RowData, decscusr_code};
const VT_SCROLLBACK_LINES: usize = 1000;
const MAX_RESPONSE_ENTRIES: usize = 64;
const MAX_RESPONSE_BYTES: usize = 8 * 1024;
const ANCHOR_SEARCH_RADIUS: usize = 512;
const RESPONSE_CLOSURE_EST_BYTES: usize = 32;
#[derive(Default)]
struct ResponseSink {
inner: Mutex<SinkInner>,
}
#[derive(Default)]
struct SinkInner {
queue: VecDeque<PendingResponse>,
bytes: usize,
title: String,
}
enum PendingResponse {
Bytes(Vec<u8>),
Color(usize, Arc<dyn Fn(Rgb) -> String + Sync + Send + 'static>),
TextArea(Arc<dyn Fn(WindowSize) -> String + Sync + Send + 'static>),
}
fn response_len(resp: &PendingResponse) -> usize {
match resp {
PendingResponse::Bytes(b) => b.len(),
PendingResponse::Color(..) | PendingResponse::TextArea(_) => RESPONSE_CLOSURE_EST_BYTES,
}
}
impl ResponseSink {
fn push(&self, resp: PendingResponse) {
let len = response_len(&resp);
if len > MAX_RESPONSE_BYTES {
warn!("vt response entry exceeds {MAX_RESPONSE_BYTES}B cap, dropped");
return;
}
let mut g = self.inner.lock().unwrap();
while g.queue.len() >= MAX_RESPONSE_ENTRIES || g.bytes + len > MAX_RESPONSE_BYTES {
let Some(oldest) = g.queue.pop_front() else { break };
g.bytes -= response_len(&oldest);
warn!(
"vt response buffer overflow (cap {MAX_RESPONSE_ENTRIES} entries / {MAX_RESPONSE_BYTES}B), oldest dropped"
);
}
g.queue.push_back(resp);
g.bytes += len;
}
}
struct VtEventListener {
sink: Arc<ResponseSink>,
}
impl EventListener for VtEventListener {
fn send_event(&self, event: Event) {
match event {
Event::Title(title) => self.sink.inner.lock().unwrap().title = title,
Event::ResetTitle => self.sink.inner.lock().unwrap().title.clear(),
Event::PtyWrite(text) => self.sink.push(PendingResponse::Bytes(text.into_bytes())),
Event::ColorRequest(index, fmt) => self.sink.push(PendingResponse::Color(index, fmt)),
Event::TextAreaSizeRequest(fmt) => self.sink.push(PendingResponse::TextArea(fmt)),
_ => {}
}
}
}
struct VtSize {
rows: usize,
cols: usize,
}
impl Dimensions for VtSize {
fn total_lines(&self) -> usize {
self.rows + VT_SCROLLBACK_LINES
}
fn screen_lines(&self) -> usize {
self.rows
}
fn columns(&self) -> usize {
self.cols
}
}
const FALLBACK_COLOR: Rgb = Rgb { r: 0xd8, g: 0xd8, b: 0xd8 };
#[derive(Clone, Copy, PartialEq)]
pub struct CellStyle {
fg: Option<Color>,
bg: Option<Color>,
flags: Flags,
}
impl Default for CellStyle {
fn default() -> Self {
Self { fg: None, bg: None, flags: Flags::empty() }
}
}
impl CellStyle {
pub fn of(cell: &Cell) -> Self {
Self { fg: normalize_color(cell.fg), bg: normalize_color(cell.bg), flags: cell.flags }
}
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
fn normalize_color(c: Color) -> Option<Color> {
let Color::Named(n) = c else { return Some(c) };
match n {
NamedColor::Black
| NamedColor::Red
| NamedColor::Green
| NamedColor::Yellow
| NamedColor::Blue
| NamedColor::Magenta
| NamedColor::Cyan
| NamedColor::White
| NamedColor::BrightBlack
| NamedColor::BrightRed
| NamedColor::BrightGreen
| NamedColor::BrightYellow
| NamedColor::BrightBlue
| NamedColor::BrightMagenta
| NamedColor::BrightCyan
| NamedColor::BrightWhite => Some(c),
_ => None,
}
}
fn color_key(c: Option<Color>) -> u64 {
match c {
None => 0,
Some(Color::Named(n)) => 1 + n as u64,
Some(Color::Indexed(i)) => 0x100 + i as u64,
Some(Color::Spec(rgb)) => {
0x1_0000 + ((rgb.r as u64) << 16 | (rgb.g as u64) << 8 | rgb.b as u64)
}
}
}
fn hash_grid_row(grid: &alacritty_terminal::grid::Grid<Cell>, cols: usize, line: Line) -> u64 {
const FNV_OFFSET: u64 = 14695981039346656037;
const FNV_PRIME: u64 = 1099511628211;
let mut h = FNV_OFFSET;
let mut mix = |v: u64| {
h ^= v;
h = h.wrapping_mul(FNV_PRIME);
};
for col in 0..cols {
let cell_ref = &grid[line][Column(col)];
if cell_ref.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) {
mix(0x42);
continue;
}
mix(cell_ref.c as u64);
if let Some(zw) = cell_ref.zerowidth() {
for z in zw {
mix(*z as u64);
}
}
let style = CellStyle::of(cell_ref);
mix(style.flags.bits() as u64);
mix(color_key(style.fg));
mix(color_key(style.bg));
}
h
}
fn push_cell_text(out: &mut String, cell: &Cell) {
if cell.c == '\t' {
out.push(' ');
} else {
out.push(cell.c);
}
if let Some(zw) = cell.zerowidth() {
out.extend(zw.iter().copied());
}
}
pub fn sgr_body(style: &CellStyle) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
for (flag, code) in [
(Flags::BOLD, "1"),
(Flags::DIM, "2"),
(Flags::ITALIC, "3"),
(Flags::UNDERLINE, "4"),
(Flags::INVERSE, "7"),
(Flags::HIDDEN, "8"),
(Flags::STRIKEOUT, "9"),
] {
if style.flags.contains(flag) {
parts.push(code.to_string());
}
}
if let Some(c) = style.fg {
let p = color_sgr(c, true);
if !p.is_empty() {
parts.push(p);
}
}
if let Some(c) = style.bg {
let p = color_sgr(c, false);
if !p.is_empty() {
parts.push(p);
}
}
(!parts.is_empty()).then(|| parts.join(";"))
}
pub fn color_sgr(c: Color, foreground: bool) -> String {
let std = if foreground { 30 } else { 40 };
let bright = if foreground { 90 } else { 100 };
let ext = if foreground { 38 } else { 48 };
match c {
Color::Named(n) => {
let idx: u8 = match n {
NamedColor::Black => 0,
NamedColor::Red => 1,
NamedColor::Green => 2,
NamedColor::Yellow => 3,
NamedColor::Blue => 4,
NamedColor::Magenta => 5,
NamedColor::Cyan => 6,
NamedColor::White => 7,
NamedColor::BrightBlack => 8,
NamedColor::BrightRed => 9,
NamedColor::BrightGreen => 10,
NamedColor::BrightYellow => 11,
NamedColor::BrightBlue => 12,
NamedColor::BrightMagenta => 13,
NamedColor::BrightCyan => 14,
_ => return String::new(),
};
if idx < 8 { (std + idx).to_string() } else { (bright + idx - 8).to_string() }
}
Color::Indexed(i) => format!("{ext};5;{i}"),
Color::Spec(rgb) => format!("{ext};2;{};{};{}", rgb.r, rgb.g, rgb.b),
}
}
pub struct VtState {
term: Term<VtEventListener>,
processor: Processor,
sink: Arc<ResponseSink>,
rows: u16,
cols: u16,
diff_engine: DiffEngine,
last_cursor: Mutex<Option<(i32, u16, u8, bool)>>,
frame_seq: u64,
}
impl VtState {
pub fn new(rows: u16, cols: u16) -> Self {
let rows = rows.max(1) as usize;
let cols = cols.max(1) as usize;
let config = TermConfig {
scrolling_history: VT_SCROLLBACK_LINES,
osc52: Osc52::Disabled,
..Default::default()
};
let sink = Arc::new(ResponseSink::default());
let size = VtSize { rows, cols };
let term = Term::new(config, &size, VtEventListener { sink: Arc::clone(&sink) });
Self {
term,
processor: Processor::new(),
sink,
rows: rows as u16,
cols: cols as u16,
diff_engine: DiffEngine::with_rows(rows),
last_cursor: Mutex::new(None),
frame_seq: 0,
}
}
pub fn feed(&mut self, bytes: &[u8]) {
self.processor.advance(&mut self.term, bytes);
}
pub fn take_responses(&mut self) -> Vec<u8> {
let pending: Vec<PendingResponse> = {
let mut g = self.sink.inner.lock().unwrap();
g.bytes = 0;
g.queue.drain(..).collect()
};
let mut out = Vec::new();
for resp in pending {
match resp {
PendingResponse::Bytes(b) => out.extend_from_slice(&b),
PendingResponse::Color(index, fmt) => {
let rgb = self.term.colors()[index].unwrap_or(FALLBACK_COLOR);
out.extend_from_slice(fmt(rgb).as_bytes());
}
PendingResponse::TextArea(fmt) => {
let ws = WindowSize {
num_lines: self.rows,
num_cols: self.cols,
cell_width: 0,
cell_height: 0,
};
out.extend_from_slice(fmt(ws).as_bytes());
}
}
}
out
}
pub fn title(&self) -> String {
self.sink.inner.lock().unwrap().title.clone()
}
pub fn resize(&mut self, rows: u16, cols: u16) {
let rows = rows.max(1) as usize;
let cols = cols.max(1) as usize;
self.rows = rows as u16;
self.cols = cols as u16;
self.term.resize(VtSize { rows, cols });
self.diff_engine.resize(rows);
self.diff_engine.invalidate();
if let Ok(mut lc) = self.last_cursor.lock() {
lc.take();
}
}
pub fn capture_visible(&self) -> String {
let grid = self.term.grid();
let rows = self.term.screen_lines();
let cols = Column(self.term.columns());
let mut out = String::new();
for row in 0..rows {
let mut line = String::new();
for cell in &grid[Line(row as i32)][Column(0)..cols] {
if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
continue;
}
line.push(cell.c);
}
out.push_str(line.trim_end());
out.push('\n');
}
out
}
#[cfg(test)]
pub fn renderable_cursor_for_test(&self) -> (i32, u16) {
let rc = self.term.renderable_content();
(rc.cursor.point.line.0, rc.cursor.point.column.0 as u16)
}
pub fn render_screen(&self) -> Vec<u8> {
let grid = self.term.grid();
let rows = self.term.screen_lines();
let cols = self.term.columns();
let mut out: Vec<u8> = Vec::with_capacity(rows * cols * 8 + 64);
let mut cur = CellStyle::default();
for row in 0..rows {
let cells: Vec<(char, CellStyle)> = grid[Line(row as i32)][Column(0)..Column(cols)]
.iter()
.filter(|c| {
!c.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
})
.map(|c| (c.c, CellStyle::of(c)))
.collect();
let trimmed =
cells.iter().rev().take_while(|(ch, st)| *ch == ' ' && st.is_default()).count();
for (ch, st) in &cells[..cells.len() - trimmed] {
if *st != cur {
out.extend_from_slice(b"\x1b[0m");
if let Some(body) = sgr_body(st) {
out.push(b'\x1b');
out.push(b'[');
out.extend_from_slice(body.as_bytes());
out.push(b'm');
}
cur = *st;
}
let ch = if *ch == '\t' { ' ' } else { *ch };
let mut buf = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
}
if row + 1 < rows {
out.extend_from_slice(b"\r\n");
}
}
if cur != CellStyle::default() {
out.extend_from_slice(b"\x1b[0m");
}
let rc = self.term.renderable_content();
let point = rc.cursor.point;
out.extend_from_slice(
format!("\x1b[{};{}H", point.line.0 + 1, point.column.0 + 1).as_bytes(),
);
match rc.cursor.shape {
CursorShape::Hidden => out.extend_from_slice(b"\x1b[?25l"),
_ => out.extend_from_slice(b"\x1b[?25h"),
}
out
}
pub fn encode_cell_frame(&mut self, session_id: &str, force_full: bool) -> String {
self.frame_seq = self.frame_seq.wrapping_add(1);
let seq = self.frame_seq;
self.encode_frame_body(session_id, false, Some(seq), force_full)
}
pub fn encode_overlay_frame(&mut self, session_id: &str) -> String {
let grid = self.term.grid();
let rows = self.term.screen_lines();
let cols = self.term.columns();
let out_rows: Vec<RowData> =
(0..rows).map(|r| self.encode_row_static(grid, cols, Line(r as i32))).collect();
let rc = self.term.renderable_content();
let cursor = Some(CursorState {
row: rc.cursor.point.line.0 + 1,
col: (rc.cursor.point.column.0 + 1) as u16,
shape: Some(decscusr_code(rc.cursor.shape)),
visible: !matches!(rc.cursor.shape, CursorShape::Hidden),
});
let frame = CellFrame {
t: "cell_frame",
session_id: session_id.to_string(),
width: cols as u16,
height: rows as u16,
full: true,
cursor,
overlay: true,
row_indices: None,
viewport: None,
viewport_fp: None,
alt_screen: Some(self.mode().contains(TermMode::ALT_SCREEN)),
bracketed_paste: Some(self.mode().contains(TermMode::BRACKETED_PASTE)),
history_size: grid.history_size() as u32,
seq: None,
rows: out_rows,
};
let json = serde_json::to_string(&frame).expect("CellFrame serialization must not fail");
crate::engine::pty::metrics::record_cell_frame_bytes(json.len());
json
}
pub fn encode_viewport_frame(&self, session_id: &str, y: u32, fp: Option<u64>) -> String {
let grid = self.term.grid();
let rows = self.term.screen_lines();
let cols = self.term.columns();
let hs = grid.history_size() as i32;
let y = (y as i64).clamp(0, hs as i64) as i32;
let top = hs - y;
let top = match fp {
Some(fp) if y > 0 => Self::relocate_anchor(grid, cols, hs, top, fp),
_ => top,
};
let start = top - hs; let y = (hs - top) as u32;
let out_rows: Vec<RowData> =
(0..rows).map(|i| self.encode_row_static(grid, cols, Line(start + i as i32))).collect();
let rc = self.term.renderable_content();
let cursor = Some(CursorState {
row: 1,
col: 1,
shape: Some(decscusr_code(rc.cursor.shape)),
visible: y == 0 && !matches!(rc.cursor.shape, CursorShape::Hidden),
});
let frame = CellFrame {
t: "cell_frame",
session_id: session_id.to_string(),
width: cols as u16,
height: rows as u16,
full: true,
cursor,
overlay: false,
row_indices: None,
viewport: Some(y),
viewport_fp: Some(format!("{:016x}", hash_grid_row(grid, cols, Line(start)))),
alt_screen: None,
bracketed_paste: Some(self.mode().contains(TermMode::BRACKETED_PASTE)),
history_size: grid.history_size() as u32,
seq: None,
rows: out_rows,
};
let json = serde_json::to_string(&frame).expect("CellFrame serialization must not fail");
crate::engine::pty::metrics::record_cell_frame_bytes(json.len());
json
}
fn relocate_anchor(
grid: &alacritty_terminal::grid::Grid<Cell>,
cols: usize,
hs: i32,
abs: i32,
fp: u64,
) -> i32 {
let matches = |cand: i32| {
(0..=hs).contains(&cand) && hash_grid_row(grid, cols, Line(cand - hs)) == fp
};
if matches(abs) {
return abs;
}
let radius = ANCHOR_SEARCH_RADIUS as i32;
for d in 1..=radius {
for cand in [abs - d, abs + d] {
if matches(cand) {
return cand;
}
}
}
abs
}
pub fn invalidate_diff(&mut self) {
self.diff_engine.invalidate();
if let Ok(mut lc) = self.last_cursor.lock() {
lc.take();
}
}
pub fn mode(&self) -> TermMode {
*self.term.mode()
}
fn encode_frame_body(
&mut self,
session_id: &str,
overlay: bool,
seq: Option<u64>,
force_full: bool,
) -> String {
use crate::engine::pty::frame::{CellFrame, RowData};
let grid = self.term.grid();
let rows = self.term.screen_lines();
let cols = self.term.columns();
if force_full {
self.diff_engine.invalidate();
}
let full = self.diff_engine.is_untracked();
let mut row_hashes: Vec<u64> = Vec::with_capacity(rows);
for r in 0..rows {
row_hashes.push(hash_grid_row(grid, cols, Line(r as i32)));
}
let changed_indices = self.diff_engine.changed_rows_from(&row_hashes);
let out_rows: Vec<RowData> = changed_indices
.iter()
.map(|&r| self.encode_row_static(grid, cols, Line(r as i32)))
.collect();
let rc = self.term.renderable_content();
let cursor_key = (
rc.cursor.point.line.0 + 1,
(rc.cursor.point.column.0 + 1) as u16,
decscusr_code(rc.cursor.shape),
!matches!(rc.cursor.shape, CursorShape::Hidden),
);
let cursor = {
let mut last = self.last_cursor.lock().unwrap();
let changed = last.map(|l| l != cursor_key).unwrap_or(true);
if changed {
*last = Some(cursor_key);
Some(CursorState {
row: cursor_key.0,
col: cursor_key.1,
shape: Some(cursor_key.2),
visible: cursor_key.3,
})
} else {
None
}
};
let row_indices = if full { None } else { Some(changed_indices) };
let frame = CellFrame {
t: "cell_frame",
session_id: session_id.to_string(),
width: cols as u16,
height: rows as u16,
full,
cursor,
overlay,
row_indices,
viewport: None,
viewport_fp: None,
alt_screen: None,
bracketed_paste: Some(self.mode().contains(TermMode::BRACKETED_PASTE)),
history_size: grid.history_size() as u32,
seq,
rows: out_rows,
};
let json = serde_json::to_string(&frame).expect("CellFrame serialization must not fail");
crate::engine::pty::metrics::record_cell_frame_bytes(json.len());
json
}
fn encode_row_static(
&self,
grid: &alacritty_terminal::grid::Grid<Cell>,
cols: usize,
line: Line,
) -> RowData {
let mut runs: Vec<String> = Vec::new();
let mut current: Option<String> = None;
let mut text = String::new();
for col in 0..cols {
let cell_ref = &grid[line][Column(col)];
if cell_ref.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER)
{
continue;
}
let style = CellStyle::of(cell_ref);
let sgr = sgr_body(&style).unwrap_or_default();
match current {
Some(ref cur) if *cur == sgr => push_cell_text(&mut text, cell_ref),
_ => {
if let Some(prev) = current.take() {
runs.push(prev);
runs.push(std::mem::take(&mut text));
}
current = Some(sgr);
push_cell_text(&mut text, cell_ref);
}
}
}
if let Some(prev) = current.take() {
runs.push(prev);
runs.push(std::mem::take(&mut text));
}
RowData { runs }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vt(rows: u16, cols: u16) -> VtState {
VtState::new(rows, cols)
}
#[test]
fn capture_strips_ansi_and_keeps_text() {
let mut v = vt(24, 80);
v.feed(b"\x1b[1;32mgreen bold\x1b[0m plain\r\n");
v.feed(b"second line\r\n");
let cap = v.capture_visible();
assert!(cap.starts_with("green bold plain\nsecond line\n"), "got: {cap:?}");
assert!(!cap.contains('\x1b'), "capture must not contain escape bytes");
}
#[test]
fn carriage_return_overwrites_in_grid() {
let mut v = vt(24, 80);
v.feed(b"AAAA\rBB\r\n");
let cap = v.capture_visible();
assert!(cap.starts_with("BBAA\n"), "CR overwrite wrong: {cap:?}");
}
#[test]
fn osc2_sets_title() {
let mut v = vt(24, 80);
v.feed(b"\x1b]2;my agent task\x07");
assert_eq!(v.title(), "my agent task");
}
#[test]
fn resize_preserves_content() {
let mut v = vt(24, 80);
v.feed(b"keep me\r\n");
v.resize(30, 120);
let cap = v.capture_visible();
assert!(cap.contains("keep me"), "content lost after resize: {cap:?}");
}
#[test]
fn split_escape_sequence_across_feeds() {
let mut v = vt(24, 80);
v.feed(b"\x1b[3");
v.feed(b"1mred\x1b[0m");
let cap = v.capture_visible();
assert!(cap.starts_with("red\n"), "split sequence mishandled: {cap:?}");
}
#[test]
fn alt_screen_toggle_restores_main() {
let mut v = vt(24, 80);
v.feed(b"main screen\r\n");
v.feed(b"\x1b[?1049h"); v.feed(b"alt content\r\n");
let alt_cap = v.capture_visible();
assert!(alt_cap.contains("alt content"), "alt screen missing content: {alt_cap:?}");
v.feed(b"\x1b[?1049l"); let cap = v.capture_visible();
assert!(cap.contains("main screen"), "main screen lost after toggle: {cap:?}");
assert!(!cap.contains("alt content"), "alt content leaked into main screen");
}
#[test]
fn wide_chars_not_duplicated_in_capture() {
let mut v = vt(24, 80);
v.feed("终端宽度测试\r\n".as_bytes());
let cap = v.capture_visible();
assert!(cap.starts_with("终端宽度测试\n"), "got: {cap:?}");
assert_eq!(cap.matches("终端宽度测试").count(), 1, "wide char duplicated: {cap:?}");
}
#[test]
fn dsr_generates_response_for_drain() {
let mut v = vt(24, 80);
v.feed(b"\x1b[6n"); let resp = v.take_responses();
let text = String::from_utf8_lossy(&resp);
assert!(text.starts_with("\x1b["), "DSR response missing: {text:?}");
assert!(text.ends_with('R'), "DSR response must be CPR: {text:?}");
assert!(v.take_responses().is_empty(), "responses must drain");
}
#[test]
fn response_buffer_overflow_drops_oldest() {
let sink = ResponseSink::default();
for _ in 0..(MAX_RESPONSE_ENTRIES + 10) {
sink.push(PendingResponse::Bytes(vec![b'x'; 4]));
}
let g = sink.inner.lock().unwrap();
assert_eq!(g.queue.len(), MAX_RESPONSE_ENTRIES);
assert!(g.bytes <= MAX_RESPONSE_BYTES);
}
#[test]
fn response_buffer_byte_cap_enforced() {
let sink = ResponseSink::default();
for _ in 0..8 {
sink.push(PendingResponse::Bytes(vec![b'y'; MAX_RESPONSE_BYTES / 3]));
}
let g = sink.inner.lock().unwrap();
assert!(g.bytes <= MAX_RESPONSE_BYTES, "byte cap violated: {}", g.bytes);
assert!(!g.queue.is_empty(), "entries within cap must be kept");
}
#[test]
fn oversized_response_entry_rejected() {
let sink = ResponseSink::default();
sink.push(PendingResponse::Bytes(vec![b'z'; MAX_RESPONSE_BYTES + 1]));
let g = sink.inner.lock().unwrap();
assert!(g.queue.is_empty(), "oversized entry must be rejected, not queued");
}
#[test]
fn render_screen_emits_styled_text_with_reset() {
let mut v = vt(24, 80);
v.feed(b"\x1b[1;31mRED\x1b[0m tail\r\n");
let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.contains("\x1b[0m\x1b[1;31mRED\x1b[0m tail"), "got: {r:?}");
}
#[test]
fn render_screen_trims_only_default_trailing_blank() {
let mut v = vt(24, 80);
v.feed(b"abc\r\n"); v.feed(b"\x1b[41mA \x1b[0m\r\n"); let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.contains("abc\r\n"), "default trailing blank must be trimmed: {r:?}");
assert!(r.contains("\x1b[41mA \r\n"), "colored trailing blank must be kept: {r:?}");
}
#[test]
fn render_screen_restores_cursor_position_and_visibility() {
let mut v = vt(24, 80);
v.feed(b"abc\r\nxy"); let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.ends_with("\x1b[2;3H\x1b[?25h"), "cursor restore wrong: {r:?}");
v.feed(b"\x1b[?25l"); let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.ends_with("\x1b[?25l"), "hidden cursor not synced: {r:?}");
}
#[test]
fn render_screen_wide_chars_once() {
let mut v = vt(24, 80);
v.feed("终端宽度测试".as_bytes());
let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert_eq!(r.matches("终端宽度测试").count(), 1, "wide char duplicated: {r:?}");
}
#[test]
fn render_screen_shows_active_screen() {
let mut v = vt(24, 80);
v.feed(b"main screen\r\n");
v.feed(b"\x1b[?1049h"); v.feed(b"alt content");
let rendered = v.render_screen();
let r = String::from_utf8_lossy(&rendered);
assert!(r.contains("alt content"), "alt screen missing: {r:?}");
assert!(!r.contains("main screen"), "main screen leaked into render: {r:?}");
}
#[test]
fn render_screen_roundtrip_reproduces_visible_screen() {
let mut v = vt(24, 80);
v.feed(b"\x1b[1;1Hheader line\x1b[K"); v.feed(b"\x1b[2;1H\x1b[32mbody: ok\x1b[0m");
v.feed(b"\x1b[5;10Hprogress: 42%");
let frame = v.render_screen();
let mut client = vt(24, 80);
client.feed(&frame);
assert_eq!(
client.capture_visible(),
v.capture_visible(),
"replay frame diverges from server grid"
);
assert_eq!(
client.term.renderable_content().cursor.point,
v.term.renderable_content().cursor.point
);
}
#[test]
fn encode_cell_frame_produces_valid_json() {
let mut v = vt(24, 80);
let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["t"], "cell_frame");
assert_eq!(parsed["session_id"], "test-session");
assert_eq!(parsed["width"], 80);
assert_eq!(parsed["height"], 24);
assert_eq!(parsed["full"], true);
assert_eq!(parsed["overlay"], false);
assert!(parsed["cursor"].is_object(), "cursor must be present");
assert_eq!(parsed["rows"].as_array().unwrap().len(), 24);
}
#[test]
fn encode_cell_frame_includes_sgr_for_styled_runs() {
let mut v = vt(24, 80);
v.feed(b"\x1b[1;31mRED\x1b[0m");
let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
let runs = parsed["rows"][0]["runs"].as_array().unwrap();
assert_eq!(runs[0].as_str(), Some("1;31"));
assert_eq!(runs[1].as_str(), Some("RED"));
let cursor = parsed["cursor"].as_object().unwrap();
assert_eq!(cursor["col"], 4);
}
#[test]
fn encode_cell_frame_merges_wide_chars_into_one_run() {
let mut v = vt(24, 80);
v.feed("你好".as_bytes()); let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
let runs = parsed["rows"][0]["runs"].as_array().unwrap();
assert_eq!(runs[0].as_str(), Some(""), "unstyled row starts with the default run");
assert_eq!(runs[1].as_str().unwrap().trim_end(), "你好");
}
#[test]
fn encode_cell_frame_empty_screen_is_one_blank_run() {
let mut v = vt(24, 80);
let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
let rows = parsed["rows"].as_array().unwrap();
assert_eq!(rows.len(), 24);
let runs = rows[0]["runs"].as_array().unwrap();
assert_eq!(runs.len(), 2);
assert_eq!(runs[0].as_str(), Some(""));
assert_eq!(runs[1].as_str().unwrap().len(), 80);
}
#[test]
fn first_cell_frame_is_full() {
let mut v = vt(24, 80);
let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], true, "first frame must be full");
assert!(parsed["row_indices"].is_null(), "full frame must not have row_indices");
}
#[test]
fn unchanged_second_frame_is_diff() {
let mut v = vt(24, 80);
v.feed(b"stable\r\n");
let _ = v.encode_cell_frame("test-session", false); let json = v.encode_cell_frame("test-session", false); let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], false, "unchanged second frame should be diff");
assert!(parsed.get("row_indices").is_some(), "diff frame must have row_indices");
let indices = parsed["row_indices"].as_array().unwrap();
assert!(indices.is_empty(), "unchanged rows must produce empty diff");
}
#[test]
fn changed_row_appears_in_row_indices() {
let mut v = vt(24, 80);
v.feed(b"stable\r\n"); let _ = v.encode_cell_frame("test-session", false); v.feed(b"\x1b[5;1Hchanged"); let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], false);
let indices = parsed["row_indices"].as_array().unwrap();
assert_eq!(indices.len(), 1, "one row changed");
let rows = parsed["rows"].as_array().unwrap();
assert_eq!(rows.len(), 1, "one row in diff");
assert!(rows[0]["runs"][1].as_str().unwrap().starts_with("changed"));
}
#[test]
fn tab_cells_are_normalized_to_spaces_in_frame_and_screen() {
let mut v = vt(24, 80);
v.feed(b" \t x"); let json = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
let runs = parsed["rows"][0]["runs"].as_array().unwrap();
let text: String =
runs.iter().skip(1).step_by(2).map(|s| s.as_str().unwrap_or_default()).collect();
assert!(!text.contains('\t'), "runs must not contain raw TAB: {text:?}");
assert!(text.contains('x'), "content must survive normalization: {text:?}");
let screen = v.render_screen();
let screen_str = String::from_utf8(screen).expect("utf8");
assert!(!screen_str.contains('\t'), "render_screen must not emit raw TAB");
}
#[test]
fn cursor_omitted_when_unchanged() {
let mut v = vt(24, 80);
v.feed(b"hello");
let json1 = v.encode_cell_frame("test-session", false);
let parsed: serde_json::Value = serde_json::from_str(&json1).expect("must be valid JSON");
assert!(parsed["cursor"].is_object(), "first frame must include cursor");
let _ = v.encode_cell_frame("test-session", false); let json2 = v.encode_cell_frame("test-session", false);
let parsed2: serde_json::Value = serde_json::from_str(&json2).expect("must be valid JSON");
assert!(parsed2["cursor"].is_null(), "cursor must be omitted when unchanged");
}
#[test]
fn invalidate_diff_forces_next_frame_full() {
let mut v = vt(24, 80);
v.feed(b"a\r\n");
let _ = v.encode_cell_frame("ts", false); let _ = v.encode_cell_frame("ts", false); v.invalidate_diff(); let json = v.encode_cell_frame("ts", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], true, "after invalidate_diff must be full");
assert!(parsed["row_indices"].is_null());
}
#[test]
fn diff_engine_resizes_on_vt_resize() {
let mut v = vt(24, 80);
let _ = v.encode_cell_frame("ts", false); let _ = v.encode_cell_frame("ts", false); v.resize(40, 80);
let json = v.encode_cell_frame("ts", false);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], true, "after resize must be full");
assert_eq!(parsed["height"], 40);
}
#[test]
fn overlay_frame_includes_shape_in_cursor() {
let mut v = vt(24, 80);
v.feed(b"hello");
let json = v.encode_overlay_frame("test-session");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["overlay"], true);
assert_eq!(parsed["full"], true);
let cursor = parsed["cursor"].as_object().unwrap();
assert!(cursor.get("shape").is_some(), "overlay cursor must include shape");
}
#[test]
fn seq_increments_on_live_frames_and_skips_viewport_overlay() {
let mut v = vt(24, 80);
let f1: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(f1["seq"], 1, "first live frame seq = 1");
let f2: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(f2["seq"], 2, "seq increments per live encode");
let vp: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 0, None)).unwrap();
assert!(vp.get("seq").is_none(), "viewport frame must omit seq");
let ov: serde_json::Value = serde_json::from_str(&v.encode_overlay_frame("ts")).unwrap();
assert!(ov.get("seq").is_none(), "overlay frame must omit seq");
let f3: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(f3["seq"], 3, "viewport/overlay must not consume seq numbers");
}
#[test]
fn force_full_emits_all_rows_and_keeps_baseline_semantics() {
let mut v = vt(24, 80);
v.feed(b"stable\r\n");
let _ = v.encode_cell_frame("ts", false); let _ = v.encode_cell_frame("ts", false);
let f: serde_json::Value = serde_json::from_str(&v.encode_cell_frame("ts", true)).unwrap();
assert_eq!(f["full"], true, "force_full must produce a full frame");
assert!(f["row_indices"].is_null(), "full frame must not have row_indices");
let rows = f["rows"].as_array().unwrap();
assert_eq!(rows.len(), 24, "full frame must carry every screen row");
let d: serde_json::Value = serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(d["full"], false, "frame after force_full returns to diff");
let indices = d["row_indices"].as_array().unwrap();
assert!(indices.is_empty(), "baseline after force_full must match current grid");
}
#[test]
fn overlay_frame_carries_alt_screen_flag() {
let mut v = vt(24, 80);
v.feed(b"\x1b[?1049h"); let parsed: serde_json::Value =
serde_json::from_str(&v.encode_overlay_frame("ts")).unwrap();
assert_eq!(parsed["alt_screen"], true, "enter overlay must carry alt_screen=true");
v.feed(b"\x1b[?1049l"); let parsed: serde_json::Value =
serde_json::from_str(&v.encode_overlay_frame("ts")).unwrap();
assert_eq!(parsed["alt_screen"], false, "exit overlay must carry alt_screen=false");
}
#[test]
fn regular_frames_omit_alt_screen_field() {
let mut v = vt(24, 80);
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert!(parsed.get("alt_screen").is_none(), "regular frame must omit alt_screen");
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 0, None)).unwrap();
assert!(parsed.get("alt_screen").is_none(), "viewport frame must omit alt_screen");
}
#[test]
fn frame_carries_bracketed_paste_flag() {
let mut v = vt(24, 80);
v.feed(b"\x1b[?2004h"); let parsed: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(
parsed["bracketed_paste"], true,
"frame after ?2004h must carry bracketed_paste=true"
);
v.feed(b"\x1b[?2004l"); let parsed: serde_json::Value =
serde_json::from_str(&v.encode_cell_frame("ts", false)).unwrap();
assert_eq!(
parsed["bracketed_paste"], false,
"frame after ?2004l must carry bracketed_paste=false"
);
}
#[test]
fn overlay_frame_carries_bracketed_paste_flag() {
let mut v = vt(24, 80);
v.feed(b"\x1b[?2004h");
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_overlay_frame("ts")).unwrap();
assert_eq!(
parsed["bracketed_paste"], true,
"overlay frame must carry bracketed_paste=true"
);
}
#[test]
fn viewport_frame_carries_bracketed_paste_flag() {
let mut v = vt(24, 80);
v.feed(b"\x1b[?2004h");
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 0, None)).unwrap();
assert_eq!(
parsed["bracketed_paste"], true,
"viewport frame must carry bracketed_paste=true"
);
}
fn vt_with_history() -> VtState {
let mut v = vt(24, 80);
for i in 0..30 {
v.feed(format!("L{i}\r\n").as_bytes());
}
v
}
#[test]
fn viewport_frame_y0_is_live_screen_with_marker() {
let v = vt_with_history();
let json = v.encode_viewport_frame("ts", 0, None);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["viewport"], 0, "viewport marker must carry y");
assert_eq!(parsed["full"], true);
assert_eq!(parsed["overlay"], false);
assert!(parsed["row_indices"].is_null());
let rows = parsed["rows"].as_array().unwrap();
assert_eq!(rows.len(), 24);
assert_eq!(top_row_text(&parsed), "L7");
assert_eq!(parsed["cursor"]["visible"].as_bool(), Some(true));
}
fn top_row_text(parsed: &serde_json::Value) -> String {
let runs = parsed["rows"][0]["runs"].as_array().unwrap();
runs[1].as_str().unwrap().chars().take(2).collect()
}
#[test]
fn viewport_frame_scrolls_into_history() {
let v = vt_with_history();
let json = v.encode_viewport_frame("ts", 7, None);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(top_row_text(&parsed), "L0");
let json = v.encode_viewport_frame("ts", 5, None);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(top_row_text(&parsed), "L2");
}
#[test]
fn viewport_frame_y_clamps_to_history_size() {
let v = vt_with_history();
let json = v.encode_viewport_frame("ts", u32::MAX, None);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["viewport"], 7, "y must clamp to history size");
assert_eq!(top_row_text(&parsed), "L0");
}
#[test]
fn viewport_frame_hides_cursor_above_bottom() {
let v = vt_with_history();
let json = v.encode_viewport_frame("ts", 6, None);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(
parsed["cursor"]["visible"].as_bool(),
Some(false),
"history window has no live cursor"
);
let json = v.encode_viewport_frame("ts", 0, None);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["cursor"]["visible"].as_bool(), Some(true));
}
#[test]
fn viewport_frame_does_not_disturb_diff_baseline() {
let mut v = vt_with_history();
let _ = v.encode_cell_frame("ts", false); let _ = v.encode_viewport_frame("ts", 6, None); let json = v.encode_cell_frame("ts", false); let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["full"], false, "viewport encode must not invalidate diff");
assert!(parsed["row_indices"].as_array().unwrap().is_empty());
}
#[test]
fn viewport_frame_empty_history_clamps_to_zero() {
let v = vt(24, 80); let json = v.encode_viewport_frame("ts", 10, None);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("must be valid JSON");
assert_eq!(parsed["viewport"], 0, "empty history clamps y to 0");
}
fn top_row_full(parsed: &serde_json::Value) -> String {
let runs = parsed["rows"][0]["runs"].as_array().unwrap();
(1..runs.len()).step_by(2).map(|i| runs[i].as_str().unwrap()).collect()
}
fn top_row_fp(parsed: &serde_json::Value) -> u64 {
let hex = parsed["viewport_fp"].as_str().expect("viewport frame must carry fp");
u64::from_str_radix(hex, 16).expect("fp must be hex u64")
}
fn feed_lines(v: &mut VtState, from: usize, to: usize) {
for i in from..to {
v.feed(format!("L{i:05}\r\n").as_bytes());
}
}
#[test]
fn viewport_frame_carries_top_row_fingerprint() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 60);
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 10, None)).unwrap();
let again: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 10, Some(top_row_fp(&parsed))))
.unwrap();
assert_eq!(top_row_full(&again), top_row_full(&parsed));
assert_eq!(again["viewport"], parsed["viewport"]);
}
#[test]
fn anchor_follows_content_when_history_grows() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 60);
let first: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 10, None)).unwrap();
let anchored_text = top_row_full(&first);
let fp = top_row_fp(&first);
let y0 = first["viewport"].as_u64().unwrap() as u32;
feed_lines(&mut v, 60, 80);
let drifted: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", y0, None)).unwrap();
assert_ne!(top_row_full(&drifted), anchored_text, "同一 y 未锚定时应指向新内容");
let held: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", y0, Some(fp))).unwrap();
assert_eq!(top_row_full(&held), anchored_text, "指纹锚定后内容必须不变");
assert!(held["viewport"].as_u64().unwrap() > y0 as u64, "历史增长后实际 y 必须前移");
}
#[test]
fn anchor_follows_content_across_scrollback_eviction() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 1500);
let first: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 100, None)).unwrap();
assert_eq!(first["history_size"], 1000, "历史应已饱和");
let anchored_text = top_row_full(&first);
let fp = top_row_fp(&first);
feed_lines(&mut v, 1500, 1700);
let held: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 100, Some(fp))).unwrap();
assert_eq!(top_row_full(&held), anchored_text, "淘汰未触及锚定行时内容必须不变");
feed_lines(&mut v, 1700, 2600);
let gone: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 100, Some(fp))).unwrap();
assert_eq!(gone["viewport"], 100, "失配必须退回请求偏移");
assert_ne!(top_row_full(&gone), anchored_text);
}
#[test]
fn unknown_fingerprint_falls_back_to_requested_offset() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 60);
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 10, Some(0xdead_beef))).unwrap();
assert_eq!(parsed["viewport"], 10);
assert_eq!(
top_row_full(&parsed),
top_row_full(&serde_json::from_str(&v.encode_viewport_frame("ts", 10, None)).unwrap()),
"失配回退后内容必须与无指纹请求一致"
);
}
#[test]
fn viewport_frame_y0_ignores_fingerprint_even_when_history_matches() {
let mut v = vt(6, 20);
for _ in 0..10 {
v.feed(b"\r\n");
}
assert!(v.term.grid().history_size() > 0, "前置:历史里应有空行");
let top_fp = hash_grid_row(v.term.grid(), 20, Line(0));
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 0, Some(top_fp))).unwrap();
assert_eq!(parsed["viewport"], 0, "y=0 必须恒服务 live 屏,不做指纹重定位");
assert_eq!(parsed["cursor"]["visible"], serde_json::Value::Bool(true));
}
#[test]
fn anchor_search_is_bounded_by_radius() {
let mut v = vt(24, 80);
feed_lines(&mut v, 0, 60);
let first: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 10, None)).unwrap();
let fp = top_row_fp(&first);
feed_lines(&mut v, 60, 1600);
let parsed: serde_json::Value =
serde_json::from_str(&v.encode_viewport_frame("ts", 10, Some(fp))).unwrap();
assert_eq!(parsed["viewport"], 10, "超出半径必须回退而非继续搜索");
}
fn visible_seq(row: &serde_json::Value) -> Vec<(String, String)> {
let mut out = Vec::new();
let runs = row["runs"].as_array().expect("row must carry runs");
for pair in runs.chunks(2) {
let sgr = pair[0].as_str().unwrap_or_default();
for c in pair[1].as_str().unwrap_or_default().chars() {
out.push((c.to_string(), sgr.to_string()));
}
}
out
}
fn grid_seq(v: &VtState, line: Line) -> Vec<(String, String)> {
let grid = v.term.grid();
let mut out = Vec::new();
for col in 0..v.term.columns() {
let cell = &grid[line][Column(col)];
if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) {
continue;
}
let sgr = sgr_body(&CellStyle::of(cell)).unwrap_or_default();
let mut text = String::from(cell.c);
if let Some(zw) = cell.zerowidth() {
text.extend(zw.iter().copied());
}
for ch in text.chars() {
out.push((ch.to_string(), sgr.clone()));
}
}
out
}
fn rows_of(feed: &[u8]) -> Vec<serde_json::Value> {
let mut v = vt(6, 20);
v.feed(feed);
let json = v.encode_viewport_frame("ts", 0, None);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
parsed["rows"].as_array().unwrap().clone()
}
#[test]
fn runs_encoding_is_lossless_against_grid() {
let cases: &[&[u8]] = &[
b"\r\n",
b"plain ascii row\r\n",
b"\x1b[1;32mgreen\x1b[0m plain\r\n",
b"\x1b[31mR\x1b[32mG\x1b[33mY\x1b[34mB\x1b[0m tail\r\n",
b"trailing spaces \r\n",
"中文测试 ターミナル 混排\r\n".as_bytes(),
"\x1b[31m中文\x1b[0m 混排\r\n".as_bytes(),
"emoji \u{1f600}\u{1f1fa}\u{1f1f8} end\r\n".as_bytes(),
"combining e\u{301} cafe\u{301}\r\n".as_bytes(),
];
for feed in cases {
let mut v = vt(6, 20);
v.feed(feed);
let json = v.encode_viewport_frame("ts", 0, None);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let rows = parsed["rows"].as_array().unwrap();
for (i, row) in rows.iter().enumerate() {
assert_eq!(
visible_seq(row),
grid_seq(&v, Line(i as i32)),
"row {i} lost information for input {:?}",
String::from_utf8_lossy(feed)
);
}
}
}
#[test]
fn runs_merges_consecutive_cells_sharing_sgr() {
let rows = rows_of(b"\x1b[1;32mAB\x1b[0mcd\r\n");
let runs = rows[0]["runs"].as_array().unwrap();
assert_eq!(runs.len(), 4, "must be (sgr, text) pairs, got {runs:?}");
assert_eq!(runs[0], "1;32");
assert_eq!(runs[1], "AB");
assert_eq!(runs[2], "");
assert_eq!(runs[3].as_str().unwrap().trim_end(), "cd");
}
#[test]
fn runs_skips_wide_char_spacers_without_empty_run() {
let rows = rows_of("\x1b[31m中文\x1b[0m\r\n".as_bytes());
let runs = rows[0]["runs"].as_array().unwrap();
assert_eq!(runs.len(), 4, "spacers must not split the run, got {runs:?}");
assert_eq!(runs[0], "31");
assert_eq!(runs[1].as_str().unwrap().trim_end(), "中文");
assert_eq!(runs[2], "");
}
#[test]
fn runs_keeps_zerowidth_combining_characters() {
let rows = rows_of("cafe\u{301}\r\n".as_bytes());
let text = rows[0]["runs"][1].as_str().unwrap();
assert!(text.starts_with("cafe\u{301}"), "combining accent must survive, got {text:?}");
}
#[test]
fn runs_handles_full_width_row_as_single_run() {
let mut v = vt(2, 200);
v.feed(&[b'x'; 200]);
let json = v.encode_viewport_frame("ts", 0, None);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let runs = parsed["rows"][0]["runs"].as_array().unwrap();
assert_eq!(runs.len(), 2, "single-style row must be one run, got {runs:?}");
assert_eq!(runs[1].as_str().unwrap().len(), 200);
}
#[test]
fn row_hash_detects_content_and_style_changes() {
let mut v = vt(6, 20);
v.feed(b"abc\r\ndef\r\n");
let base = hash_grid_row(v.term.grid(), 20, Line(0));
assert_eq!(base, hash_grid_row(v.term.grid(), 20, Line(0)), "同内容必须同指纹");
assert_ne!(base, hash_grid_row(v.term.grid(), 20, Line(1)), "不同行内容必须不同");
v.feed(b"\x1b[1;1Habd");
assert_ne!(base, hash_grid_row(v.term.grid(), 20, Line(0)), "字符变化必须检出");
v.feed(b"\x1b[1;1Habc"); assert_eq!(base, hash_grid_row(v.term.grid(), 20, Line(0)), "还原后回到原指纹");
v.feed(b"\x1b[1;1H\x1b[1mabc"); assert_ne!(base, hash_grid_row(v.term.grid(), 20, Line(0)), "样式变化必须检出");
}
#[test]
fn runs_encoding_applies_to_all_frame_types() {
let mut v = vt(6, 20);
v.feed(b"abc\r\n");
for json in [
v.encode_cell_frame("ts", false),
v.encode_overlay_frame("ts"),
v.encode_viewport_frame("ts", 0, None),
] {
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["rows"][0]["runs"].is_array(), "all frames must use runs: {parsed}");
}
}
}