Files
gitlore/crates/lore-tui/tests/vertical_slice.rs
teernisse fb40fdc677 feat(tui): Phase 3 power features — Who, Search, Timeline, Trace, File History screens
Complete TUI Phase 3 implementation with all 5 power feature screens:

- Who screen: 5 modes (expert/workload/reviews/active/overlap) with
  mode tabs, input bar, result rendering, and hint bar
- Search screen: full-text search with result list and scoring display
- Timeline screen: chronological event feed with time-relative display
- Trace screen: file provenance chains with expand/collapse, rename
  tracking, and linked issues/discussions
- File History screen: per-file MR timeline with rename chain display
  and discussion snippets

Also includes:
- Command palette overlay (fuzzy search)
- Bootstrap screen (initial sync flow)
- Action layer split from monolithic action.rs to per-screen modules
- Entity and render cache infrastructure
- Shared who_types module in core crate
- All screens wired into view/mod.rs dispatch
- 597 tests passing, clippy clean (pedantic + nursery), fmt clean
2026-02-18 22:56:38 -05:00

637 lines
20 KiB
Rust

//! Vertical slice integration tests for TUI Phase 2.
//!
//! Validates that core screens work together end-to-end with synthetic
//! data flows, navigation preserves state, stale results are dropped,
//! and input mode is always recoverable.
use ftui::render::frame::Frame;
use ftui::render::grapheme_pool::GraphemePool;
use ftui::{Cmd, Event, KeyCode, KeyEvent, Model, Modifiers};
use lore_tui::app::LoreApp;
use lore_tui::clock::FakeClock;
use lore_tui::message::{EntityKey, InputMode, Msg, Screen};
use lore_tui::state::dashboard::{DashboardData, EntityCounts, LastSyncInfo};
use lore_tui::state::issue_detail::{IssueDetailData, IssueMetadata};
use lore_tui::state::issue_list::{IssueListPage, IssueListRow};
use lore_tui::state::mr_detail::MrDetailData;
use lore_tui::state::mr_list::{MrListPage, MrListRow};
use lore_tui::task_supervisor::TaskKey;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn test_app() -> LoreApp {
let mut app = LoreApp::new();
app.clock = Box::new(FakeClock::new(chrono::Utc::now()));
app
}
fn synthetic_dashboard_data() -> DashboardData {
DashboardData {
counts: EntityCounts {
issues_total: 10,
issues_open: 5,
mrs_total: 8,
mrs_open: 3,
discussions: 15,
notes_total: 50,
notes_system_pct: 20,
documents: 20,
embeddings: 100,
},
projects: vec![],
recent: vec![],
last_sync: Some(LastSyncInfo {
status: "succeeded".into(),
finished_at: Some(1_700_000_000_000),
command: "sync".into(),
error: None,
}),
}
}
fn synthetic_issue_list_page() -> IssueListPage {
IssueListPage {
rows: vec![
IssueListRow {
project_path: "group/project".into(),
iid: 1,
title: "First issue".into(),
state: "opened".into(),
author: "alice".into(),
labels: vec!["backend".into()],
updated_at: 1_700_000_000_000,
},
IssueListRow {
project_path: "group/project".into(),
iid: 2,
title: "Second issue".into(),
state: "closed".into(),
author: "bob".into(),
labels: vec![],
updated_at: 1_700_000_010_000,
},
],
next_cursor: None,
total_count: 2,
}
}
fn synthetic_issue_detail() -> IssueDetailData {
IssueDetailData {
metadata: IssueMetadata {
iid: 1,
project_path: "group/project".into(),
title: "First issue".into(),
description: "Test description".into(),
state: "opened".into(),
author: "alice".into(),
assignees: vec!["bob".into()],
labels: vec!["backend".into()],
milestone: None,
due_date: None,
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
web_url: "https://gitlab.com/group/project/-/issues/1".into(),
discussion_count: 2,
},
cross_refs: vec![],
}
}
fn synthetic_mr_list_page() -> MrListPage {
MrListPage {
rows: vec![MrListRow {
project_path: "group/project".into(),
iid: 10,
title: "Fix auth".into(),
state: "opened".into(),
author: "alice".into(),
labels: vec![],
updated_at: 1_700_000_000_000,
draft: false,
target_branch: "main".into(),
}],
next_cursor: None,
total_count: 1,
}
}
fn synthetic_mr_detail() -> MrDetailData {
MrDetailData {
metadata: lore_tui::state::mr_detail::MrMetadata {
iid: 10,
project_path: "group/project".into(),
title: "Fix auth".into(),
description: "MR description".into(),
state: "opened".into(),
draft: false,
author: "alice".into(),
assignees: vec!["bob".into()],
reviewers: vec!["carol".into()],
labels: vec![],
source_branch: "fix-auth".into(),
target_branch: "main".into(),
merge_status: "mergeable".into(),
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
merged_at: None,
web_url: "https://gitlab.com/group/project/-/merge_requests/10".into(),
discussion_count: 1,
file_change_count: 2,
},
cross_refs: vec![],
file_changes: vec![],
}
}
/// Inject dashboard data with matching generation.
fn load_dashboard(app: &mut LoreApp) {
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::Dashboard))
.generation;
app.update(Msg::DashboardLoaded {
generation,
data: Box::new(synthetic_dashboard_data()),
});
}
/// Navigate to issue list and inject data.
fn navigate_and_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: synthetic_issue_list_page(),
});
}
/// Navigate to issue detail and inject data.
fn navigate_and_load_issue_detail(app: &mut LoreApp, key: EntityKey) {
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(synthetic_issue_detail()),
});
}
// ---------------------------------------------------------------------------
// Nav flow tests
// ---------------------------------------------------------------------------
/// TDD anchor: Dashboard -> IssueList -> IssueDetail -> Esc -> IssueList,
/// verifies cursor position is preserved on back-navigation.
#[test]
fn test_dashboard_to_issue_detail_roundtrip() {
let mut app = test_app();
assert!(app.navigation.is_at(&Screen::Dashboard));
// Navigate to IssueList and load data.
navigate_and_load_issue_list(&mut app);
assert!(app.navigation.is_at(&Screen::IssueList));
assert_eq!(app.state.issue_list.rows.len(), 2);
// Navigate to IssueDetail for issue #1.
let issue_key = EntityKey::issue(1, 1);
navigate_and_load_issue_detail(&mut app, issue_key);
assert!(matches!(app.navigation.current(), Screen::IssueDetail(_)));
// Go back — should return to IssueList with data preserved.
app.update(Msg::GoBack);
assert!(app.navigation.is_at(&Screen::IssueList));
// Data should still be there (state preserved on navigation).
assert_eq!(app.state.issue_list.rows.len(), 2);
}
/// Navigate Dashboard -> IssueList -> MrList -> MrDetail -> Home.
#[test]
fn test_full_nav_flow_home() {
let mut app = test_app();
// Issue list.
navigate_and_load_issue_list(&mut app);
assert!(app.navigation.is_at(&Screen::IssueList));
// MR list.
app.update(Msg::NavigateTo(Screen::MrList));
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::MrList))
.generation;
app.update(Msg::MrListLoaded {
generation,
page: synthetic_mr_list_page(),
});
assert!(app.navigation.is_at(&Screen::MrList));
// MR detail.
let mr_key = EntityKey::mr(1, 10);
let mr_screen = Screen::MrDetail(mr_key.clone());
app.update(Msg::NavigateTo(mr_screen.clone()));
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(mr_screen))
.generation;
app.update(Msg::MrDetailLoaded {
generation,
key: mr_key,
data: Box::new(synthetic_mr_detail()),
});
assert!(matches!(app.navigation.current(), Screen::MrDetail(_)));
// Go home.
app.update(Msg::GoHome);
assert!(app.navigation.is_at(&Screen::Dashboard));
}
/// Verify back-navigation preserves issue list data and MR list data.
#[test]
fn test_state_preserved_on_back_navigation() {
let mut app = test_app();
// Load issue list.
navigate_and_load_issue_list(&mut app);
assert_eq!(app.state.issue_list.rows.len(), 2);
// Navigate to MR list.
app.update(Msg::NavigateTo(Screen::MrList));
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::MrList))
.generation;
app.update(Msg::MrListLoaded {
generation,
page: synthetic_mr_list_page(),
});
// Both states should be populated.
assert_eq!(app.state.issue_list.rows.len(), 2);
assert_eq!(app.state.mr_list.rows.len(), 1);
// Go back to issue list — data should still be there.
app.update(Msg::GoBack);
assert!(app.navigation.is_at(&Screen::IssueList));
assert_eq!(app.state.issue_list.rows.len(), 2);
}
// ---------------------------------------------------------------------------
// Stale result guard
// ---------------------------------------------------------------------------
/// Rapidly navigate between screens, injecting out-of-order results.
/// Stale results should be silently dropped.
#[test]
fn test_stale_result_guard_rapid_navigation() {
let mut app = test_app();
// Navigate to IssueList, capturing generation.
app.update(Msg::NavigateTo(Screen::IssueList));
let generation1 = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
// Quickly navigate away and back — new generation.
app.update(Msg::GoBack);
app.update(Msg::NavigateTo(Screen::IssueList));
let generation2 = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::IssueList))
.generation;
// Late arrival of generation1 — should be dropped.
app.update(Msg::IssueListLoaded {
generation: generation1,
page: IssueListPage {
rows: vec![IssueListRow {
project_path: "g/p".into(),
iid: 999,
title: "stale".into(),
state: "opened".into(),
author: "x".into(),
labels: vec![],
updated_at: 0,
}],
next_cursor: None,
total_count: 1,
},
});
assert!(
app.state.issue_list.rows.is_empty(),
"stale result should be dropped"
);
// generation2 should be accepted.
app.update(Msg::IssueListLoaded {
generation: generation2,
page: synthetic_issue_list_page(),
});
assert_eq!(app.state.issue_list.rows.len(), 2);
assert_eq!(app.state.issue_list.rows[0].title, "First issue");
}
// ---------------------------------------------------------------------------
// Input mode fuzz (stuck-input check)
// ---------------------------------------------------------------------------
/// Fuzz 1000 random key sequences and verify:
/// 1. No panics
/// 2. InputMode is always recoverable via Esc + Ctrl+C
/// 3. Final state is consistent
#[test]
fn test_input_mode_fuzz_no_stuck_state() {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut app = test_app();
// Deterministic pseudo-random key generation.
let keys = [
KeyCode::Char('g'),
KeyCode::Char('i'),
KeyCode::Char('m'),
KeyCode::Char('h'),
KeyCode::Char('s'),
KeyCode::Char('q'),
KeyCode::Char('p'),
KeyCode::Char('/'),
KeyCode::Char('?'),
KeyCode::Tab,
KeyCode::BackTab,
KeyCode::Escape,
KeyCode::Enter,
KeyCode::Up,
KeyCode::Down,
KeyCode::Left,
KeyCode::Right,
KeyCode::Home,
KeyCode::End,
];
let modifiers_set = [
Modifiers::NONE,
Modifiers::SHIFT,
Modifiers::CTRL,
Modifiers::NONE,
Modifiers::NONE,
];
// Run 1000 random key events.
for i in 0..1000_u64 {
// Simple deterministic hash to pick key + modifier.
let mut hasher = DefaultHasher::new();
i.hash(&mut hasher);
let h = hasher.finish();
let key_code = keys[(h as usize) % keys.len()];
let mods = modifiers_set[((h >> 16) as usize) % modifiers_set.len()];
// Skip Ctrl+C (would quit) and 'q' in normal mode (would quit).
if key_code == KeyCode::Char('c') && mods.contains(Modifiers::CTRL) {
continue;
}
if key_code == KeyCode::Char('q') && mods == Modifiers::NONE {
// Only skip if in Normal mode to avoid quitting the test.
if matches!(app.input_mode, InputMode::Normal) {
continue;
}
}
let key_event = if mods == Modifiers::NONE {
KeyEvent::new(key_code)
} else {
KeyEvent::new(key_code).with_modifiers(mods)
};
let cmd = app.update(Msg::RawEvent(Event::Key(key_event)));
// Should never produce Quit from our filtered set (we skip q and Ctrl+C).
if matches!(cmd, Cmd::Quit) {
// This can happen from 'q' in non-Normal modes where we didn't filter.
// Recreate app to continue fuzzing.
app = test_app();
}
}
// Recovery check: Esc should always bring us back to Normal mode.
app.update(Msg::RawEvent(Event::Key(KeyEvent::new(KeyCode::Escape))));
// After Esc, we should be in Normal mode (or if already Normal, stay there).
// GoPrefix → Normal, Text → Normal, Palette → Normal.
assert!(
matches!(app.input_mode, InputMode::Normal),
"Esc should always recover to Normal mode, got: {:?}",
app.input_mode
);
// Ctrl+C should always quit.
let ctrl_c = KeyEvent::new(KeyCode::Char('c')).with_modifiers(Modifiers::CTRL);
let cmd = app.update(Msg::RawEvent(Event::Key(ctrl_c)));
assert!(matches!(cmd, Cmd::Quit));
}
// ---------------------------------------------------------------------------
// Bootstrap → Dashboard transition
// ---------------------------------------------------------------------------
/// Bootstrap screen should auto-transition to Dashboard when sync completes.
#[test]
fn test_bootstrap_to_dashboard_after_sync() {
let mut app = test_app();
// Start on Bootstrap screen.
app.update(Msg::NavigateTo(Screen::Bootstrap));
assert!(app.navigation.is_at(&Screen::Bootstrap));
assert!(!app.state.bootstrap.sync_started);
// User starts sync via key path (g then s).
app.update(Msg::RawEvent(Event::Key(KeyEvent::new(KeyCode::Char('g')))));
app.update(Msg::RawEvent(Event::Key(KeyEvent::new(KeyCode::Char('s')))));
assert!(app.state.bootstrap.sync_started);
// Sync completes — should auto-transition to Dashboard.
app.update(Msg::SyncCompleted { elapsed_ms: 5000 });
assert!(
app.navigation.is_at(&Screen::Dashboard),
"Should auto-transition to Dashboard after sync completes on Bootstrap"
);
}
/// SyncCompleted on non-Bootstrap screen should NOT navigate.
#[test]
fn test_sync_completed_does_not_navigate_from_other_screens() {
let mut app = test_app();
// Navigate to IssueList.
app.update(Msg::NavigateTo(Screen::IssueList));
assert!(app.navigation.is_at(&Screen::IssueList));
// SyncCompleted should be a no-op.
app.update(Msg::SyncCompleted { elapsed_ms: 3000 });
assert!(
app.navigation.is_at(&Screen::IssueList),
"SyncCompleted should not navigate when not on Bootstrap"
);
}
// ---------------------------------------------------------------------------
// Render all screens (no-panic check)
// ---------------------------------------------------------------------------
/// Render every screen variant to verify no panics with synthetic data.
#[test]
fn test_render_all_screens_no_panic() {
let mut pool = GraphemePool::new();
// Load data for all screens.
let mut app = test_app();
load_dashboard(&mut app);
navigate_and_load_issue_list(&mut app);
app.update(Msg::GoBack);
// Load MR list.
app.update(Msg::NavigateTo(Screen::MrList));
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(Screen::MrList))
.generation;
app.update(Msg::MrListLoaded {
generation,
page: synthetic_mr_list_page(),
});
app.update(Msg::GoBack);
// Render at each screen.
let screens = [
Screen::Dashboard,
Screen::IssueList,
Screen::MrList,
Screen::Bootstrap,
];
for screen in &screens {
app.update(Msg::NavigateTo(screen.clone()));
let mut frame = Frame::new(80, 24, &mut pool);
app.view(&mut frame);
}
// Render detail screens.
let issue_key = EntityKey::issue(1, 1);
navigate_and_load_issue_detail(&mut app, issue_key);
{
let mut frame = Frame::new(80, 24, &mut pool);
app.view(&mut frame);
}
app.update(Msg::GoBack);
let mr_key = EntityKey::mr(1, 10);
let mr_screen = Screen::MrDetail(mr_key.clone());
app.update(Msg::NavigateTo(mr_screen.clone()));
let generation = app
.supervisor
.submit(TaskKey::LoadScreen(mr_screen))
.generation;
app.update(Msg::MrDetailLoaded {
generation,
key: mr_key,
data: Box::new(synthetic_mr_detail()),
});
{
let mut frame = Frame::new(80, 24, &mut pool);
app.view(&mut frame);
}
}
/// Render at various terminal sizes to catch layout panics.
#[test]
fn test_render_various_sizes_no_panic() {
let mut pool = GraphemePool::new();
let app = test_app();
let sizes: [(u16, u16); 5] = [
(80, 24), // Standard
(120, 40), // Large
(40, 12), // Small
(20, 5), // Very small
(3, 3), // Minimum
];
for (w, h) in &sizes {
let mut frame = Frame::new(*w, *h, &mut pool);
app.view(&mut frame);
}
}
// ---------------------------------------------------------------------------
// Navigation depth stress
// ---------------------------------------------------------------------------
/// Navigate deep and verify back-navigation works correctly.
#[test]
fn test_deep_navigation_and_unwind() {
let mut app = test_app();
// Navigate through 10 screens.
for i in 0..5 {
app.update(Msg::NavigateTo(Screen::IssueList));
let issue_key = EntityKey::issue(1, i + 1);
app.update(Msg::NavigateTo(Screen::IssueDetail(issue_key)));
}
// Should be at IssueDetail depth.
assert!(matches!(app.navigation.current(), Screen::IssueDetail(_)));
// Unwind all the way back to Dashboard.
for _ in 0..20 {
app.update(Msg::GoBack);
if app.navigation.is_at(&Screen::Dashboard) {
break;
}
}
assert!(
app.navigation.is_at(&Screen::Dashboard),
"Should eventually reach Dashboard"
);
}
// ---------------------------------------------------------------------------
// Performance (smoke test — real benchmarks need criterion)
// ---------------------------------------------------------------------------
/// Verify that 100 update() + view() cycles complete quickly.
/// This is a smoke test, not a precise benchmark.
#[test]
fn test_update_view_cycle_performance_smoke() {
let mut pool = GraphemePool::new();
let mut app = test_app();
load_dashboard(&mut app);
let start = std::time::Instant::now();
for _ in 0..100 {
app.update(Msg::Tick);
let mut frame = Frame::new(80, 24, &mut pool);
app.view(&mut frame);
}
let elapsed = start.elapsed();
// 100 cycles should complete in well under 1 second.
// On a typical machine this takes < 10ms.
assert!(
elapsed.as_millis() < 1000,
"100 update+view cycles took {}ms — too slow",
elapsed.as_millis()
);
}