travelagent 1.10.3

Agent-first TUI code review tool
//! Smoke tests for the TUI renderer paths.
//!
//! These tests build a minimal `App` against an in-memory `StubVcs`, render
//! the UI to a `ratatui::backend::TestBackend`, and assert that the resulting
//! buffer is non-empty and contains a few load-bearing substrings (file path,
//! `@@` hunk header, `+`/`-` markers, panel titles, etc.).
//!
//! The intent is regression detection on "did the renderer crash or produce
//! nothing", not pixel-exact output — no `insta` snapshots, no full screen
//! grids, just substring contains and shape assertions. Each test runs in
//! well under 100ms because the work is bounded by an 80x24 buffer and a
//! single-file fixture.
//!
//! This module lives inside `crate::ui` because `render_unified_diff` and
//! `render_side_by_side_diff` are `pub(super)` — only visible to `mod ui`.

#![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};

/// Smallest possible VCS backend that satisfies the trait. All operations
/// either return empty results or `UnsupportedOperation` — we never exercise
/// VCS code paths from these tests.
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())
    }
}

/// Build a single-file diff with one hunk: 1 context line, 1 deletion, 1
/// addition. Mirrors what a tiny real-world diff looks like so the renderer
/// hits both `+`/`-` paint paths plus an `@@` hunk header.
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,
    }
}

/// Construct a minimal `App` with the given diff files. No remote, no tour,
/// no commit selector — the simplest viable state for exercising the render
/// path. Falls through `App::build` to share the production constructor.
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")
}

/// Collect the entire `Buffer` into one `String` so callers can use simple
/// `contains` checks. Lines are separated by `\n` so multi-row substrings
/// (which never wrap across lines in practice for our assertions) won't
/// accidentally match.
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
}

/// Returns true when at least one cell in the buffer holds a non-space
/// printable glyph — i.e. the renderer drew something.
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() {
    // Top-level render entry from `app_layout::render`. The default mode is
    // Unified, so this exercises render -> render_main_content -> render_diff_view
    // -> render_unified_diff and the header/status_bar.
    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);
    // Header shows the binary name.
    assert!(
        text.contains("trv"),
        "expected 'trv' header in buffer: {text}"
    );
    // Diff title must appear above the unified diff body.
    assert!(
        text.contains("Diff (Unified)"),
        "expected 'Diff (Unified)' panel title: {text}"
    );
}

#[test]
fn diff_unified_renders_hunk_and_diff_markers() {
    // Force the cursor into the file body so the diff lines are in-viewport
    // even though the default Overview position would also paint them — we
    // want this assertion stable regardless of the default cursor.
    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");
    // File path appears in the header / file list.
    assert!(
        text.contains("foo.rs"),
        "expected file path 'foo.rs' in render: {text}"
    );
    // Hunk header characters render literally.
    assert!(
        text.contains("@@"),
        "expected '@@' hunk header marker in render: {text}"
    );
    // At least one of the diff add/del marker rows is present. Markers are
    // rendered as `+ ` / `- ` (prefix span), so check for a column with the
    // marker followed by content from our fixture.
    assert!(
        text.contains("println!"),
        "expected diff line content 'println!' in render: {text}"
    );
}

#[test]
fn diff_side_by_side_renders_two_panes() {
    // Side-by-side mode splits the diff area; rendering must not panic on
    // a small (80-col) buffer where the per-pane content_width is tight.
    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}"
    );
    // Hunk markers still render in side-by-side view.
    assert!(
        text.contains("@@"),
        "expected '@@' hunk header marker in SBS render: {text}"
    );
    // The file path is shown in the file list panel on the left.
    assert!(
        text.contains("foo.rs"),
        "expected file path 'foo.rs' in SBS render: {text}"
    );
}

#[test]
fn status_bar_and_header_render_branch_and_title() {
    // The header + status bar are drawn from the top-level `render`. Verify
    // that branch info ('main') and the panel title both make it onto the
    // screen — they're produced by `status_bar::render_header` and
    // `status_bar::render_status_bar` respectively.
    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() {
    // Drive the renderer into Comment input mode on a real diff line. The
    // inline comment box (`comment_panel::format_comment_input_lines`) must
    // produce a visible bordered box with a type label and an empty-buffer
    // placeholder. Cursor positioning is handled in `app_layout::render`.
    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,
    ));
    // Empty buffer triggers the placeholder rendering path.
    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"
    );
    // The "Add [type] Lx" header is rendered at the top of the inline box.
    // The default first comment type id is "note" (resolve_comment_types).
    // The label is rendered uppercased by `comment_type_label` so accept either case.
    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}"
    );
    // The empty-buffer placeholder path emits this prompt.
    assert!(
        text.contains("Type your comment"),
        "expected placeholder 'Type your comment' in render: {text}"
    );
}