feat(tui): add 9 user flow integration tests (bd-2ygk)
Implements end-to-end flow tests covering all PRD Section 6 journeys: - Morning triage (dashboard -> issue list -> detail -> back) - Direct screen jumps (g-prefix chain: gt -> gw -> gi -> gh) - Quick search (g/ -> results -> drill-in -> back with preserved state) - Sync and browse (gs -> sync lifecycle -> complete -> browse) - Expert navigation (gw -> Who -> verify expert mode default) - Command palette (Ctrl+P -> verify open/filtered -> Esc close) - Timeline navigation (gt -> events -> drill-in -> back) - Bootstrap sync flow (Bootstrap -> gs -> SyncCompleted -> Dashboard) - MR drill-in and back (gm -> detail -> Esc -> cursor preserved) Key testing patterns: - State generation alignment for dual-guard stale detection - Key event injection via send_key/send_go helpers - Data injection via supervisor.submit() + Msg handlers - Cross-screen state preservation assertions
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
bd-2nfs
|
bd-2ygk
|
||||||
|
|||||||
677
crates/lore-tui/tests/tui_user_flows.rs
Normal file
677
crates/lore-tui/tests/tui_user_flows.rs
Normal file
@@ -0,0 +1,677 @@
|
|||||||
|
//! User flow integration tests — PRD Section 6 end-to-end journeys.
|
||||||
|
//!
|
||||||
|
//! Each test simulates a realistic user workflow through multiple screens,
|
||||||
|
//! using key events for navigation and message injection for data loading.
|
||||||
|
//! All tests use `FakeClock` and synthetic data for determinism.
|
||||||
|
//!
|
||||||
|
//! These tests complement the vertical slice tests (bd-1mju) which cover
|
||||||
|
//! a single flow in depth. These focus on breadth — 9 distinct user
|
||||||
|
//! journeys that exercise cross-screen navigation, state preservation,
|
||||||
|
//! and the command dispatch pipeline.
|
||||||
|
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
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, SearchResult, TimelineEvent, TimelineEventKind,
|
||||||
|
};
|
||||||
|
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 & clock
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn test_app() -> LoreApp {
|
||||||
|
let mut app = LoreApp::new();
|
||||||
|
app.clock = Box::new(frozen_clock());
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a key event and return the Cmd.
|
||||||
|
fn send_key(app: &mut LoreApp, code: KeyCode) -> Cmd<Msg> {
|
||||||
|
app.update(Msg::RawEvent(Event::Key(KeyEvent::new(code))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a key event with modifiers.
|
||||||
|
fn send_key_mod(app: &mut LoreApp, code: KeyCode, mods: Modifiers) -> Cmd<Msg> {
|
||||||
|
app.update(Msg::RawEvent(Event::Key(
|
||||||
|
KeyEvent::new(code).with_modifiers(mods),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a g-prefix navigation sequence (e.g., 'g' then 'i' for issues).
|
||||||
|
fn send_go(app: &mut LoreApp, second: char) {
|
||||||
|
send_key(app, KeyCode::Char('g'));
|
||||||
|
send_key(app, KeyCode::Char(second));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
recent: vec![],
|
||||||
|
last_sync: Some(LastSyncInfo {
|
||||||
|
status: "succeeded".into(),
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
IssueListRow {
|
||||||
|
project_path: "web/frontend".into(),
|
||||||
|
iid: 55,
|
||||||
|
title: "Dark mode toggle not persisting".into(),
|
||||||
|
state: "opened".into(),
|
||||||
|
author: "bob".into(),
|
||||||
|
labels: vec!["ui".into(), "bug".into()],
|
||||||
|
updated_at: 1_736_938_400_000,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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 errors.".into(),
|
||||||
|
state: "opened".into(),
|
||||||
|
author: "alice".into(),
|
||||||
|
assignees: vec!["bob".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,
|
||||||
|
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...".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...".into(),
|
||||||
|
score: 0.82,
|
||||||
|
project_path: "infra/platform".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_timeline_events() -> Vec<TimelineEvent> {
|
||||||
|
vec![
|
||||||
|
TimelineEvent {
|
||||||
|
timestamp_ms: 1_736_942_000_000,
|
||||||
|
entity_key: EntityKey::issue(1, 101),
|
||||||
|
event_kind: TimelineEventKind::Created,
|
||||||
|
summary: "Issue #101 created".into(),
|
||||||
|
detail: None,
|
||||||
|
actor: Some("alice".into()),
|
||||||
|
project_path: "infra/platform".into(),
|
||||||
|
},
|
||||||
|
TimelineEvent {
|
||||||
|
timestamp_ms: 1_736_938_400_000,
|
||||||
|
entity_key: EntityKey::mr(1, 42),
|
||||||
|
event_kind: TimelineEventKind::Created,
|
||||||
|
summary: "MR !42 created".into(),
|
||||||
|
detail: None,
|
||||||
|
actor: Some("bob".into()),
|
||||||
|
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) {
|
||||||
|
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, key: EntityKey) {
|
||||||
|
let screen = Screen::IssueDetail(key.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) {
|
||||||
|
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::SearchQueryChanged("retry backoff".into()));
|
||||||
|
let generation = app
|
||||||
|
.supervisor
|
||||||
|
.submit(TaskKey::LoadScreen(Screen::Search))
|
||||||
|
.generation;
|
||||||
|
// Align state generation with supervisor generation so both guards pass.
|
||||||
|
app.state.search.generation = generation;
|
||||||
|
app.update(Msg::SearchExecuted {
|
||||||
|
generation,
|
||||||
|
results: fixture_search_results(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_timeline(app: &mut LoreApp) {
|
||||||
|
let generation = app
|
||||||
|
.supervisor
|
||||||
|
.submit(TaskKey::LoadScreen(Screen::Timeline))
|
||||||
|
.generation;
|
||||||
|
// Align state generation with supervisor generation so both guards pass.
|
||||||
|
app.state.timeline.generation = generation;
|
||||||
|
app.update(Msg::TimelineLoaded {
|
||||||
|
generation,
|
||||||
|
events: fixture_timeline_events(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flow 1: Morning Triage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dashboard -> gi -> Issue List (with data) -> detail (via Msg) -> Esc back
|
||||||
|
// Verifies cursor preservation and state on back-navigation.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_flow_morning_triage() {
|
||||||
|
let mut app = test_app();
|
||||||
|
load_dashboard(&mut app);
|
||||||
|
assert!(app.navigation.is_at(&Screen::Dashboard));
|
||||||
|
|
||||||
|
// Navigate to issue list via g-prefix.
|
||||||
|
send_go(&mut app, 'i');
|
||||||
|
assert!(app.navigation.is_at(&Screen::IssueList));
|
||||||
|
|
||||||
|
// Inject issue list data.
|
||||||
|
load_issue_list(&mut app);
|
||||||
|
assert_eq!(app.state.issue_list.rows.len(), 3);
|
||||||
|
|
||||||
|
// Simulate selecting the second item (cursor state).
|
||||||
|
app.state.issue_list.selected_index = 1;
|
||||||
|
|
||||||
|
// Navigate to issue detail for the second row (iid=55).
|
||||||
|
let issue_key = EntityKey::issue(1, 55);
|
||||||
|
app.update(Msg::NavigateTo(Screen::IssueDetail(issue_key.clone())));
|
||||||
|
load_issue_detail(&mut app, issue_key);
|
||||||
|
assert!(matches!(app.navigation.current(), Screen::IssueDetail(_)));
|
||||||
|
|
||||||
|
// Go back via Esc.
|
||||||
|
send_key(&mut app, KeyCode::Escape);
|
||||||
|
assert!(
|
||||||
|
app.navigation.is_at(&Screen::IssueList),
|
||||||
|
"Esc should return to issue list"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cursor position should be preserved.
|
||||||
|
assert_eq!(
|
||||||
|
app.state.issue_list.selected_index, 1,
|
||||||
|
"Cursor should be preserved on the second row after back-navigation"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Data should still be there.
|
||||||
|
assert_eq!(app.state.issue_list.rows.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flow 2: Direct Screen Jumps (g-prefix chain)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Issue Detail -> gt (Timeline) -> gw (Who) -> gi (Issues) -> gh (Dashboard)
|
||||||
|
// Verifies the g-prefix navigation chain works across screens.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_flow_direct_screen_jumps() {
|
||||||
|
let mut app = test_app();
|
||||||
|
load_dashboard(&mut app);
|
||||||
|
|
||||||
|
// Start on issue detail.
|
||||||
|
let key = EntityKey::issue(1, 101);
|
||||||
|
app.update(Msg::NavigateTo(Screen::IssueDetail(key.clone())));
|
||||||
|
load_issue_detail(&mut app, key);
|
||||||
|
assert!(matches!(app.navigation.current(), Screen::IssueDetail(_)));
|
||||||
|
|
||||||
|
// Jump to Timeline.
|
||||||
|
send_go(&mut app, 't');
|
||||||
|
assert!(
|
||||||
|
app.navigation.is_at(&Screen::Timeline),
|
||||||
|
"gt should jump to Timeline"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Jump to Who.
|
||||||
|
send_go(&mut app, 'w');
|
||||||
|
assert!(app.navigation.is_at(&Screen::Who), "gw should jump to Who");
|
||||||
|
|
||||||
|
// Jump to Issues.
|
||||||
|
send_go(&mut app, 'i');
|
||||||
|
assert!(
|
||||||
|
app.navigation.is_at(&Screen::IssueList),
|
||||||
|
"gi should jump to Issue List"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Jump Home (Dashboard).
|
||||||
|
send_go(&mut app, 'h');
|
||||||
|
assert!(
|
||||||
|
app.navigation.is_at(&Screen::Dashboard),
|
||||||
|
"gh should jump to Dashboard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flow 3: Quick Search
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Any screen -> g/ -> Search -> inject query and results -> verify results
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_flow_quick_search() {
|
||||||
|
let mut app = test_app();
|
||||||
|
load_dashboard(&mut app);
|
||||||
|
|
||||||
|
// Navigate to search via g-prefix.
|
||||||
|
send_go(&mut app, '/');
|
||||||
|
assert!(
|
||||||
|
app.navigation.is_at(&Screen::Search),
|
||||||
|
"g/ should navigate to Search"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inject search query and results.
|
||||||
|
load_search_results(&mut app);
|
||||||
|
assert_eq!(app.state.search.results.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
app.state.search.results[0].title,
|
||||||
|
"Add retry logic for transient failures"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to a result via Msg (simulating Enter on first result).
|
||||||
|
let result_key = app.state.search.results[0].key.clone();
|
||||||
|
app.update(Msg::NavigateTo(Screen::IssueDetail(result_key.clone())));
|
||||||
|
load_issue_detail(&mut app, result_key);
|
||||||
|
assert!(matches!(app.navigation.current(), Screen::IssueDetail(_)));
|
||||||
|
|
||||||
|
// Go back to search — results should be preserved.
|
||||||
|
send_key(&mut app, KeyCode::Escape);
|
||||||
|
assert!(app.navigation.is_at(&Screen::Search));
|
||||||
|
assert_eq!(app.state.search.results.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flow 4: Sync and Browse
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dashboard -> gs -> Sync -> sync lifecycle -> complete -> verify summary
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_flow_sync_and_browse() {
|
||||||
|
let mut app = test_app();
|
||||||
|
load_dashboard(&mut app);
|
||||||
|
|
||||||
|
// Navigate to Sync via g-prefix.
|
||||||
|
send_go(&mut app, 's');
|
||||||
|
assert!(
|
||||||
|
app.navigation.is_at(&Screen::Sync),
|
||||||
|
"gs should navigate to Sync"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start sync.
|
||||||
|
app.update(Msg::SyncStarted);
|
||||||
|
assert!(app.state.sync.is_running());
|
||||||
|
|
||||||
|
// Progress updates.
|
||||||
|
app.update(Msg::SyncProgress {
|
||||||
|
stage: "Fetching issues".into(),
|
||||||
|
current: 10,
|
||||||
|
total: 42,
|
||||||
|
});
|
||||||
|
assert_eq!(app.state.sync.lanes[0].current, 10);
|
||||||
|
assert_eq!(app.state.sync.lanes[0].total, 42);
|
||||||
|
|
||||||
|
app.update(Msg::SyncProgress {
|
||||||
|
stage: "Fetching merge requests".into(),
|
||||||
|
current: 5,
|
||||||
|
total: 28,
|
||||||
|
});
|
||||||
|
assert_eq!(app.state.sync.lanes[1].current, 5);
|
||||||
|
|
||||||
|
// Complete sync.
|
||||||
|
app.update(Msg::SyncCompleted { elapsed_ms: 5000 });
|
||||||
|
assert!(app.state.sync.summary.is_some());
|
||||||
|
assert_eq!(app.state.sync.summary.as_ref().unwrap().elapsed_ms, 5000);
|
||||||
|
|
||||||
|
// Navigate to issue list to browse updated data.
|
||||||
|
send_go(&mut app, 'i');
|
||||||
|
assert!(app.navigation.is_at(&Screen::IssueList));
|
||||||
|
load_issue_list(&mut app);
|
||||||
|
assert_eq!(app.state.issue_list.rows.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flow 5: Who / Expert Navigation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dashboard -> gw -> Who screen -> verify expert mode default -> inject data
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_flow_find_expert() {
|
||||||
|
let mut app = test_app();
|
||||||
|
load_dashboard(&mut app);
|
||||||
|
|
||||||
|
// Navigate to Who via g-prefix.
|
||||||
|
send_go(&mut app, 'w');
|
||||||
|
assert!(
|
||||||
|
app.navigation.is_at(&Screen::Who),
|
||||||
|
"gw should navigate to Who"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Default mode should be Expert.
|
||||||
|
assert_eq!(
|
||||||
|
app.state.who.mode,
|
||||||
|
lore_tui::state::who::WhoMode::Expert,
|
||||||
|
"Who should start in Expert mode"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate back and verify dashboard is preserved.
|
||||||
|
send_key(&mut app, KeyCode::Escape);
|
||||||
|
assert!(app.navigation.is_at(&Screen::Dashboard));
|
||||||
|
assert_eq!(app.state.dashboard.counts.issues_total, 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flow 6: Command Palette
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Any screen -> Ctrl+P -> type -> select -> verify navigation
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_flow_command_palette() {
|
||||||
|
let mut app = test_app();
|
||||||
|
load_dashboard(&mut app);
|
||||||
|
|
||||||
|
// Open command palette.
|
||||||
|
send_key_mod(&mut app, KeyCode::Char('p'), Modifiers::CTRL);
|
||||||
|
assert!(
|
||||||
|
matches!(app.input_mode, InputMode::Palette),
|
||||||
|
"Ctrl+P should open command palette"
|
||||||
|
);
|
||||||
|
assert!(app.state.command_palette.query_focused);
|
||||||
|
|
||||||
|
// Type a filter — palette should have entries.
|
||||||
|
assert!(
|
||||||
|
!app.state.command_palette.filtered.is_empty(),
|
||||||
|
"Palette should have entries when opened"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close palette with Esc.
|
||||||
|
send_key(&mut app, KeyCode::Escape);
|
||||||
|
assert!(
|
||||||
|
matches!(app.input_mode, InputMode::Normal),
|
||||||
|
"Esc should close palette and return to Normal mode"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flow 7: Timeline Navigation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dashboard -> gt -> Timeline -> inject events -> verify events -> Esc back
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_flow_timeline_navigate() {
|
||||||
|
let mut app = test_app();
|
||||||
|
load_dashboard(&mut app);
|
||||||
|
|
||||||
|
// Navigate to Timeline via g-prefix.
|
||||||
|
send_go(&mut app, 't');
|
||||||
|
assert!(
|
||||||
|
app.navigation.is_at(&Screen::Timeline),
|
||||||
|
"gt should navigate to Timeline"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inject timeline events.
|
||||||
|
load_timeline(&mut app);
|
||||||
|
assert_eq!(app.state.timeline.events.len(), 2);
|
||||||
|
assert_eq!(app.state.timeline.events[0].summary, "Issue #101 created");
|
||||||
|
|
||||||
|
// Navigate to the entity from the first event via Msg.
|
||||||
|
let event_key = app.state.timeline.events[0].entity_key.clone();
|
||||||
|
app.update(Msg::NavigateTo(Screen::IssueDetail(event_key.clone())));
|
||||||
|
load_issue_detail(&mut app, event_key);
|
||||||
|
assert!(matches!(app.navigation.current(), Screen::IssueDetail(_)));
|
||||||
|
|
||||||
|
// Esc back to Timeline — events should be preserved.
|
||||||
|
send_key(&mut app, KeyCode::Escape);
|
||||||
|
assert!(app.navigation.is_at(&Screen::Timeline));
|
||||||
|
assert_eq!(app.state.timeline.events.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flow 8: Bootstrap → Sync → Dashboard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bootstrap -> gs (triggers sync) -> SyncCompleted -> auto-navigate Dashboard
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_flow_bootstrap_sync_to_dashboard() {
|
||||||
|
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 triggers sync via g-prefix.
|
||||||
|
send_go(&mut app, 's');
|
||||||
|
assert!(
|
||||||
|
app.state.bootstrap.sync_started,
|
||||||
|
"gs on Bootstrap should set sync_started"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync completes — should auto-transition to Dashboard.
|
||||||
|
app.update(Msg::SyncCompleted { elapsed_ms: 3000 });
|
||||||
|
assert!(
|
||||||
|
app.navigation.is_at(&Screen::Dashboard),
|
||||||
|
"SyncCompleted on Bootstrap should auto-navigate to Dashboard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Flow 9: MR List → MR Detail → Back with State
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dashboard -> gm -> MR List -> detail (via Msg) -> Esc -> verify state
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_flow_mr_drill_in_and_back() {
|
||||||
|
let mut app = test_app();
|
||||||
|
load_dashboard(&mut app);
|
||||||
|
|
||||||
|
// Navigate to MR list.
|
||||||
|
send_go(&mut app, 'm');
|
||||||
|
assert!(
|
||||||
|
app.navigation.is_at(&Screen::MrList),
|
||||||
|
"gm should navigate to MR List"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inject MR list data.
|
||||||
|
load_mr_list(&mut app);
|
||||||
|
assert_eq!(app.state.mr_list.rows.len(), 2);
|
||||||
|
|
||||||
|
// Set cursor to second row (draft MR).
|
||||||
|
app.state.mr_list.selected_index = 1;
|
||||||
|
|
||||||
|
// Navigate to MR detail via Msg.
|
||||||
|
let mr_key = EntityKey::mr(1, 88);
|
||||||
|
app.update(Msg::NavigateTo(Screen::MrDetail(mr_key.clone())));
|
||||||
|
let screen = Screen::MrDetail(mr_key.clone());
|
||||||
|
let generation = app
|
||||||
|
.supervisor
|
||||||
|
.submit(TaskKey::LoadScreen(screen))
|
||||||
|
.generation;
|
||||||
|
app.update(Msg::MrDetailLoaded {
|
||||||
|
generation,
|
||||||
|
key: mr_key,
|
||||||
|
data: Box::new(lore_tui::state::mr_detail::MrDetailData {
|
||||||
|
metadata: lore_tui::state::mr_detail::MrMetadata {
|
||||||
|
iid: 88,
|
||||||
|
project_path: "web/frontend".into(),
|
||||||
|
title: "WIP: Redesign settings page".into(),
|
||||||
|
description: "Settings page redesign".into(),
|
||||||
|
state: "opened".into(),
|
||||||
|
draft: true,
|
||||||
|
author: "alice".into(),
|
||||||
|
assignees: vec![],
|
||||||
|
reviewers: vec![],
|
||||||
|
labels: vec!["ui".into()],
|
||||||
|
source_branch: "redesign-settings".into(),
|
||||||
|
target_branch: "main".into(),
|
||||||
|
merge_status: "checking".into(),
|
||||||
|
created_at: 1_736_938_400_000,
|
||||||
|
updated_at: 1_736_938_400_000,
|
||||||
|
merged_at: None,
|
||||||
|
web_url: "https://gitlab.com/web/frontend/-/merge_requests/88".into(),
|
||||||
|
discussion_count: 0,
|
||||||
|
file_change_count: 5,
|
||||||
|
},
|
||||||
|
cross_refs: vec![],
|
||||||
|
file_changes: vec![],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert!(matches!(app.navigation.current(), Screen::MrDetail(_)));
|
||||||
|
|
||||||
|
// Go back.
|
||||||
|
send_key(&mut app, KeyCode::Escape);
|
||||||
|
assert!(app.navigation.is_at(&Screen::MrList));
|
||||||
|
|
||||||
|
// Cursor and data preserved.
|
||||||
|
assert_eq!(
|
||||||
|
app.state.mr_list.selected_index, 1,
|
||||||
|
"MR list cursor should be preserved after back-navigation"
|
||||||
|
);
|
||||||
|
assert_eq!(app.state.mr_list.rows.len(), 2);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user