Files
Kigi-CLI/crates/codegen/kigi-ratatui-inline/src/tests.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

418 lines
14 KiB
Rust

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<Rect>,
pub writer: MockWriter,
}
/// Mock writer that captures all output
#[derive(Debug, Clone)]
pub struct MockWriter {
pub buffer: Vec<u8>,
pub flush_count: usize,
pub commands: VecDeque<String>,
}
impl Write for MockWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
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<Size> {
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<u8>,
/// 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<usize> {
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<Item = (u16, u16, &'a Cell)>,
{
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<Position> {
Ok(Position::ORIGIN)
}
fn set_cursor_position<P: Into<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<Size> {
Ok(Size::new(80, 24))
}
fn window_size(&mut self) -> io::Result<WindowSize> {
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<RecordingBackend> {
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<u32>) -> 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<RecordingBackend>, 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:?}"
);
}
}