Files
gitlore/crates/lore-tui/tests/snapshot_tests.rs
teernisse 403800be22 feat(tui): add snapshot test infrastructure + terminal compat matrix (bd-2nfs)
- 6 deterministic snapshot tests at 120x40 with FakeClock frozen at 2026-01-15T12:00:00Z
- Buffer-to-plaintext serializer resolving chars, graphemes, and wide-char continuations
- Golden file management with UPDATE_SNAPSHOTS=1 env var for regeneration
- Snapshot diff output on mismatch for easy debugging
- Tests: dashboard, issue list, issue detail, MR list, search results, empty state
- TERMINAL_COMPAT.md template for manual QA across iTerm2/tmux/Alacritty/kitty/WezTerm
2026-02-19 00:38:11 -05:00

454 lines
15 KiB
Rust

//! 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<SearchResult> {
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);
}