#![cfg(test)]
use std::path::PathBuf;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use crate::app::{App, AppMode, DiffSource, DiffViewMode, InputMode, LocalState};
use crate::theme::Theme;
use travelagent_core::error::{Result as CoreResult, TrvError};
use travelagent_core::model::{
DiffFile, DiffHunk, DiffLine, FileStatus, LineOrigin, ReviewSession, SessionDiffSource,
};
use travelagent_core::vcs::{VcsBackend, VcsInfo, VcsType};
struct StubVcs {
info: VcsInfo,
}
impl VcsBackend for StubVcs {
fn info(&self) -> &VcsInfo {
&self.info
}
fn get_working_tree_diff(&self) -> CoreResult<Vec<DiffFile>> {
Err(TrvError::NoChanges)
}
fn fetch_context_lines(
&self,
_file_path: &std::path::Path,
_file_status: FileStatus,
_start_line: u32,
_end_line: u32,
) -> CoreResult<Vec<DiffLine>> {
Ok(Vec::new())
}
}
fn make_test_diff_file() -> DiffFile {
DiffFile {
old_path: Some(PathBuf::from("src/foo.rs")),
new_path: Some(PathBuf::from("src/foo.rs")),
status: FileStatus::Modified,
hunks: vec![DiffHunk {
header: "@@ -1,2 +1,2 @@".to_string(),
lines: vec![
DiffLine {
origin: LineOrigin::Context,
content: "fn hello() {".to_string(),
old_lineno: Some(1),
new_lineno: Some(1),
highlighted_spans: None,
},
DiffLine {
origin: LineOrigin::Deletion,
content: " println!(\"old\");".to_string(),
old_lineno: Some(2),
new_lineno: None,
highlighted_spans: None,
},
DiffLine {
origin: LineOrigin::Addition,
content: " println!(\"new\");".to_string(),
old_lineno: None,
new_lineno: Some(2),
highlighted_spans: None,
},
],
old_start: 1,
old_count: 2,
new_start: 1,
new_count: 2,
}],
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
fn build_render_test_app(files: Vec<DiffFile>) -> App {
let vcs_info = VcsInfo {
root_path: PathBuf::from("/tmp/trv-render-test"),
head_commit: "head".to_string(),
branch_name: Some("main".to_string()),
vcs_type: VcsType::Git,
};
let session = ReviewSession::new(
vcs_info.root_path.clone(),
vcs_info.head_commit.clone(),
vcs_info.branch_name.clone(),
SessionDiffSource::WorkingTree,
);
App::build(
Box::new(StubVcs {
info: vcs_info.clone(),
}),
vcs_info,
Theme::dark(),
None,
false,
files,
session,
DiffSource::WorkingTree,
InputMode::Normal,
Vec::new(),
None,
crate::test_support::runtime_handle(),
AppMode::Local(LocalState::default()),
)
.expect("failed to build render-test app")
}
fn buffer_to_string(buffer: &Buffer) -> String {
let mut out =
String::with_capacity((buffer.area.width as usize + 1) * buffer.area.height as usize);
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
out.push_str(buffer.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "));
}
out.push('\n');
}
out
}
fn buffer_has_content(buffer: &Buffer) -> bool {
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
if let Some(cell) = buffer.cell((x, y))
&& !cell.symbol().trim().is_empty()
{
return true;
}
}
}
false
}
#[test]
fn app_layout_render_unified_smoke() {
let mut app = build_render_test_app(vec![make_test_diff_file()]);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
assert!(buffer_has_content(&buf), "render produced an empty buffer");
let text = buffer_to_string(&buf);
assert!(
text.contains("trv"),
"expected 'trv' header in buffer: {text}"
);
assert!(
text.contains("Diff (Unified)"),
"expected 'Diff (Unified)' panel title: {text}"
);
}
#[test]
fn diff_unified_renders_hunk_and_diff_markers() {
let mut app = build_render_test_app(vec![make_test_diff_file()]);
app.nav.diff_view_mode = DiffViewMode::Unified;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let text = buffer_to_string(&buf);
assert!(buffer_has_content(&buf), "buffer was empty");
assert!(
text.contains("foo.rs"),
"expected file path 'foo.rs' in render: {text}"
);
assert!(
text.contains("@@"),
"expected '@@' hunk header marker in render: {text}"
);
assert!(
text.contains("println!"),
"expected diff line content 'println!' in render: {text}"
);
}
#[test]
fn diff_side_by_side_renders_two_panes() {
let mut app = build_render_test_app(vec![make_test_diff_file()]);
app.nav.diff_view_mode = DiffViewMode::SideBySide;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let text = buffer_to_string(&buf);
assert!(buffer_has_content(&buf), "buffer was empty");
assert!(
text.contains("Diff (Side-by-Side)"),
"expected 'Diff (Side-by-Side)' panel title: {text}"
);
assert!(
text.contains("@@"),
"expected '@@' hunk header marker in SBS render: {text}"
);
assert!(
text.contains("foo.rs"),
"expected file path 'foo.rs' in SBS render: {text}"
);
}
#[test]
fn status_bar_and_header_render_branch_and_title() {
let mut app = build_render_test_app(vec![make_test_diff_file()]);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let text = buffer_to_string(&buf);
assert!(buffer_has_content(&buf), "buffer was empty");
assert!(
text.contains("main"),
"expected branch 'main' (from VcsInfo) in header: {text}"
);
assert!(
text.contains("trv"),
"expected 'trv' title in header: {text}"
);
}
#[test]
fn comment_input_box_renders_with_cursor_marker() {
let mut app = build_render_test_app(vec![make_test_diff_file()]);
app.nav.input_mode = InputMode::Comment;
app.comment.is_file_level = false;
app.comment.is_review_level = false;
app.comment.line = Some((2, travelagent_core::model::LineSide::New));
app.comment.line_range = Some((
travelagent_core::model::LineRange { start: 2, end: 2 },
travelagent_core::model::LineSide::New,
));
app.comment.buffer.clear();
app.comment.cursor = 0;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let text = buffer_to_string(&buf);
assert!(
buffer_has_content(&buf),
"comment-mode render produced empty buffer"
);
assert!(
text.contains("Add"),
"expected inline comment header 'Add' in render: {text}"
);
let lowered = text.to_lowercase();
assert!(
lowered.contains("[note]"),
"expected '[note]' comment-type label (case-insensitive) in render: {text}"
);
assert!(
text.contains("Type your comment"),
"expected placeholder 'Type your comment' in render: {text}"
);
}
fn build_render_test_app_rooted(root: PathBuf, rel_path: &str) -> App {
let vcs_info = VcsInfo {
root_path: root,
head_commit: "head".to_string(),
branch_name: Some("main".to_string()),
vcs_type: VcsType::Git,
};
let session = ReviewSession::new(
vcs_info.root_path.clone(),
vcs_info.head_commit.clone(),
vcs_info.branch_name.clone(),
SessionDiffSource::WorkingTree,
);
let file = DiffFile {
old_path: Some(PathBuf::from(rel_path)),
new_path: Some(PathBuf::from(rel_path)),
status: FileStatus::Modified,
hunks: vec![DiffHunk {
header: "@@ -1,1 +1,1 @@".to_string(),
lines: vec![DiffLine {
origin: LineOrigin::Context,
content: "x".to_string(),
old_lineno: Some(1),
new_lineno: Some(1),
highlighted_spans: None,
}],
old_start: 1,
old_count: 1,
new_start: 1,
new_count: 1,
}],
is_binary: false,
is_too_large: false,
is_commit_message: false,
};
App::build(
Box::new(StubVcs {
info: vcs_info.clone(),
}),
vcs_info,
Theme::dark(),
None,
false,
vec![file],
session,
DiffSource::WorkingTree,
InputMode::Normal,
Vec::new(),
None,
crate::test_support::runtime_handle(),
AppMode::Local(LocalState::default()),
)
.expect("failed to build rooted render-test app")
}
#[test]
fn viewer_raw_renders_full_file_with_gutter() {
let dir = tempfile::tempdir().expect("tempdir");
let rel = "src/lib.rs";
let abs = dir.path().join(rel);
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
std::fs::write(&abs, "fn only_in_full_file() {}\nfn second() {}\n").unwrap();
let mut app = build_render_test_app_rooted(dir.path().to_path_buf(), rel);
app.nav.focused_panel = crate::app::FocusedPanel::Diff;
app.toggle_viewer(); assert!(
app.viewer.is_active(),
"viewer should activate for a readable file"
);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let text = buffer_to_string(&buf);
assert!(
buffer_has_content(&buf),
"viewer render produced empty buffer"
);
assert!(
text.contains("Viewer (Raw)"),
"expected 'Viewer (Raw)' panel title: {text}"
);
assert!(
text.contains("only_in_full_file"),
"expected full-file content (not in the diff) in viewer: {text}"
);
assert!(
text.contains('\u{2502}'),
"expected line-number gutter divider in raw viewer: {text}"
);
}
#[test]
fn viewer_rendered_markdown_shows_heading_text() {
let dir = tempfile::tempdir().expect("tempdir");
let rel = "README.md";
let abs = dir.path().join(rel);
std::fs::write(&abs, "# Welcome Heading\n\nSome body text.\n").unwrap();
let mut app = build_render_test_app_rooted(dir.path().to_path_buf(), rel);
app.nav.focused_panel = crate::app::FocusedPanel::Diff;
app.toggle_viewer();
assert!(app.viewer.is_active());
assert!(
app.viewer.set_rendered(std::path::Path::new(rel)),
"markdown must be renderable"
);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let text = buffer_to_string(&buf);
assert!(
text.contains("Viewer (Rendered)"),
"expected 'Viewer (Rendered)' panel title: {text}"
);
assert!(
text.contains("Welcome Heading"),
"expected rendered markdown heading text in viewer: {text}"
);
}
#[test]
fn viewer_falls_back_to_diff_when_file_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let mut app = build_render_test_app_rooted(dir.path().to_path_buf(), "ghost.rs");
app.toggle_viewer();
assert!(
!app.viewer.is_active(),
"viewer must not activate when the file can't be read"
);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let text = buffer_to_string(&buf);
assert!(
text.contains("Diff ("),
"expected fallback to the diff pane: {text}"
);
}
#[test]
fn mcp_indicator_survives_narrow_status_bar() {
let mut app = build_render_test_app(vec![make_test_diff_file()]);
app.mcp_listener.request_on();
let backend = TestBackend::new(60, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let text = buffer_to_string(&buf);
assert!(
text.contains("[mcp:"),
"MCP indicator must stay visible on a narrow status bar: {text}"
);
}
#[test]
fn status_bar_keeps_both_mcp_and_hints_at_typical_width() {
let mut app = build_render_test_app(vec![make_test_diff_file()]);
app.mcp_listener.request_on();
let backend = TestBackend::new(130, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let buf = terminal.backend().buffer().clone();
let text = buffer_to_string(&buf);
assert!(text.contains("[mcp:"), "expected [mcp:] indicator: {text}");
assert!(
text.contains("(help)") && text.contains("(commands)"),
"expected the keybinding hints menu to survive alongside the indicator: {text}"
);
}