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
This commit is contained in:
636
crates/lore-tui/tests/vertical_slice.rs
Normal file
636
crates/lore-tui/tests/vertical_slice.rs
Normal file
@@ -0,0 +1,636 @@
|
||||
//! 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()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user