//! Snapshot tests for deterministic TUI rendering. //! //! Each test renders a screen at a fixed terminal size (120x40) with //! FakeClock frozen at 2026-01-15T12:00:00Z, then compares the plain-text //! output against a golden file in `tests/snapshots/`. //! //! To update golden files after intentional changes: //! UPDATE_SNAPSHOTS=1 cargo test -p lore-tui snapshot //! //! Golden files are UTF-8 plain text with LF line endings, diffable in VCS. use std::path::PathBuf; use chrono::{TimeZone, Utc}; use ftui::Model; use ftui::render::frame::Frame; use ftui::render::grapheme_pool::GraphemePool; use lore_tui::app::LoreApp; use lore_tui::clock::FakeClock; use lore_tui::message::{EntityKey, Msg, Screen, SearchResult}; use lore_tui::state::dashboard::{DashboardData, EntityCounts, LastSyncInfo, ProjectSyncInfo}; use lore_tui::state::issue_detail::{IssueDetailData, IssueMetadata}; use lore_tui::state::issue_list::{IssueListPage, IssueListRow}; use lore_tui::state::mr_list::{MrListPage, MrListRow}; use lore_tui::task_supervisor::TaskKey; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /// Fixed terminal size for all snapshot tests. const WIDTH: u16 = 120; const HEIGHT: u16 = 40; /// Frozen clock epoch: 2026-01-15T12:00:00Z. fn frozen_clock() -> FakeClock { FakeClock::new(Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap()) } /// Path to the snapshots directory (relative to crate root). fn snapshots_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots") } // --------------------------------------------------------------------------- // Buffer serializer // --------------------------------------------------------------------------- /// Serialize a Frame's buffer to plain text. /// /// - Direct chars are rendered as-is. /// - Grapheme references are resolved via the pool. /// - Continuation cells (wide char trailing cells) are skipped. /// - Empty cells become spaces. /// - Each row is right-trimmed and joined with '\n'. fn serialize_frame(frame: &Frame<'_>) -> String { let w = frame.buffer.width(); let h = frame.buffer.height(); let mut lines = Vec::with_capacity(h as usize); for y in 0..h { let mut row = String::with_capacity(w as usize); for x in 0..w { if let Some(cell) = frame.buffer.get(x, y) { let content = cell.content; if content.is_continuation() { // Skip — part of a wide character already rendered. continue; } else if content.is_empty() { row.push(' '); } else if let Some(ch) = content.as_char() { row.push(ch); } else if let Some(gid) = content.grapheme_id() { if let Some(grapheme) = frame.pool.get(gid) { row.push_str(grapheme); } else { row.push('?'); // Fallback for unresolved grapheme. } } else { row.push(' '); } } else { row.push(' '); } } lines.push(row.trim_end().to_string()); } // Trim trailing empty lines. while lines.last().is_some_and(|l| l.is_empty()) { lines.pop(); } let mut result = lines.join("\n"); result.push('\n'); // Trailing newline for VCS friendliness. result } // --------------------------------------------------------------------------- // Snapshot assertion // --------------------------------------------------------------------------- /// Compare rendered output against a golden file. /// /// If `UPDATE_SNAPSHOTS=1` is set, overwrites the golden file instead. /// On mismatch, prints a clear diff showing expected vs actual. fn assert_snapshot(name: &str, actual: &str) { let path = snapshots_dir().join(format!("{name}.snap")); if std::env::var("UPDATE_SNAPSHOTS").is_ok() { std::fs::write(&path, actual).unwrap_or_else(|e| { panic!("Failed to write snapshot {}: {e}", path.display()); }); eprintln!("Updated snapshot: {}", path.display()); return; } if !path.exists() { panic!( "Golden file missing: {}\n\ Run with UPDATE_SNAPSHOTS=1 to create it.\n\ Actual output:\n{}", path.display(), actual ); } let expected = std::fs::read_to_string(&path).unwrap_or_else(|e| { panic!("Failed to read snapshot {}: {e}", path.display()); }); if actual != expected { // Print a useful diff. let actual_lines: Vec<&str> = actual.lines().collect(); let expected_lines: Vec<&str> = expected.lines().collect(); let max = actual_lines.len().max(expected_lines.len()); let mut diff = String::new(); for i in 0..max { let a = actual_lines.get(i).copied().unwrap_or(""); let e = expected_lines.get(i).copied().unwrap_or(""); if a != e { diff.push_str(&format!(" line {i:3}: expected: {e:?}\n")); diff.push_str(&format!(" line {i:3}: actual: {a:?}\n")); } } panic!( "Snapshot mismatch: {}\n\ Run with UPDATE_SNAPSHOTS=1 to update.\n\n\ Differences:\n{diff}", path.display() ); } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn test_app() -> LoreApp { let mut app = LoreApp::new(); app.clock = Box::new(frozen_clock()); app } fn render_app(app: &LoreApp) -> String { let mut pool = GraphemePool::new(); let mut frame = Frame::new(WIDTH, HEIGHT, &mut pool); app.view(&mut frame); serialize_frame(&frame) } // -- Synthetic data fixtures ------------------------------------------------ fn fixture_dashboard_data() -> DashboardData { DashboardData { counts: EntityCounts { issues_total: 42, issues_open: 15, mrs_total: 28, mrs_open: 7, discussions: 120, notes_total: 350, notes_system_pct: 18, documents: 85, embeddings: 200, }, projects: vec![ ProjectSyncInfo { path: "infra/platform".into(), minutes_since_sync: 5, }, ProjectSyncInfo { path: "web/frontend".into(), minutes_since_sync: 12, }, ProjectSyncInfo { path: "api/backend".into(), minutes_since_sync: 8, }, ProjectSyncInfo { path: "tools/scripts".into(), minutes_since_sync: 4, }, ], recent: vec![], last_sync: Some(LastSyncInfo { status: "succeeded".into(), // 2026-01-15T11:55:00Z — 5 min before frozen clock. finished_at: Some(1_736_942_100_000), command: "sync".into(), error: None, }), } } fn fixture_issue_list() -> IssueListPage { IssueListPage { rows: vec![ IssueListRow { project_path: "infra/platform".into(), iid: 101, title: "Add retry logic for transient failures".into(), state: "opened".into(), author: "alice".into(), labels: vec!["backend".into(), "reliability".into()], updated_at: 1_736_942_000_000, // ~5 min before frozen }, IssueListRow { project_path: "web/frontend".into(), iid: 55, title: "Dark mode toggle not persisting across sessions".into(), state: "opened".into(), author: "bob".into(), labels: vec!["ui".into(), "bug".into()], updated_at: 1_736_938_400_000, // ~1 hr before frozen }, IssueListRow { project_path: "api/backend".into(), iid: 203, title: "Migrate user service to async runtime".into(), state: "closed".into(), author: "carol".into(), labels: vec!["backend".into(), "refactor".into()], updated_at: 1_736_856_000_000, // ~1 day before frozen }, ], next_cursor: None, total_count: 3, } } fn fixture_issue_detail() -> IssueDetailData { IssueDetailData { metadata: IssueMetadata { iid: 101, project_path: "infra/platform".into(), title: "Add retry logic for transient failures".into(), description: "## Problem\n\nTransient network failures cause cascading \ errors in the ingestion pipeline. We need exponential \ backoff with jitter.\n\n## Approach\n\n1. Wrap HTTP calls \ in a retry decorator\n2. Use exponential backoff (base 1s, \ max 30s)\n3. Add jitter to prevent thundering herd" .into(), state: "opened".into(), author: "alice".into(), assignees: vec!["bob".into(), "carol".into()], labels: vec!["backend".into(), "reliability".into()], milestone: Some("v2.0".into()), due_date: Some("2026-02-01".into()), created_at: 1_736_856_000_000, // ~1 day before frozen updated_at: 1_736_942_000_000, web_url: "https://gitlab.com/infra/platform/-/issues/101".into(), discussion_count: 3, }, cross_refs: vec![], } } fn fixture_mr_list() -> MrListPage { MrListPage { rows: vec![ MrListRow { project_path: "infra/platform".into(), iid: 42, title: "Implement exponential backoff for HTTP client".into(), state: "opened".into(), author: "bob".into(), labels: vec!["backend".into()], updated_at: 1_736_942_000_000, draft: false, target_branch: "main".into(), }, MrListRow { project_path: "web/frontend".into(), iid: 88, title: "WIP: Redesign settings page".into(), state: "opened".into(), author: "alice".into(), labels: vec!["ui".into()], updated_at: 1_736_938_400_000, draft: true, target_branch: "main".into(), }, ], next_cursor: None, total_count: 2, } } fn fixture_search_results() -> Vec { vec![ SearchResult { key: EntityKey::issue(1, 101), title: "Add retry logic for transient failures".into(), snippet: "...exponential backoff with jitter for transient network...".into(), score: 0.95, project_path: "infra/platform".into(), }, SearchResult { key: EntityKey::mr(1, 42), title: "Implement exponential backoff for HTTP client".into(), snippet: "...wraps reqwest calls in retry decorator with backoff...".into(), score: 0.82, project_path: "infra/platform".into(), }, ] } // -- Data injection helpers ------------------------------------------------- fn load_dashboard(app: &mut LoreApp) { let generation = app .supervisor .submit(TaskKey::LoadScreen(Screen::Dashboard)) .generation; app.update(Msg::DashboardLoaded { generation, data: Box::new(fixture_dashboard_data()), }); } fn load_issue_list(app: &mut LoreApp) { app.update(Msg::NavigateTo(Screen::IssueList)); let generation = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; app.update(Msg::IssueListLoaded { generation, page: fixture_issue_list(), }); } fn load_issue_detail(app: &mut LoreApp) { let key = EntityKey::issue(1, 101); let screen = Screen::IssueDetail(key.clone()); app.update(Msg::NavigateTo(screen.clone())); let generation = app .supervisor .submit(TaskKey::LoadScreen(screen)) .generation; app.update(Msg::IssueDetailLoaded { generation, key, data: Box::new(fixture_issue_detail()), }); } fn load_mr_list(app: &mut LoreApp) { app.update(Msg::NavigateTo(Screen::MrList)); let generation = app .supervisor .submit(TaskKey::LoadScreen(Screen::MrList)) .generation; app.update(Msg::MrListLoaded { generation, page: fixture_mr_list(), }); } fn load_search_results(app: &mut LoreApp) { app.update(Msg::NavigateTo(Screen::Search)); // Set the query text first so the search state has context. app.update(Msg::SearchQueryChanged("retry backoff".into())); let generation = app .supervisor .submit(TaskKey::LoadScreen(Screen::Search)) .generation; app.update(Msg::SearchExecuted { generation, results: fixture_search_results(), }); } // --------------------------------------------------------------------------- // Snapshot tests // --------------------------------------------------------------------------- #[test] fn test_dashboard_snapshot() { let mut app = test_app(); load_dashboard(&mut app); let output = render_app(&app); assert_snapshot("dashboard_default", &output); } #[test] fn test_issue_list_snapshot() { let mut app = test_app(); load_dashboard(&mut app); // Load dashboard first for realistic nav. load_issue_list(&mut app); let output = render_app(&app); assert_snapshot("issue_list_default", &output); } #[test] fn test_issue_detail_snapshot() { let mut app = test_app(); load_dashboard(&mut app); load_issue_list(&mut app); load_issue_detail(&mut app); let output = render_app(&app); assert_snapshot("issue_detail", &output); } #[test] fn test_mr_list_snapshot() { let mut app = test_app(); load_dashboard(&mut app); load_mr_list(&mut app); let output = render_app(&app); assert_snapshot("mr_list_default", &output); } #[test] fn test_search_results_snapshot() { let mut app = test_app(); load_dashboard(&mut app); load_search_results(&mut app); let output = render_app(&app); assert_snapshot("search_results", &output); } #[test] fn test_empty_state_snapshot() { let app = test_app(); // No data loaded — Dashboard with initial/empty state. let output = render_app(&app); assert_snapshot("empty_state", &output); }