bd-26f2: Common widgets (render_breadcrumb, render_status_bar, render_loading, render_error_toast, render_help_overlay) + render_screen top-level dispatch wired to LoreApp::view(). 27 widget tests. bd-2w1p: Add half-life fields to ScoringConfig with validation. bd-1soz: Add half_life_decay() pure function. bd-18dn: Add normalize_query_path() for path canonicalization. Phase 1 modules: CommandRegistry, NavigationStack, CrashContext, TaskSupervisor, AppState with per-screen states. 172 lore-tui tests passing, clippy clean, fmt clean.
713 lines
23 KiB
Rust
713 lines
23 KiB
Rust
#![allow(dead_code)] // Phase 1: methods consumed as screens are implemented
|
|
|
|
//! Full FrankenTUI Model implementation for the lore TUI.
|
|
//!
|
|
//! LoreApp is the central coordinator: it owns all state, dispatches
|
|
//! messages through a 5-stage key pipeline, records crash context
|
|
//! breadcrumbs, manages async tasks via the supervisor, and routes
|
|
//! view() to per-screen render functions.
|
|
|
|
use chrono::TimeDelta;
|
|
use ftui::{Cmd, Event, Frame, KeyCode, KeyEvent, Model, Modifiers};
|
|
|
|
use crate::clock::{Clock, SystemClock};
|
|
use crate::commands::{CommandRegistry, build_registry};
|
|
use crate::crash_context::{CrashContext, CrashEvent};
|
|
use crate::db::DbManager;
|
|
use crate::message::{InputMode, Msg, Screen};
|
|
use crate::navigation::NavigationStack;
|
|
use crate::state::{AppState, LoadState};
|
|
use crate::task_supervisor::{TaskKey, TaskSupervisor};
|
|
|
|
/// Timeout for the g-prefix key sequence.
|
|
const GO_PREFIX_TIMEOUT: TimeDelta = TimeDelta::milliseconds(500);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LoreApp
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Root model for the lore TUI.
|
|
///
|
|
/// Owns all state and implements the FrankenTUI Model trait. The
|
|
/// update() method is the single entry point for all state transitions.
|
|
pub struct LoreApp {
|
|
pub state: AppState,
|
|
pub navigation: NavigationStack,
|
|
pub supervisor: TaskSupervisor,
|
|
pub crash_context: CrashContext,
|
|
pub command_registry: CommandRegistry,
|
|
pub input_mode: InputMode,
|
|
pub clock: Box<dyn Clock>,
|
|
pub db: Option<DbManager>,
|
|
}
|
|
|
|
impl LoreApp {
|
|
/// Create a new LoreApp with default state.
|
|
///
|
|
/// Uses a real system clock and no DB connection (set separately).
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
state: AppState::default(),
|
|
navigation: NavigationStack::new(),
|
|
supervisor: TaskSupervisor::new(),
|
|
crash_context: CrashContext::new(),
|
|
command_registry: build_registry(),
|
|
input_mode: InputMode::Normal,
|
|
clock: Box::new(SystemClock),
|
|
db: None,
|
|
}
|
|
}
|
|
|
|
/// Create a LoreApp for testing with a custom clock.
|
|
#[cfg(test)]
|
|
fn with_clock(clock: Box<dyn Clock>) -> Self {
|
|
Self {
|
|
clock,
|
|
..Self::new()
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Key dispatch
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Normalize terminal key variants for cross-terminal consistency.
|
|
fn normalize_key(key: &mut KeyEvent) {
|
|
// BackTab -> Shift+Tab canonical form.
|
|
if key.code == KeyCode::BackTab {
|
|
key.code = KeyCode::Tab;
|
|
key.modifiers |= Modifiers::SHIFT;
|
|
}
|
|
}
|
|
|
|
/// 5-stage key dispatch pipeline.
|
|
///
|
|
/// Returns the Cmd to execute (Quit, None, or a task command).
|
|
fn interpret_key(&mut self, mut key: KeyEvent) -> Cmd<Msg> {
|
|
Self::normalize_key(&mut key);
|
|
|
|
let screen = self.navigation.current().clone();
|
|
|
|
// Record key press in crash context.
|
|
self.crash_context.push(CrashEvent::KeyPress {
|
|
key: format!("{:?}", key.code),
|
|
mode: format!("{:?}", self.input_mode),
|
|
screen: screen.label().to_string(),
|
|
});
|
|
|
|
// --- Stage 1: Quit check ---
|
|
// Ctrl+C always quits regardless of mode.
|
|
if key.code == KeyCode::Char('c') && key.modifiers.contains(Modifiers::CTRL) {
|
|
return Cmd::quit();
|
|
}
|
|
|
|
// --- Stage 2: InputMode routing ---
|
|
match &self.input_mode {
|
|
InputMode::Text => {
|
|
return self.handle_text_mode_key(&key, &screen);
|
|
}
|
|
InputMode::Palette => {
|
|
return self.handle_palette_mode_key(&key, &screen);
|
|
}
|
|
InputMode::GoPrefix { started_at } => {
|
|
let elapsed = self.clock.now().signed_duration_since(*started_at);
|
|
if elapsed > GO_PREFIX_TIMEOUT {
|
|
// Timeout expired — cancel prefix and re-process as normal.
|
|
self.input_mode = InputMode::Normal;
|
|
} else {
|
|
return self.handle_go_prefix_key(&key, &screen);
|
|
}
|
|
}
|
|
InputMode::Normal => {}
|
|
}
|
|
|
|
// --- Stage 3: Global shortcuts (Normal mode) ---
|
|
// 'q' quits.
|
|
if key.code == KeyCode::Char('q') && key.modifiers == Modifiers::NONE {
|
|
return Cmd::quit();
|
|
}
|
|
|
|
// 'g' starts prefix sequence.
|
|
if self
|
|
.command_registry
|
|
.is_sequence_starter(&key.code, &key.modifiers)
|
|
{
|
|
self.input_mode = InputMode::GoPrefix {
|
|
started_at: self.clock.now(),
|
|
};
|
|
return Cmd::none();
|
|
}
|
|
|
|
// Registry-based single-key lookup.
|
|
if let Some(cmd_def) =
|
|
self.command_registry
|
|
.lookup_key(&key.code, &key.modifiers, &screen, &self.input_mode)
|
|
{
|
|
return self.execute_command(cmd_def.id, &screen);
|
|
}
|
|
|
|
// --- Stage 4: Screen-local keys ---
|
|
// Delegated to AppState::interpret_screen_key in future phases.
|
|
|
|
// --- Stage 5: Fallback (unhandled) ---
|
|
Cmd::none()
|
|
}
|
|
|
|
/// Handle keys in Text input mode.
|
|
///
|
|
/// Only Esc and Ctrl+P pass through; everything else is consumed by
|
|
/// the focused text widget (handled in future phases).
|
|
fn handle_text_mode_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
|
|
// Esc blurs the text input.
|
|
if key.code == KeyCode::Escape {
|
|
self.state.blur_text_focus();
|
|
self.input_mode = InputMode::Normal;
|
|
return Cmd::none();
|
|
}
|
|
|
|
// Ctrl+P opens palette even in text mode.
|
|
if let Some(cmd_def) =
|
|
self.command_registry
|
|
.lookup_key(&key.code, &key.modifiers, screen, &InputMode::Text)
|
|
{
|
|
return self.execute_command(cmd_def.id, screen);
|
|
}
|
|
|
|
// All other keys consumed by text widget (future).
|
|
Cmd::none()
|
|
}
|
|
|
|
/// Handle keys in Palette mode.
|
|
fn handle_palette_mode_key(&mut self, key: &KeyEvent, _screen: &Screen) -> Cmd<Msg> {
|
|
if key.code == KeyCode::Escape {
|
|
self.input_mode = InputMode::Normal;
|
|
return Cmd::none();
|
|
}
|
|
// Palette key dispatch will be expanded in the palette widget phase.
|
|
Cmd::none()
|
|
}
|
|
|
|
/// Handle the second key of a g-prefix sequence.
|
|
fn handle_go_prefix_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
|
|
self.input_mode = InputMode::Normal;
|
|
|
|
if let Some(cmd_def) = self.command_registry.complete_sequence(
|
|
&KeyCode::Char('g'),
|
|
&Modifiers::NONE,
|
|
&key.code,
|
|
&key.modifiers,
|
|
screen,
|
|
) {
|
|
return self.execute_command(cmd_def.id, screen);
|
|
}
|
|
|
|
// Invalid second key — cancel prefix silently.
|
|
Cmd::none()
|
|
}
|
|
|
|
/// Execute a command by ID.
|
|
fn execute_command(&mut self, id: &str, _screen: &Screen) -> Cmd<Msg> {
|
|
match id {
|
|
"quit" => Cmd::quit(),
|
|
"go_back" => {
|
|
self.navigation.pop();
|
|
Cmd::none()
|
|
}
|
|
"show_help" => {
|
|
self.state.show_help = !self.state.show_help;
|
|
Cmd::none()
|
|
}
|
|
"command_palette" => {
|
|
self.input_mode = InputMode::Palette;
|
|
self.state.command_palette.query_focused = true;
|
|
Cmd::none()
|
|
}
|
|
"open_in_browser" => {
|
|
// Will dispatch OpenInBrowser msg in future phase.
|
|
Cmd::none()
|
|
}
|
|
"show_cli" => {
|
|
// Will show CLI equivalent in future phase.
|
|
Cmd::none()
|
|
}
|
|
"go_home" => self.navigate_to(Screen::Dashboard),
|
|
"go_issues" => self.navigate_to(Screen::IssueList),
|
|
"go_mrs" => self.navigate_to(Screen::MrList),
|
|
"go_search" => self.navigate_to(Screen::Search),
|
|
"go_timeline" => self.navigate_to(Screen::Timeline),
|
|
"go_who" => self.navigate_to(Screen::Who),
|
|
"go_sync" => self.navigate_to(Screen::Sync),
|
|
"jump_back" => {
|
|
self.navigation.jump_back();
|
|
Cmd::none()
|
|
}
|
|
"jump_forward" => {
|
|
self.navigation.jump_forward();
|
|
Cmd::none()
|
|
}
|
|
"move_down" | "move_up" | "select_item" | "focus_filter" | "scroll_to_top" => {
|
|
// Screen-specific actions — delegated in future phases.
|
|
Cmd::none()
|
|
}
|
|
_ => Cmd::none(),
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Navigation helpers
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Navigate to a screen, pushing the nav stack and starting a data load.
|
|
fn navigate_to(&mut self, screen: Screen) -> Cmd<Msg> {
|
|
let screen_label = screen.label().to_string();
|
|
let current_label = self.navigation.current().label().to_string();
|
|
|
|
self.crash_context.push(CrashEvent::StateTransition {
|
|
from: current_label,
|
|
to: screen_label,
|
|
});
|
|
|
|
self.navigation.push(screen.clone());
|
|
self.state
|
|
.set_loading(screen.clone(), LoadState::Refreshing);
|
|
|
|
// Spawn supervised task for data loading (placeholder — actual DB
|
|
// query dispatch comes in Phase 2 screen implementations).
|
|
let _handle = self.supervisor.submit(TaskKey::LoadScreen(screen));
|
|
|
|
Cmd::none()
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Message dispatch (non-key)
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Handle non-key messages.
|
|
fn handle_msg(&mut self, msg: Msg) -> Cmd<Msg> {
|
|
// Record in crash context.
|
|
self.crash_context.push(CrashEvent::MsgDispatched {
|
|
msg_name: format!("{msg:?}")
|
|
.split('(')
|
|
.next()
|
|
.unwrap_or("?")
|
|
.to_string(),
|
|
screen: self.navigation.current().label().to_string(),
|
|
});
|
|
|
|
match msg {
|
|
Msg::Quit => Cmd::quit(),
|
|
|
|
// --- Navigation ---
|
|
Msg::NavigateTo(screen) => self.navigate_to(screen),
|
|
Msg::GoBack => {
|
|
self.navigation.pop();
|
|
Cmd::none()
|
|
}
|
|
Msg::GoForward => {
|
|
self.navigation.go_forward();
|
|
Cmd::none()
|
|
}
|
|
Msg::GoHome => self.navigate_to(Screen::Dashboard),
|
|
Msg::JumpBack(_) => {
|
|
self.navigation.jump_back();
|
|
Cmd::none()
|
|
}
|
|
Msg::JumpForward(_) => {
|
|
self.navigation.jump_forward();
|
|
Cmd::none()
|
|
}
|
|
|
|
// --- Error ---
|
|
Msg::Error(err) => {
|
|
self.state.set_error(err.to_string());
|
|
Cmd::none()
|
|
}
|
|
|
|
// --- Help / UI ---
|
|
Msg::ShowHelp => {
|
|
self.state.show_help = !self.state.show_help;
|
|
Cmd::none()
|
|
}
|
|
Msg::BlurTextInput => {
|
|
self.state.blur_text_focus();
|
|
self.input_mode = InputMode::Normal;
|
|
Cmd::none()
|
|
}
|
|
|
|
// --- Terminal ---
|
|
Msg::Resize { width, height } => {
|
|
self.state.terminal_size = (width, height);
|
|
Cmd::none()
|
|
}
|
|
Msg::Tick => Cmd::none(),
|
|
|
|
// --- Loaded results (stale guard) ---
|
|
Msg::IssueListLoaded { generation, rows } => {
|
|
if self
|
|
.supervisor
|
|
.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation)
|
|
{
|
|
self.state.issue_list.rows = rows;
|
|
self.state.set_loading(Screen::IssueList, LoadState::Idle);
|
|
self.supervisor
|
|
.complete(&TaskKey::LoadScreen(Screen::IssueList), generation);
|
|
}
|
|
Cmd::none()
|
|
}
|
|
Msg::MrListLoaded { generation, rows } => {
|
|
if self
|
|
.supervisor
|
|
.is_current(&TaskKey::LoadScreen(Screen::MrList), generation)
|
|
{
|
|
self.state.mr_list.rows = rows;
|
|
self.state.set_loading(Screen::MrList, LoadState::Idle);
|
|
self.supervisor
|
|
.complete(&TaskKey::LoadScreen(Screen::MrList), generation);
|
|
}
|
|
Cmd::none()
|
|
}
|
|
Msg::DashboardLoaded { generation, data } => {
|
|
if self
|
|
.supervisor
|
|
.is_current(&TaskKey::LoadScreen(Screen::Dashboard), generation)
|
|
{
|
|
self.state.dashboard.issue_count = data.issue_count;
|
|
self.state.dashboard.mr_count = data.mr_count;
|
|
self.state.set_loading(Screen::Dashboard, LoadState::Idle);
|
|
self.supervisor
|
|
.complete(&TaskKey::LoadScreen(Screen::Dashboard), generation);
|
|
}
|
|
Cmd::none()
|
|
}
|
|
|
|
// All other message variants: no-op for now.
|
|
// Future phases will fill these in as screens are implemented.
|
|
_ => Cmd::none(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for LoreApp {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl Model for LoreApp {
|
|
type Message = Msg;
|
|
|
|
fn init(&mut self) -> Cmd<Self::Message> {
|
|
// Install crash context panic hook.
|
|
CrashContext::install_panic_hook(&self.crash_context);
|
|
CrashContext::prune_crash_files();
|
|
|
|
// Navigate to dashboard (will trigger data load in future phase).
|
|
Cmd::none()
|
|
}
|
|
|
|
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
|
|
// Route raw key events through the 5-stage pipeline.
|
|
if let Msg::RawEvent(Event::Key(key)) = msg {
|
|
return self.interpret_key(key);
|
|
}
|
|
|
|
// Everything else goes through message dispatch.
|
|
self.handle_msg(msg)
|
|
}
|
|
|
|
fn view(&self, frame: &mut Frame) {
|
|
crate::view::render_screen(frame, self);
|
|
}
|
|
}
|
|
|
|
/// Verify that `App::fullscreen(LoreApp::new()).run()` compiles.
|
|
#[cfg(test)]
|
|
fn _assert_app_fullscreen_compiles() {
|
|
fn _inner() {
|
|
use ftui::App;
|
|
let _app_builder = App::fullscreen(LoreApp::new());
|
|
}
|
|
}
|
|
|
|
/// Verify that `App::inline(LoreApp::new(), 12).run()` compiles.
|
|
#[cfg(test)]
|
|
fn _assert_app_inline_compiles() {
|
|
fn _inner() {
|
|
use ftui::App;
|
|
let _app_builder = App::inline(LoreApp::new(), 12);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::clock::FakeClock;
|
|
|
|
fn test_app() -> LoreApp {
|
|
LoreApp::with_clock(Box::new(FakeClock::new(chrono::Utc::now())))
|
|
}
|
|
|
|
#[test]
|
|
fn test_lore_app_init_returns_none() {
|
|
let mut app = test_app();
|
|
let cmd = app.init();
|
|
assert!(matches!(cmd, Cmd::None));
|
|
}
|
|
|
|
#[test]
|
|
fn test_lore_app_quit_returns_quit_cmd() {
|
|
let mut app = test_app();
|
|
let cmd = app.update(Msg::Quit);
|
|
assert!(matches!(cmd, Cmd::Quit));
|
|
}
|
|
|
|
#[test]
|
|
fn test_lore_app_tick_returns_none() {
|
|
let mut app = test_app();
|
|
let cmd = app.update(Msg::Tick);
|
|
assert!(matches!(cmd, Cmd::None));
|
|
}
|
|
|
|
#[test]
|
|
fn test_lore_app_navigate_to_updates_nav_stack() {
|
|
let mut app = test_app();
|
|
let cmd = app.update(Msg::NavigateTo(Screen::IssueList));
|
|
assert!(matches!(cmd, Cmd::None));
|
|
assert!(app.navigation.is_at(&Screen::IssueList));
|
|
assert_eq!(app.navigation.depth(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_lore_app_go_back() {
|
|
let mut app = test_app();
|
|
app.update(Msg::NavigateTo(Screen::IssueList));
|
|
app.update(Msg::GoBack);
|
|
assert!(app.navigation.is_at(&Screen::Dashboard));
|
|
}
|
|
|
|
#[test]
|
|
fn test_lore_app_go_forward() {
|
|
let mut app = test_app();
|
|
app.update(Msg::NavigateTo(Screen::IssueList));
|
|
app.update(Msg::GoBack);
|
|
app.update(Msg::GoForward);
|
|
assert!(app.navigation.is_at(&Screen::IssueList));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ctrl_c_always_quits() {
|
|
let mut app = test_app();
|
|
let key = KeyEvent::new(KeyCode::Char('c')).with_modifiers(Modifiers::CTRL);
|
|
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
|
|
assert!(matches!(cmd, Cmd::Quit));
|
|
}
|
|
|
|
#[test]
|
|
fn test_q_key_quits_in_normal_mode() {
|
|
let mut app = test_app();
|
|
let key = KeyEvent::new(KeyCode::Char('q'));
|
|
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
|
|
assert!(matches!(cmd, Cmd::Quit));
|
|
}
|
|
|
|
#[test]
|
|
fn test_q_key_blocked_in_text_mode() {
|
|
let mut app = test_app();
|
|
app.input_mode = InputMode::Text;
|
|
let key = KeyEvent::new(KeyCode::Char('q'));
|
|
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
|
|
// q in text mode should NOT quit.
|
|
assert!(matches!(cmd, Cmd::None));
|
|
}
|
|
|
|
#[test]
|
|
fn test_esc_blurs_text_mode() {
|
|
let mut app = test_app();
|
|
app.input_mode = InputMode::Text;
|
|
app.state.search.query_focused = true;
|
|
|
|
let key = KeyEvent::new(KeyCode::Escape);
|
|
app.update(Msg::RawEvent(Event::Key(key)));
|
|
|
|
assert!(matches!(app.input_mode, InputMode::Normal));
|
|
assert!(!app.state.has_text_focus());
|
|
}
|
|
|
|
#[test]
|
|
fn test_g_prefix_enters_go_mode() {
|
|
let mut app = test_app();
|
|
let key = KeyEvent::new(KeyCode::Char('g'));
|
|
app.update(Msg::RawEvent(Event::Key(key)));
|
|
assert!(matches!(app.input_mode, InputMode::GoPrefix { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn test_g_then_i_navigates_to_issues() {
|
|
let mut app = test_app();
|
|
|
|
// First key: 'g'
|
|
let key_g = KeyEvent::new(KeyCode::Char('g'));
|
|
app.update(Msg::RawEvent(Event::Key(key_g)));
|
|
|
|
// Second key: 'i'
|
|
let key_i = KeyEvent::new(KeyCode::Char('i'));
|
|
app.update(Msg::RawEvent(Event::Key(key_i)));
|
|
|
|
assert!(app.navigation.is_at(&Screen::IssueList));
|
|
}
|
|
|
|
#[test]
|
|
fn test_go_prefix_timeout_cancels() {
|
|
let clock = FakeClock::new(chrono::Utc::now());
|
|
let mut app = LoreApp::with_clock(Box::new(clock.clone()));
|
|
|
|
// Press 'g'.
|
|
let key_g = KeyEvent::new(KeyCode::Char('g'));
|
|
app.update(Msg::RawEvent(Event::Key(key_g)));
|
|
assert!(matches!(app.input_mode, InputMode::GoPrefix { .. }));
|
|
|
|
// Advance clock past timeout.
|
|
clock.advance(TimeDelta::milliseconds(600));
|
|
|
|
// Press 'i' after timeout — should NOT navigate to issues.
|
|
let key_i = KeyEvent::new(KeyCode::Char('i'));
|
|
app.update(Msg::RawEvent(Event::Key(key_i)));
|
|
|
|
// Should still be at Dashboard (no navigation happened).
|
|
assert!(app.navigation.is_at(&Screen::Dashboard));
|
|
assert!(matches!(app.input_mode, InputMode::Normal));
|
|
}
|
|
|
|
#[test]
|
|
fn test_show_help_toggles() {
|
|
let mut app = test_app();
|
|
assert!(!app.state.show_help);
|
|
|
|
app.update(Msg::ShowHelp);
|
|
assert!(app.state.show_help);
|
|
|
|
app.update(Msg::ShowHelp);
|
|
assert!(!app.state.show_help);
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_msg_sets_toast() {
|
|
let mut app = test_app();
|
|
app.update(Msg::Error(crate::message::AppError::DbBusy));
|
|
assert!(app.state.error_toast.is_some());
|
|
assert!(app.state.error_toast.as_ref().unwrap().contains("busy"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resize_updates_terminal_size() {
|
|
let mut app = test_app();
|
|
app.update(Msg::Resize {
|
|
width: 120,
|
|
height: 40,
|
|
});
|
|
assert_eq!(app.state.terminal_size, (120, 40));
|
|
}
|
|
|
|
#[test]
|
|
fn test_stale_result_dropped() {
|
|
let mut app = test_app();
|
|
|
|
// Submit two tasks for IssueList — second supersedes first.
|
|
let gen1 = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
|
.generation;
|
|
let gen2 = app
|
|
.supervisor
|
|
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
|
.generation;
|
|
|
|
// Stale result with gen1 should be ignored.
|
|
app.update(Msg::IssueListLoaded {
|
|
generation: gen1,
|
|
rows: vec![crate::message::IssueRow {
|
|
key: crate::message::EntityKey::issue(1, 1),
|
|
title: "stale".into(),
|
|
state: "opened".into(),
|
|
}],
|
|
});
|
|
assert!(app.state.issue_list.rows.is_empty());
|
|
|
|
// Current result with gen2 should be applied.
|
|
app.update(Msg::IssueListLoaded {
|
|
generation: gen2,
|
|
rows: vec![crate::message::IssueRow {
|
|
key: crate::message::EntityKey::issue(1, 2),
|
|
title: "fresh".into(),
|
|
state: "opened".into(),
|
|
}],
|
|
});
|
|
assert_eq!(app.state.issue_list.rows.len(), 1);
|
|
assert_eq!(app.state.issue_list.rows[0].title, "fresh");
|
|
}
|
|
|
|
#[test]
|
|
fn test_crash_context_records_events() {
|
|
let mut app = test_app();
|
|
app.update(Msg::Tick);
|
|
app.update(Msg::NavigateTo(Screen::IssueList));
|
|
|
|
// Should have recorded at least 2 events.
|
|
assert!(app.crash_context.len() >= 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_navigate_sets_loading_state() {
|
|
let mut app = test_app();
|
|
app.update(Msg::NavigateTo(Screen::IssueList));
|
|
assert_eq!(
|
|
*app.state.load_state.get(&Screen::IssueList),
|
|
LoadState::Refreshing
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_command_palette_opens_from_ctrl_p() {
|
|
let mut app = test_app();
|
|
let key = KeyEvent::new(KeyCode::Char('p')).with_modifiers(Modifiers::CTRL);
|
|
app.update(Msg::RawEvent(Event::Key(key)));
|
|
assert!(matches!(app.input_mode, InputMode::Palette));
|
|
assert!(app.state.command_palette.query_focused);
|
|
}
|
|
|
|
#[test]
|
|
fn test_esc_closes_palette() {
|
|
let mut app = test_app();
|
|
app.input_mode = InputMode::Palette;
|
|
|
|
let key = KeyEvent::new(KeyCode::Escape);
|
|
app.update(Msg::RawEvent(Event::Key(key)));
|
|
|
|
assert!(matches!(app.input_mode, InputMode::Normal));
|
|
}
|
|
|
|
#[test]
|
|
fn test_blur_text_input_msg() {
|
|
let mut app = test_app();
|
|
app.input_mode = InputMode::Text;
|
|
app.state.search.query_focused = true;
|
|
|
|
app.update(Msg::BlurTextInput);
|
|
|
|
assert!(matches!(app.input_mode, InputMode::Normal));
|
|
assert!(!app.state.has_text_focus());
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_is_new() {
|
|
let app = LoreApp::default();
|
|
assert!(app.navigation.is_at(&Screen::Dashboard));
|
|
assert!(matches!(app.input_mode, InputMode::Normal));
|
|
}
|
|
}
|