- 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
454 lines
15 KiB
Rust
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);
|
|
}
|