use std::{ collections::VecDeque, io::{self, Write}, }; use ratatui::layout::{Rect, Size}; use crate::common::TerminalLike; /// Mock terminal for testing #[derive(Debug, Clone)] pub struct MockTerminal { pub size: Size, pub viewport_area: Rect, pub clear_count: usize, pub viewport_updates: Vec, pub writer: MockWriter, } /// Mock writer that captures all output #[derive(Debug, Clone)] pub struct MockWriter { pub buffer: Vec, pub flush_count: usize, pub commands: VecDeque, } impl Write for MockWriter { fn write(&mut self, buf: &[u8]) -> io::Result { self.buffer.extend_from_slice(buf); // Parse and store readable command representation if let Ok(s) = std::str::from_utf8(buf) { self.commands.push_back(s.to_string()); } Ok(buf.len()) } fn flush(&mut self) -> io::Result<()> { self.flush_count += 1; Ok(()) } } impl MockTerminal { pub fn new(width: u16, height: u16, viewport_height: u16) -> Self { let viewport_y = height - viewport_height; Self { size: Size { width, height }, viewport_area: Rect::new(0, viewport_y, width, viewport_height), clear_count: 0, viewport_updates: Vec::new(), writer: MockWriter { buffer: Vec::new(), flush_count: 0, commands: VecDeque::new(), }, } } } impl TerminalLike for MockTerminal { type Writer = MockWriter; fn size(&self) -> io::Result { Ok(self.size) } fn viewport_area(&self) -> Rect { self.viewport_area } fn clear(&mut self) -> io::Result<()> { self.clear_count += 1; Ok(()) } fn set_viewport_area(&mut self, area: Rect) { self.viewport_updates.push(area); self.viewport_area = area; } fn writer_mut(&mut self) -> &mut Self::Writer { &mut self.writer } fn reset_back_buffer(&mut self) { // Mock implementation - just track that it was called self.clear_count += 1; } } /// Tests for the diffed OSC 8 hyperlink layer (`set_frame_links` / /// `flush_with_links`). mod links { use std::io::{self, Write}; use ratatui::backend::{Backend, WindowSize}; use ratatui::buffer::Cell; use ratatui::layout::{Position, Rect, Size}; use ratatui::style::Style; use ratatui::{TerminalOptions, Viewport}; use crate::{LinkSpan, Terminal}; /// Backend that records the raw byte stream and renders each drawn cell as /// its bare symbol, so tests can assert on OSC 8 sequences interleaved with /// cell content without depending on crossterm's exact SGR output. #[derive(Default)] struct RecordingBackend { buf: Vec, /// Total lines passed to `append_lines` (used by the /// `set_viewport_height` grow-path test). appended_lines: u16, } impl Write for RecordingBackend { fn write(&mut self, b: &[u8]) -> io::Result { self.buf.extend_from_slice(b); Ok(b.len()) } fn flush(&mut self) -> io::Result<()> { Ok(()) } } impl Backend for RecordingBackend { fn draw<'a, I>(&mut self, content: I) -> io::Result<()> where I: Iterator, { for (_x, _y, cell) in content { self.buf.extend_from_slice(cell.symbol().as_bytes()); } Ok(()) } fn hide_cursor(&mut self) -> io::Result<()> { Ok(()) } fn show_cursor(&mut self) -> io::Result<()> { Ok(()) } fn get_cursor_position(&mut self) -> io::Result { Ok(Position::ORIGIN) } fn set_cursor_position>(&mut self, _position: P) -> io::Result<()> { Ok(()) } fn clear(&mut self) -> io::Result<()> { Ok(()) } fn clear_region(&mut self, _clear_type: ratatui::backend::ClearType) -> io::Result<()> { Ok(()) } fn append_lines(&mut self, n: u16) -> io::Result<()> { self.appended_lines += n; Ok(()) } fn size(&self) -> io::Result { Ok(Size::new(80, 24)) } fn window_size(&mut self) -> io::Result { Ok(WindowSize { columns_rows: Size::new(80, 24), pixels: Size::new(0, 0), }) } fn flush(&mut self) -> io::Result<()> { Ok(()) } } fn term(w: u16, h: u16) -> Terminal { Terminal::with_options( RecordingBackend::default(), TerminalOptions { viewport: Viewport::Fixed(Rect::new(0, 0, w, h)), }, ) .unwrap() } fn span(col_start: u16, col_end: u16, url: &str, id: Option) -> LinkSpan { LinkSpan { row: 0, col_start, col_end, url: url.into(), id, } } /// Render `text` at (0,0), set `spans`, flush, and return the bytes emitted /// during this single frame. fn frame(t: &mut Terminal, text: &str, spans: &[LinkSpan]) -> String { t.backend_mut().buf.clear(); { let mut f = t.get_frame(); f.buffer_mut().set_string(0, 0, text, Style::default()); } t.set_frame_links(spans); t.flush_with_links().unwrap(); t.swap_buffers(); String::from_utf8(t.backend().buf.clone()).unwrap() } #[test] fn emits_osc8_around_linked_cells() { let mut t = term(20, 3); let out = frame(&mut t, "AB", &[span(0, 2, "https://kimi.com", None)]); assert!( out.contains("\x1b]8;;https://kimi.com\x07"), "missing open: {out:?}" ); assert!(out.contains("AB")); assert!(out.contains("\x1b]8;;\x07"), "missing close: {out:?}"); } #[test] fn no_link_emits_no_osc8() { let mut t = term(20, 3); let out = frame(&mut t, "AB", &[]); assert!(!out.contains("\x1b]8;"), "unexpected OSC8: {out:?}"); } #[test] fn grow_viewport_scrolls_committed_lines_into_history() { // A small inline viewport near the bottom of the screen, grown to full // height, must scroll the rows it will cover up into native scrollback // (append_lines) instead of overwriting them. Regression guard for the // earlier-commented-out scroll_up in set_viewport_height's grow path // (the overlay host depends on this in minimal mode). let mut t = Terminal::with_options( RecordingBackend::default(), TerminalOptions { viewport: Viewport::Inline(3), }, ) .unwrap(); // Pin the 3-row viewport near the bottom of the 24-row screen. t.set_viewport_area(Rect::new(0, 21, 80, 3)); let before = t.backend().appended_lines; // Grow to full height: overflow = (21 + 24) - 24 = 21 rows must scroll up. t.set_viewport_height(24).unwrap(); let scrolled = t.backend().appended_lines - before; assert!( scrolled >= 21, "expected >= 21 lines scrolled into history, got {scrolled}" ); } /// Regression: `set_viewport_height` must judge grow-vs-shrink against the /// live `viewport_area.height`, not the stored `Viewport::Inline(height)`. /// /// Minimal mode resizes the viewport out-of-band via `set_viewport_area` /// (its content-anchored commit path shrinks the region before /// `insert_before`), which leaves the stored `Inline` height STALE. If the /// next `set_viewport_height` compared against that stale (larger) height, a /// genuine grow would be misread as a shrink: the grow-time `scroll_up` /// would be skipped and the viewport's top would not move up, so the taller /// viewport would run off the bottom of the screen (dropdown items rendered /// off-screen — the "empty dropdown over a full screen" bug). #[test] fn grow_after_out_of_band_area_shrink_still_scrolls() { let mut t = Terminal::with_options( RecordingBackend::default(), TerminalOptions { // Stored Inline height starts tall (mimics a streaming turn that // grew the viewport to near full screen). viewport: Viewport::Inline(21), }, ) .unwrap(); // Out-of-band shrink to a 3-row viewport pinned at the bottom of the // 24-row screen — as the commit path does. This does NOT update the // stored Inline height (still 21), creating the drift. t.set_viewport_area(Rect::new(0, 21, 80, 3)); let before = t.backend().appended_lines; // Grow to 10 rows. Against the real height (3) this is a GROW that // overflows the bottom by (21 + 10) - 24 = 7 rows, which must scroll up. // Against the stale stored height (21) it would look like a shrink and // scroll nothing. t.set_viewport_height(10).unwrap(); let scrolled = t.backend().appended_lines - before; assert!( scrolled >= 7, "grow after an out-of-band area shrink must scroll the covered rows \ into history (expected >= 7, got {scrolled})" ); // The viewport top moved up so the whole 10-row region fits on screen. let area = t.viewport_area(); assert_eq!(area.height, 10, "height should be the requested 10"); assert!( area.y + area.height <= 24, "viewport must fit on screen, got y={} h={}", area.y, area.height ); } #[test] fn link_removed_next_frame_rewrites_cells_without_osc8() { let mut t = term(20, 3); let _ = frame(&mut t, "AB", &[span(0, 2, "https://kimi.com", None)]); // Same glyphs, but the link is gone: the cells must be rewritten (so the // terminal's hyperlink clears) and carry no OSC 8. This is the `/new` // regression — clearing is driven purely by the diff. let out = frame(&mut t, "AB", &[]); assert!(out.contains("AB"), "cells should be redrawn: {out:?}"); assert!(!out.contains("\x1b]8;"), "stale OSC8 leaked: {out:?}"); } #[test] fn unchanged_link_and_content_emits_nothing() { let mut t = term(20, 3); let _ = frame(&mut t, "AB", &[span(0, 2, "https://kimi.com", None)]); // Identical glyphs AND identical link → empty diff → no output at all. let out = frame(&mut t, "AB", &[span(0, 2, "https://kimi.com", None)]); assert!(out.is_empty(), "expected empty diff, got: {out:?}"); } #[test] fn retargeted_link_rewrites_cells() { let mut t = term(20, 3); let _ = frame(&mut t, "AB", &[span(0, 2, "https://a", None)]); let out = frame(&mut t, "AB", &[span(0, 2, "https://b", None)]); assert!( out.contains("\x1b]8;;https://b\x07"), "new url not emitted: {out:?}" ); } #[test] fn emit_id_param_included() { let mut t = term(20, 3); let out = frame(&mut t, "AB", &[span(0, 2, "https://kimi.com", Some(7))]); assert!( out.contains("\x1b]8;id=7;https://kimi.com\x07"), "id param missing: {out:?}" ); } #[test] fn url_control_chars_sanitized() { let mut t = term(20, 3); let out = frame(&mut t, "AB", &[span(0, 2, "https://x\x07\x1b/y", None)]); assert!( out.contains("\x1b]8;;https://x/y\x07"), "url not sanitized: {out:?}" ); } #[test] fn distinct_links_split_into_separate_runs() { let mut t = term(20, 3); // "AxB": A→a, gap x (no link), B→b. let out = frame( &mut t, "AxB", &[span(0, 1, "https://a", None), span(2, 3, "https://b", None)], ); // Each link wraps exactly its own cell; the gap is not wrapped. assert!( out.contains("\x1b]8;;https://a\x07A\x1b]8;;\x07"), "a-run: {out:?}" ); assert!( out.contains("\x1b]8;;https://b\x07B\x1b]8;;\x07"), "b-run: {out:?}" ); } #[test] fn wide_char_under_link_wraps_lead_cell_only() { let mut t = term(20, 3); // A width-2 char occupies two cells; only the lead cell is drawn, and // the OSC 8 wraps it. let out = frame(&mut t, "世", &[span(0, 2, "https://kimi.com", None)]); assert!( out.contains("\x1b]8;;https://kimi.com\x07世\x1b]8;;\x07"), "wide-char run: {out:?}" ); } #[test] fn nonzero_origin_viewport_maps_links() { // The screen→cell mapping subtracts the viewport offset; verify a link // at an absolute (row, col) inside a non-origin viewport wraps the right // cells (regression guard for `(y - area.y)` / `(x - area.x)`). let area = Rect::new(2, 5, 20, 4); let mut t = Terminal::with_options( RecordingBackend::default(), TerminalOptions { viewport: Viewport::Fixed(area), }, ) .unwrap(); { let mut f = t.get_frame(); f.buffer_mut().set_string(2, 5, "AB", Style::default()); } t.set_frame_links(&[LinkSpan { row: 5, col_start: 2, col_end: 4, url: "https://kimi.com".into(), id: None, }]); t.flush_with_links().unwrap(); let out = String::from_utf8(t.backend().buf.clone()).unwrap(); assert!( out.contains("\x1b]8;;https://kimi.com\x07AB\x1b]8;;\x07"), "non-origin mapping: {out:?}" ); } }