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