//! The main event loop. [`MenuRunner`] wraps any [`Menu`] //! implementation, drives the action/event channel loop, //! and broadcasts state changes. Frontends never mutate //! state directly. They send actions and react to events. use std::sync::Arc; use indexmap::IndexSet; use tokio::sync::{broadcast, mpsc}; use tracing::{debug, info, trace}; use crate::debounce::{DebouncedDispatcher, hook_response_to_action}; use crate::error::PiklError; use crate::event::{Action, ColumnHeader, MenuEvent, MenuResult, Mode, ViewState, VisibleItem}; use crate::hook::{HookEvent, HookHandler}; use crate::model::traits::MutableMenu; use crate::navigation::Viewport; use serde_json::Value; /// Result of applying a single action to the menu state. /// The run loop uses this to decide what to broadcast. #[derive(Debug)] pub enum ActionOutcome { /// State changed, broadcast to subscribers. Broadcast, /// User confirmed a selection. Selected { items: Vec<(Value, usize)> }, /// User cancelled. Cancelled, /// Menu closed by hook command. Closed, /// User quicklisted the filtered items. Quicklist { items: Vec<(Value, usize)> }, /// Nothing happened (e.g. confirm on empty list). NoOp, } /// Tracks multi-select state: which items are selected (by /// original index), with undo/redo support. pub struct SelectionState { selected: IndexSet, undo_stack: Vec>, redo_stack: Vec>, multi_enabled: bool, selection_order: bool, } impl SelectionState { fn new() -> Self { Self { selected: IndexSet::new(), undo_stack: Vec::new(), redo_stack: Vec::new(), multi_enabled: false, selection_order: false, } } fn push_undo(&mut self) { self.undo_stack.push(self.selected.clone()); self.redo_stack.clear(); } /// Toggle an item in/out of the selection set. pub fn toggle(&mut self, original_index: usize) { self.push_undo(); if !self.selected.shift_remove(&original_index) { self.selected.insert(original_index); } } /// Force-select a range of items (no toggle). pub fn select_range(&mut self, indices: impl Iterator) { self.push_undo(); for idx in indices { self.selected.insert(idx); } } /// Select all the given items. pub fn select_all(&mut self, indices: impl Iterator) { self.push_undo(); for idx in indices { self.selected.insert(idx); } } /// Clear all selections. pub fn clear(&mut self) { if self.selected.is_empty() { return; } self.push_undo(); self.selected.clear(); } /// Undo the last selection change. pub fn undo(&mut self) { if let Some(prev) = self.undo_stack.pop() { self.redo_stack .push(std::mem::replace(&mut self.selected, prev)); } } /// Redo a previously undone selection change. pub fn redo(&mut self) { if let Some(next) = self.redo_stack.pop() { self.undo_stack .push(std::mem::replace(&mut self.selected, next)); } } pub fn is_selected(&self, original_index: usize) -> bool { self.selected.contains(&original_index) } pub fn count(&self) -> usize { self.selected.len() } /// Items sorted by original index (input order). pub fn ordered_items(&self) -> Vec { let mut items: Vec = self.selected.iter().copied().collect(); items.sort_unstable(); items } /// Items in insertion order (the order they were selected). pub fn insertion_ordered(&self) -> Vec { self.selected.iter().copied().collect() } } /// The menu engine. Wraps any [`Menu`] implementation and /// drives it with an action/event channel loop. Create one, /// grab the action sender and event subscriber, then call /// [`MenuRunner::run`] to start the event loop. pub struct MenuRunner { menu: M, viewport: Viewport, filter_text: Arc, mode: Mode, action_rx: mpsc::Receiver, event_tx: broadcast::Sender, dispatcher: Option, previous_cursor: Option, generation: u64, selection: SelectionState, visual_anchor: Option, } impl MenuRunner { /// Create a menu runner wrapping the given menu backend. /// Returns the runner and an action sender. Call /// [`subscribe`](Self::subscribe) to get an event handle, /// then [`run`](Self::run) to start the event loop. pub fn new(menu: M) -> (Self, mpsc::Sender) { let (action_tx, action_rx) = mpsc::channel(256); // 1024 slots: large enough that a burst of rapid state changes // (e.g. streaming AddItems + filter updates) won't cause lag for // subscribers. If a subscriber does fall behind, it gets a Lagged // error and can catch up from the next StateChanged. let (event_tx, _) = broadcast::channel(1024); let runner = Self { menu, viewport: Viewport::new(), filter_text: Arc::from(""), mode: Mode::default(), action_rx, event_tx, dispatcher: None, previous_cursor: None, generation: 0, selection: SelectionState::new(), visual_anchor: None, }; (runner, action_tx) } /// Subscribe to menu events. Returns a broadcast receiver /// that gets state changes, selections, and cancellations. pub fn subscribe(&self) -> broadcast::Receiver { self.event_tx.subscribe() } /// Set a hook handler. Wraps it in a DebouncedDispatcher /// with no debounce (all events fire immediately). Use /// [`set_dispatcher`] for custom debounce settings. pub fn set_hook_handler( &mut self, handler: Arc, action_tx: mpsc::Sender, ) { let dispatcher = DebouncedDispatcher::new(handler, action_tx); self.dispatcher = Some(dispatcher); } /// Set a hook handler with a pre-configured dispatcher. pub fn set_dispatcher(&mut self, dispatcher: DebouncedDispatcher) { self.dispatcher = Some(dispatcher); } /// Enable multi-select mode. pub fn set_multi(&mut self, enabled: bool) { self.selection.multi_enabled = enabled; } /// Enable selection-order output (insertion order instead /// of input order). pub fn set_selection_order(&mut self, enabled: bool) { self.selection.selection_order = enabled; } /// Re-run the filter against all items with the current /// filter text. Updates the viewport with the new count. fn run_filter(&mut self) { let start = std::time::Instant::now(); self.menu.apply_filter(&self.filter_text); let matched = self.menu.filtered_count(); let total = self.menu.total(); let duration_us = start.elapsed().as_micros() as u64; debug!( filter = %self.filter_text, matched, total, duration_us, "filter applied" ); self.viewport.set_filtered_count(matched); } /// Build a [`ViewState`] snapshot from the current filter /// results and viewport position. fn build_view_state(&mut self) -> ViewState { self.generation += 1; let range = self.viewport.visible_range(); let has_columns = self.menu.column_config().is_some(); let visible_items: Vec = range .clone() .filter_map(|i| { self.menu.filtered_label(i).map(|label| { let formatted_text = self.menu.formatted_label(i); let orig_idx = self.menu.original_index(i).unwrap_or(0); let column_values = if has_columns { self.menu.column_values(i) } else { None }; VisibleItem { label: label.to_string(), formatted_text, index: i, selected: self.selection.is_selected(orig_idx), column_values, } }) }) .collect(); // Compute column headers with widths if table mode is active let columns = self.menu.column_config().map(|config| { config .columns .iter() .enumerate() .map(|(col_idx, col_def)| { let header_len = col_def.display_name.len(); let max_cell = visible_items .iter() .filter_map(|vi| { vi.column_values .as_ref() .and_then(|cv| cv.get(col_idx)) .map(|s| s.len()) }) .max() .unwrap_or(0); let width = header_len.max(max_cell).min(60) as u16; ColumnHeader { display_name: col_def.display_name.clone(), width, } }) .collect() }); let cursor = if self.menu.filtered_count() == 0 { 0 } else { self.viewport.cursor() - range.start }; ViewState { visible_items, cursor, filter_text: Arc::clone(&self.filter_text), total_items: self.menu.total(), total_filtered: self.menu.filtered_count(), mode: self.mode, selection_count: self.selection.count(), multi_enabled: self.selection.multi_enabled, columns, generation: self.generation, } } /// Send the current view state to all subscribers. fn broadcast_state(&mut self) { let vs = self.build_view_state(); trace!( generation = vs.generation, filtered = vs.total_filtered, "state broadcast" ); let _ = self.event_tx.send(MenuEvent::StateChanged(vs)); } /// Emit a hook event through the dispatcher, if one is set. fn emit_hook(&mut self, event: HookEvent) { if let Some(dispatcher) = &mut self.dispatcher { debug!(hook_event = ?event.kind(), "hook dispatched"); dispatcher.dispatch(event); } } /// Check if the cursor moved to a different item and /// emit a Hover event if so. fn check_cursor_hover(&mut self) { if self.menu.filtered_count() == 0 { self.previous_cursor = None; return; } let current = self.viewport.cursor(); let current_orig = self.menu.original_index(current); if current_orig != self.previous_cursor { self.previous_cursor = current_orig; if let Some(value) = self.menu.serialize_filtered(current).cloned() && let Some(orig_idx) = current_orig { trace!(cursor = current, original_index = orig_idx, "hover changed"); self.emit_hook(HookEvent::Hover { item: value, index: orig_idx, }); } } } /// Apply a single action to the menu state. Pure state /// transition: no channels, no async. Testable in isolation. pub fn apply_action(&mut self, action: Action) -> ActionOutcome { match action { Action::UpdateFilter(text) => { debug!(filter = %text, "filter updated"); self.filter_text = Arc::from(text); self.run_filter(); ActionOutcome::Broadcast } Action::MoveUp(n) => { self.viewport.move_up(n); ActionOutcome::Broadcast } Action::MoveDown(n) => { self.viewport.move_down(n); ActionOutcome::Broadcast } Action::MoveToTop => { self.viewport.move_to_top(); ActionOutcome::Broadcast } Action::MoveToBottom => { self.viewport.move_to_bottom(); ActionOutcome::Broadcast } Action::PageUp(n) => { self.viewport.page_up(n); ActionOutcome::Broadcast } Action::PageDown(n) => { self.viewport.page_down(n); ActionOutcome::Broadcast } Action::ToggleSelect => { if !self.selection.multi_enabled { return ActionOutcome::NoOp; } if self.menu.filtered_count() == 0 { return ActionOutcome::NoOp; } let cursor = self.viewport.cursor(); if let Some(orig_idx) = self.menu.original_index(cursor) { self.selection.toggle(orig_idx); } ActionOutcome::Broadcast } Action::SelectRange { start, end } => { if !self.selection.multi_enabled { return ActionOutcome::NoOp; } let min = start.min(end); let max = start.max(end); let indices: Vec = (min..=max) .filter_map(|i| self.menu.original_index(i)) .collect(); self.selection.select_range(indices.into_iter()); ActionOutcome::Broadcast } Action::ClearSelections => { self.selection.clear(); ActionOutcome::Broadcast } Action::SelectAll => { if !self.selection.multi_enabled { return ActionOutcome::NoOp; } let indices: Vec = (0..self.menu.filtered_count()) .filter_map(|i| self.menu.original_index(i)) .collect(); self.selection.select_all(indices.into_iter()); ActionOutcome::Broadcast } Action::UndoSelection => { self.selection.undo(); ActionOutcome::Broadcast } Action::RedoSelection => { self.selection.redo(); ActionOutcome::Broadcast } Action::Confirm => { if self.menu.filtered_count() == 0 && self.selection.count() == 0 { return ActionOutcome::NoOp; } if self.selection.multi_enabled && self.selection.count() > 0 { // Multi-select: return all selected items let indices = if self.selection.selection_order { self.selection.insertion_ordered() } else { self.selection.ordered_items() }; let items: Vec<(Value, usize)> = indices .into_iter() .filter_map(|orig_idx| { self.menu .serialize_original(orig_idx) .map(|v| (v.clone(), orig_idx)) }) .collect(); if items.is_empty() { return ActionOutcome::NoOp; } ActionOutcome::Selected { items } } else { // Single-select: cursor item as one-element vec if self.menu.filtered_count() == 0 { return ActionOutcome::NoOp; } let cursor = self.viewport.cursor(); let index = self.menu.original_index(cursor).unwrap_or(0); match self.menu.serialize_filtered(cursor) { Some(value) => ActionOutcome::Selected { items: vec![(value.clone(), index)], }, None => ActionOutcome::NoOp, } } } Action::Quicklist => { if self.menu.filtered_count() == 0 { return ActionOutcome::NoOp; } let items: Vec<(Value, usize)> = self .menu .collect_filtered() .into_iter() .map(|(v, idx)| (v.clone(), idx)) .collect(); ActionOutcome::Quicklist { items } } Action::Cancel => ActionOutcome::Cancelled, Action::Resize { height } => { trace!(height, "viewport resized"); self.viewport.set_height(height as usize); ActionOutcome::Broadcast } Action::HalfPageUp(n) => { self.viewport.half_page_up(n); ActionOutcome::Broadcast } Action::HalfPageDown(n) => { self.viewport.half_page_down(n); ActionOutcome::Broadcast } Action::SetMode(m) => { debug!(mode = ?m, "mode changed"); if m == Mode::Visual { self.visual_anchor = Some(self.viewport.cursor()); } else if self.mode == Mode::Visual { self.visual_anchor = None; } self.mode = m; ActionOutcome::Broadcast } Action::AddItems(values) => { debug!(count = values.len(), "items added"); self.menu.add_raw(values); self.run_filter(); ActionOutcome::Broadcast } Action::ReplaceItems(values) => { debug!(count = values.len(), "items replaced"); // Smart cursor: try to keep selection on the same original item. let cursor = self.viewport.cursor(); let old_value = self.menu.serialize_filtered(cursor).cloned(); self.menu.replace_all(values); self.run_filter(); // Try to find the old item in the new set if let Some(ref old_val) = old_value { let mut found = false; for i in 0..self.menu.filtered_count() { if self.menu.serialize_filtered(i) == Some(old_val) { self.viewport.set_cursor(i); found = true; break; } } if !found { self.viewport.clamp(); } } else { self.viewport.clamp(); } ActionOutcome::Broadcast } Action::RemoveItems(indices) => { debug!(count = indices.len(), "items removed"); self.menu.remove_by_indices(indices); self.run_filter(); self.viewport.clamp(); ActionOutcome::Broadcast } Action::ProcessHookResponse(resp) => { let action = hook_response_to_action(resp); self.apply_action(action) } Action::CloseMenu => ActionOutcome::Closed, } } /// Set the initial mode before running the event loop. /// Used by `--start-mode` CLI flag. pub fn set_initial_mode(&mut self, mode: Mode) { self.mode = mode; } /// Run the menu event loop. Consumes actions and /// broadcasts events. /// /// **Ordering guarantee:** Actions are processed /// sequentially in the order received. Each action's /// state change is fully applied before the next action /// begins. A `Confirm` sent right after an /// `UpdateFilter` will always select from the filtered /// results, never stale pre-filter state. /// /// This holds regardless of how actions are sent (TUI /// keypresses, headless scripts, programmatic sends). /// It's enforced by the single `recv()` loop below and /// must be preserved by any future refactors. pub async fn run(mut self) -> Result { self.run_filter(); self.broadcast_state(); let total_items = self.menu.total(); let filtered = self.menu.filtered_count(); info!(total_items, filtered, "menu opened"); // Emit Open event self.emit_hook(HookEvent::Open); while let Some(action) = self.action_rx.recv().await { let is_filter_update = matches!(&action, Action::UpdateFilter(_)); match self.apply_action(action) { ActionOutcome::Broadcast => { self.broadcast_state(); // Emit Filter event if the filter changed if is_filter_update { let text = self.filter_text.to_string(); self.emit_hook(HookEvent::Filter { text }); } // Check for cursor movement -> Hover self.check_cursor_hover(); } ActionOutcome::Selected { items } => { let count = items.len(); info!(count, "item(s) selected"); // Emit Select event for the first item (primary selection) if let Some((value, index)) = items.first() { self.emit_hook(HookEvent::Select { item: value.clone(), index: *index, }); } // Emit Close event self.emit_hook(HookEvent::Close); let _ = self.event_tx.send(MenuEvent::Selected(items.clone())); return Ok(MenuResult::Selected { items }); } ActionOutcome::Quicklist { items } => { let values: Vec = items.iter().map(|(v, _)| v.clone()).collect(); let count = values.len(); info!(count, "quicklist returned"); self.emit_hook(HookEvent::Quicklist { items: values.clone(), count, }); self.emit_hook(HookEvent::Close); let _ = self.event_tx.send(MenuEvent::Quicklist(values)); return Ok(MenuResult::Quicklist { items }); } ActionOutcome::Cancelled => { info!("menu cancelled"); self.emit_hook(HookEvent::Cancel); self.emit_hook(HookEvent::Close); let _ = self.event_tx.send(MenuEvent::Cancelled); return Ok(MenuResult::Cancelled); } ActionOutcome::Closed => { info!("menu closed by hook"); self.emit_hook(HookEvent::Close); let _ = self.event_tx.send(MenuEvent::Cancelled); return Ok(MenuResult::Cancelled); } ActionOutcome::NoOp => {} } } // Sender dropped self.emit_hook(HookEvent::Close); Ok(MenuResult::Cancelled) } } #[cfg(test)] mod tests { use super::*; use crate::event::MenuEvent; use crate::item::Item; use crate::model::traits::Menu; use crate::runtime::json_menu::JsonMenu; fn test_menu() -> (MenuRunner, mpsc::Sender) { let items = vec![ Item::from_plain_text("alpha"), Item::from_plain_text("beta"), Item::from_plain_text("gamma"), Item::from_plain_text("delta"), ]; MenuRunner::new(JsonMenu::new(items, "label".to_string())) } /// Set up a menu runner with filter applied and viewport sized. /// Ready for sync apply_action tests. fn ready_menu() -> MenuRunner { let (mut m, _tx) = test_menu(); m.run_filter(); m.apply_action(Action::Resize { height: 10 }); m } // -- Sync apply_action tests -- #[test] fn apply_move_down_updates_viewport() { let mut m = ready_menu(); let outcome = m.apply_action(Action::MoveDown(1)); assert!(matches!(outcome, ActionOutcome::Broadcast)); assert_eq!(m.viewport.cursor(), 1); } #[test] fn apply_move_up_updates_viewport() { let mut m = ready_menu(); m.apply_action(Action::MoveDown(2)); let outcome = m.apply_action(Action::MoveUp(1)); assert!(matches!(outcome, ActionOutcome::Broadcast)); assert_eq!(m.viewport.cursor(), 1); } #[test] fn apply_filter_changes_results() { let mut m = ready_menu(); let outcome = m.apply_action(Action::UpdateFilter("al".to_string())); assert!(matches!(outcome, ActionOutcome::Broadcast)); assert_eq!(&*m.filter_text, "al"); // alpha matches "al" assert!(m.menu.filtered_count() >= 1); } #[test] fn apply_confirm_returns_selected() { let mut m = ready_menu(); m.apply_action(Action::MoveDown(1)); let outcome = m.apply_action(Action::Confirm); assert!( matches!(&outcome, ActionOutcome::Selected { items } if items[0].0.as_str() == Some("beta")) ); } #[test] fn apply_confirm_on_empty_is_noop() { let mut m = ready_menu(); m.apply_action(Action::UpdateFilter("zzzzz".to_string())); assert_eq!(m.menu.filtered_count(), 0); let outcome = m.apply_action(Action::Confirm); assert!(matches!(outcome, ActionOutcome::NoOp)); } #[test] fn apply_cancel_returns_cancelled() { let mut m = ready_menu(); let outcome = m.apply_action(Action::Cancel); assert!(matches!(outcome, ActionOutcome::Cancelled)); } #[test] fn apply_resize_updates_viewport() { let mut m = ready_menu(); let outcome = m.apply_action(Action::Resize { height: 3 }); assert!(matches!(outcome, ActionOutcome::Broadcast)); assert_eq!(m.viewport.visible_range(), 0..3); } #[test] fn apply_add_items_runs_filter() { let mut m = ready_menu(); assert_eq!(m.menu.total(), 4); let outcome = m.apply_action(Action::AddItems(vec![serde_json::Value::String( "epsilon".to_string(), )])); assert!(matches!(outcome, ActionOutcome::Broadcast)); assert_eq!(m.menu.total(), 5); assert_eq!(m.menu.filtered_count(), 5); } #[test] fn apply_move_to_top_and_bottom() { let mut m = ready_menu(); m.apply_action(Action::MoveToBottom); assert_eq!(m.viewport.cursor(), 3); m.apply_action(Action::MoveToTop); assert_eq!(m.viewport.cursor(), 0); } #[test] fn apply_page_movement() { let (mut m, _tx) = test_menu(); m.run_filter(); m.apply_action(Action::Resize { height: 2 }); m.apply_action(Action::PageDown(1)); assert_eq!(m.viewport.cursor(), 2); m.apply_action(Action::PageUp(1)); assert_eq!(m.viewport.cursor(), 0); } #[tokio::test] async fn initial_state_broadcast() { let (menu, tx) = test_menu(); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); // Should receive initial state if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { assert_eq!(vs.total_items, 4); assert_eq!(vs.total_filtered, 4); assert_eq!(vs.cursor, 0); assert!(vs.filter_text.is_empty()); } // Cancel to exit let _ = tx.send(Action::Cancel).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); assert!(matches!(result, Ok(MenuResult::Cancelled))); } #[tokio::test] async fn filter_updates() { let (menu, tx) = test_menu(); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); // Skip initial state let _ = rx.recv().await; // Filter to "al" let _ = tx.send(Action::UpdateFilter("al".to_string())).await; if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { assert_eq!(vs.total_items, 4); // alpha should match "al" assert!(vs.total_filtered >= 1); assert_eq!(&*vs.filter_text, "al"); } let _ = tx.send(Action::Cancel).await; let _ = handle.await; } #[tokio::test] async fn confirm_selection() { let (menu, tx) = test_menu(); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); // Skip initial state let _ = rx.recv().await; // Need to send resize first so viewport has height let _ = tx.send(Action::Resize { height: 10 }).await; let _ = rx.recv().await; // Move down and confirm let _ = tx.send(Action::MoveDown(1)).await; let _ = rx.recv().await; let _ = tx.send(Action::Confirm).await; // Should get Selected event if let Ok(MenuEvent::Selected(items)) = rx.recv().await { assert_eq!(items[0].0.as_str(), Some("beta")); } let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); assert!(matches!(result, Ok(MenuResult::Selected { .. }))); } #[tokio::test] async fn confirm_on_empty_is_noop() { let (menu, tx) = test_menu(); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial // Filter to something that matches nothing let _ = tx.send(Action::UpdateFilter("zzzzz".to_string())).await; if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { assert_eq!(vs.total_filtered, 0); } // Confirm should be no-op let _ = tx.send(Action::Confirm).await; // Cancel to exit (should still work) let _ = tx.send(Action::Cancel).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); assert!(matches!(result, Ok(MenuResult::Cancelled))); } #[tokio::test] async fn sender_drop_cancels() { let (menu, tx) = test_menu(); let _rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); // Drop the only sender. drop(tx); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); assert!(matches!(result, Ok(MenuResult::Cancelled))); } // -- End-to-end output correctness -- #[tokio::test] async fn confirm_first_item_value() { let (menu, tx) = test_menu(); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial state let _ = tx.send(Action::Resize { height: 10 }).await; let _ = rx.recv().await; // Confirm at cursor 0, should get "alpha" let _ = tx.send(Action::Confirm).await; let event = rx.recv().await; assert!( matches!(&event, Ok(MenuEvent::Selected(items)) if items[0].0.as_str() == Some("alpha")) ); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); assert!( matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("alpha")) ); } #[tokio::test] async fn confirm_third_item_value() { let (menu, tx) = test_menu(); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial let _ = tx.send(Action::Resize { height: 10 }).await; let _ = rx.recv().await; // Move down twice -> cursor at index 2 -> "gamma" let _ = tx.send(Action::MoveDown(1)).await; let _ = rx.recv().await; let _ = tx.send(Action::MoveDown(1)).await; let _ = rx.recv().await; let _ = tx.send(Action::Confirm).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); assert!( matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("gamma")) ); } #[tokio::test] async fn filter_then_confirm_correct_item() { let (menu, tx) = test_menu(); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial let _ = tx.send(Action::Resize { height: 10 }).await; let _ = rx.recv().await; // Filter to "del", should match "delta" let _ = tx.send(Action::UpdateFilter("del".to_string())).await; if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { assert!(vs.total_filtered >= 1); assert_eq!(vs.visible_items[0].label, "delta"); } // Confirm, should select "delta" let _ = tx.send(Action::Confirm).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); assert!( matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta")) ); } #[tokio::test] async fn add_items_then_confirm_new_item() { let (menu, tx) = test_menu(); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial let _ = tx.send(Action::Resize { height: 10 }).await; let _ = rx.recv().await; // Add a new item let _ = tx .send(Action::AddItems(vec![serde_json::Value::String( "epsilon".to_string(), )])) .await; if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { assert_eq!(vs.total_items, 5); assert_eq!(vs.total_filtered, 5); } // Filter to "eps", only epsilon should match let _ = tx.send(Action::UpdateFilter("eps".to_string())).await; if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { assert!(vs.total_filtered >= 1); assert_eq!(vs.visible_items[0].label, "epsilon"); } let _ = tx.send(Action::Confirm).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); assert!( matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("epsilon")) ); } #[tokio::test] async fn cancel_returns_no_item() { let (menu, tx) = test_menu(); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial let _ = tx.send(Action::Cancel).await; // Should get Cancelled event assert!(matches!(rx.recv().await, Ok(MenuEvent::Cancelled))); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); assert!(matches!(result, Ok(MenuResult::Cancelled))); } // -- Ordering invariant tests -- // These tests verify the sequential processing guarantee documented on // MenuRunner::run(). Actions sent back-to-back without waiting for // intermediate events must still be processed in order, each fully // applied before the next begins. #[tokio::test] async fn actions_process_in_order_filter_then_confirm() { // Send filter + confirm back-to-back with no recv between them. // Confirm must act on the filtered results, not the pre-filter state. let items = vec![ Item::from_plain_text("alpha"), Item::from_plain_text("beta"), Item::from_plain_text("banana"), ]; let (menu, tx) = MenuRunner::new(JsonMenu::new(items, "label".to_string())); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial let _ = tx.send(Action::Resize { height: 50 }).await; let _ = rx.recv().await; // Back-to-back, no waiting between these let _ = tx.send(Action::UpdateFilter("ban".to_string())).await; let _ = tx.send(Action::Confirm).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); // Must get "banana". Filter was applied before confirm ran. assert!(matches!( result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("banana") )); } #[tokio::test] async fn actions_process_in_order_move_then_confirm() { // Send multiple moves + confirm back-to-back. // Confirm must reflect the final cursor position. let (menu, tx) = test_menu(); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial let _ = tx.send(Action::Resize { height: 50 }).await; let _ = rx.recv().await; // Three moves down back-to-back, then confirm let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(Action::Confirm).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); // Cursor at index 3 -> "delta" assert!(matches!( result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta") )); } // -- New action variant tests -- #[test] fn apply_half_page_down() { let (mut m, _tx) = test_menu(); m.run_filter(); m.apply_action(Action::Resize { height: 4 }); let outcome = m.apply_action(Action::HalfPageDown(1)); assert!(matches!(outcome, ActionOutcome::Broadcast)); assert_eq!(m.viewport.cursor(), 2); // 4/2 = 2 } #[test] fn apply_half_page_up() { let (mut m, _tx) = test_menu(); m.run_filter(); m.apply_action(Action::Resize { height: 4 }); m.apply_action(Action::HalfPageDown(1)); let outcome = m.apply_action(Action::HalfPageUp(1)); assert!(matches!(outcome, ActionOutcome::Broadcast)); assert_eq!(m.viewport.cursor(), 0); } #[test] fn apply_set_mode_normal() { let mut m = ready_menu(); let outcome = m.apply_action(Action::SetMode(Mode::Normal)); assert!(matches!(outcome, ActionOutcome::Broadcast)); assert_eq!(m.build_view_state().mode, Mode::Normal); } #[test] fn apply_set_mode_insert() { let mut m = ready_menu(); m.apply_action(Action::SetMode(Mode::Normal)); let outcome = m.apply_action(Action::SetMode(Mode::Insert)); assert!(matches!(outcome, ActionOutcome::Broadcast)); assert_eq!(m.build_view_state().mode, Mode::Insert); } #[test] fn apply_set_mode_preserves_filter() { let mut m = ready_menu(); m.apply_action(Action::UpdateFilter("al".to_string())); let count_before = m.menu.filtered_count(); let filter_before = m.filter_text.clone(); m.apply_action(Action::SetMode(Mode::Normal)); assert_eq!(&*m.filter_text, &*filter_before); assert_eq!(m.menu.filtered_count(), count_before); } #[test] fn set_initial_mode_works() { let (mut m, _tx) = test_menu(); m.set_initial_mode(Mode::Normal); m.run_filter(); assert_eq!(m.build_view_state().mode, Mode::Normal); } #[test] fn view_state_mode_after_switch() { let mut m = ready_menu(); m.apply_action(Action::SetMode(Mode::Normal)); let vs = m.build_view_state(); assert_eq!(vs.mode, Mode::Normal); } #[tokio::test] async fn actions_process_in_order_add_items_then_filter_then_confirm() { // AddItems + filter + confirm, all back-to-back. // The filter must see the newly added items. let (menu, tx) = test_menu(); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial let _ = tx.send(Action::Resize { height: 50 }).await; let _ = rx.recv().await; // All back-to-back let _ = tx .send(Action::AddItems(vec![serde_json::Value::String( "zephyr".to_string(), )])) .await; let _ = tx.send(Action::UpdateFilter("zep".to_string())).await; let _ = tx.send(Action::Confirm).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); // Must find "zephyr". It was added before the filter ran. assert!(matches!( result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("zephyr") )); } // -- Replace/Remove/Close action tests -- #[test] fn apply_replace_items() { let mut m = ready_menu(); assert_eq!(m.menu.total(), 4); let outcome = m.apply_action(Action::ReplaceItems(vec![ serde_json::Value::String("x".to_string()), serde_json::Value::String("y".to_string()), ])); assert!(matches!(outcome, ActionOutcome::Broadcast)); assert_eq!(m.menu.total(), 2); assert_eq!(m.menu.filtered_count(), 2); } #[test] fn apply_replace_items_preserves_cursor() { let mut m = ready_menu(); // Move to "beta" (index 1) m.apply_action(Action::MoveDown(1)); // Replace items, keeping "beta" in the new set m.apply_action(Action::ReplaceItems(vec![ serde_json::Value::String("alpha".to_string()), serde_json::Value::String("beta".to_string()), serde_json::Value::String("zeta".to_string()), ])); // Cursor should still be on "beta" let vs = m.build_view_state(); assert_eq!(vs.visible_items[vs.cursor].label, "beta"); } #[test] fn apply_remove_items() { let mut m = ready_menu(); assert_eq!(m.menu.total(), 4); let outcome = m.apply_action(Action::RemoveItems(vec![1, 3])); assert!(matches!(outcome, ActionOutcome::Broadcast)); assert_eq!(m.menu.total(), 2); // alpha and gamma should remain assert_eq!(m.menu.filtered_label(0), Some("alpha")); assert_eq!(m.menu.filtered_label(1), Some("gamma")); } #[test] fn apply_close_menu() { let mut m = ready_menu(); let outcome = m.apply_action(Action::CloseMenu); assert!(matches!(outcome, ActionOutcome::Closed)); } #[test] fn apply_hook_response_close() { use crate::hook::HookResponse; let mut m = ready_menu(); let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::Close)); assert!(matches!(outcome, ActionOutcome::Closed)); } #[test] fn apply_hook_response_add_items() { use crate::hook::HookResponse; let mut m = ready_menu(); let outcome = m.apply_action(Action::ProcessHookResponse(HookResponse::AddItems { items: vec![serde_json::json!("new")], })); assert!(matches!(outcome, ActionOutcome::Broadcast)); assert_eq!(m.menu.total(), 5); } #[test] fn confirm_returns_original_index() { let mut m = ready_menu(); // Filter to narrow results, then confirm m.apply_action(Action::UpdateFilter("del".to_string())); assert!(m.menu.filtered_count() >= 1); let outcome = m.apply_action(Action::Confirm); // "delta" is at original index 3 assert!(matches!(&outcome, ActionOutcome::Selected { items } if items[0].1 == 3)); } // -- Quicklist tests -- #[test] fn apply_quicklist_returns_all_filtered() { let mut m = ready_menu(); let outcome = m.apply_action(Action::Quicklist); match outcome { ActionOutcome::Quicklist { items } => { assert_eq!(items.len(), 4); let labels: Vec<&str> = items.iter().filter_map(|(v, _)| v.as_str()).collect(); assert_eq!(labels, vec!["alpha", "beta", "gamma", "delta"]); } other => panic!("expected Quicklist, got {other:?}"), } } #[test] fn apply_quicklist_respects_filter() { let mut m = ready_menu(); m.apply_action(Action::UpdateFilter("a".to_string())); let outcome = m.apply_action(Action::Quicklist); match outcome { ActionOutcome::Quicklist { items } => { // "alpha", "gamma", "delta" all contain 'a' assert!(items.len() >= 2); for (v, _) in &items { let label = v.as_str().unwrap_or(""); assert!( label.contains('a'), "filtered item should contain 'a': {label}" ); } } other => panic!("expected Quicklist, got {other:?}"), } } #[test] fn apply_quicklist_on_empty_is_noop() { let mut m = ready_menu(); m.apply_action(Action::UpdateFilter("zzzzz".to_string())); assert_eq!(m.menu.filtered_count(), 0); let outcome = m.apply_action(Action::Quicklist); assert!(matches!(outcome, ActionOutcome::NoOp)); } #[test] fn apply_quicklist_preserves_original_indices() { let mut m = ready_menu(); // Filter to "del" -> only "delta" at original index 3 m.apply_action(Action::UpdateFilter("del".to_string())); let outcome = m.apply_action(Action::Quicklist); match outcome { ActionOutcome::Quicklist { items } => { assert_eq!(items.len(), 1); assert_eq!(items[0].0.as_str(), Some("delta")); assert_eq!(items[0].1, 3); } other => panic!("expected Quicklist, got {other:?}"), } } #[tokio::test] async fn quicklist_event_broadcast() { let (menu, tx) = test_menu(); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); // Skip initial state let _ = rx.recv().await; let _ = tx.send(Action::Resize { height: 10 }).await; let _ = rx.recv().await; let _ = tx.send(Action::Quicklist).await; if let Ok(MenuEvent::Quicklist(values)) = rx.recv().await { assert_eq!(values.len(), 4); } else { panic!("expected Quicklist event"); } let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); assert!(matches!(result, Ok(MenuResult::Quicklist { .. }))); } #[tokio::test] async fn quicklist_after_filter() { let (menu, tx) = test_menu(); let mut rx = menu.subscribe(); let handle = tokio::spawn(async move { menu.run().await }); let _ = rx.recv().await; // initial let _ = tx.send(Action::Resize { height: 10 }).await; let _ = rx.recv().await; // Filter then quicklist back-to-back let _ = tx.send(Action::UpdateFilter("al".to_string())).await; let _ = tx.send(Action::Quicklist).await; let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); match result { Ok(MenuResult::Quicklist { items }) => { // "alpha" matches "al" assert!(!items.is_empty()); for (v, _) in &items { let label = v.as_str().unwrap_or(""); assert!( label.contains("al"), "quicklist item should match filter: {label}" ); } } other => panic!("expected Quicklist, got {other:?}"), } } // -- Hook event tests -- #[tokio::test] async fn hook_events_fire_on_lifecycle() { use crate::hook::{HookEvent, HookEventKind, HookHandler}; use std::sync::Mutex; struct Recorder(Mutex>); impl HookHandler for Recorder { fn handle(&self, event: HookEvent) -> Result<(), PiklError> { if let Ok(mut v) = self.0.lock() { v.push(event.kind()); } Ok(()) } } let recorder = Arc::new(Recorder(Mutex::new(Vec::new()))); let (mut m, action_tx) = test_menu(); m.set_hook_handler(Arc::clone(&recorder) as Arc, action_tx); m.run_filter(); m.apply_action(Action::Resize { height: 10 }); // Simulate lifecycle: the Open event is emitted in run(), // but we can test Filter/Hover/Cancel manually m.emit_hook(HookEvent::Open); m.apply_action(Action::UpdateFilter("al".to_string())); m.emit_hook(HookEvent::Filter { text: "al".to_string(), }); m.apply_action(Action::MoveDown(1)); m.check_cursor_hover(); // Give spawned tasks a chance to complete tokio::task::yield_now().await; tokio::time::sleep(std::time::Duration::from_millis(10)).await; let events = recorder.0.lock().map(|v| v.clone()).unwrap_or_default(); assert!(events.contains(&HookEventKind::Open)); assert!(events.contains(&HookEventKind::Filter)); } // -- SelectionState unit tests -- #[test] fn selection_toggle_adds_and_removes() { let mut s = SelectionState::new(); s.multi_enabled = true; s.toggle(3); assert!(s.is_selected(3)); assert_eq!(s.count(), 1); s.toggle(3); assert!(!s.is_selected(3)); assert_eq!(s.count(), 0); } #[test] fn selection_undo_redo() { let mut s = SelectionState::new(); s.multi_enabled = true; s.toggle(1); s.toggle(2); assert_eq!(s.count(), 2); s.undo(); assert_eq!(s.count(), 1); assert!(s.is_selected(1)); assert!(!s.is_selected(2)); s.redo(); assert_eq!(s.count(), 2); assert!(s.is_selected(2)); } #[test] fn selection_undo_on_empty_is_noop() { let mut s = SelectionState::new(); s.undo(); assert_eq!(s.count(), 0); } #[test] fn selection_redo_on_empty_is_noop() { let mut s = SelectionState::new(); s.redo(); assert_eq!(s.count(), 0); } #[test] fn selection_clear_then_undo_restores() { let mut s = SelectionState::new(); s.multi_enabled = true; s.toggle(0); s.toggle(2); assert_eq!(s.count(), 2); s.clear(); assert_eq!(s.count(), 0); s.undo(); assert_eq!(s.count(), 2); assert!(s.is_selected(0)); assert!(s.is_selected(2)); } #[test] fn selection_select_range() { let mut s = SelectionState::new(); s.multi_enabled = true; s.select_range([1, 2, 3].into_iter()); assert_eq!(s.count(), 3); assert!(s.is_selected(1)); assert!(s.is_selected(2)); assert!(s.is_selected(3)); } #[test] fn selection_select_all() { let mut s = SelectionState::new(); s.multi_enabled = true; s.select_all([0, 1, 2, 3].into_iter()); assert_eq!(s.count(), 4); } #[test] fn selection_ordered_items_sorted() { let mut s = SelectionState::new(); s.multi_enabled = true; s.toggle(3); s.toggle(1); s.toggle(0); assert_eq!(s.ordered_items(), vec![0, 1, 3]); } #[test] fn selection_insertion_ordered() { let mut s = SelectionState::new(); s.multi_enabled = true; s.toggle(3); s.toggle(1); s.toggle(0); assert_eq!(s.insertion_ordered(), vec![3, 1, 0]); } #[test] fn selection_new_action_clears_redo() { let mut s = SelectionState::new(); s.multi_enabled = true; s.toggle(1); s.toggle(2); s.undo(); // redo stack has the {1,2} state s.toggle(3); // new action, should clear redo s.redo(); // should be noop assert_eq!(s.count(), 2); assert!(s.is_selected(1)); assert!(s.is_selected(3)); } // -- Multi-select integration tests (apply_action) -- fn ready_multi_menu() -> MenuRunner { let (mut m, _tx) = test_menu(); m.set_multi(true); m.run_filter(); m.apply_action(Action::Resize { height: 10 }); m } #[test] fn toggle_then_confirm_returns_selected() { let mut m = ready_multi_menu(); m.apply_action(Action::MoveDown(1)); // cursor on "beta" m.apply_action(Action::ToggleSelect); let outcome = m.apply_action(Action::Confirm); match outcome { ActionOutcome::Selected { items } => { assert_eq!(items.len(), 1); assert_eq!(items[0].0.as_str(), Some("beta")); assert_eq!(items[0].1, 1); } other => panic!("expected Selected, got {other:?}"), } } #[test] fn toggle_two_items_confirm_returns_both() { let mut m = ready_multi_menu(); m.apply_action(Action::ToggleSelect); // select "alpha" (0) m.apply_action(Action::MoveDown(2)); // cursor on "gamma" (2) m.apply_action(Action::ToggleSelect); let outcome = m.apply_action(Action::Confirm); match outcome { ActionOutcome::Selected { items } => { assert_eq!(items.len(), 2); // Default order = input order assert_eq!(items[0].0.as_str(), Some("alpha")); assert_eq!(items[1].0.as_str(), Some("gamma")); } other => panic!("expected Selected, got {other:?}"), } } #[test] fn toggle_then_untoggle_falls_back_to_cursor() { let mut m = ready_multi_menu(); m.apply_action(Action::MoveDown(1)); // cursor on "beta" m.apply_action(Action::ToggleSelect); m.apply_action(Action::ToggleSelect); // untoggle let outcome = m.apply_action(Action::Confirm); match outcome { ActionOutcome::Selected { items } => { // No selections, fallback to cursor assert_eq!(items.len(), 1); assert_eq!(items[0].0.as_str(), Some("beta")); } other => panic!("expected Selected, got {other:?}"), } } #[test] fn multi_no_selections_returns_cursor() { let mut m = ready_multi_menu(); m.apply_action(Action::MoveDown(2)); // cursor on "gamma" let outcome = m.apply_action(Action::Confirm); match outcome { ActionOutcome::Selected { items } => { assert_eq!(items.len(), 1); assert_eq!(items[0].0.as_str(), Some("gamma")); } other => panic!("expected Selected, got {other:?}"), } } #[test] fn select_all_then_confirm() { let mut m = ready_multi_menu(); m.apply_action(Action::SelectAll); let outcome = m.apply_action(Action::Confirm); match outcome { ActionOutcome::Selected { items } => { assert_eq!(items.len(), 4); let labels: Vec<&str> = items.iter().filter_map(|(v, _)| v.as_str()).collect(); assert_eq!(labels, vec!["alpha", "beta", "gamma", "delta"]); } other => panic!("expected Selected, got {other:?}"), } } #[test] fn clear_selections_after_select() { let mut m = ready_multi_menu(); m.apply_action(Action::ToggleSelect); // select alpha m.apply_action(Action::ClearSelections); assert_eq!(m.selection.count(), 0); let outcome = m.apply_action(Action::Confirm); match outcome { ActionOutcome::Selected { items } => { assert_eq!(items.len(), 1); assert_eq!(items[0].0.as_str(), Some("alpha")); // cursor item } other => panic!("expected Selected, got {other:?}"), } } #[test] fn undo_redo_through_apply_action() { let mut m = ready_multi_menu(); m.apply_action(Action::ToggleSelect); // select alpha m.apply_action(Action::MoveDown(1)); m.apply_action(Action::ToggleSelect); // select beta assert_eq!(m.selection.count(), 2); m.apply_action(Action::UndoSelection); assert_eq!(m.selection.count(), 1); assert!(m.selection.is_selected(0)); // alpha assert!(!m.selection.is_selected(1)); // beta undone m.apply_action(Action::RedoSelection); assert_eq!(m.selection.count(), 2); } #[test] fn selections_survive_filter_change() { let mut m = ready_multi_menu(); m.apply_action(Action::ToggleSelect); // select alpha (orig idx 0) m.apply_action(Action::MoveDown(1)); m.apply_action(Action::ToggleSelect); // select beta (orig idx 1) // Change filter m.apply_action(Action::UpdateFilter("del".to_string())); // Selections should survive assert_eq!(m.selection.count(), 2); assert!(m.selection.is_selected(0)); assert!(m.selection.is_selected(1)); // Confirm should return the selected items (alpha, beta) even // though they're not in the filtered set let outcome = m.apply_action(Action::Confirm); match outcome { ActionOutcome::Selected { items } => { assert_eq!(items.len(), 2); assert_eq!(items[0].0.as_str(), Some("alpha")); assert_eq!(items[1].0.as_str(), Some("beta")); } other => panic!("expected Selected, got {other:?}"), } } #[test] fn multi_disabled_toggle_is_noop() { let mut m = ready_menu(); // not multi let outcome = m.apply_action(Action::ToggleSelect); assert!(matches!(outcome, ActionOutcome::NoOp)); } #[test] fn select_range_selects_all_in_range() { let mut m = ready_multi_menu(); m.apply_action(Action::SelectRange { start: 1, end: 3 }); assert_eq!(m.selection.count(), 3); assert!(!m.selection.is_selected(0)); assert!(m.selection.is_selected(1)); assert!(m.selection.is_selected(2)); assert!(m.selection.is_selected(3)); } #[test] fn visual_anchor_set_on_visual_mode() { let mut m = ready_multi_menu(); m.apply_action(Action::MoveDown(2)); // cursor at 2 m.apply_action(Action::SetMode(Mode::Visual)); assert_eq!(m.visual_anchor, Some(2)); m.apply_action(Action::SetMode(Mode::Normal)); assert_eq!(m.visual_anchor, None); } #[test] fn view_state_shows_selection() { let mut m = ready_multi_menu(); m.apply_action(Action::ToggleSelect); // select alpha let vs = m.build_view_state(); assert!(vs.multi_enabled); assert_eq!(vs.selection_count, 1); assert!(vs.visible_items[0].selected); assert!(!vs.visible_items[1].selected); } // -- Column / table mode tests -- use crate::model::column::ColumnConfig; fn table_menu() -> MenuRunner { let items = vec![ Item::new( serde_json::json!({"label": "alice", "meta": {"age": "30", "city": "toronto"}}), "label", ), Item::new( serde_json::json!({"label": "bob", "meta": {"age": "25", "city": "vancouver"}}), "label", ), Item::new( serde_json::json!({"label": "charlie", "meta": {"age": "35", "city": "montreal"}}), "label", ), ]; let mut json_menu = JsonMenu::new(items, "label".to_string()); json_menu.set_column_config(ColumnConfig::parse("label,meta.age:Age,meta.city:City")); let (runner, _tx) = MenuRunner::new(json_menu); runner } fn ready_table_menu() -> MenuRunner { let mut m = table_menu(); m.run_filter(); m.apply_action(Action::Resize { height: 10 }); m } #[test] fn view_state_has_columns_when_config_set() { let mut m = ready_table_menu(); let vs = m.build_view_state(); assert!(vs.columns.is_some(), "columns should be Some"); let cols = vs.columns.as_ref().unwrap(); assert_eq!(cols.len(), 3); assert_eq!(cols[0].display_name, "label"); assert_eq!(cols[1].display_name, "Age"); assert_eq!(cols[2].display_name, "City"); } #[test] fn view_state_column_widths_computed() { let mut m = ready_table_menu(); let vs = m.build_view_state(); let cols = vs.columns.as_ref().unwrap(); // "label" header is 5 chars, "charlie" is 7 chars: width should be 7 assert_eq!(cols[0].width, 7); // "Age" header is 3 chars, max cell "35" is 2 chars: width should be 3 assert_eq!(cols[1].width, 3); // "City" header is 4 chars, "vancouver" is 9 chars: width should be 9 assert_eq!(cols[2].width, 9); } #[test] fn view_state_items_have_column_values() { let mut m = ready_table_menu(); let vs = m.build_view_state(); let item = &vs.visible_items[0]; assert!(item.column_values.is_some()); let vals = item.column_values.as_ref().unwrap(); assert_eq!(vals, &["alice", "30", "toronto"]); } #[test] fn view_state_no_columns_without_config() { let mut m = ready_menu(); let vs = m.build_view_state(); assert!(vs.columns.is_none()); assert!(vs.visible_items[0].column_values.is_none()); } #[test] fn table_mode_filter_still_works() { let mut m = ready_table_menu(); m.apply_action(Action::UpdateFilter("bob".to_string())); let vs = m.build_view_state(); assert_eq!(vs.total_filtered, 1); assert_eq!(vs.visible_items[0].label, "bob"); // Column values should still be present let vals = vs.visible_items[0].column_values.as_ref().unwrap(); assert_eq!(vals, &["bob", "25", "vancouver"]); } #[test] fn table_mode_column_widths_change_with_filter() { let mut m = ready_table_menu(); m.apply_action(Action::UpdateFilter("alice".to_string())); let vs = m.build_view_state(); let cols = vs.columns.as_ref().unwrap(); // Only "alice" visible now, so label width = max(5, 5) = 5 assert_eq!(cols[0].width, 5); // City "toronto" (7) vs header "City" (4): width = 7 assert_eq!(cols[2].width, 7); } }