mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
test(tui): add vt100 snapshot harness and baselines
This commit is contained in:
@@ -59,6 +59,7 @@ ratatui = { version = "0.29.0", features = [
|
||||
"unstable-rendered-line-info",
|
||||
"unstable-widget-ref",
|
||||
"underline-color",
|
||||
"unstable-backend-writer",
|
||||
] }
|
||||
ratatui-image = "8.0.0"
|
||||
regex-lite = "0.1"
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::tui::TerminalInfo;
|
||||
use code_core::history::state::HistoryRecord;
|
||||
use code_core::config::{Config, ConfigOverrides, ConfigToml};
|
||||
use code_core::protocol::Event;
|
||||
use std::collections::VecDeque;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -62,6 +63,55 @@ impl ChatWidgetHarness {
|
||||
self.chat.handle_code_event(event);
|
||||
}
|
||||
|
||||
pub(crate) fn flush_into_widget(&mut self) {
|
||||
let mut queue: VecDeque<AppEvent> = self.drain_events().into();
|
||||
if queue.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut debug = Vec::new();
|
||||
while let Some(event) = queue.pop_front() {
|
||||
match event {
|
||||
AppEvent::InsertHistory(lines) => {
|
||||
debug.push("InsertHistory");
|
||||
self.chat.insert_history_lines(lines);
|
||||
}
|
||||
AppEvent::InsertHistoryWithKind { id, kind, lines } => {
|
||||
debug.push("InsertHistoryWithKind");
|
||||
self.chat.insert_history_lines_with_kind(kind, id, lines);
|
||||
}
|
||||
AppEvent::InsertFinalAnswer { id, lines, source } => {
|
||||
debug.push("InsertFinalAnswer");
|
||||
self.chat.insert_final_answer_with_id(id, lines, source);
|
||||
}
|
||||
AppEvent::InsertBackgroundEvent { message, placement, order } => {
|
||||
debug.push("InsertBackgroundEvent");
|
||||
self.chat
|
||||
.insert_background_event_with_placement(message, placement, order);
|
||||
}
|
||||
AppEvent::CommitTick => {
|
||||
debug.push("CommitTick");
|
||||
self.chat.on_commit_tick();
|
||||
let newly_emitted = self.drain_events();
|
||||
if !newly_emitted.is_empty() {
|
||||
queue.extend(newly_emitted);
|
||||
}
|
||||
}
|
||||
AppEvent::StartCommitAnimation => {
|
||||
debug.push("StartCommitAnimation");
|
||||
}
|
||||
AppEvent::StopCommitAnimation => {
|
||||
debug.push("StopCommitAnimation");
|
||||
}
|
||||
// Other events are either no-ops for VT100 rendering or handled elsewhere.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !debug.is_empty() {
|
||||
println!("flush events: {debug:?}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn drain_events(&self) -> Vec<AppEvent> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(ev) = self.events.try_recv() {
|
||||
@@ -102,7 +152,8 @@ impl ChatWidgetHarness {
|
||||
collected
|
||||
}
|
||||
|
||||
pub(crate) fn history_records(&self) -> Vec<HistoryRecord> {
|
||||
pub(crate) fn history_records(&mut self) -> Vec<HistoryRecord> {
|
||||
self.flush_into_widget();
|
||||
self.chat.history_state.records.clone()
|
||||
}
|
||||
}
|
||||
|
||||
+59
-1
@@ -96,6 +96,8 @@ mod agent_install_helpers;
|
||||
// Internal vt100-based replay tests live as a separate source file to keep them
|
||||
// close to the widget code. Include them in unit tests.
|
||||
mod updates;
|
||||
#[cfg(any(test, feature = "test-helpers"))]
|
||||
pub mod test_backend;
|
||||
|
||||
pub use cli::Cli;
|
||||
pub use self::markdown_render::render_markdown_text;
|
||||
@@ -104,11 +106,67 @@ pub use public_widgets::composer_input::{ComposerAction, ComposerInput};
|
||||
#[cfg(feature = "test-helpers")]
|
||||
pub mod test_helpers {
|
||||
pub use crate::chatwidget::smoke_helpers::ChatWidgetHarness;
|
||||
#[cfg(test)]
|
||||
pub use crate::test_backend::VT100Backend;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use code_core::history::state::HistoryRecord;
|
||||
use std::time::Duration;
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
/// Render successive frames of the chat widget into a VT100-backed terminal.
|
||||
/// Each entry in `frames` specifies the `(width, height)` for that capture.
|
||||
/// Returns a vector of screen dumps, one per frame.
|
||||
pub fn render_chat_widget_frames_to_vt100(
|
||||
harness: &mut ChatWidgetHarness,
|
||||
frames: &[(u16, u16)],
|
||||
) -> Vec<String> {
|
||||
use crate::test_backend::VT100Backend;
|
||||
|
||||
frames
|
||||
.iter()
|
||||
.map(|&(width, height)| {
|
||||
harness.flush_into_widget();
|
||||
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut terminal = ratatui::Terminal::new(backend).expect("create terminal");
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
let chat_widget = harness.chat();
|
||||
frame.render_widget_ref(&*chat_widget, area);
|
||||
})
|
||||
.expect("draw");
|
||||
|
||||
terminal.backend_mut().flush().expect("flush");
|
||||
|
||||
let screen = terminal.backend().vt100().screen();
|
||||
let (rows, cols) = screen.size();
|
||||
let snapshot = screen
|
||||
.rows(0, cols)
|
||||
.take(rows.into())
|
||||
.map(|row| row.trim_end().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
snapshot
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convenience helper to capture a single VT100 frame at the provided size.
|
||||
pub fn render_chat_widget_to_vt100(
|
||||
harness: &mut ChatWidgetHarness,
|
||||
width: u16,
|
||||
height: u16,
|
||||
) -> String {
|
||||
render_chat_widget_frames_to_vt100(harness, &[(width, height)])
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn assert_has_terminal_chunk_containing(
|
||||
harness: &mut ChatWidgetHarness,
|
||||
needle: &str,
|
||||
@@ -184,7 +242,7 @@ pub mod test_helpers {
|
||||
crate::chatwidget::smoke_helpers::assert_no_events(&events);
|
||||
}
|
||||
|
||||
pub fn history_records(harness: &ChatWidgetHarness) -> Vec<HistoryRecord> {
|
||||
pub fn history_records(harness: &mut ChatWidgetHarness) -> Vec<HistoryRecord> {
|
||||
harness.history_records()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! VT100-based test backend for ratatui
|
||||
//!
|
||||
//! This module provides a minimal VT100Backend implementation that wraps
|
||||
//! a vt100::Parser to emulate a terminal for testing purposes.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! This backend is only available when the `test-helpers` feature is enabled:
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use code_tui::test_backend::VT100Backend;
|
||||
//! use ratatui::Terminal;
|
||||
//!
|
||||
//! let backend = VT100Backend::new(80, 24);
|
||||
//! let mut terminal = Terminal::new(backend)?;
|
||||
//! terminal.draw(|frame| {
|
||||
//! // render widgets
|
||||
//! })?;
|
||||
//! let output = terminal.backend().to_string();
|
||||
//! ```
|
||||
//!
|
||||
//! ## Implementation Notes
|
||||
//!
|
||||
//! This implementation is derived from codex-rs/tui/src/test_backend.rs and requires
|
||||
//! the `unstable-backend-writer` feature in ratatui to access the CrosstermBackend's
|
||||
//! internal writer.
|
||||
|
||||
use std::fmt::{self};
|
||||
use std::io::Write;
|
||||
use std::io::{self};
|
||||
|
||||
use ratatui::prelude::CrosstermBackend;
|
||||
|
||||
use ratatui::backend::Backend;
|
||||
use ratatui::backend::ClearType;
|
||||
use ratatui::backend::WindowSize;
|
||||
use ratatui::buffer::Cell;
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::Size;
|
||||
|
||||
/// VT100-backed terminal emulator for testing
|
||||
///
|
||||
/// This wraps a CrosstermBackend and a vt100::Parser to mock
|
||||
/// a "real" terminal without requiring an actual TTY.
|
||||
///
|
||||
/// Importantly, this wrapper avoids calling any crossterm methods
|
||||
/// which write to stdout regardless of the writer. This includes:
|
||||
/// - getting the terminal size
|
||||
/// - getting the cursor position
|
||||
pub struct VT100Backend {
|
||||
crossterm_backend: CrosstermBackend<vt100::Parser>,
|
||||
}
|
||||
|
||||
impl VT100Backend {
|
||||
/// Creates a new `TestBackend` with the specified width and height.
|
||||
pub fn new(width: u16, height: u16) -> Self {
|
||||
Self {
|
||||
crossterm_backend: CrosstermBackend::new(vt100::Parser::new(height, width, 0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vt100(&self) -> &vt100::Parser {
|
||||
self.crossterm_backend.writer()
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for VT100Backend {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.crossterm_backend.writer_mut().write(buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.crossterm_backend.writer_mut().flush()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VT100Backend {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.crossterm_backend.writer().screen().contents())
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for VT100Backend {
|
||||
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
|
||||
where
|
||||
I: Iterator<Item = (u16, u16, &'a Cell)>,
|
||||
{
|
||||
self.crossterm_backend.draw(content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hide_cursor(&mut self) -> io::Result<()> {
|
||||
self.crossterm_backend.hide_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_cursor(&mut self) -> io::Result<()> {
|
||||
self.crossterm_backend.show_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_cursor_position(&mut self) -> io::Result<Position> {
|
||||
Ok(self.vt100().screen().cursor_position().into())
|
||||
}
|
||||
|
||||
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
|
||||
self.crossterm_backend.set_cursor_position(position)
|
||||
}
|
||||
|
||||
fn clear(&mut self) -> io::Result<()> {
|
||||
self.crossterm_backend.clear()
|
||||
}
|
||||
|
||||
fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
|
||||
self.crossterm_backend.clear_region(clear_type)
|
||||
}
|
||||
|
||||
fn append_lines(&mut self, line_count: u16) -> io::Result<()> {
|
||||
self.crossterm_backend.append_lines(line_count)
|
||||
}
|
||||
|
||||
fn size(&self) -> io::Result<Size> {
|
||||
Ok(self.vt100().screen().size().into())
|
||||
}
|
||||
|
||||
fn window_size(&mut self) -> io::Result<WindowSize> {
|
||||
Ok(WindowSize {
|
||||
columns_rows: self.vt100().screen().size().into(),
|
||||
// Arbitrary size, we don't rely on this in testing.
|
||||
pixels: Size {
|
||||
width: 640,
|
||||
height: 480,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.crossterm_backend.writer_mut().flush()
|
||||
}
|
||||
|
||||
fn scroll_region_up(&mut self, region: std::ops::Range<u16>, scroll_by: u16) -> io::Result<()> {
|
||||
self.crossterm_backend.scroll_region_up(region, scroll_by)
|
||||
}
|
||||
|
||||
fn scroll_region_down(
|
||||
&mut self,
|
||||
region: std::ops::Range<u16>,
|
||||
scroll_by: u16,
|
||||
) -> io::Result<()> {
|
||||
self.crossterm_backend.scroll_region_down(region, scroll_by)
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
---
|
||||
source: tui/tests/vt100_chatwidget_snapshot.rs
|
||||
expression: combined
|
||||
---
|
||||
--- frame 0 ---
|
||||
┌──┐
|
||||
│ │
|
||||
└──┘
|
||||
cr
|
||||
a
|
||||
|
||||
--- frame 1 ---
|
||||
┌──┐
|
||||
│ │
|
||||
└──┘
|
||||
cr
|
||||
a
|
||||
|
||||
--- frame 2 ---
|
||||
┌──┐
|
||||
│ │
|
||||
└──┘
|
||||
cr
|
||||
a
|
||||
|
||||
--- frame 3 ---
|
||||
┌──┐
|
||||
│ │
|
||||
└──┘
|
||||
cr
|
||||
a
|
||||
|
||||
--- frame 4 ---
|
||||
┌──┐
|
||||
│ │
|
||||
└──┘
|
||||
ta
|
||||
wi
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/tests/vt100_chatwidget_snapshot.rs
|
||||
expression: output
|
||||
---
|
||||
┌────────────────────┐
|
||||
│ Code │
|
||||
└────────────────────┘
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/tests/vt100_chatwidget_snapshot.rs
|
||||
expression: output
|
||||
---
|
||||
┌────────────────────┐
|
||||
│ Code │
|
||||
└────────────────────┘
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/tests/vt100_chatwidget_snapshot.rs
|
||||
expression: output
|
||||
---
|
||||
┌────────────────────┐
|
||||
│ Code │
|
||||
└────────────────────┘
|
||||
@@ -0,0 +1,4 @@
|
||||
#[path = "../src/test_backend.rs"]
|
||||
mod inner;
|
||||
|
||||
pub use inner::VT100Backend;
|
||||
@@ -503,7 +503,7 @@ fn smoke_exec_command_stream() {
|
||||
}),
|
||||
});
|
||||
|
||||
let records = code_tui::test_helpers::history_records(&harness);
|
||||
let records = code_tui::test_helpers::history_records(&mut harness);
|
||||
let exec_record = records
|
||||
.into_iter()
|
||||
.find_map(|record| match record {
|
||||
@@ -548,7 +548,7 @@ fn smoke_approval_flow() {
|
||||
}),
|
||||
});
|
||||
|
||||
let records = code_tui::test_helpers::history_records(&harness);
|
||||
let records = code_tui::test_helpers::history_records(&mut harness);
|
||||
let patch_record = records
|
||||
.into_iter()
|
||||
.find_map(|record| match record {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
//! VT100-backed snapshot tests for ChatWidget
|
||||
//!
|
||||
//! These tests render ChatWidget into a VT100Backend terminal at a fixed size
|
||||
//! and snapshot the screen contents using insta.
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! This test harness provides a minimal VT100-backed TUI snapshot testing infrastructure
|
||||
//! for the chat history widget. It uses:
|
||||
//!
|
||||
//! - `ChatWidgetHarness`: Test helper for managing ChatWidget state
|
||||
//! - `insta`: Snapshot testing framework for comparing rendered output
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! To run these tests:
|
||||
//! ```bash
|
||||
//! cargo test --package code-tui --test vt100_chatwidget_snapshot --features test-helpers
|
||||
//! ```
|
||||
//!
|
||||
//! To update snapshots after making changes:
|
||||
//! ```bash
|
||||
//! # Review and accept new snapshots
|
||||
//! cargo insta review --test vt100_chatwidget_snapshot
|
||||
//! # Or automatically accept all changes (use with caution)
|
||||
//! cargo insta accept --test vt100_chatwidget_snapshot
|
||||
//! ```
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! `render_chat_widget_to_vt100()` flushes pending history events into the widget,
|
||||
//! flattens the transcript into a fixed-width buffer, and returns it as a string so
|
||||
//! the snapshots capture exactly what would be visible on a terminal screen.
|
||||
//! Snapshots live in `tests/snapshots/` with a `.snap` extension.
|
||||
//!
|
||||
//! ## Feature Flags
|
||||
//!
|
||||
//! - `test-helpers`: Required to enable the test harness and VT100Backend
|
||||
//! - `unstable-backend-writer`: Required for VT100Backend (enabled in Cargo.toml)
|
||||
|
||||
#![cfg(test)]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use code_core::protocol::{
|
||||
AgentMessageDeltaEvent, AgentMessageEvent, Event, EventMsg, OrderMeta,
|
||||
};
|
||||
use code_tui::test_helpers::{
|
||||
render_chat_widget_frames_to_vt100, render_chat_widget_to_vt100, ChatWidgetHarness,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn baseline_empty_chat() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
|
||||
let output = render_chat_widget_to_vt100(&mut harness, 80, 24);
|
||||
insta::assert_snapshot!("empty_chat", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_simple_conversation() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "msg-1".into(),
|
||||
event_seq: 0,
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "Hello! ".into(),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(0),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "msg-1".into(),
|
||||
event_seq: 1,
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "How can I help you today?".into(),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(1),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "msg-1".into(),
|
||||
event_seq: 2,
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "Hello! How can I help you today?".into(),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(2),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "msg-2".into(),
|
||||
event_seq: 0,
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "I can help with ".into(),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 2,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(0),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "msg-2".into(),
|
||||
event_seq: 1,
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "various tasks including:\n\n".into(),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 2,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(1),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "msg-2".into(),
|
||||
event_seq: 2,
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "- Writing code\n".into(),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 2,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(2),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "msg-2".into(),
|
||||
event_seq: 3,
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "- Reading files\n".into(),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 2,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(3),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "msg-2".into(),
|
||||
event_seq: 4,
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "- Running commands".into(),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 2,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(4),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "msg-2".into(),
|
||||
event_seq: 5,
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "I can help with various tasks including:\n\n- Writing code\n- Reading files\n- Running commands".into(),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 2,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(5),
|
||||
}),
|
||||
});
|
||||
|
||||
let records = code_tui::test_helpers::history_records(&mut harness);
|
||||
dbg!(&records);
|
||||
assert!(!records.is_empty(), "history should contain records after events");
|
||||
assert!(
|
||||
records.iter().any(|record| match record {
|
||||
code_core::history::state::HistoryRecord::AssistantMessage(state) => {
|
||||
state.markdown.contains("How can I help you today")
|
||||
}
|
||||
_ => false,
|
||||
}),
|
||||
"assistant message should be recorded"
|
||||
);
|
||||
|
||||
let output = render_chat_widget_to_vt100(&mut harness, 80, 24);
|
||||
insta::assert_snapshot!("simple_conversation", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_multiline_formatting() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "msg-code".into(),
|
||||
event_seq: 0,
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "Here's a simple function:\n\n```rust\n".into(),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(0),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "msg-code".into(),
|
||||
event_seq: 1,
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "fn hello() {\n println!(\"Hello, world!\");\n}\n```".into(),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(1),
|
||||
}),
|
||||
});
|
||||
|
||||
harness.handle_event(Event {
|
||||
id: "msg-code".into(),
|
||||
event_seq: 2,
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "Here's a simple function:\n\n```rust\nfn hello() {\n println!(\"Hello, world!\");\n}\n```".into(),
|
||||
}),
|
||||
order: Some(OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(2),
|
||||
}),
|
||||
});
|
||||
|
||||
let output = render_chat_widget_to_vt100(&mut harness, 80, 24);
|
||||
insta::assert_snapshot!("multiline_formatting", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "repro for bottom-line clipping"]
|
||||
fn clip_repro_autofollow_wrap() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
|
||||
let mut frames: Vec<String> = Vec::new();
|
||||
|
||||
// Initial frame before streaming begins
|
||||
frames.extend(render_chat_widget_frames_to_vt100(&mut harness, &[(80, 6)]));
|
||||
|
||||
let base_order = |seq: u64| OrderMeta {
|
||||
request_ordinal: 1,
|
||||
output_index: Some(0),
|
||||
sequence_number: Some(seq),
|
||||
};
|
||||
|
||||
let stream_id = "answer-1".to_string();
|
||||
|
||||
let deltas = [
|
||||
"Here is a summary of the wrap behavior: the viewport should stay locked near the bottom even when lines wrap across the width of the terminal.",
|
||||
" Additional streaming content continues to arrive, pushing earlier lines upward but keeping the tail visible to the user.",
|
||||
" Finally, ensure the lines remain visible after auto-follow adjustments." ,
|
||||
];
|
||||
|
||||
for (idx, delta) in deltas.iter().enumerate() {
|
||||
harness.handle_event(Event {
|
||||
id: stream_id.clone(),
|
||||
event_seq: idx as u64,
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: delta.to_string(),
|
||||
}),
|
||||
order: Some(base_order(idx as u64)),
|
||||
});
|
||||
frames.extend(render_chat_widget_frames_to_vt100(&mut harness, &[(80, 6)]));
|
||||
}
|
||||
|
||||
// Final assistant message closes the stream
|
||||
let final_message = deltas.join("");
|
||||
harness.handle_event(Event {
|
||||
id: stream_id.clone(),
|
||||
event_seq: deltas.len() as u64,
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: final_message.clone(),
|
||||
}),
|
||||
order: Some(base_order(deltas.len() as u64)),
|
||||
});
|
||||
let records = code_tui::test_helpers::history_records(&mut harness);
|
||||
assert!(!records.is_empty(), "history should contain records for clip repro");
|
||||
frames.extend(render_chat_widget_frames_to_vt100(&mut harness, &[(80, 6)]));
|
||||
|
||||
let combined = frames
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, frame)| format!("--- frame {} ---\n{}", idx, frame))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let last_frame = frames.last().expect("last frame available");
|
||||
insta::assert_snapshot!("clip_repro_autofollow_wrap", combined);
|
||||
|
||||
assert!(
|
||||
last_frame.contains("tail visible to the user")
|
||||
|| last_frame.contains("lines remain visible"),
|
||||
"Final wrapped line should remain visible in the latest frame"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user