Files
gitlore/crates/lore-tui/tests/tui_user_flows.rs
teernisse 9bcc512639 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
2026-02-19 00:52:58 -05:00

678 lines
22 KiB
Rust

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