Synced from monorepo Changes: - Workspace server: report `/ready` as failed with dwell on hub connect failure - Refresh OIDC token for the Grok agent in the shell - ACP terminal output recorder - Cross-platform provider auth commands in the shell - Default `/resume` to Grok sessions with a hint for hidden external sessions - Resume sessions by title with `--resume` - Limit app-builder archive size - Data-driven tag labels for slash commands - Doctor fixes for tmux - Custom provider gateways and subprocess environment policy in the shell - `/tutorial` — opt-in onboarding tour of Grok Build - Soft and required CLI version checks in the shell - Privacy banner env overrides survive live settings updates - Add remote flag to override the image-edit model - Return profile fields from auth info even when the access token is expired - Add edit control on queued prompt rows - Keep fail-closed policy when clearing orphans with no team - Setting to disable the Ctrl+Space/F8 voice shortcut - Pass `--raw` to pw-record so Linux dictation works on older PipeWire - Validate git URLs when adding marketplace entries - Stop shipping stale tool-doc parameter and tool names - Re-point dashboard attach after `/fork` only when the parent was attached - Surface Grok Computer media-generation results as file-path chunks - Clear web background-task tray on kill and keep the task description - Show privacy upsell banner in agent view until acted on - Add tools-server client callback surface - Protect persistent global hook sources Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
7798 lines
282 KiB
Rust
7798 lines
282 KiB
Rust
//! End-to-end integration tests for the settings modal.
|
||
//!
|
||
//! Every new `SettingMeta` in `default_settings()` MUST add an entry to
|
||
//! `ALL_SETTINGS_EXERCISED` and a test for its keyboard AND mouse paths.
|
||
//!
|
||
//! Drives the modal directly through `SettingsModalState` + public
|
||
//! key/mouse handlers (same dispatch path as runtime, without chrome).
|
||
|
||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind};
|
||
use ratatui::layout::Rect;
|
||
use std::sync::Arc;
|
||
|
||
use xai_grok_pager::app::actions::Action;
|
||
use xai_grok_pager::settings::{
|
||
EnumChoice, PagerLocalSnapshot, SettingCategory, SettingKind, SettingMeta, SettingOwner,
|
||
SettingValue, SettingsRegistry,
|
||
};
|
||
use xai_grok_pager::views::settings_modal::{
|
||
RowEntry, SettingsKeyOutcome, SettingsModalMode, SettingsModalState, handle_settings_key,
|
||
handle_settings_mouse,
|
||
};
|
||
use xai_grok_shell::agent::config::UiConfig;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Compile-time exhaustive matrix
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Every setting exercised by this file. Must stay in sync with
|
||
/// `SettingsRegistry::defaults().all()`.
|
||
const ALL_SETTINGS_EXERCISED: &[&str] = &[
|
||
"compact_mode",
|
||
"screen_mode",
|
||
"show_timestamps",
|
||
"show_timeline",
|
||
"page_flip_on_send",
|
||
"combine_queued_prompts",
|
||
"simple_mode",
|
||
"vim_mode",
|
||
"remember_tool_approvals",
|
||
"toolset.ask_user_question.timeout_enabled",
|
||
"keep_text_selection",
|
||
"theme",
|
||
"auto_dark_theme",
|
||
"auto_light_theme",
|
||
"render_mermaid",
|
||
"multiline_mode",
|
||
"permission_mode",
|
||
"default_model",
|
||
"max_thoughts_width",
|
||
"scroll_speed",
|
||
"scroll_mode",
|
||
"scroll_lines",
|
||
"invert_scroll",
|
||
"display_refresh_auto_cadence",
|
||
"coding_data_sharing",
|
||
"default_selected_permission",
|
||
"plan_mode",
|
||
"show_tips",
|
||
"auto_update",
|
||
"fork_secondary_model",
|
||
"show_thinking_blocks",
|
||
"prompt_suggestions",
|
||
"group_tool_verbs",
|
||
"collapsed_edit_blocks",
|
||
"respect_manual_folds",
|
||
"hunk_tracker_mode",
|
||
"voice_keybind_enabled",
|
||
"voice_capture_mode",
|
||
"voice_stt_language",
|
||
// Contextual-hints group + its per-tip child toggles (exercised via the
|
||
// group sub-sheet, not as top-level rows).
|
||
"contextual_hints",
|
||
"contextual_hints.undo",
|
||
"contextual_hints.plan_mode",
|
||
"contextual_hints.image_input",
|
||
"contextual_hints.send_now",
|
||
"contextual_hints.small_screen",
|
||
"contextual_hints.word_select",
|
||
"contextual_hints.ssh_wrap",
|
||
];
|
||
|
||
#[test]
|
||
fn every_registered_setting_is_exercised() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let mut missing: Vec<&str> = Vec::new();
|
||
for meta in reg.all() {
|
||
if !ALL_SETTINGS_EXERCISED.contains(&meta.key) {
|
||
missing.push(meta.key);
|
||
}
|
||
}
|
||
assert!(
|
||
missing.is_empty(),
|
||
"settings registered but not exercised in tests/settings_e2e.rs: {missing:?}\n\
|
||
Add a row to ALL_SETTINGS_EXERCISED + a keyboard test + a mouse test."
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn matrix_is_subset_of_registry() {
|
||
// Reverse: catches stale entries left after a setting is removed.
|
||
let reg = SettingsRegistry::defaults();
|
||
let registered: std::collections::HashSet<&str> = reg.all().iter().map(|m| m.key).collect();
|
||
for &key in ALL_SETTINGS_EXERCISED {
|
||
assert!(
|
||
registered.contains(key),
|
||
"ALL_SETTINGS_EXERCISED contains `{key}` but no setting is registered with that key"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn make_state() -> SettingsModalState {
|
||
// Voice rows are hidden when the process gate is off (default until startup).
|
||
xai_grok_pager::app::set_voice_mode_enabled_for_test(true);
|
||
SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
// auto_mode_gate on so the permission_mode picker shows the full catalog
|
||
// (including Auto); the gate-off filtering is covered by a dedicated test.
|
||
PagerLocalSnapshot {
|
||
auto_mode_gate: true,
|
||
..PagerLocalSnapshot::default()
|
||
},
|
||
)
|
||
}
|
||
|
||
fn press(key: KeyCode) -> KeyEvent {
|
||
KeyEvent {
|
||
code: key,
|
||
modifiers: KeyModifiers::NONE,
|
||
kind: KeyEventKind::Press,
|
||
state: crossterm::event::KeyEventState::NONE,
|
||
}
|
||
}
|
||
|
||
fn press_with(key: KeyCode, mods: KeyModifiers) -> KeyEvent {
|
||
KeyEvent {
|
||
code: key,
|
||
modifiers: mods,
|
||
kind: KeyEventKind::Press,
|
||
state: crossterm::event::KeyEventState::NONE,
|
||
}
|
||
}
|
||
|
||
/// Find the row index of a given setting key in the modal's row list.
|
||
fn row_idx_for(state: &SettingsModalState, target: &str) -> usize {
|
||
state
|
||
.rows
|
||
.iter()
|
||
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == target))
|
||
.unwrap_or_else(|| panic!("setting `{target}` not present in modal rows"))
|
||
}
|
||
|
||
/// Whether `key` is hidden inside a group sub-sheet (a `SettingKind::Group`
|
||
/// child). Such keys are NOT emitted as top-level rows (`build_rows` skips
|
||
/// them), so they can't be navigated to or `d`-reset directly — only toggled
|
||
/// inside the sub-sheet. Mirrors `build_rows`'s group-child skip.
|
||
fn is_group_child(reg: &SettingsRegistry, key: &str) -> bool {
|
||
reg.all().iter().any(|m| match &m.kind {
|
||
SettingKind::Group { children } => children.contains(&key),
|
||
_ => false,
|
||
})
|
||
}
|
||
|
||
/// Move keyboard selection forward to a given setting key via `j`.
|
||
/// Target must be at-or-after current selection (forward-only).
|
||
fn navigate_to(state: &mut SettingsModalState, target: &str) {
|
||
let goal = row_idx_for(state, target);
|
||
assert!(
|
||
state.selected <= goal,
|
||
"navigate_to(`{target}`): test misconfigured — target row {goal} is BEFORE current \
|
||
selection {}; navigate_to only walks forward",
|
||
state.selected,
|
||
);
|
||
let mut guard = 0;
|
||
while state.selected != goal {
|
||
let outcome = handle_settings_key(state, &press(KeyCode::Char('j')));
|
||
if matches!(outcome, SettingsKeyOutcome::Unchanged) {
|
||
panic!(
|
||
"navigate_to(`{target}`): walked off the bottom of the row list \
|
||
without finding the target (currently at row {})",
|
||
state.selected,
|
||
);
|
||
}
|
||
guard += 1;
|
||
if guard > 100 {
|
||
panic!("navigate_to(`{target}`): runaway navigation (100+ keystrokes)");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Assert that `outcome` is the typed setter Action for the given bool key.
|
||
fn assert_set_bool_action(outcome: SettingsKeyOutcome, key: &str, expected: bool) {
|
||
let action = match outcome {
|
||
SettingsKeyOutcome::Action(a) => a,
|
||
other => panic!(
|
||
"expected typed setter Action for `{key}={expected}`, but no Action was emitted \
|
||
— outcome was {other:?}. Likely causes: focused row isn't a Bool, registry skew, \
|
||
or click target outside the row's hit-rect."
|
||
),
|
||
};
|
||
match (key, action) {
|
||
("compact_mode", Action::SetCompactMode(b)) => {
|
||
assert_eq!(b, expected, "SetCompactMode value differs from expected")
|
||
}
|
||
("show_timestamps", Action::SetTimestamps(b)) => {
|
||
assert_eq!(b, expected, "SetTimestamps value differs from expected")
|
||
}
|
||
("show_timeline", Action::SetTimeline(b)) => {
|
||
assert_eq!(b, expected, "SetTimeline value differs from expected")
|
||
}
|
||
("page_flip_on_send", Action::SetPageFlipOnSend(b)) => {
|
||
assert_eq!(b, expected, "SetPageFlipOnSend value differs from expected")
|
||
}
|
||
("combine_queued_prompts", Action::SetCombineQueuedPrompts(b)) => {
|
||
assert_eq!(
|
||
b, expected,
|
||
"SetCombineQueuedPrompts value differs from expected"
|
||
)
|
||
}
|
||
("simple_mode", Action::SetSimpleMode(b)) => {
|
||
assert_eq!(b, expected, "SetSimpleMode value differs from expected")
|
||
}
|
||
("multiline_mode", Action::SetMultilineMode(b)) => {
|
||
assert_eq!(b, expected, "SetMultilineMode value differs from expected")
|
||
}
|
||
("vim_mode", Action::SetVimMode(b)) => {
|
||
assert_eq!(b, expected, "SetVimMode value differs from expected")
|
||
}
|
||
("remember_tool_approvals", Action::SetRememberToolApprovals(b)) => {
|
||
assert_eq!(
|
||
b, expected,
|
||
"SetRememberToolApprovals value differs from expected"
|
||
)
|
||
}
|
||
("voice_keybind_enabled", Action::SetVoiceKeybindEnabled(b)) => {
|
||
assert_eq!(
|
||
b, expected,
|
||
"SetVoiceKeybindEnabled value differs from expected"
|
||
)
|
||
}
|
||
(
|
||
"toolset.ask_user_question.timeout_enabled",
|
||
Action::SetAskUserQuestionTimeoutEnabled(b),
|
||
) => {
|
||
assert_eq!(
|
||
b, expected,
|
||
"SetAskUserQuestionTimeoutEnabled value differs from expected"
|
||
)
|
||
}
|
||
|
||
("show_tips", Action::SetShowTips(b)) => {
|
||
assert_eq!(b, expected, "SetShowTips value differs from expected")
|
||
}
|
||
("auto_update", Action::SetAutoUpdate(b)) => {
|
||
assert_eq!(b, expected, "SetAutoUpdate value differs from expected")
|
||
}
|
||
("respect_manual_folds", Action::SetRespectManualFolds(b)) => {
|
||
assert_eq!(
|
||
b, expected,
|
||
"SetRespectManualFolds value differs from expected"
|
||
)
|
||
}
|
||
("show_thinking_blocks", Action::SetShowThinkingBlocks(b)) => {
|
||
assert_eq!(
|
||
b, expected,
|
||
"SetShowThinkingBlocks value differs from expected"
|
||
)
|
||
}
|
||
("prompt_suggestions", Action::SetPromptSuggestions(b)) => {
|
||
assert_eq!(
|
||
b, expected,
|
||
"SetPromptSuggestions value differs from expected"
|
||
)
|
||
}
|
||
("group_tool_verbs", Action::SetGroupToolVerbs(b)) => {
|
||
assert_eq!(b, expected, "SetGroupToolVerbs value differs from expected")
|
||
}
|
||
("collapsed_edit_blocks", Action::SetCollapsedEditBlocks(b)) => {
|
||
assert_eq!(
|
||
b, expected,
|
||
"SetCollapsedEditBlocks value differs from expected"
|
||
)
|
||
}
|
||
("invert_scroll", Action::SetInvertScroll(b)) => {
|
||
assert_eq!(b, expected, "SetInvertScroll value differs from expected")
|
||
}
|
||
("display_refresh_auto_cadence", Action::SetDisplayRefreshAutoCadence(b)) => {
|
||
assert_eq!(
|
||
b, expected,
|
||
"SetDisplayRefreshAutoCadence value differs from expected"
|
||
)
|
||
}
|
||
(key, action) => panic!(
|
||
"expected typed setter for `{key}={expected}`, got wrong Action variant: {action:?}"
|
||
),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Modal lifecycle — F2 / Esc / Ctrl+,
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn f2_closes_modal_from_any_browse_position() {
|
||
let mut s = make_state();
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::F(2)));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Close));
|
||
|
||
// Same expectation when focus is on a later row.
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "simple_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::F(2)));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Close));
|
||
}
|
||
|
||
/// Esc in Browse mode is handled by the modal chrome, not the settings
|
||
/// handler — so the handler returns `Unchanged`.
|
||
#[test]
|
||
fn esc_in_browse_mode_is_chrome_handled_not_modal_handled() {
|
||
let mut s = make_state();
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||
"Esc should fall through to the chrome, not be matched here: {outcome:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_comma_closes_modal_in_kitty_terminals() {
|
||
let mut s = make_state();
|
||
let outcome = handle_settings_key(
|
||
&mut s,
|
||
&press_with(KeyCode::Char(','), KeyModifiers::CONTROL),
|
||
);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Close));
|
||
}
|
||
|
||
#[test]
|
||
fn cmd_comma_closes_modal_on_macos() {
|
||
// SUPER == Cmd on macOS.
|
||
let mut s = make_state();
|
||
let outcome = handle_settings_key(&mut s, &press_with(KeyCode::Char(','), KeyModifiers::SUPER));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Close));
|
||
}
|
||
|
||
#[test]
|
||
fn esc_in_filter_mode_exits_filter_not_modal() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
assert!(matches!(s.mode(), SettingsModalMode::FilterFocused));
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
// Esc inside filter should NOT close the modal.
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Per-setting keyboard paths
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn space_on_compact_mode_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "compact_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "compact_mode", true);
|
||
}
|
||
|
||
#[test]
|
||
fn space_on_show_timestamps_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "show_timestamps");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "show_timestamps", false);
|
||
}
|
||
|
||
#[test]
|
||
fn space_on_show_timeline_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "show_timeline");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
// Toggling flips the default; derived so it follows SHOW_TIMELINE_DEFAULT.
|
||
let default_on = UiConfig::default().show_timeline_enabled();
|
||
assert_set_bool_action(outcome, "show_timeline", !default_on);
|
||
}
|
||
|
||
#[test]
|
||
fn space_on_page_flip_on_send_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "page_flip_on_send");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
let default_on = UiConfig::default().page_flip_on_send_enabled();
|
||
assert_set_bool_action(outcome, "page_flip_on_send", !default_on);
|
||
}
|
||
|
||
#[test]
|
||
fn space_on_combine_queued_prompts_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "combine_queued_prompts");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
let default_on = UiConfig::default().combine_queued_prompts.unwrap_or(false);
|
||
assert_set_bool_action(outcome, "combine_queued_prompts", !default_on);
|
||
}
|
||
|
||
#[test]
|
||
fn space_on_simple_mode_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "simple_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "simple_mode", false);
|
||
}
|
||
|
||
#[test]
|
||
fn space_on_remember_tool_approvals_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "remember_tool_approvals");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
// Default is false, so toggling flips it on.
|
||
assert_set_bool_action(outcome, "remember_tool_approvals", true);
|
||
}
|
||
|
||
/// The Ask-Question timeout row renders in Agent & Approval directly above
|
||
/// Plan Mode, reads the resolved default ON, and Space dispatches the typed
|
||
/// setter toggling it off.
|
||
#[test]
|
||
fn space_on_ask_user_question_timeout_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
let row = row_idx_for(&s, "toolset.ask_user_question.timeout_enabled");
|
||
assert_eq!(
|
||
row_idx_for(&s, "plan_mode"),
|
||
row + 1,
|
||
"Ask-Question timeout must render directly above Plan Mode"
|
||
);
|
||
navigate_to(&mut s, "toolset.ask_user_question.timeout_enabled");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
// Default is true (timer armed), so toggling flips it off.
|
||
assert_set_bool_action(outcome, "toolset.ask_user_question.timeout_enabled", false);
|
||
}
|
||
|
||
#[test]
|
||
fn enter_on_bool_row_also_toggles() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "compact_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert_set_bool_action(outcome, "compact_mode", true);
|
||
}
|
||
|
||
/// The contextual-hints group: Enter opens the sub-sheet, j navigates the three
|
||
/// child toggles, Space toggles the focused child (default ON → false), Esc
|
||
/// returns to Browse. Exercises the group row + all three children.
|
||
#[test]
|
||
fn enter_on_contextual_hints_group_opens_sub_sheet_and_toggles_children() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "contextual_hints");
|
||
|
||
let out = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(matches!(out, SettingsKeyOutcome::Changed));
|
||
assert!(matches!(
|
||
s.mode(),
|
||
SettingsModalMode::PickingGroup { child_idx: 0, .. }
|
||
));
|
||
|
||
// child 0: undo.
|
||
let out = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert!(
|
||
matches!(
|
||
out,
|
||
SettingsKeyOutcome::Action(Action::SetContextualHintUndo(false))
|
||
),
|
||
"Space on undo must toggle it off, got {out:?}",
|
||
);
|
||
// child 1: plan_mode.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('j')));
|
||
let out = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert!(
|
||
matches!(
|
||
out,
|
||
SettingsKeyOutcome::Action(Action::SetContextualHintPlanMode(false))
|
||
),
|
||
"Space on plan_mode must toggle it off, got {out:?}",
|
||
);
|
||
// child 2: image_input.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('j')));
|
||
let out = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert!(
|
||
matches!(
|
||
out,
|
||
SettingsKeyOutcome::Action(Action::SetContextualHintImageInput(false))
|
||
),
|
||
"Space on image_input must toggle it off, got {out:?}",
|
||
);
|
||
|
||
// Esc returns to Browse.
|
||
let out = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
assert!(matches!(out, SettingsKeyOutcome::Changed));
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
}
|
||
|
||
/// Mouse parity for the group: clicking the group row's value column opens the
|
||
/// sub-sheet, and clicking a child row toggles it in one click.
|
||
#[test]
|
||
fn mouse_click_on_contextual_hints_group_opens_sub_sheet_and_toggles_child() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let group_row = row_idx_for(&s, "contextual_hints") as u16;
|
||
|
||
// Click the value column (chevron) → opens the sub-sheet in one click.
|
||
let out = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
group_row,
|
||
);
|
||
assert!(matches!(out, SettingsKeyOutcome::Changed));
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::PickingGroup { .. }),
|
||
"click on the group value column must open the sub-sheet, got {:?}",
|
||
s.mode(),
|
||
);
|
||
|
||
// Synthesize child hit-rects (the renderer doesn't run in tests) and click
|
||
// the first child → toggles undo off in one click.
|
||
s.picker_choice_rects = (0..3)
|
||
.map(|i| Rect {
|
||
x: 0,
|
||
y: i as u16,
|
||
width: 80,
|
||
height: 1,
|
||
})
|
||
.collect();
|
||
let out = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
1,
|
||
0,
|
||
);
|
||
assert!(
|
||
matches!(
|
||
out,
|
||
SettingsKeyOutcome::Action(Action::SetContextualHintUndo(false))
|
||
),
|
||
"click on the first child must toggle undo off, got {out:?}",
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Per-setting MOUSE paths (keyboard ↔ mouse parity)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Lay out enough row_rects so that `handle_settings_mouse` can resolve
|
||
/// a click to the desired row index. We bypass the renderer here because
|
||
/// the test doesn't run inside a real terminal.
|
||
fn synth_rects(state: &mut SettingsModalState) {
|
||
state.list_area = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 80,
|
||
height: state.rows.len() as u16,
|
||
};
|
||
state.row_rects = (0..state.rows.len())
|
||
.map(|i| Rect {
|
||
x: 0,
|
||
y: i as u16,
|
||
width: 80,
|
||
height: 1,
|
||
})
|
||
.collect();
|
||
// Value column hit-rects on the right side of each row.
|
||
state.value_hit_rects = (0..state.rows.len())
|
||
.map(|i| Rect {
|
||
x: 70,
|
||
y: i as u16,
|
||
width: 8,
|
||
height: 1,
|
||
})
|
||
.collect();
|
||
}
|
||
|
||
/// Click on already-selected `compact_mode` toggles immediately.
|
||
#[test]
|
||
fn mouse_click_on_compact_mode_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "compact_mode") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
5,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "compact_mode", true);
|
||
}
|
||
|
||
/// Click on an unselected row only selects it; a second click toggles.
|
||
#[test]
|
||
fn mouse_click_on_show_timestamps_two_stage_select_then_toggle() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "show_timestamps") as u16;
|
||
|
||
// First click: select-only.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first click on a different row body should only select, got: {outcome:?}"
|
||
);
|
||
|
||
// Second click on the SAME row should now toggle (already focused).
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "show_timestamps", false);
|
||
}
|
||
|
||
/// Value-column click toggles `show_timeline` in one click.
|
||
#[test]
|
||
fn mouse_click_on_show_timeline_indicator_toggles_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "show_timeline") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
let default_on = UiConfig::default().show_timeline_enabled();
|
||
assert_set_bool_action(outcome, "show_timeline", !default_on);
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_click_on_page_flip_on_send_indicator_toggles_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "page_flip_on_send") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
let default_on = UiConfig::default().page_flip_on_send_enabled();
|
||
assert_set_bool_action(outcome, "page_flip_on_send", !default_on);
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_click_on_combine_queued_prompts_indicator_toggles_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "combine_queued_prompts") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
let default_on = UiConfig::default().combine_queued_prompts.unwrap_or(false);
|
||
assert_set_bool_action(outcome, "combine_queued_prompts", !default_on);
|
||
}
|
||
|
||
/// Value-column click toggles `remember_tool_approvals` in one click.
|
||
#[test]
|
||
fn mouse_click_on_remember_tool_approvals_indicator_toggles_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "remember_tool_approvals") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "remember_tool_approvals", true);
|
||
}
|
||
|
||
/// Value-column click toggles the Ask-Question timeout in one click.
|
||
#[test]
|
||
fn mouse_click_on_ask_user_question_timeout_indicator_toggles_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "toolset.ask_user_question.timeout_enabled") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "toolset.ask_user_question.timeout_enabled", false);
|
||
}
|
||
|
||
/// Click on the value column toggles in one click regardless of selection.
|
||
#[test]
|
||
fn mouse_click_on_simple_mode_indicator_toggles_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "simple_mode") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "simple_mode", false);
|
||
}
|
||
|
||
/// Click on the already-selected row body toggles.
|
||
#[test]
|
||
fn mouse_click_on_already_selected_row_body_toggles() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "compact_mode") as u16;
|
||
// compact_mode is the initial selection, so a body click toggles.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "compact_mode", true);
|
||
}
|
||
|
||
/// Click anywhere on the focused row toggles.
|
||
#[test]
|
||
fn mouse_click_at_row_edge_on_focused_row_toggles() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "compact_mode") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
70, // far right, within list_area width=80
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "compact_mode", true);
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_click_on_header_is_a_no_op() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
// The first header (Appearance) is at row index 0.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
5,
|
||
0,
|
||
);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_click_outside_list_is_a_no_op() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
100, // outside list_area (width=80)
|
||
0,
|
||
);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_scroll_down_advances_selection() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
// Click inside list_area — synth_rects sets height = rows.len().
|
||
let outcome = handle_settings_mouse(&mut s, MouseEventKind::ScrollDown, 5, 1);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
// Scroll-down emits 3 advances. From the initial selection
|
||
// (compact_mode), this lands 3 settings later. Later changes add more
|
||
// settings between compact_mode and the end, so we compute the
|
||
// expected landing row by inspecting the row list — keeps the
|
||
// test stable across such additions.
|
||
let setting_keys: Vec<&str> = s
|
||
.rows
|
||
.iter()
|
||
.filter_map(|r| match r {
|
||
RowEntry::Setting { key, .. } => Some(*key),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let compact_pos = setting_keys
|
||
.iter()
|
||
.position(|k| *k == "compact_mode")
|
||
.unwrap();
|
||
let expected_key = setting_keys
|
||
.get(compact_pos + 3)
|
||
// If there are fewer than 3 settings after compact_mode, the
|
||
// last setting absorbs all extra advances.
|
||
.copied()
|
||
.unwrap_or(*setting_keys.last().unwrap());
|
||
match &s.rows[s.selected] {
|
||
RowEntry::Setting { key, .. } => assert_eq!(*key, expected_key),
|
||
_ => panic!("expected setting row after scroll"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_scroll_up_returns_selection_to_first() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
// First advance to the bottom.
|
||
let _ = handle_settings_mouse(&mut s, MouseEventKind::ScrollDown, 5, 1);
|
||
// Then scroll back up.
|
||
let outcome = handle_settings_mouse(&mut s, MouseEventKind::ScrollUp, 5, 1);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
match &s.rows[s.selected] {
|
||
RowEntry::Setting { key, .. } => assert_eq!(*key, "compact_mode"),
|
||
_ => panic!("expected setting row at top"),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filter mode
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Filter mode accepts chars into the query editor and must never leak
|
||
/// an `Action`.
|
||
#[test]
|
||
fn slash_enters_filter_mode_and_chars_go_to_query_no_action_leak() {
|
||
let mut s = make_state();
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert!(matches!(s.mode(), SettingsModalMode::FilterFocused));
|
||
|
||
for c in "compact density".chars() {
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
match outcome {
|
||
SettingsKeyOutcome::Changed | SettingsKeyOutcome::Unchanged => {}
|
||
SettingsKeyOutcome::Action(a) => {
|
||
panic!("filter mode leaked Action({a:?}) for char {c:?}");
|
||
}
|
||
SettingsKeyOutcome::ActionPair(a, b) => {
|
||
panic!("filter mode leaked ActionPair({a:?}, {b:?}) for char {c:?}");
|
||
}
|
||
SettingsKeyOutcome::Close => {
|
||
panic!("filter mode unexpectedly closed on char {c:?}");
|
||
}
|
||
}
|
||
}
|
||
assert_eq!(s.query(), "compact density");
|
||
|
||
let reg = SettingsRegistry::defaults();
|
||
let hits = reg.search(s.query());
|
||
assert_eq!(hits.len(), 1);
|
||
assert_eq!(hits[0].key, "compact_mode");
|
||
}
|
||
|
||
/// `Ctrl+,` closes the modal from FilterFocused mode.
|
||
#[test]
|
||
fn ctrl_comma_from_filter_mode_closes_modal() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
assert!(matches!(s.mode(), SettingsModalMode::FilterFocused));
|
||
let outcome = handle_settings_key(
|
||
&mut s,
|
||
&press_with(KeyCode::Char(','), KeyModifiers::CONTROL),
|
||
);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Close));
|
||
}
|
||
|
||
/// F2 from FilterFocused mode also closes.
|
||
#[test]
|
||
fn f2_from_filter_mode_closes_modal() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
assert!(matches!(s.mode(), SettingsModalMode::FilterFocused));
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::F(2)));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Close));
|
||
}
|
||
|
||
/// "stamp" matches only `show_timestamps` — narrows to header + setting.
|
||
#[test]
|
||
fn filter_query_stamp_matches_show_timestamps_only() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "stamp".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
let filtered = s.filtered_indices();
|
||
assert_eq!(
|
||
filtered.len(),
|
||
2,
|
||
"expected exactly 2 visible rows (Appearance header + show_timestamps), \
|
||
got {filtered:?}"
|
||
);
|
||
// Header must come before its child setting.
|
||
match &s.rows[filtered[0]] {
|
||
RowEntry::Header { category } => assert_eq!(*category, SettingCategory::Appearance),
|
||
other => panic!("expected Header at filtered[0], got {other:?}"),
|
||
}
|
||
match &s.rows[filtered[1]] {
|
||
RowEntry::Setting { key, .. } => assert_eq!(*key, "show_timestamps"),
|
||
other => panic!("expected Setting at filtered[1], got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Non-matching query produces an empty filtered set.
|
||
#[test]
|
||
fn filter_query_nonexistent_shows_zero_settings() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "xyzzy-no-match".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
assert!(
|
||
s.filtered_indices().is_empty(),
|
||
"nonexistent query should produce empty filtered set, got {:?}",
|
||
s.filtered_indices(),
|
||
);
|
||
}
|
||
|
||
/// Empty query shows all rows.
|
||
#[test]
|
||
fn filter_empty_query_shows_all_rows() {
|
||
let s = make_state();
|
||
let filtered = s.filtered_indices();
|
||
let expected: Vec<usize> = (0..s.rows.len()).collect();
|
||
assert_eq!(
|
||
filtered, expected,
|
||
"empty-query filtered set must equal (0..rows.len())"
|
||
);
|
||
}
|
||
|
||
/// Esc in filter mode clears the query and returns to Browse.
|
||
#[test]
|
||
fn filter_esc_clears_query_and_returns_to_browse() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "stamp".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
assert_eq!(s.query(), "stamp");
|
||
assert_eq!(s.filtered_indices().len(), 2);
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
assert_eq!(s.query(), "", "Esc must clear the query");
|
||
// Filter is inert again — full set restored in original order.
|
||
let expected: Vec<usize> = (0..s.rows.len()).collect();
|
||
assert_eq!(
|
||
s.filtered_indices(),
|
||
expected.as_slice(),
|
||
"Esc must restore filtered_indices to (0..rows.len()) — full set in row-order"
|
||
);
|
||
}
|
||
|
||
/// Navigation under filter walks only the filtered subset.
|
||
/// Up must not land on a header.
|
||
#[test]
|
||
fn filter_navigation_lands_on_filtered_subset_only() {
|
||
let mut s = make_state();
|
||
// Sanity check: initial selection is compact_mode (row 1).
|
||
let compact_idx = row_idx_for(&s, "compact_mode");
|
||
let show_ts_idx = row_idx_for(&s, "show_timestamps");
|
||
assert_eq!(s.selected, compact_idx);
|
||
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "stamp".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
// After filtering for "stamp", compact_mode is hidden — selection
|
||
// snaps to the only remaining setting (show_timestamps).
|
||
assert_eq!(
|
||
s.selected, show_ts_idx,
|
||
"selection should snap to show_timestamps when compact_mode is filtered out"
|
||
);
|
||
|
||
// Down arrow shouldn't move (only one setting in the filter).
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||
"Down at last visible setting should be Unchanged, got {outcome:?}"
|
||
);
|
||
assert_eq!(s.selected, show_ts_idx);
|
||
|
||
// Up arrow shouldn't move either (only one setting in the filter,
|
||
// and headers are not selectable).
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Up));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||
"Up at first visible setting should be Unchanged, got {outcome:?}"
|
||
);
|
||
assert_eq!(s.selected, show_ts_idx);
|
||
|
||
// Up must not land on a header.
|
||
let appearance_header_idx = s
|
||
.rows
|
||
.iter()
|
||
.position(|r| {
|
||
matches!(
|
||
r,
|
||
RowEntry::Header {
|
||
category: SettingCategory::Appearance
|
||
}
|
||
)
|
||
})
|
||
.expect("Appearance header must exist in default registry");
|
||
assert_ne!(
|
||
s.selected, appearance_header_idx,
|
||
"Up arrow must NOT land on a header (regression: header became selectable)"
|
||
);
|
||
}
|
||
|
||
/// Backspace pops a char and re-broadens the visible set.
|
||
#[test]
|
||
fn filter_backspace_broadens_visible_set() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "stamp".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
let narrowed = s.filtered_indices().len();
|
||
assert_eq!(
|
||
narrowed, 2,
|
||
"expected narrowed filter (header + show_timestamps)"
|
||
);
|
||
|
||
// Pop trailing 'p' → "stam". Still matches only show_timestamps
|
||
// (substring of "timestamps"); same 2 visible rows.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Backspace));
|
||
assert_eq!(s.query(), "stam");
|
||
assert_eq!(s.filtered_indices().len(), 2);
|
||
|
||
// Empty the query out — now everything is visible. We pop one at
|
||
// a time and check at each step that the cache regenerates
|
||
// (rather than just shrinks).
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Backspace)); // → "sta"
|
||
assert_eq!(s.query(), "sta");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Backspace)); // → "st"
|
||
assert_eq!(s.query(), "st");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Backspace)); // → "s"
|
||
assert_eq!(s.query(), "s");
|
||
// "s" matches multiple settings (compact_mode via "messages" in
|
||
// its description, show_timestamps via "show"/"timestamps",
|
||
// simple_mode via "simple"/"ascii"). So the filtered set MUST be
|
||
// larger than 2 here — proving each Backspace re-broadens.
|
||
let setting_count_at_s = s
|
||
.filtered_indices()
|
||
.iter()
|
||
.filter(|&&i| matches!(s.rows[i], RowEntry::Setting { .. }))
|
||
.count();
|
||
assert!(
|
||
setting_count_at_s >= 2,
|
||
"popping query from 'stam' to 's' must re-broaden — \
|
||
expected >=2 visible settings, got {setting_count_at_s} \
|
||
(filtered = {:?})",
|
||
s.filtered_indices()
|
||
);
|
||
|
||
// Final pop → "". Filter inert, full set restored in order.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Backspace));
|
||
assert_eq!(s.query(), "");
|
||
let expected: Vec<usize> = (0..s.rows.len()).collect();
|
||
assert_eq!(
|
||
s.filtered_indices(),
|
||
expected.as_slice(),
|
||
"empty query must re-broaden to (0..rows.len()) in row order",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn programmatic_filter_query_is_single_line_and_cursor_ends() {
|
||
let mut state = make_state();
|
||
state.set_query("sta\r\nmp\n");
|
||
assert_eq!(state.query(), "stamp");
|
||
assert_eq!(state.query_cursor(), state.query().len());
|
||
assert_eq!(state.filtered_indices().len(), 2);
|
||
}
|
||
|
||
/// Multi-keyword AND query narrows correctly.
|
||
#[test]
|
||
fn filter_with_multiple_matches_navigates_between_settings() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
// "compact" is a keyword on compact_mode; "simple" on simple_mode.
|
||
// Use an OR-of-substrings approach: the query "mode" alone matches
|
||
// too many things now (theme descriptions contain "mode"). We
|
||
// instead use two distinct keyword matches separated by a single
|
||
// word that's not in the theme catalog — testing the multi-word
|
||
// AND behavior on a tight set.
|
||
for c in "ascii minimal".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
// Both keywords are on simple_mode → exactly one setting matches.
|
||
let filtered = s.filtered_indices();
|
||
let setting_keys: Vec<&str> = filtered
|
||
.iter()
|
||
.filter_map(|&i| match &s.rows[i] {
|
||
RowEntry::Setting { key, .. } => Some(*key),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(setting_keys, vec!["simple_mode"]);
|
||
|
||
// Selection should snap to the only visible setting.
|
||
let simple_idx = row_idx_for(&s, "simple_mode");
|
||
assert_eq!(s.selected, simple_idx);
|
||
|
||
// Down at the only visible setting is Unchanged.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
|
||
assert_eq!(s.selected, simple_idx);
|
||
|
||
// Up at the only visible setting is Unchanged (headers are not
|
||
// selectable).
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Up));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
|
||
assert_eq!(s.selected, simple_idx);
|
||
|
||
// Drop the second keyword (Backspace x8 to remove "minimal" — 7
|
||
// chars + 1 space). Now "ascii" alone still matches only
|
||
// simple_mode but we're back to a single-keyword filter that
|
||
// doesn't ambiguously broaden. Asserts the "filter still narrows
|
||
// correctly when one keyword drops out" property.
|
||
for _ in 0..8 {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Backspace));
|
||
}
|
||
assert_eq!(s.query(), "ascii");
|
||
let filtered_after_pop = s.filtered_indices();
|
||
let after_pop_keys: Vec<&str> = filtered_after_pop
|
||
.iter()
|
||
.filter_map(|&i| match &s.rows[i] {
|
||
RowEntry::Setting { key, .. } => Some(*key),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(after_pop_keys, vec!["simple_mode"]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filter mode — Enter commits with preserved query
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Enter in FilterFocused exits filter focus and preserves the query.
|
||
#[test]
|
||
fn filter_enter_commits_and_preserves_query() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "stamp".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
let show_ts_idx = row_idx_for(&s, "show_timestamps");
|
||
assert!(matches!(s.mode(), SettingsModalMode::FilterFocused));
|
||
assert_eq!(s.query(), "stamp");
|
||
assert_eq!(s.selected, show_ts_idx);
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Enter must exit FilterFocused"
|
||
);
|
||
assert_eq!(
|
||
s.query(),
|
||
"stamp",
|
||
"Enter must PRESERVE the query (Issue 1)"
|
||
);
|
||
assert_eq!(
|
||
s.filtered_indices().len(),
|
||
2,
|
||
"filter must remain active after Enter-commit"
|
||
);
|
||
assert_eq!(s.selected, show_ts_idx);
|
||
|
||
// And now the user can toggle directly without re-navigating.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "show_timestamps", false);
|
||
}
|
||
|
||
/// Backspace in Browse continues editing the preserved query.
|
||
#[test]
|
||
fn browse_backspace_pops_query_after_filter_commit() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "stamp".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Home));
|
||
assert_eq!(s.query_cursor(), 0);
|
||
// Commit
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
assert_eq!(s.query(), "stamp");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Left));
|
||
assert_eq!(
|
||
s.query(),
|
||
"stamp",
|
||
"unfocused Browse navigation must not edit the query",
|
||
);
|
||
|
||
// Backspace in Browse pops one char, stays in Browse, re-runs
|
||
// invalidate_filter.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Backspace));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
assert_eq!(s.query(), "stam");
|
||
assert_eq!(s.filtered_indices().len(), 2);
|
||
|
||
let grapheme = "👩🏽\u{200d}💻";
|
||
for modifiers in [
|
||
KeyModifiers::ALT,
|
||
KeyModifiers::CONTROL,
|
||
KeyModifiers::SUPER,
|
||
KeyModifiers::SHIFT,
|
||
] {
|
||
s.set_query(format!("x{grapheme}"));
|
||
let outcome = handle_settings_key(&mut s, &press_with(KeyCode::Backspace, modifiers));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert_eq!(
|
||
s.query(),
|
||
"x",
|
||
"{modifiers:?}+Backspace must remove exactly one trailing grapheme",
|
||
);
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
}
|
||
|
||
// Backspace on empty query is Unchanged (and the query stays "").
|
||
s.set_query("");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Backspace));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||
"Backspace on empty query must be Unchanged"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_uses_canonical_word_keys_without_cursor_side_effects() {
|
||
let mut state = make_state();
|
||
let _ = handle_settings_key(&mut state, &press(KeyCode::Char('/')));
|
||
|
||
for key in [
|
||
press_with(KeyCode::Left, KeyModifiers::ALT),
|
||
press_with(KeyCode::Char('b'), KeyModifiers::ALT),
|
||
press_with(KeyCode::Left, KeyModifiers::CONTROL),
|
||
] {
|
||
state.set_query("alpha-beta");
|
||
let outcome = handle_settings_key(&mut state, &key);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert_eq!(state.query(), "alpha-beta");
|
||
assert_eq!(state.query_cursor(), "alpha-".len());
|
||
}
|
||
for key in [
|
||
press_with(KeyCode::Right, KeyModifiers::ALT),
|
||
press_with(KeyCode::Char('f'), KeyModifiers::ALT),
|
||
] {
|
||
state.set_query("alpha-beta");
|
||
let _ = handle_settings_key(&mut state, &press(KeyCode::Home));
|
||
let outcome = handle_settings_key(&mut state, &key);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert_eq!(state.query_cursor(), "alpha".len());
|
||
}
|
||
|
||
state.set_query("stamp");
|
||
let compact_idx = row_idx_for(&state, "compact_mode");
|
||
let show_timestamps_idx = row_idx_for(&state, "show_timestamps");
|
||
let filtered_before = state.filtered_indices().to_vec();
|
||
state.selected = compact_idx;
|
||
let _ = handle_settings_key(&mut state, &press_with(KeyCode::Left, KeyModifiers::ALT));
|
||
assert_eq!(state.filtered_indices(), filtered_before.as_slice());
|
||
assert_eq!(
|
||
state.selected, compact_idx,
|
||
"cursor-only motion must not clamp selection",
|
||
);
|
||
let _ = handle_settings_key(&mut state, &press(KeyCode::End));
|
||
let _ = handle_settings_key(&mut state, &press(KeyCode::Backspace));
|
||
assert_eq!(state.query(), "stam");
|
||
assert_eq!(
|
||
state.selected, show_timestamps_idx,
|
||
"text mutation must recompute filtering and clamp selection",
|
||
);
|
||
|
||
state.set_query("alpha-beta");
|
||
let _ = handle_settings_key(
|
||
&mut state,
|
||
&press_with(KeyCode::Backspace, KeyModifiers::ALT),
|
||
);
|
||
assert_eq!(state.query(), "alpha-");
|
||
}
|
||
|
||
#[test]
|
||
fn filter_grapheme_and_line_commands_are_canonical() {
|
||
let mut state = make_state();
|
||
let _ = handle_settings_key(&mut state, &press(KeyCode::Char('/')));
|
||
let grapheme = "👩🏽\u{200d}💻";
|
||
state.set_query(format!("a{grapheme}b"));
|
||
|
||
let _ = handle_settings_key(&mut state, &press(KeyCode::Home));
|
||
assert_eq!(state.query_cursor(), 0);
|
||
let _ = handle_settings_key(&mut state, &press(KeyCode::Right));
|
||
assert_eq!(state.query_cursor(), 1);
|
||
let _ = handle_settings_key(&mut state, &press(KeyCode::Delete));
|
||
assert_eq!(state.query(), "ab");
|
||
let _ = handle_settings_key(&mut state, &press(KeyCode::End));
|
||
assert_eq!(state.query_cursor(), state.query().len());
|
||
let _ = handle_settings_key(&mut state, &press_with(KeyCode::Left, KeyModifiers::SUPER));
|
||
assert_eq!(state.query_cursor(), 0);
|
||
let _ = handle_settings_key(&mut state, &press_with(KeyCode::Right, KeyModifiers::SUPER));
|
||
assert_eq!(state.query_cursor(), state.query().len());
|
||
}
|
||
|
||
#[test]
|
||
fn filter_ctrl_kill_keys_and_unsafe_insert_policy() {
|
||
let mut state = make_state();
|
||
let _ = handle_settings_key(&mut state, &press(KeyCode::Char('/')));
|
||
|
||
state.set_query("alpha beta");
|
||
let _ = handle_settings_key(
|
||
&mut state,
|
||
&press_with(KeyCode::Char('w'), KeyModifiers::CONTROL),
|
||
);
|
||
assert_eq!(state.query(), "alpha ");
|
||
|
||
state.set_query("alpha beta");
|
||
let _ = handle_settings_key(
|
||
&mut state,
|
||
&press_with(KeyCode::Char('u'), KeyModifiers::CONTROL),
|
||
);
|
||
assert!(state.query().is_empty());
|
||
|
||
state.set_query("alpha beta");
|
||
let _ = handle_settings_key(&mut state, &press(KeyCode::Home));
|
||
let _ = handle_settings_key(
|
||
&mut state,
|
||
&press_with(KeyCode::Char('k'), KeyModifiers::CONTROL),
|
||
);
|
||
assert!(state.query().is_empty());
|
||
|
||
let outcome = handle_settings_key(&mut state, &press(KeyCode::Char('\u{202e}')));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert!(
|
||
state.query().is_empty(),
|
||
"unsafe display characters must be consumed without insertion",
|
||
);
|
||
}
|
||
|
||
/// PageDown/PageUp navigate in filter mode (advance x10 per press).
|
||
#[test]
|
||
fn filter_pageup_pagedown_navigates_in_filter_mode() {
|
||
let mut s = make_state();
|
||
// Enter filter mode without typing — filtered_cache stays full.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
assert!(matches!(s.mode(), SettingsModalMode::FilterFocused));
|
||
|
||
// PageDown from compact_mode advances toward the last row.
|
||
let compact_idx = row_idx_for(&s, "compact_mode");
|
||
let last_idx = s
|
||
.rows
|
||
.iter()
|
||
.rposition(|r| matches!(r, RowEntry::Setting { .. }))
|
||
.expect("default registry must contain at least one setting");
|
||
assert_eq!(s.selected, compact_idx);
|
||
|
||
// Compute how many PageDown presses are needed to reach the
|
||
// last row (each press advances by 10 selectable rows; round
|
||
// up so we definitely reach it). A single press isn't enough
|
||
// once the registry exceeds ~10 settings.
|
||
let total_selectable = s
|
||
.rows
|
||
.iter()
|
||
.filter(|r| matches!(r, RowEntry::Setting { .. }))
|
||
.count();
|
||
let presses = total_selectable.div_ceil(10).max(1);
|
||
for _ in 0..presses {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::PageDown));
|
||
}
|
||
assert_eq!(
|
||
s.selected, last_idx,
|
||
"PageDown × {presses} in filter must reach last selectable row",
|
||
);
|
||
|
||
// PageUp returns to the top.
|
||
for _ in 0..presses {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::PageUp));
|
||
}
|
||
assert_eq!(s.selected, compact_idx);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filter mode — g/G filter-aware navigation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// `g` lands on the first selectable row (not a header).
|
||
#[test]
|
||
fn g_jumps_to_first_visible_setting() {
|
||
let mut s = make_state();
|
||
// Move to the last row first so g actually has to navigate.
|
||
navigate_to(&mut s, "simple_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char('g')));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
let first_setting_idx = row_idx_for(&s, "compact_mode");
|
||
assert_eq!(
|
||
s.selected, first_setting_idx,
|
||
"`g` must land on first selectable row (compact_mode), not a header"
|
||
);
|
||
}
|
||
|
||
/// `G` lands on the last selectable row.
|
||
#[test]
|
||
fn shift_g_jumps_to_last_visible_setting() {
|
||
let mut s = make_state();
|
||
// Default selection is compact_mode (first setting).
|
||
let outcome = handle_settings_key(&mut s, &press_with(KeyCode::Char('G'), KeyModifiers::SHIFT));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
// Compute the last setting row in row-order at runtime.
|
||
let last_setting_idx = s
|
||
.rows
|
||
.iter()
|
||
.rposition(|r| matches!(r, RowEntry::Setting { .. }))
|
||
.expect("default registry must contain at least one setting");
|
||
assert_eq!(
|
||
s.selected, last_setting_idx,
|
||
"`G` must land on last selectable row in the registry"
|
||
);
|
||
}
|
||
|
||
/// `g` respects the active filter — lands on the first visible setting.
|
||
#[test]
|
||
fn g_jumps_to_first_filtered_row_under_active_filter() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "stamp".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
// Commit filter into Browse with query preserved.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
|
||
// Selection is already on show_timestamps (snapped by
|
||
// clamp_selected_to_visible). g should be a no-op (Unchanged)
|
||
// because the first visible setting IS the current selection.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char('g')));
|
||
let show_ts_idx = row_idx_for(&s, "show_timestamps");
|
||
assert_eq!(s.selected, show_ts_idx);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||
"g on the already-first-visible row should be Unchanged"
|
||
);
|
||
}
|
||
|
||
/// `G` respects the active filter — lands on the last visible setting.
|
||
#[test]
|
||
fn shift_g_jumps_to_last_filtered_row_under_active_filter() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "stamp".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
|
||
let outcome = handle_settings_key(&mut s, &press_with(KeyCode::Char('G'), KeyModifiers::SHIFT));
|
||
let show_ts_idx = row_idx_for(&s, "show_timestamps");
|
||
assert_eq!(s.selected, show_ts_idx);
|
||
// G must NOT land on compact_mode or simple_mode (both hidden).
|
||
let compact_idx = row_idx_for(&s, "compact_mode");
|
||
let simple_idx = row_idx_for(&s, "simple_mode");
|
||
assert_ne!(s.selected, compact_idx, "G must not land on hidden row");
|
||
assert_ne!(s.selected, simple_idx, "G must not land on hidden row");
|
||
// Outcome is Unchanged because show_timestamps is already last.
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Selection stability
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Filter keeps selection when the focused row remains visible.
|
||
#[test]
|
||
fn filter_keeps_selection_when_currently_selected_row_remains_visible() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "show_timestamps");
|
||
let show_ts_idx = s.selected;
|
||
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "stamp".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
assert_eq!(
|
||
s.selected, show_ts_idx,
|
||
"selection should NOT move — show_timestamps was already focused and matches the filter"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Multi-word AND semantics
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// One unmatched word in a multi-word query empties the result (AND).
|
||
#[test]
|
||
fn filter_multi_word_with_one_unmatched_word_shows_zero_settings() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
// "compact" alone matches compact_mode; appending an unmatched
|
||
// word forces AND→empty.
|
||
for c in "compact xyzzy".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
assert!(
|
||
s.filtered_indices().is_empty(),
|
||
"AND across words: one unmatched word should empty the result, got {:?}",
|
||
s.filtered_indices()
|
||
);
|
||
}
|
||
|
||
/// AND narrows strictly: adding an unmatched word yields 0 results.
|
||
#[test]
|
||
fn filter_and_semantics_narrow_strictly() {
|
||
let reg = SettingsRegistry::defaults();
|
||
// "ascii" matches only simple_mode (keyword).
|
||
let single = reg.search("ascii");
|
||
assert_eq!(single.len(), 1);
|
||
assert_eq!(single[0].key, "simple_mode");
|
||
// "ascii minimal" — both simple_mode keywords. Still 1 match.
|
||
let conjunction = reg.search("ascii minimal");
|
||
assert_eq!(conjunction.len(), 1);
|
||
assert_eq!(conjunction[0].key, "simple_mode");
|
||
// Adding an unmatched word must yield 0.
|
||
let with_unmatched = reg.search("ascii xyzzy");
|
||
assert!(
|
||
with_unmatched.is_empty(),
|
||
"AND with unmatched word must yield empty, got {:?}",
|
||
with_unmatched.iter().map(|m| m.key).collect::<Vec<_>>()
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mouse parity under active filter
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Lay out `row_rects` for filtered state — only visible rows get rects.
|
||
fn synth_rects_filtered(state: &mut SettingsModalState) {
|
||
let filter: Vec<usize> = state.filtered_indices().to_vec();
|
||
state.list_area = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 80,
|
||
height: filter.len() as u16,
|
||
};
|
||
state.row_rects = vec![Rect::default(); state.rows.len()];
|
||
state.value_hit_rects = vec![Rect::default(); state.rows.len()];
|
||
for (pos, &row_idx) in filter.iter().enumerate() {
|
||
state.row_rects[row_idx] = Rect {
|
||
x: 0,
|
||
y: pos as u16,
|
||
width: 80,
|
||
height: 1,
|
||
};
|
||
state.value_hit_rects[row_idx] = Rect {
|
||
x: 70,
|
||
y: pos as u16,
|
||
width: 8,
|
||
height: 1,
|
||
};
|
||
}
|
||
}
|
||
|
||
/// Mouse click on a visible filtered row toggles it.
|
||
#[test]
|
||
fn mouse_click_on_visible_filtered_row_toggles() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "stamp".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
// Commit so we can click without typing.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
synth_rects_filtered(&mut s);
|
||
// show_timestamps is selected (snapped). filtered layout:
|
||
// pos 0 = Appearance header, pos 1 = show_timestamps.
|
||
let y_in_filter = 1u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
y_in_filter,
|
||
);
|
||
assert_set_bool_action(outcome, "show_timestamps", false);
|
||
}
|
||
|
||
/// Click at a filtered-out row position is a no-op.
|
||
#[test]
|
||
fn mouse_click_at_filtered_out_row_position_is_no_op() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "stamp".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
synth_rects_filtered(&mut s);
|
||
// y=2 would be compact_mode in an unfiltered layout but is outside
|
||
// the filtered list_area (height=2). Click should be Unchanged.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
2,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||
"click outside filtered list_area must be Unchanged, got {outcome:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filter-rebuild timing — cache stability
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// `filtered_indices()` is a stable borrow, not regenerated per call.
|
||
#[test]
|
||
fn filter_cache_pointer_is_stable_across_reads() {
|
||
let s = make_state();
|
||
let ptr1 = s.filtered_indices().as_ptr();
|
||
let ptr2 = s.filtered_indices().as_ptr();
|
||
assert_eq!(
|
||
ptr1, ptr2,
|
||
"filtered_indices() should be a stable borrow, not a fresh allocation per read"
|
||
);
|
||
}
|
||
|
||
/// `filtered_cache` is regenerated when the query mutates.
|
||
#[test]
|
||
fn filter_cache_pointer_changes_on_query_mutation() {
|
||
let mut s = make_state();
|
||
let ptr_before = s.filtered_indices().as_ptr();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
// Filter mode entry alone doesn't mutate query. Type a char to
|
||
// force `invalidate_filter`.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('s')));
|
||
let ptr_after = s.filtered_indices().as_ptr();
|
||
assert_ne!(
|
||
ptr_before, ptr_after,
|
||
"filtered_cache must be regenerated on query mutation"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Render with filter — translation + "No matches"
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// `scroll_offset` stays within `filtered_indices()` bounds under filter.
|
||
#[test]
|
||
fn render_with_filter_active_and_small_viewport_clamps_scroll() {
|
||
use ratatui::buffer::Buffer;
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "stamp".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
let area = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 80,
|
||
height: 12,
|
||
};
|
||
let mut buf = Buffer::empty(area);
|
||
xai_grok_pager::views::settings_modal::render_settings_modal(
|
||
&mut buf, area, &mut s, false, None,
|
||
);
|
||
let visible = s.filtered_indices().len();
|
||
assert!(
|
||
s.scroll_offset <= visible.saturating_sub(1).max(0),
|
||
"scroll_offset ({}) must be within filtered_indices bounds ({})",
|
||
s.scroll_offset,
|
||
visible
|
||
);
|
||
}
|
||
|
||
/// "No matches" placeholder renders and echoes the query.
|
||
#[test]
|
||
fn render_no_matches_placeholder_includes_query() {
|
||
use ratatui::buffer::Buffer;
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "xyzzy".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
assert!(s.filtered_indices().is_empty());
|
||
// Larger area than the modal-min so the chrome + search bar +
|
||
// footer all fit and the empty-state placeholder lands in the
|
||
// remaining content area.
|
||
let area = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 120,
|
||
height: 30,
|
||
};
|
||
let mut buf = Buffer::empty(area);
|
||
xai_grok_pager::views::settings_modal::render_settings_modal(
|
||
&mut buf, area, &mut s, false, None,
|
||
);
|
||
// Scan all cells for the substring "No matches" and "xyzzy".
|
||
let mut all_text = String::new();
|
||
for y in 0..area.height {
|
||
for x in 0..area.width {
|
||
if let Some(cell) = buf.cell((x, y)) {
|
||
all_text.push_str(cell.symbol());
|
||
}
|
||
}
|
||
all_text.push('\n');
|
||
}
|
||
assert!(
|
||
all_text.contains("No matches"),
|
||
"rendered buffer must contain 'No matches' placeholder, got:\n{all_text}"
|
||
);
|
||
assert!(
|
||
all_text.contains("xyzzy"),
|
||
"rendered buffer must echo the query, got:\n{all_text}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PickingEnum / EditingValue Esc routing
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Esc in `PickingEnum` returns to Browse.
|
||
#[test]
|
||
fn esc_in_picking_enum_mode_returns_to_browse() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "scroll_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
}
|
||
|
||
/// Esc in `EditingValue` returns to Browse.
|
||
#[test]
|
||
fn esc_in_editing_value_mode_returns_to_browse() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "max_thoughts_width");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Registry contracts
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Pins which keys belong to each SettingKind.
|
||
#[test]
|
||
fn registry_kind_membership_through_pr_14() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let mut by_kind: std::collections::BTreeMap<&str, Vec<&str>> =
|
||
std::collections::BTreeMap::new();
|
||
for meta in reg.all() {
|
||
let kind_tag = match &meta.kind {
|
||
SettingKind::Bool { .. } => "Bool",
|
||
SettingKind::String { .. } => "String",
|
||
SettingKind::Enum { .. } => "Enum",
|
||
SettingKind::Int { .. } => "Int",
|
||
|
||
SettingKind::DynamicEnum { .. } => "DynamicEnum",
|
||
SettingKind::Group { .. } => "Group",
|
||
other => panic!(
|
||
"registry_kind_membership: setting `{}` has unknown kind {:?} — \
|
||
add an arm here AND a kind-membership assertion below",
|
||
meta.key, other,
|
||
),
|
||
};
|
||
by_kind.entry(kind_tag).or_default().push(meta.key);
|
||
}
|
||
for keys in by_kind.values_mut() {
|
||
keys.sort();
|
||
}
|
||
|
||
let bool_keys = by_kind.remove("Bool").unwrap_or_default();
|
||
assert_eq!(
|
||
bool_keys,
|
||
vec![
|
||
"compact_mode",
|
||
"group_tool_verbs",
|
||
"collapsed_edit_blocks",
|
||
"invert_scroll",
|
||
"display_refresh_auto_cadence",
|
||
"multiline_mode",
|
||
"prompt_suggestions",
|
||
"respect_manual_folds",
|
||
"show_thinking_blocks",
|
||
"show_timeline",
|
||
"show_timestamps",
|
||
"page_flip_on_send",
|
||
"combine_queued_prompts",
|
||
"simple_mode",
|
||
"vim_mode",
|
||
"remember_tool_approvals",
|
||
"toolset.ask_user_question.timeout_enabled",
|
||
"auto_update",
|
||
"show_tips",
|
||
"voice_keybind_enabled",
|
||
// Per-tip contextual-hint children (hidden from the top-level list,
|
||
// toggled inside the group sub-sheet) are still Bool settings.
|
||
"contextual_hints.undo",
|
||
"contextual_hints.plan_mode",
|
||
"contextual_hints.image_input",
|
||
"contextual_hints.send_now",
|
||
"contextual_hints.small_screen",
|
||
"contextual_hints.word_select",
|
||
"contextual_hints.ssh_wrap",
|
||
]
|
||
.into_iter()
|
||
.collect::<std::collections::BTreeSet<_>>()
|
||
.into_iter()
|
||
.collect::<Vec<_>>(),
|
||
"Bool kind membership drift",
|
||
);
|
||
|
||
let enum_keys = by_kind.remove("Enum").unwrap_or_default();
|
||
assert_eq!(
|
||
enum_keys,
|
||
vec![
|
||
"auto_dark_theme",
|
||
"auto_light_theme",
|
||
"coding_data_sharing",
|
||
"default_selected_permission",
|
||
"hunk_tracker_mode",
|
||
"keep_text_selection",
|
||
"permission_mode",
|
||
"plan_mode",
|
||
"render_mermaid",
|
||
"screen_mode",
|
||
"scroll_mode",
|
||
"theme",
|
||
"voice_capture_mode",
|
||
"voice_stt_language",
|
||
],
|
||
"Enum kind membership drift",
|
||
);
|
||
|
||
let string_keys = by_kind.remove("String").unwrap_or_default();
|
||
assert!(
|
||
string_keys.is_empty(),
|
||
"no String-kind settings should remain — `default_model` + `fork_secondary_model` \
|
||
migrated to DynamicEnum; got: {string_keys:?}",
|
||
);
|
||
|
||
let dynamic_enum_keys = by_kind.remove("DynamicEnum").unwrap_or_default();
|
||
assert_eq!(
|
||
dynamic_enum_keys,
|
||
vec!["default_model", "fork_secondary_model",],
|
||
"DynamicEnum kind membership drift",
|
||
);
|
||
|
||
let int_keys = by_kind.remove("Int").unwrap_or_default();
|
||
let mut sorted_int = int_keys.clone();
|
||
sorted_int.sort();
|
||
assert_eq!(
|
||
sorted_int,
|
||
vec!["max_thoughts_width", "scroll_lines", "scroll_speed"],
|
||
"Int kind membership drift (PR 8)",
|
||
);
|
||
|
||
let group_keys = by_kind.remove("Group").unwrap_or_default();
|
||
assert_eq!(
|
||
group_keys,
|
||
vec!["contextual_hints"],
|
||
"Group kind membership drift",
|
||
);
|
||
|
||
// No unexpected kinds.
|
||
assert!(
|
||
by_kind.is_empty(),
|
||
"registry contains unexpected SettingKind variants: {by_kind:?}",
|
||
);
|
||
}
|
||
|
||
/// Pins the set of Enum-kind settings (sorted, order-independent).
|
||
#[test]
|
||
fn enum_settings_membership_through_pr_14() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let mut enum_keys: Vec<&str> = reg
|
||
.all()
|
||
.iter()
|
||
.filter(|m| matches!(m.kind, SettingKind::Enum { .. }))
|
||
.map(|m| m.key)
|
||
.collect();
|
||
enum_keys.sort();
|
||
assert_eq!(
|
||
enum_keys,
|
||
vec![
|
||
"auto_dark_theme",
|
||
"auto_light_theme",
|
||
"coding_data_sharing",
|
||
"default_selected_permission",
|
||
"hunk_tracker_mode",
|
||
"keep_text_selection",
|
||
"permission_mode",
|
||
"plan_mode",
|
||
"render_mermaid",
|
||
"screen_mode",
|
||
"scroll_mode",
|
||
"theme",
|
||
"voice_capture_mode",
|
||
"voice_stt_language",
|
||
],
|
||
);
|
||
}
|
||
|
||
/// `current_value_for` and `default_value_for` must agree at
|
||
/// `UiConfig::default()` with independently hard-coded expectations.
|
||
#[test]
|
||
fn defaults_round_trip_through_registry() {
|
||
use xai_grok_pager::settings::{SettingValue, current_value_for};
|
||
let reg = SettingsRegistry::defaults();
|
||
let ui = UiConfig::default();
|
||
let pager = PagerLocalSnapshot::default();
|
||
|
||
// `current_value_for` for these keys reads process-wide caches, not `ui`.
|
||
// Reset to defaults so a sibling test on this worker thread can't leak in.
|
||
xai_grok_pager::appearance::cache::set_keep_text_selection(
|
||
xai_grok_pager::appearance::TextSelection::Flash,
|
||
);
|
||
xai_grok_pager::appearance::cache::set_show_thinking_blocks(true);
|
||
xai_grok_pager::appearance::cache::set_prompt_suggestions(true);
|
||
xai_grok_pager::appearance::cache::set_group_tool_verbs(true);
|
||
xai_grok_pager::appearance::cache::set_page_flip_on_send(true);
|
||
xai_grok_pager::appearance::cache::set_combine_queued_prompts(false);
|
||
xai_grok_pager::appearance::cache::set_scroll_mode(
|
||
xai_grok_pager::appearance::ScrollMode::Auto,
|
||
);
|
||
xai_grok_pager::appearance::cache::set_invert_scroll(false);
|
||
// 3 = the registry default shown while the profile is in charge.
|
||
xai_grok_pager::appearance::cache::set_scroll_lines(3);
|
||
|
||
// Hard-coded per-key expectations (independent of registry).
|
||
let expected = |key: &str| -> SettingValue {
|
||
match key {
|
||
"compact_mode" => SettingValue::Bool(false),
|
||
"screen_mode" => SettingValue::Enum("fullscreen"),
|
||
"show_timestamps" => SettingValue::Bool(true),
|
||
"show_timeline" => SettingValue::Bool(false),
|
||
"page_flip_on_send" => SettingValue::Bool(true),
|
||
"combine_queued_prompts" => SettingValue::Bool(false),
|
||
"simple_mode" => SettingValue::Bool(true),
|
||
"vim_mode" => SettingValue::Bool(false),
|
||
"remember_tool_approvals" => SettingValue::Bool(false),
|
||
"toolset.ask_user_question.timeout_enabled" => SettingValue::Bool(true),
|
||
"keep_text_selection" => SettingValue::Enum("flash"),
|
||
"theme" => SettingValue::Enum("groknight"),
|
||
"auto_dark_theme" => SettingValue::Enum("groknight"),
|
||
"auto_light_theme" => SettingValue::Enum("grokday"),
|
||
"render_mermaid" => SettingValue::Enum("auto"),
|
||
"multiline_mode" => SettingValue::Bool(false),
|
||
"permission_mode" => SettingValue::Enum("ask"),
|
||
"default_model" => SettingValue::String(String::new()),
|
||
"max_thoughts_width" => SettingValue::Int(120),
|
||
"scroll_speed" => SettingValue::Int(50),
|
||
"scroll_mode" => SettingValue::Enum("auto"),
|
||
"scroll_lines" => SettingValue::Int(3),
|
||
"invert_scroll" => SettingValue::Bool(false),
|
||
"display_refresh_auto_cadence" => SettingValue::Bool(false),
|
||
"coding_data_sharing" => SettingValue::Enum("opt-out"),
|
||
"default_selected_permission" => SettingValue::Enum("always_allow_all_sessions"),
|
||
"hunk_tracker_mode" => SettingValue::Enum("agent_only"),
|
||
"voice_keybind_enabled" => SettingValue::Bool(true),
|
||
"voice_capture_mode" => SettingValue::Enum("hold"),
|
||
"voice_stt_language" => SettingValue::Enum("en"),
|
||
"plan_mode" => SettingValue::Enum("off"),
|
||
"show_tips" => SettingValue::Bool(true),
|
||
"auto_update" => SettingValue::Bool(true),
|
||
"fork_secondary_model" => SettingValue::String(String::new()),
|
||
"show_thinking_blocks" => SettingValue::Bool(true),
|
||
"prompt_suggestions" => SettingValue::Bool(true),
|
||
"group_tool_verbs" => SettingValue::Bool(true),
|
||
"collapsed_edit_blocks" => SettingValue::Bool(false),
|
||
"respect_manual_folds" => SettingValue::Bool(false),
|
||
// Per-tip contextual-hint children default ON (inherit → true).
|
||
"contextual_hints.undo" => SettingValue::Bool(true),
|
||
"contextual_hints.plan_mode" => SettingValue::Bool(true),
|
||
"contextual_hints.image_input" => SettingValue::Bool(true),
|
||
"contextual_hints.send_now" => SettingValue::Bool(true),
|
||
"contextual_hints.small_screen" => SettingValue::Bool(true),
|
||
"contextual_hints.word_select" => SettingValue::Bool(true),
|
||
"contextual_hints.ssh_wrap" => SettingValue::Bool(true),
|
||
other => panic!("test must list expected default for `{other}`"),
|
||
}
|
||
};
|
||
|
||
for meta in reg.all() {
|
||
// Group rows carry no scalar value/default to round-trip.
|
||
if matches!(meta.kind, SettingKind::Group { .. }) {
|
||
continue;
|
||
}
|
||
let live_value = current_value_for(meta.key, &ui, &pager)
|
||
.unwrap_or_else(|| panic!("current_value_for(`{}`) returned None", meta.key));
|
||
let default_value = xai_grok_pager::settings::default_value_for(meta);
|
||
let expected_value = expected(meta.key);
|
||
|
||
assert_eq!(
|
||
live_value, expected_value,
|
||
"current_value_for(`{}`) drifted from expected",
|
||
meta.key
|
||
);
|
||
assert_eq!(
|
||
default_value, expected_value,
|
||
"default_value_for(`{}`) drifted from expected",
|
||
meta.key
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Initial modal-open state selects the first setting row.
|
||
#[test]
|
||
fn initial_state_selects_first_setting_row() {
|
||
let s = make_state();
|
||
match &s.rows[s.selected] {
|
||
RowEntry::Setting { key, .. } => assert_eq!(*key, "compact_mode"),
|
||
_ => panic!("initial selection must land on a setting row, not a header"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn settings_value_payload_matches_kind() {
|
||
// Every Bool setting's typed setter carries a Bool value.
|
||
let reg = SettingsRegistry::defaults();
|
||
for meta in reg.all() {
|
||
if !matches!(meta.kind, SettingKind::Bool { .. }) {
|
||
continue;
|
||
}
|
||
// Group children are toggled inside the sub-sheet, not as top-level
|
||
// rows (covered by the group sub-sheet e2e test), so skip navigation.
|
||
if is_group_child(®, meta.key) {
|
||
continue;
|
||
}
|
||
let mut state = make_state();
|
||
navigate_to(&mut state, meta.key);
|
||
let outcome = handle_settings_key(&mut state, &press(KeyCode::Char(' ')));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetCompactMode(_))
|
||
| SettingsKeyOutcome::Action(Action::SetTimestamps(_))
|
||
| SettingsKeyOutcome::Action(Action::SetTimeline(_))
|
||
| SettingsKeyOutcome::Action(Action::SetPageFlipOnSend(_))
|
||
| SettingsKeyOutcome::Action(Action::SetCombineQueuedPrompts(_))
|
||
| SettingsKeyOutcome::Action(Action::SetSimpleMode(_))
|
||
| SettingsKeyOutcome::Action(Action::SetMultilineMode(_))
|
||
| SettingsKeyOutcome::Action(Action::SetVimMode(_))
|
||
| SettingsKeyOutcome::Action(Action::SetRememberToolApprovals(_))
|
||
| SettingsKeyOutcome::Action(Action::SetAskUserQuestionTimeoutEnabled(_))
|
||
| SettingsKeyOutcome::Action(Action::SetShowTips(_))
|
||
| SettingsKeyOutcome::Action(Action::SetAutoUpdate(_))
|
||
| SettingsKeyOutcome::Action(Action::SetRespectManualFolds(_))
|
||
| SettingsKeyOutcome::Action(Action::SetShowThinkingBlocks(_))
|
||
| SettingsKeyOutcome::Action(Action::SetPromptSuggestions(_))
|
||
| SettingsKeyOutcome::Action(Action::SetGroupToolVerbs(_))
|
||
| SettingsKeyOutcome::Action(Action::SetCollapsedEditBlocks(_))
|
||
| SettingsKeyOutcome::Action(Action::SetInvertScroll(_))
|
||
| SettingsKeyOutcome::Action(Action::SetDisplayRefreshAutoCadence(_))
|
||
| SettingsKeyOutcome::Action(Action::SetVoiceKeybindEnabled(_)) => {}
|
||
other => panic!(
|
||
"expected a typed bool setter for `{}`, got {:?}",
|
||
meta.key, other
|
||
),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `SettingValue` variants with same payload must not compare equal.
|
||
#[test]
|
||
fn setting_value_variants_are_distinct() {
|
||
let b = SettingValue::Bool(true);
|
||
let e = SettingValue::Enum("true");
|
||
let s = SettingValue::String("true".to_string());
|
||
let i = SettingValue::Int(1);
|
||
assert_ne!(b, e);
|
||
assert_ne!(b, s);
|
||
assert_ne!(b, i);
|
||
assert_ne!(e, s);
|
||
assert_ne!(e, i);
|
||
assert_ne!(s, i);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// KeyEventKind filtering
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Release events are dropped (kitty-keyboard protocol parity).
|
||
#[test]
|
||
fn release_event_kind_is_dropped() {
|
||
let mut s = make_state();
|
||
let key = KeyEvent {
|
||
code: KeyCode::Char(' '),
|
||
modifiers: KeyModifiers::NONE,
|
||
kind: KeyEventKind::Release,
|
||
state: crossterm::event::KeyEventState::NONE,
|
||
};
|
||
let outcome = handle_settings_key(&mut s, &key);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||
"Release event must produce Unchanged, got: {outcome:?}"
|
||
);
|
||
}
|
||
|
||
/// Repeat Space is dropped (prevents disk thrash from held key).
|
||
#[test]
|
||
fn repeat_space_is_dropped_no_disk_thrash() {
|
||
let mut s = make_state();
|
||
let key = KeyEvent {
|
||
code: KeyCode::Char(' '),
|
||
modifiers: KeyModifiers::NONE,
|
||
kind: KeyEventKind::Repeat,
|
||
state: crossterm::event::KeyEventState::NONE,
|
||
};
|
||
let outcome = handle_settings_key(&mut s, &key);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||
"Repeat on Space must not dispatch an Action, got: {outcome:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn repeat_enter_is_dropped() {
|
||
let mut s = make_state();
|
||
let key = KeyEvent {
|
||
code: KeyCode::Enter,
|
||
modifiers: KeyModifiers::NONE,
|
||
kind: KeyEventKind::Repeat,
|
||
state: crossterm::event::KeyEventState::NONE,
|
||
};
|
||
let outcome = handle_settings_key(&mut s, &key);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
|
||
}
|
||
|
||
/// Repeat on j (navigation) is allowed — held arrow keys feel snappy.
|
||
#[test]
|
||
fn repeat_j_navigation_is_processed() {
|
||
let mut s = make_state();
|
||
let key = KeyEvent {
|
||
code: KeyCode::Char('j'),
|
||
modifiers: KeyModifiers::NONE,
|
||
kind: KeyEventKind::Repeat,
|
||
state: crossterm::event::KeyEventState::NONE,
|
||
};
|
||
let outcome = handle_settings_key(&mut s, &key);
|
||
// From the initial state (compact_mode), Repeat j advances to the next
|
||
// Appearance row: screen_mode.
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
match &s.rows[s.selected] {
|
||
RowEntry::Setting { key, .. } => assert_eq!(*key, "screen_mode"),
|
||
_ => panic!("expected setting row after Repeat j"),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// d-key reset-to-default
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// `d` dispatches `Action::OpenResetConfirm` on the focused row.
|
||
#[test]
|
||
fn d_key_emits_open_reset_confirm_action_for_compact_mode() {
|
||
let mut s = make_state();
|
||
// Default selection lands on `compact_mode`.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char('d')));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::OpenResetConfirm { key }) => {
|
||
assert_eq!(key, "compact_mode");
|
||
}
|
||
other => panic!("expected Action::OpenResetConfirm, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `d` fires `OpenResetConfirm` with the right key for every setting.
|
||
#[test]
|
||
fn d_key_emits_open_reset_confirm_for_every_setting() {
|
||
let reg = SettingsRegistry::defaults();
|
||
for meta in reg.all() {
|
||
// Group rows have no scalar value to reset (consistent with the registry
|
||
// reset-arm coverage test), and their children are hidden from the
|
||
// top-level list — neither is `d`-resettable directly.
|
||
if matches!(meta.kind, SettingKind::Group { .. }) || is_group_child(®, meta.key) {
|
||
continue;
|
||
}
|
||
let mut s = make_state();
|
||
// Some rows are terminal-gated (e.g. `voice_capture_mode` is hidden
|
||
// without key-release reporting, which tests run without). Skip settings
|
||
// with no visible row; their reset path is covered by the dispatch
|
||
// round-trip tests.
|
||
let has_row = s
|
||
.rows
|
||
.iter()
|
||
.any(|r| matches!(r, RowEntry::Setting { key, .. } if *key == meta.key));
|
||
if !has_row {
|
||
continue;
|
||
}
|
||
navigate_to(&mut s, meta.key);
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char('d')));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::OpenResetConfirm { key }) => {
|
||
assert_eq!(
|
||
key, meta.key,
|
||
"d on `{}` must target `{}`",
|
||
meta.key, meta.key
|
||
);
|
||
}
|
||
other => panic!(
|
||
"expected Action::OpenResetConfirm for `{}`, got {other:?}",
|
||
meta.key
|
||
),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `d` on a header row is a no-op.
|
||
#[test]
|
||
fn d_key_on_header_row_is_unchanged() {
|
||
let mut s = make_state();
|
||
let header_idx = s
|
||
.rows
|
||
.iter()
|
||
.position(|r| {
|
||
matches!(
|
||
r,
|
||
RowEntry::Header {
|
||
category: SettingCategory::Appearance
|
||
}
|
||
)
|
||
})
|
||
.expect("Appearance header must exist");
|
||
s.selected = header_idx;
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char('d')));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||
"d on header row must be Unchanged, got: {outcome:?}",
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mouse hit-rect edge cases
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Click with empty `row_rects` (partial render) is a no-op.
|
||
#[test]
|
||
fn mouse_click_inside_list_with_empty_row_rects_is_no_op() {
|
||
let mut s = make_state();
|
||
s.list_area = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 80,
|
||
height: 10,
|
||
};
|
||
// No row_rects populated — simulates a partial render where the
|
||
// chrome drew but row layout was aborted.
|
||
s.row_rects.clear();
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
5,
|
||
5,
|
||
);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
|
||
}
|
||
|
||
/// Scroll-down at last row returns `Unchanged`.
|
||
#[test]
|
||
fn scroll_down_at_last_row_is_unchanged() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
// Move to the last selectable row first.
|
||
let last_idx = s
|
||
.rows
|
||
.iter()
|
||
.rposition(|r| matches!(r, RowEntry::Setting { .. }))
|
||
.unwrap();
|
||
s.selected = last_idx;
|
||
let outcome = handle_settings_mouse(&mut s, MouseEventKind::ScrollDown, 5, 1);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||
"scroll-down at last row should be Unchanged, got: {outcome:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stub tests — `#[ignore] + unimplemented!()` until wired up.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Multi-word AND filter narrows to matching settings + section headers.
|
||
#[test]
|
||
fn pr2_filter_matches_multi_word_and() {
|
||
let mut s = make_state();
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char('/')));
|
||
for c in "compact density".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(c)));
|
||
}
|
||
let filtered = s.filtered_indices();
|
||
assert_eq!(
|
||
filtered.len(),
|
||
2,
|
||
"expected exactly 2 visible rows (Appearance header + compact_mode), \
|
||
got {filtered:?}"
|
||
);
|
||
// First entry: Appearance section header.
|
||
match &s.rows[filtered[0]] {
|
||
RowEntry::Header { category } => assert_eq!(*category, SettingCategory::Appearance),
|
||
other => panic!("expected Header at filtered[0], got {other:?}"),
|
||
}
|
||
// Second entry: compact_mode setting.
|
||
match &s.rows[filtered[1]] {
|
||
RowEntry::Setting { key, .. } => assert_eq!(*key, "compact_mode"),
|
||
other => panic!("expected Setting at filtered[1], got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Enum chooser sub-mode: Esc inside `PickingEnum` reverts
|
||
/// to the original value AND transitions back to Browse. This e2e
|
||
/// exercises the FULL production path:
|
||
///
|
||
/// Browse (synthetic Enum row focused)
|
||
/// → Enter → try_enter_picking_enum() → PickingEnum
|
||
/// → Down → choices_idx 0 → 1 (preview dispatch)
|
||
/// → Esc → action_for_enum(key, original) → Browse
|
||
///
|
||
/// Unlike a version that set `state.mode = PickingEnum {...}`
|
||
/// directly, this proves the *production* entry path
|
||
/// (`handle_browse::Enter` → `try_enter_picking_enum`) — the only
|
||
/// place `try_enter_picking_enum` is reachable in production code.
|
||
///
|
||
/// This verifies the *structural* outcome (mode + Changed); the
|
||
/// Action variant assertion lands once
|
||
/// `action_for_enum("theme", _)` ships.
|
||
#[test]
|
||
fn pr3_esc_in_picker_reverts_to_original() {
|
||
// Synthetic Enum registry — `action_for_enum` returns None for
|
||
// this key, so the outcome is structural (Changed + Browse mode)
|
||
// rather than an Action variant assertion.
|
||
let registry = SettingsRegistry::from_entries(vec![SettingMeta {
|
||
key: "test_enum",
|
||
category: SettingCategory::Appearance,
|
||
owner: SettingOwner::Shared,
|
||
label: "Test enum",
|
||
description: "Synthetic Enum entry for PR 3 picker revert path.",
|
||
keywords: &["test"],
|
||
kind: SettingKind::Enum {
|
||
default: "first",
|
||
choices: &[
|
||
EnumChoice {
|
||
canonical: "first",
|
||
display: "First",
|
||
description: "First option.",
|
||
},
|
||
EnumChoice {
|
||
canonical: "second",
|
||
display: "Second",
|
||
description: "Second option.",
|
||
},
|
||
],
|
||
supports_preview: true,
|
||
},
|
||
restart_required: false,
|
||
hidden_in_minimal: false,
|
||
}]);
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(registry),
|
||
UiConfig::default(),
|
||
PagerLocalSnapshot::default(),
|
||
);
|
||
|
||
// Sanity: modal starts in Browse on the synthetic Enum row.
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
match &s.rows[s.selected] {
|
||
RowEntry::Setting { key, .. } => assert_eq!(*key, "test_enum"),
|
||
_ => panic!("initial selection must be the synthetic Enum row"),
|
||
}
|
||
|
||
// Step 1: Enter on the Enum row → try_enter_picking_enum() fires
|
||
// and seeds choices_idx + original_value from the row's current
|
||
// value (None → fallback to first canonical).
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on Enum row should produce Changed, got {outcome:?}"
|
||
);
|
||
match s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
key,
|
||
choices_idx,
|
||
ref original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(key, "test_enum");
|
||
assert_eq!(choices_idx, 0);
|
||
assert_eq!(original_value, &SettingValue::Enum("first"));
|
||
}
|
||
ref other => panic!("expected PickingEnum mode after Enter, got {other:?}"),
|
||
}
|
||
|
||
// Step 2: Down → preview-navigate to choice 1 (live preview
|
||
// dispatch via action_for_enum, returns None here → Changed).
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
match s.mode() {
|
||
SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, 1),
|
||
ref other => panic!("expected PickingEnum mode after Down, got {other:?}"),
|
||
}
|
||
|
||
// Step 3: Esc → revert. action_for_enum returns None (no Enum
|
||
// arms yet), so the outcome is Changed. A later change will tighten
|
||
// the assertion to `SettingsKeyOutcome::Action(Action::SetTheme("first"))`
|
||
// once the theme arm ships.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Esc revert outcome should be Changed (or Action when arms exist), got {outcome:?}"
|
||
);
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Esc must transition the modal back to Browse"
|
||
);
|
||
}
|
||
|
||
/// Full theme picker e2e via the production entry path.
|
||
///
|
||
/// Browse (theme row focused)
|
||
/// → Enter → try_enter_picking_enum() → PickingEnum
|
||
/// → Down → choices_idx default → next, dispatches
|
||
/// `Action::PreviewTheme(...)` (preview-only,
|
||
/// no persist Effect, no toast)
|
||
/// → Up → preview-revert
|
||
/// → Down → preview to next
|
||
/// → Enter → dispatches `Action::SetTheme(current)` COMMIT
|
||
/// (single persist + toast per picker cycle)
|
||
///
|
||
/// The test (a) verifies the
|
||
/// PREVIEW vs COMMIT split (Up/Down emit Preview Actions, Enter
|
||
/// emits a Set/commit Action), and (b) derives expected canonicals
|
||
/// from the registry — a future catalog reorder doesn't break the
|
||
/// test for a non-bug reason.
|
||
#[test]
|
||
fn pr4_theme_preview_and_commit_e2e() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let theme_meta = reg
|
||
.find("theme")
|
||
.expect("registry must contain `theme` for PR 4");
|
||
let (default_canonical, default_idx, choices_count, next_canonical, next_idx) =
|
||
match &theme_meta.kind {
|
||
SettingKind::Enum {
|
||
default, choices, ..
|
||
} => {
|
||
let default_idx = choices
|
||
.iter()
|
||
.position(|c| c.canonical == *default)
|
||
.expect("theme default must exist in choices");
|
||
assert!(
|
||
default_idx + 1 < choices.len(),
|
||
"test requires at least one choice AFTER the default; reorder?"
|
||
);
|
||
let next = choices[default_idx + 1].canonical;
|
||
(*default, default_idx, choices.len(), next, default_idx + 1)
|
||
}
|
||
other => panic!("expected Enum kind for `theme`, got {other:?}"),
|
||
};
|
||
assert!(
|
||
choices_count >= 3,
|
||
"PR 4 test requires ≥3 theme choices, got {choices_count}",
|
||
);
|
||
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "theme");
|
||
match &s.rows[s.selected] {
|
||
RowEntry::Setting { key, .. } => assert_eq!(*key, "theme"),
|
||
_ => panic!("expected to land on theme row"),
|
||
}
|
||
|
||
// Enter on Enum row → PickingEnum, seeded to the default.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on theme row should transition to PickingEnum, got {outcome:?}"
|
||
);
|
||
let original_canonical = match &s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
key,
|
||
choices_idx,
|
||
original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(*key, "theme");
|
||
// choices_idx points at the registry's default (derived
|
||
// dynamically — no hardcoded "1").
|
||
assert_eq!(*choices_idx, default_idx);
|
||
match original_value {
|
||
SettingValue::Enum(s) => *s,
|
||
other => panic!("expected Enum original_value, got {other:?}"),
|
||
}
|
||
}
|
||
ref other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
};
|
||
assert_eq!(original_canonical, default_canonical);
|
||
|
||
// Down → preview-navigate to next choice. The dispatched Action
|
||
// is now a PREVIEW (no persist).
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::PreviewTheme(name)) => {
|
||
assert_eq!(
|
||
name, next_canonical,
|
||
"preview dispatch must carry the canonical of the new focused choice",
|
||
);
|
||
}
|
||
other => panic!("expected Action::PreviewTheme(\"{next_canonical}\"), got {other:?}"),
|
||
}
|
||
match s.mode() {
|
||
SettingsModalMode::PickingEnum { choices_idx, .. } => assert_eq!(choices_idx, next_idx),
|
||
ref other => panic!("expected PickingEnum after Down, got {other:?}"),
|
||
}
|
||
|
||
// Up → preview-revert to default.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Up));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::PreviewTheme(name)) => {
|
||
assert_eq!(name, default_canonical);
|
||
}
|
||
other => panic!("expected Action::PreviewTheme(\"{default_canonical}\"), got {other:?}"),
|
||
}
|
||
|
||
// Down again so commit lands on a non-default canonical.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
|
||
// Enter → COMMIT. Dispatches `Action::SetTheme(current_canonical)`
|
||
// — a typed Action variant carrying the current preview value.
|
||
// The dispatcher's `set_theme` emits Effect::PersistSetting +
|
||
// toast (exercised by the strangler-fig e2e test below).
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetTheme(name)) => {
|
||
assert_eq!(
|
||
name, next_canonical,
|
||
"Enter must commit the current preview, not the original"
|
||
);
|
||
}
|
||
other => panic!("expected Action::SetTheme(\"{next_canonical}\") commit, got {other:?}"),
|
||
}
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Enter commit must return to Browse"
|
||
);
|
||
}
|
||
|
||
// Strangler-fig dispatch-layer tests for the typed Actions are in
|
||
// `crates/codegen/xai-grok-pager/src/app/dispatch.rs::tests` (next to
|
||
// the `set_compact_mode_emits_persist_setting_with_correct_payload`
|
||
// family) — see `set_theme_emits_persist_setting_with_correct_payload`
|
||
// and friends. The dispatch tests live there because the `AppView`
|
||
// test fixture (`test_app_with_agent`) isn't exported across the
|
||
// crate boundary.
|
||
|
||
/// Esc inside the theme picker dispatches a PREVIEW
|
||
/// Action (not a commit) — Esc revert
|
||
/// is a preview-style restore, not a re-persist.
|
||
#[test]
|
||
fn pr4_theme_picker_esc_dispatches_revert_action() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let default_canonical = match ®.find("theme").unwrap().kind {
|
||
SettingKind::Enum { default, .. } => *default,
|
||
_ => panic!("theme must be Enum"),
|
||
};
|
||
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "theme");
|
||
|
||
// Enter PickingEnum.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
|
||
// Preview-navigate so the original/current distinction is visible.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
|
||
// Esc → must dispatch PreviewTheme(original) AND return to Browse.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::PreviewTheme(name)) => {
|
||
assert_eq!(
|
||
name, default_canonical,
|
||
"Esc must carry the original canonical as a PREVIEW (not commit)",
|
||
);
|
||
}
|
||
other => {
|
||
panic!("expected Action::PreviewTheme(\"{default_canonical}\") on Esc, got {other:?}")
|
||
}
|
||
}
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Esc must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// `action_for_enum` (preview) and
|
||
/// `action_for_enum_commit` map every theme-family key to the
|
||
/// matching typed Action variant:
|
||
/// parameterised across keys AND derives expected next-canonical
|
||
/// from the registry (catalog-reorder-resilient).
|
||
///
|
||
/// Also exercises EVERY choice (not just first Down), so
|
||
/// a refactor that routes correctly for choice 0 but breaks for
|
||
/// choice N>0 gets caught.
|
||
#[test]
|
||
fn pr4_picker_dispatches_each_theme_settings_action_variant() {
|
||
let reg = SettingsRegistry::defaults();
|
||
|
||
for key in &["theme", "auto_dark_theme", "auto_light_theme"] {
|
||
let meta = reg
|
||
.find(key)
|
||
.unwrap_or_else(|| panic!("registry missing `{key}`"));
|
||
let (default_idx, choices) = match &meta.kind {
|
||
SettingKind::Enum {
|
||
default, choices, ..
|
||
} => {
|
||
let i = choices
|
||
.iter()
|
||
.position(|c| c.canonical == *default)
|
||
.unwrap();
|
||
(i, *choices)
|
||
}
|
||
_ => panic!("`{key}` must be Enum"),
|
||
};
|
||
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, key);
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
|
||
// Navigate forward through every remaining choice, asserting
|
||
// the variant on each Preview dispatch.
|
||
for (next_idx, choice) in choices.iter().enumerate().skip(default_idx + 1) {
|
||
let expected = choice.canonical;
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
match (*key, outcome) {
|
||
("theme", SettingsKeyOutcome::Action(Action::PreviewTheme(name))) => {
|
||
assert_eq!(name, expected, "theme preview at idx {next_idx}");
|
||
}
|
||
(
|
||
"auto_dark_theme",
|
||
SettingsKeyOutcome::Action(Action::PreviewAutoDarkTheme(name)),
|
||
) => {
|
||
assert_eq!(name, expected, "auto_dark preview at idx {next_idx}");
|
||
}
|
||
(
|
||
"auto_light_theme",
|
||
SettingsKeyOutcome::Action(Action::PreviewAutoLightTheme(name)),
|
||
) => {
|
||
assert_eq!(name, expected, "auto_light preview at idx {next_idx}");
|
||
}
|
||
(k, other) => panic!(
|
||
"Down on `{k}` at idx {next_idx} should dispatch matching Preview Action, got {other:?}"
|
||
),
|
||
}
|
||
}
|
||
|
||
// Enter at the LAST choice → COMMIT Action variant for that
|
||
// canonical.
|
||
let last_canonical = choices.last().unwrap().canonical;
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match (*key, outcome) {
|
||
("theme", SettingsKeyOutcome::Action(Action::SetTheme(name))) => {
|
||
assert_eq!(name, last_canonical);
|
||
}
|
||
("auto_dark_theme", SettingsKeyOutcome::Action(Action::SetAutoDarkTheme(name))) => {
|
||
assert_eq!(name, last_canonical);
|
||
}
|
||
("auto_light_theme", SettingsKeyOutcome::Action(Action::SetAutoLightTheme(name))) => {
|
||
assert_eq!(name, last_canonical);
|
||
}
|
||
(k, other) => panic!(
|
||
"Enter on `{k}` last choice should dispatch matching commit Action, got {other:?}"
|
||
),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mouse-path coverage for the new Enum settings.
|
||
// The `every_registered_setting_is_exercised`
|
||
// test's docstring promises "keyboard test + mouse test" per
|
||
// registered key. Earlier only keyboard tests shipped for the 3 new
|
||
// enums; these tests close that gap.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Clicking on an Enum row in Browse mode selects it without firing
|
||
/// any Action — Enum rows require an explicit Enter to open the
|
||
/// picker (mouse picker-entry is deferred to a future change). The body
|
||
/// click outside the indicator hit-rect (cols 0-4) is a
|
||
/// select-only event.
|
||
#[test]
|
||
fn pr4_mouse_click_on_theme_row_selects_without_emitting_action() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "theme") as u16;
|
||
// Body click (col 20 is well outside the 0-4 indicator hit-rect).
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
20,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first click on theme row should only select (no Action), got {outcome:?}",
|
||
);
|
||
assert_eq!(
|
||
s.selected, row_y as usize,
|
||
"selection must move to theme row"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn pr4_mouse_click_on_auto_dark_theme_row_selects_without_emitting_action() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "auto_dark_theme") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
20,
|
||
row_y,
|
||
);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert_eq!(s.selected, row_y as usize);
|
||
}
|
||
|
||
#[test]
|
||
fn pr4_mouse_click_on_auto_light_theme_row_selects_without_emitting_action() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "auto_light_theme") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
20,
|
||
row_y,
|
||
);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert_eq!(s.selected, row_y as usize);
|
||
}
|
||
|
||
/// In picker mode (PickingEnum), mouse events are no-ops — clicks
|
||
/// don't select picker choices yet. Pins the contract so a
|
||
/// future change that adds click-to-pick must update this test
|
||
/// explicitly.
|
||
#[test]
|
||
fn pr4_mouse_click_in_theme_picker_is_no_op() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "theme");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
synth_rects(&mut s);
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
20,
|
||
5,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||
"picker mode must ignore mouse clicks in PR 4, got {outcome:?}",
|
||
);
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// `multiline_mode` (first PAGER-owned setting)
|
||
//
|
||
// Unlike the SHARED bools (which round-trip through
|
||
// `Effect::PersistSetting` and the shell), `multiline_mode` is
|
||
// PAGER-owned: state lives on `AgentView.multiline_mode`, the modal
|
||
// reads from `PagerLocalSnapshot`, and the dispatcher's
|
||
// `set_multiline_mode` is the single mutation owner. No disk persist,
|
||
// no `Effect`, no toast on the no-op fast path.
|
||
//
|
||
// These tests mirror the keyboard + mouse coverage promised by
|
||
// `ALL_SETTINGS_EXERCISED` — same rigor as `compact_mode` et al.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Keyboard Space on the multiline row dispatches the typed setter
|
||
/// with the inverted snapshot value (default false → true). The modal
|
||
/// builds the bool from `PagerLocalSnapshot.multiline_mode` via the
|
||
/// `current_value_for` arm.
|
||
#[test]
|
||
fn pr5_space_on_multiline_mode_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "multiline_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "multiline_mode", true);
|
||
}
|
||
|
||
/// Enter on the multiline row also toggles (same Bool semantics as
|
||
/// compact_mode / show_timestamps / simple_mode). Pins the contract
|
||
/// that Bool row Enter and Space behave identically across both
|
||
/// SHELL/SHARED and PAGER-owned settings.
|
||
#[test]
|
||
fn pr5_enter_on_multiline_mode_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "multiline_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert_set_bool_action(outcome, "multiline_mode", true);
|
||
}
|
||
|
||
/// Two-stage select-then-toggle for `multiline_mode` mouse path.
|
||
#[test]
|
||
fn pr5_mouse_click_on_multiline_mode_two_stage_toggles() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "multiline_mode") as u16;
|
||
|
||
// First click: select-only (initial focus is on compact_mode).
|
||
// Click at column=10 (outside the indicator hit-rect 0..5).
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first click on a different row body should only select, got: {outcome:?}"
|
||
);
|
||
assert_eq!(
|
||
s.selected, row_y as usize,
|
||
"first click must move selection to multiline_mode row",
|
||
);
|
||
|
||
// Second click on the now-focused row: toggle.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "multiline_mode", true);
|
||
}
|
||
|
||
/// Value-column click on `multiline_mode` toggles in one click.
|
||
#[test]
|
||
fn pr5_mouse_click_on_multiline_indicator_toggles_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "multiline_mode") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "multiline_mode", true);
|
||
}
|
||
|
||
/// Snapshot `multiline_mode: true` → Space dispatches `SetMultilineMode(false)`.
|
||
#[test]
|
||
fn pr5_snapshot_when_on_dispatches_off() {
|
||
let snapshot = PagerLocalSnapshot {
|
||
multiline_mode: true,
|
||
yolo_mode: false,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
snapshot,
|
||
);
|
||
navigate_to(&mut s, "multiline_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "multiline_mode", false);
|
||
}
|
||
|
||
/// `multiline_mode` lives under Editor, not Appearance.
|
||
#[test]
|
||
fn pr5_multiline_mode_renders_under_editor_category() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("multiline_mode")
|
||
.expect("multiline_mode must be registered");
|
||
assert_eq!(
|
||
meta.category,
|
||
SettingCategory::Editor,
|
||
"multiline_mode must live under Editor"
|
||
);
|
||
assert_eq!(
|
||
meta.owner,
|
||
SettingOwner::Pager,
|
||
"multiline_mode must be PAGER-owned"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// permission_mode (security-relevant Enum, no preview)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// `permission_mode` lives under the `Agent` section.
|
||
#[test]
|
||
fn pr6_permission_mode_renders_under_agent_category() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("permission_mode")
|
||
.expect("permission_mode must be registered");
|
||
assert_eq!(
|
||
meta.category,
|
||
SettingCategory::Agent,
|
||
"permission_mode must live under Agent"
|
||
);
|
||
assert_eq!(
|
||
meta.owner,
|
||
SettingOwner::Shell,
|
||
"permission_mode is SHELL-owned (persists via shell + ACP)"
|
||
);
|
||
}
|
||
|
||
/// `permission_mode` must be `supports_preview: false` — toggling
|
||
/// drains the permission queue (irreversible side effect).
|
||
#[test]
|
||
fn pr6_permission_mode_does_not_support_preview() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("permission_mode")
|
||
.expect("permission_mode must be registered");
|
||
match &meta.kind {
|
||
SettingKind::Enum {
|
||
supports_preview, ..
|
||
} => {
|
||
assert!(
|
||
!supports_preview,
|
||
"permission_mode MUST be supports_preview: false — toggling YOLO has \
|
||
irreversible side effects (drains permission_queue) so per-keystroke \
|
||
preview is unsafe",
|
||
);
|
||
}
|
||
other => panic!("expected Enum kind for permission_mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `permission_mode` reads from pager snapshot, not `ui` (live state).
|
||
#[test]
|
||
fn pr6_current_value_for_reads_pager_snapshot() {
|
||
use xai_grok_pager::settings::current_value_for;
|
||
|
||
let ui = UiConfig::default();
|
||
|
||
let off_snap = PagerLocalSnapshot {
|
||
multiline_mode: false,
|
||
yolo_mode: false,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
let on_snap = PagerLocalSnapshot {
|
||
multiline_mode: false,
|
||
yolo_mode: true,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
|
||
assert_eq!(
|
||
current_value_for("permission_mode", &ui, &off_snap),
|
||
Some(SettingValue::Enum("ask")),
|
||
"yolo=false → 'ask'",
|
||
);
|
||
assert_eq!(
|
||
current_value_for("permission_mode", &ui, &on_snap),
|
||
Some(SettingValue::Enum("always-approve")),
|
||
"yolo=true → 'always-approve'",
|
||
);
|
||
|
||
// Defensive: even when `ui.permission_mode` says one thing,
|
||
// the snapshot wins. Pins the LIVE-state-over-disk contract.
|
||
let conflicting_ui = UiConfig {
|
||
permission_mode: Some("ask".into()),
|
||
..UiConfig::default()
|
||
};
|
||
assert_eq!(
|
||
current_value_for("permission_mode", &conflicting_ui, &on_snap),
|
||
Some(SettingValue::Enum("always-approve")),
|
||
"snapshot must win over `ui.permission_mode` when they disagree — \
|
||
the snapshot is the LIVE state, `ui` is the at-startup persisted state",
|
||
);
|
||
}
|
||
|
||
/// Enter on `permission_mode` opens the picker seeded to current state.
|
||
#[test]
|
||
fn pr6_enter_on_permission_mode_row_enters_picking_enum() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "permission_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on permission_mode row must transition to PickingEnum, got {outcome:?}"
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
key,
|
||
original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(*key, "permission_mode");
|
||
assert_eq!(
|
||
original_value,
|
||
&SettingValue::Enum("ask"),
|
||
"default snapshot yolo_mode=false → original 'ask'"
|
||
);
|
||
}
|
||
other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Nav in `permission_mode` picker must NOT dispatch preview Actions
|
||
/// (would drain permission queue on every keystroke).
|
||
#[test]
|
||
fn pr6_permission_mode_picker_nav_does_not_dispatch_preview() {
|
||
// Two-key navigation: open the picker, then exercise both
|
||
// "advance" keys (Down, j) and both "retreat" keys (Up, k).
|
||
// We re-open the picker between key probes so each key starts
|
||
// at a known-position. The test must
|
||
// catch a hypothetical j/k path that bypasses set_picker_idx.
|
||
for nav_key in &[
|
||
KeyCode::Down,
|
||
KeyCode::Char('j'),
|
||
KeyCode::Up,
|
||
KeyCode::Char('k'),
|
||
] {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "permission_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
|
||
// For "retreat" keys (Up/k) at choices_idx=0, the outcome is
|
||
// Unchanged (clamp at first). We pre-navigate down so retreat
|
||
// keys have something to retreat from.
|
||
if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
}
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(*nav_key));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Nav key {nav_key:?} in permission_mode picker MUST NOT dispatch a preview \
|
||
Action — that would persist on every keystroke and drain the \
|
||
permission_queue. Got {outcome:?}",
|
||
);
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
}
|
||
}
|
||
|
||
/// Enter on "always-approve" commits `SetPermissionMode(AlwaysApprove)`.
|
||
#[test]
|
||
fn pr6_permission_mode_picker_enter_dispatches_set_permission_mode_commit() {
|
||
use xai_grok_pager::app::actions::PermissionModeKind;
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("permission_mode").unwrap();
|
||
let choices = match &meta.kind {
|
||
SettingKind::Enum { choices, .. } => *choices,
|
||
_ => panic!("permission_mode must be Enum"),
|
||
};
|
||
let always_idx = choices
|
||
.iter()
|
||
.position(|c| c.canonical == "always-approve")
|
||
.expect("permission_mode must include the 'always-approve' choice");
|
||
let default_canonical = match &meta.kind {
|
||
SettingKind::Enum { default, .. } => *default,
|
||
_ => unreachable!(),
|
||
};
|
||
let initial_idx = choices
|
||
.iter()
|
||
.position(|c| c.canonical == default_canonical)
|
||
.expect("registered default must be present in catalog");
|
||
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "permission_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"),
|
||
"Enter on permission_mode row must open the picker, got {:?}",
|
||
s.mode(),
|
||
);
|
||
// Navigate from the seeded default canonical to "always-approve".
|
||
let steps = always_idx as isize - initial_idx as isize;
|
||
let nav_key = if steps > 0 {
|
||
KeyCode::Down
|
||
} else {
|
||
KeyCode::Up
|
||
};
|
||
for _ in 0..steps.unsigned_abs() {
|
||
let _ = handle_settings_key(&mut s, &press(nav_key));
|
||
}
|
||
// Enter → commit.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetPermissionMode(
|
||
PermissionModeKind::AlwaysApprove,
|
||
)) => {}
|
||
other => panic!(
|
||
"Enter on 'always-approve' must commit Action::SetPermissionMode(AlwaysApprove), \
|
||
got {other:?}"
|
||
),
|
||
}
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Enter commit must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// Esc in non-preview picker returns to Browse without Action.
|
||
#[test]
|
||
fn pr6_permission_mode_picker_esc_does_not_dispatch_action() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "permission_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
// Navigate so the original/current distinction would matter for
|
||
// a preview-supporting Enum.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Esc on non-preview Enum picker must NOT emit an Action — \
|
||
doing so would re-persist on every Esc. Got {outcome:?}"
|
||
);
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Esc must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// Picker seeds at "always-approve" when `yolo_mode: true`.
|
||
#[test]
|
||
fn pr6_picker_seeds_choices_idx_from_pager_snapshot_yolo_true() {
|
||
let snapshot = PagerLocalSnapshot {
|
||
multiline_mode: false,
|
||
yolo_mode: true,
|
||
auto_mode_gate: true,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
snapshot,
|
||
);
|
||
navigate_to(&mut s, "permission_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
let reg = SettingsRegistry::defaults();
|
||
let always_idx = match ®.find("permission_mode").unwrap().kind {
|
||
SettingKind::Enum { choices, .. } => choices
|
||
.iter()
|
||
.position(|c| c.canonical == "always-approve")
|
||
.expect("permission_mode must have 'always-approve' choice"),
|
||
_ => panic!("permission_mode must be Enum"),
|
||
};
|
||
match s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
choices_idx,
|
||
ref original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(
|
||
choices_idx, always_idx,
|
||
"picker must seed at the 'always-approve' index when snapshot says yolo=true"
|
||
);
|
||
assert_eq!(
|
||
original_value,
|
||
&SettingValue::Enum("always-approve"),
|
||
"original_value must match the live snapshot"
|
||
);
|
||
}
|
||
ref other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Exactly 4 canonical choices: {ask, auto, always-approve, default}.
|
||
#[test]
|
||
fn pr6_permission_mode_choices_use_canonical_strings() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("permission_mode").unwrap();
|
||
let canonicals: Vec<&str> = match &meta.kind {
|
||
SettingKind::Enum { choices, .. } => choices.iter().map(|c| c.canonical).collect(),
|
||
_ => panic!("permission_mode must be Enum"),
|
||
};
|
||
assert_eq!(
|
||
canonicals.len(),
|
||
4,
|
||
"permission_mode catalog must be exactly {{ask, auto, always-approve, default}} — adding a \
|
||
choice requires updating action_for_enum_commit, apply_setting_rollback, \
|
||
PermissionModeKind, AND load_permission_mode (PR 11 contract)",
|
||
);
|
||
assert!(
|
||
canonicals.contains(&"auto"),
|
||
"permission_mode must include 'auto' canonical (auto permission mode feature)"
|
||
);
|
||
assert!(
|
||
canonicals.contains(&"ask"),
|
||
"permission_mode must include 'ask' canonical (shell schema)"
|
||
);
|
||
assert!(
|
||
canonicals.contains(&"always-approve"),
|
||
"permission_mode must include 'always-approve' canonical (shell schema)"
|
||
);
|
||
assert!(
|
||
canonicals.contains(&"default"),
|
||
"permission_mode must include 'default' canonical (PR 11 — agent's \
|
||
default permission behavior)"
|
||
);
|
||
}
|
||
|
||
/// Search "yolo" finds exactly `permission_mode`.
|
||
#[test]
|
||
fn pr6_search_yolo_matches_permission_mode() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let hits = reg.search("yolo");
|
||
assert_eq!(
|
||
hits.len(),
|
||
1,
|
||
"search('yolo') must return exactly one result (permission_mode) — \
|
||
found {} results: {:?}",
|
||
hits.len(),
|
||
hits.iter().map(|m| m.key).collect::<Vec<_>>(),
|
||
);
|
||
assert_eq!(
|
||
hits[0].key, "permission_mode",
|
||
"search('yolo') unique result must be permission_mode"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mouse path tests for permission_mode (keyboard ↔ mouse parity)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// First click on unselected `permission_mode` row only selects.
|
||
#[test]
|
||
fn pr6_mouse_click_on_unselected_permission_mode_row_only_selects() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "permission_mode") as u16;
|
||
|
||
// First click: selects only.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first body-click on unselected permission_mode row should only select, got: {outcome:?}",
|
||
);
|
||
assert_eq!(
|
||
s.selected, row_y as usize,
|
||
"selection must move to permission_mode row",
|
||
);
|
||
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"first body-click must NOT enter the picker (two-stage UX), mode is {:?}",
|
||
s.mode(),
|
||
);
|
||
}
|
||
|
||
/// Second click on selected `permission_mode` opens the picker.
|
||
#[test]
|
||
fn pr6_mouse_click_on_selected_permission_mode_row_opens_picker() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "permission_mode") as u16;
|
||
|
||
// First click: select.
|
||
let _ = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
|
||
// Second click on the now-focused row: open the picker.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"second click on focused Enum row must open picker (Changed), got: {outcome:?}",
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => {
|
||
assert_eq!(*key, "permission_mode");
|
||
}
|
||
other => panic!(
|
||
"second click on focused permission_mode row must enter PickingEnum, got {other:?}",
|
||
),
|
||
}
|
||
}
|
||
|
||
/// Value-column click opens picker in one click.
|
||
#[test]
|
||
fn pr6_mouse_click_on_permission_mode_indicator_opens_picker_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "permission_mode") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"value click must open picker in one click, got: {outcome:?}",
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => {
|
||
assert_eq!(*key, "permission_mode");
|
||
}
|
||
other => {
|
||
panic!("value click on permission_mode must enter PickingEnum, got {other:?}",)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// permission_mode 3-state tests (default/ask/always-approve)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Picking "Default" dispatches `SetPermissionMode(Default)`.
|
||
#[test]
|
||
fn pr11_picker_commit_for_default_dispatches_set_permission_mode_default() {
|
||
use xai_grok_pager::app::actions::PermissionModeKind;
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("permission_mode").unwrap();
|
||
let choices = match &meta.kind {
|
||
SettingKind::Enum { choices, .. } => *choices,
|
||
_ => panic!("permission_mode must be Enum"),
|
||
};
|
||
let default_idx = choices
|
||
.iter()
|
||
.position(|c| c.canonical == "default")
|
||
.expect("permission_mode must include the 'default' choice (PR 11)");
|
||
let initial_idx = choices
|
||
.iter()
|
||
.position(|c| c.canonical == "ask")
|
||
.expect("'ask' canonical must be present");
|
||
assert_ne!(
|
||
default_idx, initial_idx,
|
||
"test invariant: 'default' must be a distinct choice from 'ask'"
|
||
);
|
||
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "permission_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"),
|
||
"Enter on permission_mode row must open the picker, got {:?}",
|
||
s.mode(),
|
||
);
|
||
let steps = default_idx as isize - initial_idx as isize;
|
||
let nav_key = if steps > 0 {
|
||
KeyCode::Down
|
||
} else {
|
||
KeyCode::Up
|
||
};
|
||
for _ in 0..steps.unsigned_abs() {
|
||
let _ = handle_settings_key(&mut s, &press(nav_key));
|
||
}
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetPermissionMode(PermissionModeKind::Default)) => {}
|
||
other => panic!(
|
||
"Enter on 'default' must commit Action::SetPermissionMode(Default), got {other:?}"
|
||
),
|
||
}
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Enter commit must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// Picking "Ask" dispatches `SetPermissionMode(Ask)`.
|
||
#[test]
|
||
fn pr11_picker_commit_for_ask_dispatches_set_permission_mode_ask() {
|
||
use xai_grok_pager::app::actions::PermissionModeKind;
|
||
// Set snapshot so the picker opens seeded at "always-approve",
|
||
// then navigate to "ask" to commit a non-default selection.
|
||
let snapshot = PagerLocalSnapshot {
|
||
yolo_mode: true,
|
||
auto_mode_gate: true,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
snapshot,
|
||
);
|
||
navigate_to(&mut s, "permission_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "permission_mode"),
|
||
"Enter on permission_mode row must open the picker, got {:?}",
|
||
s.mode(),
|
||
);
|
||
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("permission_mode").unwrap();
|
||
let choices = match &meta.kind {
|
||
SettingKind::Enum { choices, .. } => *choices,
|
||
_ => panic!("permission_mode must be Enum"),
|
||
};
|
||
let always_idx = choices
|
||
.iter()
|
||
.position(|c| c.canonical == "always-approve")
|
||
.unwrap();
|
||
let ask_idx = choices.iter().position(|c| c.canonical == "ask").unwrap();
|
||
let steps = ask_idx as isize - always_idx as isize;
|
||
let nav_key = if steps > 0 {
|
||
KeyCode::Down
|
||
} else {
|
||
KeyCode::Up
|
||
};
|
||
for _ in 0..steps.unsigned_abs() {
|
||
let _ = handle_settings_key(&mut s, &press(nav_key));
|
||
}
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetPermissionMode(PermissionModeKind::Ask)) => {}
|
||
other => {
|
||
panic!("Enter on 'ask' must commit Action::SetPermissionMode(Ask), got {other:?}")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Returns "default" when `ui.permission_mode == "default"` and yolo=false.
|
||
#[test]
|
||
fn pr11_current_value_for_returns_default_when_ui_says_default() {
|
||
use xai_grok_pager::settings::current_value_for;
|
||
let ui = UiConfig {
|
||
permission_mode: Some("default".into()),
|
||
..UiConfig::default()
|
||
};
|
||
let pager = PagerLocalSnapshot {
|
||
yolo_mode: false,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
assert_eq!(
|
||
current_value_for("permission_mode", &ui, &pager),
|
||
Some(SettingValue::Enum("default")),
|
||
"ui.permission_mode = 'default' + yolo=false → 'default'"
|
||
);
|
||
}
|
||
|
||
/// Live yolo_mode=true overrides ui.permission_mode → "always-approve".
|
||
#[test]
|
||
fn pr11_current_value_for_pager_yolo_overrides_default_canonical() {
|
||
use xai_grok_pager::settings::current_value_for;
|
||
let ui = UiConfig {
|
||
permission_mode: Some("default".into()),
|
||
..UiConfig::default()
|
||
};
|
||
let pager = PagerLocalSnapshot {
|
||
yolo_mode: true,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
assert_eq!(
|
||
current_value_for("permission_mode", &ui, &pager),
|
||
Some(SettingValue::Enum("always-approve")),
|
||
"pager.yolo_mode=true wins over ui.permission_mode='default' — LIVE state precedence",
|
||
);
|
||
}
|
||
|
||
/// yolo=true + ui=None → "always-approve" (--yolo startup baseline).
|
||
#[test]
|
||
fn pr11_current_value_for_yolo_true_with_ui_none_returns_always_approve() {
|
||
use xai_grok_pager::settings::current_value_for;
|
||
let ui = UiConfig {
|
||
permission_mode: None,
|
||
..UiConfig::default()
|
||
};
|
||
let pager = PagerLocalSnapshot {
|
||
yolo_mode: true,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
assert_eq!(
|
||
current_value_for("permission_mode", &ui, &pager),
|
||
Some(SettingValue::Enum("always-approve")),
|
||
"pager.yolo_mode=true + ui.permission_mode=None → 'always-approve' \
|
||
(LIVE-state baseline for `--yolo` startup with no config setting)"
|
||
);
|
||
}
|
||
|
||
/// Non-"default" values with yolo=false fall through to "ask".
|
||
#[test]
|
||
fn pr11_current_value_for_falls_through_to_ask() {
|
||
use xai_grok_pager::settings::current_value_for;
|
||
let pager = PagerLocalSnapshot {
|
||
yolo_mode: false,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
// Explicit "ask" → "ask"
|
||
let ui_ask = UiConfig {
|
||
permission_mode: Some("ask".into()),
|
||
..UiConfig::default()
|
||
};
|
||
assert_eq!(
|
||
current_value_for("permission_mode", &ui_ask, &pager),
|
||
Some(SettingValue::Enum("ask")),
|
||
"explicit 'ask' → 'ask'"
|
||
);
|
||
// None → "ask" (the registry default)
|
||
let ui_none = UiConfig {
|
||
permission_mode: None,
|
||
..UiConfig::default()
|
||
};
|
||
assert_eq!(
|
||
current_value_for("permission_mode", &ui_none, &pager),
|
||
Some(SettingValue::Enum("ask")),
|
||
"None → 'ask' (registry default)"
|
||
);
|
||
// Garbage → "ask" (defensive fallthrough)
|
||
let ui_garbage = UiConfig {
|
||
permission_mode: Some("nonexistent".into()),
|
||
..UiConfig::default()
|
||
};
|
||
assert_eq!(
|
||
current_value_for("permission_mode", &ui_garbage, &pager),
|
||
Some(SettingValue::Enum("ask")),
|
||
"garbage canonical → 'ask' (defensive fallthrough)"
|
||
);
|
||
}
|
||
|
||
/// `PermissionModeKind` canonical strings round-trip.
|
||
#[test]
|
||
fn pr11_permission_mode_kind_canonical_round_trip() {
|
||
use xai_grok_pager::app::actions::PermissionModeKind;
|
||
for kind in [
|
||
PermissionModeKind::Default,
|
||
PermissionModeKind::Ask,
|
||
PermissionModeKind::AlwaysApprove,
|
||
] {
|
||
let canonical = kind.as_canonical();
|
||
let recovered = PermissionModeKind::from_canonical(canonical)
|
||
.unwrap_or_else(|| panic!("from_canonical('{canonical}') must round-trip"));
|
||
assert_eq!(
|
||
recovered, kind,
|
||
"PermissionModeKind::from_canonical(as_canonical({kind:?})) must round-trip"
|
||
);
|
||
}
|
||
// Garbage input → None
|
||
assert!(PermissionModeKind::from_canonical("nonexistent").is_none());
|
||
assert!(PermissionModeKind::from_canonical("").is_none());
|
||
}
|
||
|
||
/// Catalog canonicals match `PermissionModeKind::as_canonical`.
|
||
#[test]
|
||
fn pr11_permission_mode_kind_canonical_strings_match_choices_catalog() {
|
||
use xai_grok_pager::app::actions::PermissionModeKind;
|
||
let catalog_canonicals: std::collections::HashSet<&str> = SettingsRegistry::defaults()
|
||
.find("permission_mode")
|
||
.and_then(|m| match &m.kind {
|
||
SettingKind::Enum { choices, .. } => Some(*choices),
|
||
_ => None,
|
||
})
|
||
.map(|c| c.iter().map(|c| c.canonical).collect())
|
||
.expect("permission_mode must be registered");
|
||
|
||
for kind in [
|
||
PermissionModeKind::Default,
|
||
PermissionModeKind::Ask,
|
||
PermissionModeKind::Auto,
|
||
PermissionModeKind::AlwaysApprove,
|
||
] {
|
||
assert!(
|
||
catalog_canonicals.contains(kind.as_canonical()),
|
||
"catalog must contain `{}` (from PermissionModeKind::{kind:?})",
|
||
kind.as_canonical(),
|
||
);
|
||
}
|
||
assert_eq!(
|
||
catalog_canonicals.len(),
|
||
4,
|
||
"catalog must be exactly {{ask, auto, always-approve, default}} — adding a fifth \
|
||
choice requires adding a PermissionModeKind variant AND updating action_for_enum_commit \
|
||
+ apply_setting_rollback + load_permission_mode + this test (PR 11 contract)",
|
||
);
|
||
}
|
||
|
||
/// Only `AlwaysApprove` projects to `true`.
|
||
#[test]
|
||
fn pr11_permission_mode_kind_is_always_approve_projection() {
|
||
use xai_grok_pager::app::actions::PermissionModeKind;
|
||
assert!(PermissionModeKind::AlwaysApprove.is_always_approve());
|
||
assert!(!PermissionModeKind::Ask.is_always_approve());
|
||
assert!(
|
||
!PermissionModeKind::Default.is_always_approve(),
|
||
"PR 11: Default must project onto yolo=false — it's an alias for Ask at runtime, \
|
||
NOT an alias for AlwaysApprove"
|
||
);
|
||
}
|
||
|
||
// cycle_mode delegation tests live in `dispatch.rs::tests`.
|
||
|
||
// The previous
|
||
// `pr7_d_key_opens_reset_confirmation_modal` was a duplicate of
|
||
// `d_key_emits_open_reset_confirm_action_for_compact_mode` at L1617.
|
||
// Removed to eliminate redundancy — the canonical d→OpenResetConfirm
|
||
// contract is asserted there + by the parameterised
|
||
// `d_key_emits_open_reset_confirm_for_every_setting` test.
|
||
//
|
||
// The full y/n-via-handle_modal_key dispatch path is exercised by
|
||
// the dispatch.rs::tests family
|
||
// (dispatch_confirm_reset_setting_reset_dispatches_typed_setter_for_*
|
||
// + dispatch_confirm_reset_setting_cancel_preserves_modal_state).
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Render-side tests for the reset-confirm overlay.
|
||
//
|
||
// These tests assert that the rendered buffer contains the
|
||
// confirmation prompt text + breadcrumb + y/n shortcuts. Without
|
||
// them, a future change that breaks the overlay's rendering layer
|
||
// would silently regress to "user can't see the dialog".
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// User-feedback follow-up: the reset-confirm overlay applies a
|
||
/// uniform "being reset" dim style to **every cell** of the focused
|
||
/// row's rect — label cells, value cells, AND description cells —
|
||
/// so the visual emphasis is the whole row about to be reset, not
|
||
/// biased toward the description column (which already had the
|
||
/// dimmest fg before the overlay's blend was applied).
|
||
#[test]
|
||
fn reset_overlay_dims_all_rows_except_target() {
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::style::Modifier;
|
||
use xai_grok_pager::views::settings_modal::ResetConfirmOverlay;
|
||
// Set up a state with at least 3 rows visible AND navigate to a
|
||
// specific target (NOT the initially-selected row) so we can
|
||
// assert dim-vs-full-intensity for both target and non-target
|
||
// rows.
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "show_timestamps");
|
||
let target_idx = s.selected;
|
||
assert!(
|
||
target_idx > 0,
|
||
"test setup must select a non-first row so we can sample at least one earlier non-target row"
|
||
);
|
||
|
||
let area = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 120,
|
||
height: 30,
|
||
};
|
||
let mut buf = Buffer::empty(area);
|
||
let overlay = ResetConfirmOverlay {
|
||
prompt: "Reset 'Show timestamps' to default (on)?",
|
||
breadcrumb_suffix: "Reset 'Show timestamps'",
|
||
};
|
||
xai_grok_pager::views::settings_modal::render_settings_modal(
|
||
&mut buf,
|
||
area,
|
||
&mut s,
|
||
false,
|
||
Some(&overlay),
|
||
);
|
||
|
||
// Locate the target row and a non-target row from the rendered
|
||
// row_rects. Pick the row before the target so it's clearly on a
|
||
// different y-line.
|
||
let target_rect = s.row_rects[target_idx];
|
||
let non_target_idx = (0..target_idx)
|
||
.rev()
|
||
.find(|&i| {
|
||
let r = s.row_rects[i];
|
||
r.width > 0 && r.height > 0
|
||
})
|
||
.expect("at least one visible non-target row above the target");
|
||
let non_target_rect = s.row_rects[non_target_idx];
|
||
|
||
assert!(
|
||
target_rect.width > 0 && target_rect.height > 0,
|
||
"target row's hit-rect must be populated by the render path"
|
||
);
|
||
assert!(
|
||
non_target_rect.width > 0 && non_target_rect.height > 0,
|
||
"non-target row's hit-rect must be populated by the render path"
|
||
);
|
||
assert_ne!(
|
||
target_rect.y, non_target_rect.y,
|
||
"target + non-target rows must be on distinct y-lines"
|
||
);
|
||
|
||
// **Spotlight invariant.** The reset overlay applies
|
||
// `Modifier::DIM` to every cell outside the target row's
|
||
// y-range inside the list area, and leaves the target row at
|
||
// full intensity. We assert both sides of that contract:
|
||
// - every cell in the non-target row's rect has DIM
|
||
// - no cell in the target row's rect has DIM
|
||
let has_dim = |x: u16, y: u16| -> bool {
|
||
buf.cell((x, y))
|
||
.map(|c| c.modifier.contains(Modifier::DIM))
|
||
.unwrap_or(false)
|
||
};
|
||
|
||
let mut non_target_dim_count = 0usize;
|
||
for dx in 0..non_target_rect.width {
|
||
if has_dim(non_target_rect.x + dx, non_target_rect.y) {
|
||
non_target_dim_count += 1;
|
||
}
|
||
}
|
||
assert_eq!(
|
||
non_target_dim_count, non_target_rect.width as usize,
|
||
"every cell in the non-target row's rect must carry Modifier::DIM \
|
||
(got {} out of {})",
|
||
non_target_dim_count, non_target_rect.width
|
||
);
|
||
|
||
let mut target_dim_count = 0usize;
|
||
for dx in 0..target_rect.width {
|
||
if has_dim(target_rect.x + dx, target_rect.y) {
|
||
target_dim_count += 1;
|
||
}
|
||
}
|
||
assert_eq!(
|
||
target_dim_count, 0,
|
||
"no cell in the target row's rect may carry Modifier::DIM \
|
||
(got {} dimmed out of {})",
|
||
target_dim_count, target_rect.width
|
||
);
|
||
|
||
// **Action-element invariant.** The prompt row
|
||
// (rendered ABOVE the row list) and the y/n action footer
|
||
// shortcuts must stay at full intensity — they're the entire
|
||
// point of the overlay. Earlier revisions only asserted dim/no-dim
|
||
// inside `list_area`, so a future refactor that widened the dim
|
||
// sweep to include the prompt or the action footer would have
|
||
// silently regressed without test feedback.
|
||
//
|
||
// The prompt row sits at `area.y` (line 0 of the modal's content
|
||
// area). Sample multiple x-positions to defend against a
|
||
// future regression that only dims a sub-region of the prompt
|
||
// line.
|
||
let prompt_y = area.y;
|
||
let mut prompt_dim_count = 0usize;
|
||
for dx in 0..area.width {
|
||
if has_dim(area.x + dx, prompt_y) {
|
||
prompt_dim_count += 1;
|
||
}
|
||
}
|
||
assert_eq!(
|
||
prompt_dim_count, 0,
|
||
"no cell on the prompt row (y={prompt_y}) may carry Modifier::DIM — \
|
||
the prompt is an action element and must stay at full intensity",
|
||
);
|
||
|
||
// The action footer's `y reset` / `n cancel` shortcuts render
|
||
// at the modal's bottom edge. Locate them via the rendered row
|
||
// text and assert no cell on those lines carries DIM.
|
||
let find_row_y = |needle: &str| -> Option<u16> {
|
||
for y in area.y..area.y + area.height {
|
||
let mut row_text = String::new();
|
||
for x in area.x..area.x + area.width {
|
||
if let Some(cell) = buf.cell((x, y)) {
|
||
row_text.push_str(cell.symbol());
|
||
}
|
||
}
|
||
if row_text.contains(needle) {
|
||
return Some(y);
|
||
}
|
||
}
|
||
None
|
||
};
|
||
let action_rows: Vec<u16> = ["reset", "cancel"]
|
||
.iter()
|
||
.filter_map(|n| find_row_y(n))
|
||
.collect();
|
||
assert!(
|
||
!action_rows.is_empty(),
|
||
"reset/cancel action footer must be visible — found neither row",
|
||
);
|
||
for action_y in action_rows {
|
||
let mut action_dim_count = 0usize;
|
||
for dx in 0..area.width {
|
||
if has_dim(area.x + dx, action_y) {
|
||
action_dim_count += 1;
|
||
}
|
||
}
|
||
assert_eq!(
|
||
action_dim_count, 0,
|
||
"no cell on the action footer row (y={action_y}) may carry \
|
||
Modifier::DIM — y/n shortcuts are action elements and must \
|
||
stay at full intensity",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// User-feedback follow-up: the settings modal renders a 1-line
|
||
/// "Ask Grok" tip footer at the bottom of the content area in
|
||
/// Browse, FilterFocused, and PickingEnum modes (always-on tip).
|
||
/// The footer is suppressed in `EditingValue` because the editor
|
||
/// needs every line for input + validation. This pins the
|
||
/// discoverability contract.
|
||
#[test]
|
||
fn docs_footer_renders_for_browse_and_picker() {
|
||
use ratatui::buffer::Buffer;
|
||
let area = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 120,
|
||
height: 30,
|
||
};
|
||
for fixture_label in ["browse", "picker"] {
|
||
let mut s = make_state();
|
||
if fixture_label == "picker" {
|
||
// Navigate to a row with an Enum kind (theme).
|
||
navigate_to(&mut s, "theme");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
}
|
||
let mut buf = Buffer::empty(area);
|
||
xai_grok_pager::views::settings_modal::render_settings_modal(
|
||
&mut buf, area, &mut s, false, None,
|
||
);
|
||
let mut all_text = String::new();
|
||
for y in 0..area.height {
|
||
for x in 0..area.width {
|
||
if let Some(cell) = buf.cell((x, y)) {
|
||
all_text.push_str(cell.symbol());
|
||
}
|
||
}
|
||
all_text.push('\n');
|
||
}
|
||
assert!(
|
||
all_text.contains("Ask Grok"),
|
||
"[{fixture_label}] docs footer (`Ask Grok`) must appear in the rendered modal:\n\
|
||
{all_text}"
|
||
);
|
||
assert!(
|
||
all_text.contains("change theme to grokday"),
|
||
"[{fixture_label}] docs footer must include the example phrasing"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// User-feedback follow-up: expandable rows + restart pill on
|
||
// expand/edit.
|
||
//
|
||
// Right/`l` expands the focused row's description inline below the
|
||
// label line; Left/`h` collapses it. Multiple rows can be expanded
|
||
// simultaneously. The "restart" pill renders only while the row is
|
||
// expanded (change-time feedback is the toast's job).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Helper: render the modal into a sized buffer and return the full
|
||
/// rendered text as a single newline-joined string. Used by the
|
||
/// expand/collapse tests to detect description text in the buffer.
|
||
fn render_modal_to_string(s: &mut SettingsModalState, width: u16, height: u16) -> String {
|
||
use ratatui::buffer::Buffer;
|
||
let area = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width,
|
||
height,
|
||
};
|
||
let mut buf = Buffer::empty(area);
|
||
xai_grok_pager::views::settings_modal::render_settings_modal(&mut buf, area, s, false, None);
|
||
let mut out = String::new();
|
||
for y in 0..area.height {
|
||
for x in 0..area.width {
|
||
if let Some(cell) = buf.cell((x, y)) {
|
||
out.push_str(cell.symbol());
|
||
}
|
||
}
|
||
out.push('\n');
|
||
}
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn right_arrow_expands_focused_row() {
|
||
let mut s = make_state();
|
||
// The default focus is `compact_mode`. Press Right.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Right));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Right on focused row must transition to Changed (expanded), got {outcome:?}"
|
||
);
|
||
assert!(
|
||
s.expanded_keys.contains("compact_mode"),
|
||
"expanded_keys must contain `compact_mode` after Right, got {:?}",
|
||
s.expanded_keys
|
||
);
|
||
|
||
let rendered = render_modal_to_string(&mut s, 120, 30);
|
||
// `compact_mode`'s description starts with "Reduce padding".
|
||
assert!(
|
||
rendered.contains("Reduce padding"),
|
||
"expanded row's description must appear in the rendered modal, got:\n{rendered}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn left_arrow_collapses_focused_row() {
|
||
let mut s = make_state();
|
||
// Pre-expand via Right.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Right));
|
||
assert!(s.expanded_keys.contains("compact_mode"));
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Left));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Left on expanded row must transition to Changed (collapsed), got {outcome:?}"
|
||
);
|
||
assert!(
|
||
!s.expanded_keys.contains("compact_mode"),
|
||
"expanded_keys must NOT contain `compact_mode` after Left, got {:?}",
|
||
s.expanded_keys
|
||
);
|
||
|
||
let rendered = render_modal_to_string(&mut s, 120, 30);
|
||
assert!(
|
||
!rendered.contains("Reduce padding"),
|
||
"collapsed row's description must NOT appear in the rendered modal, got:\n{rendered}"
|
||
);
|
||
}
|
||
|
||
/// Restart-required setting at its registered default, not expanded:
|
||
/// the pill is HIDDEN. User-feedback gate keeps the modal clean for
|
||
/// the common "browsing only" case.
|
||
#[test]
|
||
fn restart_pill_hidden_when_not_expanded_and_not_edited() {
|
||
let mut s = make_state();
|
||
// `show_tips` is restart_required: true and its registered
|
||
// default is `true` (matches the snapshot's None → true fallback
|
||
// in current_value_for). Not expanded → no pill.
|
||
navigate_to(&mut s, "show_tips");
|
||
assert!(!s.expanded_keys.contains("show_tips"));
|
||
|
||
let rendered = render_modal_to_string(&mut s, 120, 30);
|
||
let restart_lines: Vec<&str> = rendered
|
||
.lines()
|
||
.filter(|l| l.contains("Show tips") && l.contains("restart"))
|
||
.collect();
|
||
assert!(
|
||
restart_lines.is_empty(),
|
||
"restart pill must NOT render on at-default + collapsed restart_required row, \
|
||
found: {restart_lines:?}"
|
||
);
|
||
}
|
||
|
||
/// Same setting, after Right → expanded. Pill renders.
|
||
#[test]
|
||
fn restart_pill_visible_when_expanded() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "show_tips");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Right));
|
||
assert!(s.expanded_keys.contains("show_tips"));
|
||
|
||
let rendered = render_modal_to_string(&mut s, 120, 30);
|
||
let restart_lines: Vec<&str> = rendered
|
||
.lines()
|
||
.filter(|l| l.contains("Show tips") && l.contains("restart"))
|
||
.collect();
|
||
assert!(
|
||
!restart_lines.is_empty(),
|
||
"restart pill must render on expanded restart_required row, got:\n{rendered}"
|
||
);
|
||
}
|
||
|
||
/// Edited value (differs from registered default) but collapsed:
|
||
/// NO pill. A collapsed non-default row showing it forever misreads
|
||
/// as "restart pending" — the exact repro a user hit with a
|
||
/// previously-set Off value in a fresh session.
|
||
#[test]
|
||
fn restart_pill_hidden_when_edited_but_collapsed() {
|
||
use xai_grok_pager::settings::{PagerLocalSnapshot, SettingsRegistry};
|
||
use xai_grok_shell::agent::config::UiConfig;
|
||
|
||
// Construct a state where `show_tips` is NOT at its registered
|
||
// default of `true`.
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
PagerLocalSnapshot {
|
||
show_tips: Some(false),
|
||
..PagerLocalSnapshot::default()
|
||
},
|
||
);
|
||
navigate_to(&mut s, "show_tips");
|
||
assert!(!s.expanded_keys.contains("show_tips"));
|
||
|
||
let rendered = render_modal_to_string(&mut s, 120, 30);
|
||
let restart_lines: Vec<&str> = rendered
|
||
.lines()
|
||
.filter(|l| l.contains("Show tips") && l.contains("restart"))
|
||
.collect();
|
||
assert!(
|
||
restart_lines.is_empty(),
|
||
"restart pill must NOT render on edited-but-collapsed row, found: {restart_lines:?}"
|
||
);
|
||
}
|
||
|
||
/// Long descriptions wrap to the modal's content width on multiple
|
||
/// lines. The wrapped output covers the description verbatim
|
||
/// modulo whitespace normalization.
|
||
#[test]
|
||
fn expanded_description_wraps_to_modal_width() {
|
||
let mut s = make_state();
|
||
// `permission_mode`'s description is long enough to wrap at 80
|
||
// cols. Expand and check that the entire description text is
|
||
// present in the buffer.
|
||
//
|
||
// **Width.** The `→ expand` shortcut was added to the
|
||
// Browse footer which can push the footer onto an extra line
|
||
// at narrower widths; we render at 80 cols to keep the full
|
||
// wrapped description visible.
|
||
navigate_to(&mut s, "permission_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Right));
|
||
|
||
// **Height bump.** Each non-first section header
|
||
// earns a 1-line gap above it. With permission_mode focused
|
||
// (Agent & Approval), two such gaps sit between Appearance
|
||
// and the expanded row's wrapped description, which would
|
||
// squeeze the 3rd wrapped line off the bottom at height=30.
|
||
// Render at 34 lines so the existing assertion about
|
||
// "automatically" still holds.
|
||
let rendered = render_modal_to_string(&mut s, 80, 34);
|
||
// Distinctive phrases from the description text:
|
||
assert!(
|
||
rendered.contains("Default") || rendered.contains("default"),
|
||
"wrapped description must include the 'Default uses' phrase"
|
||
);
|
||
assert!(
|
||
rendered.contains("Always") && rendered.contains("automatically"),
|
||
"wrapped description must include the 'Always approve grants all \
|
||
permissions automatically' phrase, got:\n{rendered}"
|
||
);
|
||
}
|
||
|
||
/// Mouse click on the expand-triangle glyph (col 0 of a setting
|
||
/// row) toggles expansion — keyboard ↔ mouse parity for the
|
||
/// new expand affordance.
|
||
#[test]
|
||
fn click_on_expand_glyph_toggles_expansion() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "compact_mode") as u16;
|
||
|
||
// First click on col 0 (triangle) — expand.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
0,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"triangle click must transition to Changed (expanded), got {outcome:?}"
|
||
);
|
||
assert!(
|
||
s.expanded_keys.contains("compact_mode"),
|
||
"triangle click on collapsed row must expand it"
|
||
);
|
||
|
||
// Second click on col 0 — collapse.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
0,
|
||
row_y,
|
||
);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert!(
|
||
!s.expanded_keys.contains("compact_mode"),
|
||
"triangle click on expanded row must collapse it"
|
||
);
|
||
}
|
||
|
||
/// The `l` / `h` vim aliases mirror the Right / Left arrows.
|
||
#[test]
|
||
fn vim_l_h_keys_toggle_expansion() {
|
||
let mut s = make_state();
|
||
// l → expand.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char('l')));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert!(s.expanded_keys.contains("compact_mode"));
|
||
|
||
// h → collapse.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char('h')));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert!(!s.expanded_keys.contains("compact_mode"));
|
||
}
|
||
|
||
/// The confirmation overlay renders the prompt text inline at the
|
||
/// top of the content area — fixes the critical UX bug
|
||
/// where the dialog was invisible.
|
||
#[test]
|
||
fn reset_confirm_overlay_renders_prompt_with_setting_label_and_default() {
|
||
use ratatui::buffer::Buffer;
|
||
use xai_grok_pager::views::settings_modal::ResetConfirmOverlay;
|
||
let mut s = make_state();
|
||
let area = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 120,
|
||
height: 30,
|
||
};
|
||
let mut buf = Buffer::empty(area);
|
||
let overlay = ResetConfirmOverlay {
|
||
prompt: "Reset 'Compact mode' to default (off)?",
|
||
breadcrumb_suffix: "Reset 'Compact mode'",
|
||
};
|
||
xai_grok_pager::views::settings_modal::render_settings_modal(
|
||
&mut buf,
|
||
area,
|
||
&mut s,
|
||
false,
|
||
Some(&overlay),
|
||
);
|
||
let mut all_text = String::new();
|
||
for y in 0..area.height {
|
||
for x in 0..area.width {
|
||
if let Some(cell) = buf.cell((x, y)) {
|
||
all_text.push_str(cell.symbol());
|
||
}
|
||
}
|
||
all_text.push('\n');
|
||
}
|
||
// The prompt text appears in the rendered buffer.
|
||
assert!(
|
||
all_text.contains("Reset 'Compact mode' to default (off)?"),
|
||
"overlay prompt must appear in the rendered buffer:\n{all_text}"
|
||
);
|
||
// The chrome breadcrumb shows the abbreviated form.
|
||
assert!(
|
||
all_text.contains("Reset 'Compact mode'"),
|
||
"breadcrumb suffix must appear in the chrome title:\n{all_text}"
|
||
);
|
||
// The confirmation footer shortcuts are visible.
|
||
assert!(
|
||
all_text.contains("y reset"),
|
||
"y reset shortcut must appear in the rendered buffer:\n{all_text}",
|
||
);
|
||
assert!(
|
||
all_text.contains("n cancel"),
|
||
"n cancel shortcut must appear in the rendered buffer:\n{all_text}",
|
||
);
|
||
}
|
||
|
||
/// Verify the modal helper `reset_confirm_prompt` constructs a
|
||
/// well-formed prompt for each registered setting. Catches a
|
||
/// formatter regression where a registry catalog reorder or a
|
||
/// missing display string would render an empty or garbled prompt.
|
||
#[test]
|
||
fn reset_confirm_prompt_helper_builds_well_formed_string_for_every_setting() {
|
||
use xai_grok_pager::settings::{PagerLocalSnapshot, SettingsRegistry};
|
||
use xai_grok_pager::views::modal::{ActiveModal, ModalConfirmation, reset_confirm_prompt};
|
||
use xai_grok_shell::agent::config::UiConfig;
|
||
let reg = SettingsRegistry::defaults();
|
||
for meta in reg.all() {
|
||
let state = Box::new(SettingsModalState::new(
|
||
Arc::new(reg.clone()),
|
||
UiConfig::default(),
|
||
PagerLocalSnapshot::default(),
|
||
));
|
||
let modal = ActiveModal::ResetSettingsConfirm {
|
||
modal: ModalConfirmation::reset_settings(),
|
||
key: meta.key,
|
||
settings_state: state,
|
||
};
|
||
let prompt = reset_confirm_prompt(&modal).unwrap_or_else(|| {
|
||
panic!(
|
||
"reset_confirm_prompt returned None for `{}` — registry skew",
|
||
meta.key
|
||
)
|
||
});
|
||
assert!(
|
||
prompt.contains(meta.label),
|
||
"prompt for `{}` must contain its label `{}`, got: {prompt:?}",
|
||
meta.key,
|
||
meta.label,
|
||
);
|
||
assert!(
|
||
prompt.starts_with("Reset"),
|
||
"prompt for `{}` must start with 'Reset', got: {prompt:?}",
|
||
meta.key,
|
||
);
|
||
assert!(
|
||
prompt.ends_with("?"),
|
||
"prompt for `{}` must be phrased as a question, got: {prompt:?}",
|
||
meta.key,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// String + Int editor + validators
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Int stepper: Enter opens, Up/Down/Left/Right step+clamp, Enter commits.
|
||
#[test]
|
||
fn pr15_int_stepper_commit_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "max_thoughts_width");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on Int row must transition to EditingValue, got {outcome:?}"
|
||
);
|
||
assert_eq!(
|
||
s.editing_buffer(),
|
||
Some("120"),
|
||
"buffer must seed from default",
|
||
);
|
||
|
||
// Stepper: Up = +5 → 125. Right = +10 → 135. Down x3 = -15
|
||
// → 120. Up x16 = +80 → 200.
|
||
for _ in 0..16 {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Up));
|
||
}
|
||
assert_eq!(s.editing_buffer(), Some("200"));
|
||
|
||
// Enter commits at 200.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetMaxThoughtsWidth(200)) => {}
|
||
other => panic!("expected SetMaxThoughtsWidth(200), got {other:?}"),
|
||
}
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"successful commit must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// `default_model` DynamicEnum picker: Enter opens, catalog rows
|
||
/// dispatch `SetDefaultModel(<ModelId>)` resolved from snapshot.
|
||
#[test]
|
||
fn pr14_default_model_picker_commits_resolved_model_id() {
|
||
let snapshot = PagerLocalSnapshot {
|
||
available_models: vec![
|
||
(
|
||
"Grok 4.5".to_string(),
|
||
agent_client_protocol::ModelId::new(std::sync::Arc::from("grok-4.5")),
|
||
),
|
||
(
|
||
"Grok 3".to_string(),
|
||
agent_client_protocol::ModelId::new(std::sync::Arc::from("grok-3")),
|
||
),
|
||
],
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
snapshot,
|
||
);
|
||
navigate_to(&mut s, "default_model");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on DynamicEnum row must transition to PickingEnum, got {outcome:?}"
|
||
);
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "default_model"),
|
||
"Enter must transition to PickingEnum for default_model"
|
||
);
|
||
|
||
// Walk down past row 0 ("(no override)") to row 1 ("Grok 4.5").
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Down on picker must move the focus, got {outcome:?}"
|
||
);
|
||
|
||
// Enter commits the selected model.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetDefaultModel(id)) => {
|
||
assert_eq!(
|
||
id.0.as_ref(),
|
||
"grok-4.5",
|
||
"committed id must match snapshot"
|
||
);
|
||
}
|
||
other => panic!("expected SetDefaultModel(<id>) on commit, got {other:?}"),
|
||
}
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"successful commit must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// Row-0 "(no override)" dispatches `ClearDefaultModel`.
|
||
#[test]
|
||
fn pr14_default_model_picker_row_zero_commits_clear_action() {
|
||
let snapshot = PagerLocalSnapshot {
|
||
available_models: vec![(
|
||
"Grok 3".to_string(),
|
||
agent_client_protocol::ModelId::new(std::sync::Arc::from("grok-3")),
|
||
)],
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
snapshot,
|
||
);
|
||
navigate_to(&mut s, "default_model");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
// Picker opens with choices_idx at the snapshot's current model,
|
||
// OR at 0 when current_model_name is None. The fixture above
|
||
// leaves current_model_name as None → picker opens on row 0.
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { choices_idx, .. } => {
|
||
assert_eq!(
|
||
*choices_idx, 0,
|
||
"picker must seed at row 0 when no current model is set"
|
||
);
|
||
}
|
||
other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
// Enter on row 0 → ClearDefaultModel.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::ClearDefaultModel) => {}
|
||
other => panic!("expected ClearDefaultModel from row-0 commit, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Mouse click on `default_model` opens picker (keyboard ↔ mouse parity).
|
||
#[test]
|
||
fn pr14_mouse_click_on_dynamic_enum_row_opens_picker() {
|
||
let snapshot = PagerLocalSnapshot {
|
||
available_models: vec![(
|
||
"Grok 3".to_string(),
|
||
agent_client_protocol::ModelId::new(std::sync::Arc::from("grok-3")),
|
||
)],
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
snapshot,
|
||
);
|
||
s.list_area = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 80,
|
||
height: 80,
|
||
};
|
||
s.row_rects.resize(s.rows.len(), Rect::default());
|
||
let row_idx = row_idx_for(&s, "default_model");
|
||
s.row_rects[row_idx] = Rect {
|
||
x: 0,
|
||
y: row_idx as u16,
|
||
width: 80,
|
||
height: 1,
|
||
};
|
||
// First click: select.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
20,
|
||
row_idx as u16,
|
||
);
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
// Second click on the same row: opens picker.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
20,
|
||
row_idx as u16,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"second click on DynamicEnum row must open picker, got {outcome:?}",
|
||
);
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::PickingEnum { key, .. } if key == "default_model"),
|
||
"second click on DynamicEnum row must transition to PickingEnum, got {:?}",
|
||
s.mode(),
|
||
);
|
||
}
|
||
|
||
/// Mouse click on `max_thoughts_width` opens the inline editor.
|
||
#[test]
|
||
fn pr8_mouse_click_on_int_row_opens_editor() {
|
||
let mut s = make_state();
|
||
s.list_area = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 80,
|
||
height: 80,
|
||
};
|
||
s.row_rects.resize(s.rows.len(), Rect::default());
|
||
let row_idx = row_idx_for(&s, "max_thoughts_width");
|
||
s.row_rects[row_idx] = Rect {
|
||
x: 0,
|
||
y: row_idx as u16,
|
||
width: 80,
|
||
height: 1,
|
||
};
|
||
// First click selects.
|
||
let _ = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
20,
|
||
row_idx as u16,
|
||
);
|
||
// Second click opens editor.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
20,
|
||
row_idx as u16,
|
||
);
|
||
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"second click on Int row must be Changed, got {outcome:?}",
|
||
);
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. } if key == "max_thoughts_width"),
|
||
"second click on Int row must transition to EditingValue, got {:?}",
|
||
s.mode(),
|
||
);
|
||
}
|
||
|
||
/// Int stepper: Up/Down ±5, Left/Right ±10, clamped to [min, max].
|
||
#[test]
|
||
fn pr15_int_stepper_up_down_left_right_steps_and_clamps() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "max_thoughts_width");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
// Default 120. Up: 120 + 5 = 125.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Up));
|
||
assert_eq!(s.editing_buffer(), Some("125"));
|
||
// Right: 125 + 10 = 135.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Right));
|
||
assert_eq!(s.editing_buffer(), Some("135"));
|
||
// Down 20x: 135 - 100 = 35 → clamps to min (40).
|
||
for _ in 0..20 {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
}
|
||
assert_eq!(s.editing_buffer(), Some("40"), "must clamp to min");
|
||
// Right 100x: 40 + 1000 = 1040 → clamps to max (500).
|
||
for _ in 0..100 {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Right));
|
||
}
|
||
assert_eq!(s.editing_buffer(), Some("500"), "must clamp to max");
|
||
// Left 100x: 500 - 1000 = -500 → clamps to min (40).
|
||
for _ in 0..100 {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Left));
|
||
}
|
||
assert_eq!(s.editing_buffer(), Some("40"), "Left must clamp to min",);
|
||
}
|
||
|
||
/// Int stepper rejects all text-input keys.
|
||
#[test]
|
||
fn pr15_int_stepper_rejects_text_input_keys() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "max_thoughts_width");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
let initial_buffer = s
|
||
.editing_buffer()
|
||
.expect("expected EditingValue")
|
||
.to_owned();
|
||
assert_eq!(initial_buffer, "120", "buffer seeds from default");
|
||
|
||
let reject_keys = &[
|
||
KeyCode::Char('5'),
|
||
KeyCode::Char('a'),
|
||
KeyCode::Char('-'),
|
||
// Extended reject-set.
|
||
KeyCode::Char(' '), // Space (the Bool-toggle key in Browse mode)
|
||
KeyCode::Char('+'), // Plus (would be a naïve numpad expectation)
|
||
KeyCode::Char('.'), // Decimal point
|
||
KeyCode::Backspace,
|
||
KeyCode::Delete,
|
||
KeyCode::Home,
|
||
KeyCode::End,
|
||
KeyCode::Insert,
|
||
KeyCode::PageUp,
|
||
KeyCode::PageDown,
|
||
];
|
||
for k in reject_keys {
|
||
let outcome = handle_settings_key(&mut s, &press(*k));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||
"Int stepper must reject {k:?} (got {outcome:?})",
|
||
);
|
||
assert_eq!(
|
||
s.editing_buffer(),
|
||
Some(initial_buffer.as_str()),
|
||
"buffer must stay at {initial_buffer:?} after {k:?}",
|
||
);
|
||
}
|
||
|
||
// Ctrl-modifier chords are also rejected.
|
||
use crossterm::event::KeyModifiers;
|
||
let press_with =
|
||
|code: KeyCode, mods: KeyModifiers| crossterm::event::KeyEvent::new(code, mods);
|
||
let outcome = handle_settings_key(
|
||
&mut s,
|
||
&press_with(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||
"Int stepper must reject Ctrl+c (got {outcome:?})",
|
||
);
|
||
assert_eq!(
|
||
s.editing_buffer(),
|
||
Some(initial_buffer.as_str()),
|
||
"buffer must stay unchanged after Ctrl+c",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn int_stepper_rejects_modified_esc_but_plain_esc_cancels() {
|
||
let mut state = make_state();
|
||
navigate_to(&mut state, "max_thoughts_width");
|
||
let _ = handle_settings_key(&mut state, &press(KeyCode::Enter));
|
||
for modifiers in [
|
||
KeyModifiers::ALT,
|
||
KeyModifiers::CONTROL,
|
||
KeyModifiers::SUPER,
|
||
] {
|
||
let outcome = handle_settings_key(&mut state, &press_with(KeyCode::Esc, modifiers));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Unchanged));
|
||
assert!(matches!(
|
||
state.mode(),
|
||
SettingsModalMode::EditingValue { .. }
|
||
));
|
||
assert_eq!(state.editing_buffer(), Some("120"));
|
||
}
|
||
|
||
let outcome = handle_settings_key(&mut state, &press(KeyCode::Esc));
|
||
assert!(matches!(outcome, SettingsKeyOutcome::Changed));
|
||
assert!(matches!(state.mode(), SettingsModalMode::Browse));
|
||
}
|
||
|
||
/// Esc in EditingValue returns to Browse without dispatching.
|
||
#[test]
|
||
fn pr8_esc_in_editing_value_cancels_without_dispatch() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "max_thoughts_width");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
// Type some chars to dirty the buffer.
|
||
for ch in "999".chars() {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Char(ch)));
|
||
}
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Esc in EditingValue must be Changed (mode swap), got {outcome:?}"
|
||
);
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Esc must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// `default_model` and `max_thoughts_width` defaults round-trip
|
||
/// against hard-coded literals.
|
||
#[test]
|
||
fn pr8_default_model_and_max_thoughts_width_defaults_roundtrip() {
|
||
use xai_grok_pager::settings::current_value_for;
|
||
let reg = SettingsRegistry::defaults();
|
||
let ui = UiConfig::default();
|
||
let pager = PagerLocalSnapshot::default();
|
||
|
||
// default_model: registered default is the empty-string sentinel
|
||
// (no UiConfig mirror — cfg.models.default is resolved
|
||
// dynamically). `current_value_for` reads from
|
||
// `pager.current_model_name` which is None by default →
|
||
// `unwrap_or_default()` produces the empty string. Both paths
|
||
// converge on `SettingValue::String("")`.
|
||
let dm_meta = reg.find("default_model").unwrap();
|
||
assert_eq!(
|
||
xai_grok_pager::settings::default_value_for(dm_meta),
|
||
SettingValue::String(String::new()),
|
||
"default_model registered default must be the empty string",
|
||
);
|
||
assert_eq!(
|
||
current_value_for("default_model", &ui, &pager).unwrap(),
|
||
SettingValue::String(String::new()),
|
||
"default_model current_value_for with empty pager snapshot must be empty",
|
||
);
|
||
|
||
// max_thoughts_width: registered default is 120 (matches
|
||
// UiConfig::default()'s DEFAULT_MAX_THOUGHTS_WIDTH constant).
|
||
let mt_meta = reg.find("max_thoughts_width").unwrap();
|
||
assert_eq!(
|
||
xai_grok_pager::settings::default_value_for(mt_meta),
|
||
SettingValue::Int(120),
|
||
"max_thoughts_width registered default must be 120",
|
||
);
|
||
assert_eq!(
|
||
current_value_for("max_thoughts_width", &ui, &pager).unwrap(),
|
||
SettingValue::Int(120),
|
||
"max_thoughts_width current_value_for must be 120 (UiConfig::default())",
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// coding_data_sharing (Privacy Enum, no preview — async ACP)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// `coding_data_sharing` lives under `Privacy`.
|
||
#[test]
|
||
fn pr9_coding_data_sharing_renders_under_privacy_category() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("coding_data_sharing")
|
||
.expect("coding_data_sharing must be registered");
|
||
assert_eq!(
|
||
meta.category,
|
||
SettingCategory::Privacy,
|
||
"coding_data_sharing must live under Privacy"
|
||
);
|
||
assert_eq!(
|
||
meta.owner,
|
||
SettingOwner::Shell,
|
||
"coding_data_sharing is SHELL-owned (auth-metadata-backed, persists via ACP)"
|
||
);
|
||
}
|
||
|
||
/// `coding_data_sharing` must be `supports_preview: false` (async ACP).
|
||
#[test]
|
||
fn pr9_coding_data_sharing_does_not_support_preview() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("coding_data_sharing")
|
||
.expect("coding_data_sharing must be registered");
|
||
match &meta.kind {
|
||
SettingKind::Enum {
|
||
supports_preview, ..
|
||
} => {
|
||
assert!(
|
||
!supports_preview,
|
||
"coding_data_sharing MUST be supports_preview: false — every preview \
|
||
would fire an async ACP round-trip OR commit-on-every-nav, both \
|
||
unacceptable",
|
||
);
|
||
}
|
||
other => panic!("expected Enum kind for coding_data_sharing, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Reads from pager snapshot; inverts `_opt_out` bool.
|
||
#[test]
|
||
fn pr9_current_value_for_reads_pager_snapshot_inverts_opt_out() {
|
||
use xai_grok_pager::settings::current_value_for;
|
||
|
||
let ui = UiConfig::default();
|
||
|
||
let opted_in_snap = PagerLocalSnapshot {
|
||
coding_data_sharing_opt_out: false,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
let opted_out_snap = PagerLocalSnapshot {
|
||
coding_data_sharing_opt_out: true,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
|
||
assert_eq!(
|
||
current_value_for("coding_data_sharing", &ui, &opted_in_snap),
|
||
Some(SettingValue::Enum("opt-in")),
|
||
"opt_out=false → canonical 'opt-in' (user IS sharing data)",
|
||
);
|
||
assert_eq!(
|
||
current_value_for("coding_data_sharing", &ui, &opted_out_snap),
|
||
Some(SettingValue::Enum("opt-out")),
|
||
"opt_out=true → canonical 'opt-out' (user opted OUT of sharing)",
|
||
);
|
||
}
|
||
|
||
/// Enter opens picker seeded to current state.
|
||
#[test]
|
||
fn pr9_enter_on_coding_data_sharing_row_enters_picking_enum() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "coding_data_sharing");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on coding_data_sharing row must transition to PickingEnum, got {outcome:?}"
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
key,
|
||
original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(*key, "coding_data_sharing");
|
||
assert_eq!(
|
||
original_value,
|
||
&SettingValue::Enum("opt-out"),
|
||
"default snapshot opt_out=true → original 'opt-out'"
|
||
);
|
||
}
|
||
other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Nav in picker must NOT dispatch preview (async ACP).
|
||
#[test]
|
||
fn pr9_coding_data_sharing_picker_nav_does_not_dispatch_preview() {
|
||
for nav_key in &[
|
||
KeyCode::Down,
|
||
KeyCode::Char('j'),
|
||
KeyCode::Up,
|
||
KeyCode::Char('k'),
|
||
] {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "coding_data_sharing");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
|
||
// Pre-position so the nav key under test has room to move no matter
|
||
// which choice the registry default opens the picker on (Up needs
|
||
// idx > 0, Down needs idx < last).
|
||
if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
} else {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Up));
|
||
}
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(*nav_key));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Nav key {nav_key:?} in coding_data_sharing picker MUST NOT dispatch a preview \
|
||
Action — that would fire a network round-trip per keystroke. Got {outcome:?}",
|
||
);
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
}
|
||
}
|
||
|
||
/// Enter commits `SetCodingDataSharing { opted_in }` (opt-in→true).
|
||
#[test]
|
||
fn pr9_coding_data_sharing_picker_enter_dispatches_set_commit() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("coding_data_sharing").unwrap();
|
||
let (default_canonical, choices) = match &meta.kind {
|
||
SettingKind::Enum {
|
||
default, choices, ..
|
||
} => (*default, *choices),
|
||
_ => panic!("coding_data_sharing must be Enum"),
|
||
};
|
||
// Resolve "the other" canonical from the registry rather than
|
||
// hardcoding — robust against future catalog additions.
|
||
let other_canonical = choices
|
||
.iter()
|
||
.map(|c| c.canonical)
|
||
.find(|c| *c != default_canonical)
|
||
.expect("coding_data_sharing must have ≥2 choices");
|
||
let expected_opted_in = match other_canonical {
|
||
"opt-in" => true,
|
||
"opt-out" => false,
|
||
_ => panic!("unexpected canonical: {other_canonical:?}"),
|
||
};
|
||
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "coding_data_sharing");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
// Nav to the OTHER choice — direction depends on where the registry
|
||
// default opened the picker, so derive it instead of hardcoding Down.
|
||
let default_idx = choices
|
||
.iter()
|
||
.position(|c| c.canonical == default_canonical)
|
||
.expect("default must be a registry choice");
|
||
let other_idx = choices
|
||
.iter()
|
||
.position(|c| c.canonical == other_canonical)
|
||
.expect("other choice must be in the registry");
|
||
let nav = if other_idx > default_idx {
|
||
KeyCode::Down
|
||
} else {
|
||
KeyCode::Up
|
||
};
|
||
let _ = handle_settings_key(&mut s, &press(nav));
|
||
// Enter → commit.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetCodingDataSharing { opted_in }) => {
|
||
assert_eq!(
|
||
opted_in, expected_opted_in,
|
||
"Enter must commit `{other_canonical}` → SetCodingDataSharing(opted_in={expected_opted_in})"
|
||
);
|
||
}
|
||
other => panic!("expected Action::SetCodingDataSharing commit, got {other:?}"),
|
||
}
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Enter commit must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// Esc in non-preview picker returns to Browse without Action.
|
||
#[test]
|
||
fn pr9_coding_data_sharing_picker_esc_does_not_dispatch_action() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "coding_data_sharing");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Esc on non-preview Enum picker must NOT emit an Action — \
|
||
doing so would fire an ACP round-trip on every Esc. Got {outcome:?}"
|
||
);
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Esc must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// Picker seeds at "opt-out" when `coding_data_sharing_opt_out: true`.
|
||
#[test]
|
||
fn pr9_picker_seeds_choices_idx_from_pager_snapshot_opt_out_true() {
|
||
let snapshot = PagerLocalSnapshot {
|
||
coding_data_sharing_opt_out: true,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
snapshot,
|
||
);
|
||
navigate_to(&mut s, "coding_data_sharing");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
let reg = SettingsRegistry::defaults();
|
||
let opt_out_idx = match ®.find("coding_data_sharing").unwrap().kind {
|
||
SettingKind::Enum { choices, .. } => choices
|
||
.iter()
|
||
.position(|c| c.canonical == "opt-out")
|
||
.expect("coding_data_sharing must have 'opt-out' choice"),
|
||
_ => panic!("coding_data_sharing must be Enum"),
|
||
};
|
||
match s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
choices_idx,
|
||
ref original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(
|
||
choices_idx, opt_out_idx,
|
||
"picker must seed at the 'opt-out' index when snapshot says opt_out=true"
|
||
);
|
||
assert_eq!(
|
||
original_value,
|
||
&SettingValue::Enum("opt-out"),
|
||
"original_value must match the live snapshot"
|
||
);
|
||
}
|
||
ref other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Exactly 2 canonical choices: {opt-in, opt-out}.
|
||
#[test]
|
||
fn pr9_coding_data_sharing_choices_use_canonical_strings() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("coding_data_sharing").unwrap();
|
||
let canonicals: Vec<&str> = match &meta.kind {
|
||
SettingKind::Enum { choices, .. } => choices.iter().map(|c| c.canonical).collect(),
|
||
_ => panic!("coding_data_sharing must be Enum"),
|
||
};
|
||
assert_eq!(
|
||
canonicals.len(),
|
||
2,
|
||
"coding_data_sharing catalog must be exactly {{opt-in, opt-out}} — adding a \
|
||
choice requires updating the action_for_enum_commit arm in \
|
||
views/settings_modal.rs AND the action_for_reset arm in dispatch.rs",
|
||
);
|
||
assert!(
|
||
canonicals.contains(&"opt-in"),
|
||
"coding_data_sharing must include 'opt-in' canonical"
|
||
);
|
||
assert!(
|
||
canonicals.contains(&"opt-out"),
|
||
"coding_data_sharing must include 'opt-out' canonical"
|
||
);
|
||
}
|
||
|
||
/// Search "privacy" finds exactly `coding_data_sharing`.
|
||
#[test]
|
||
fn pr9_search_privacy_matches_coding_data_sharing() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let hits = reg.search("privacy");
|
||
// The category label "Privacy" appears as a header but is not
|
||
// part of `search()`'s haystack (search ignores categories);
|
||
// matches come from the meta's keywords + label + description.
|
||
let hit_keys: Vec<&str> = hits.iter().map(|m| m.key).collect();
|
||
assert_eq!(
|
||
hits.len(),
|
||
1,
|
||
"search('privacy') must return EXACTLY one result (coding_data_sharing). \
|
||
Found {} results: {hit_keys:?}. \
|
||
If this fails because another setting added 'privacy' to its keywords/label/\
|
||
description, decide: (a) is 'privacy' a real keyword for that setting? If yes, \
|
||
loosen this assertion to a presence-only check `hit_keys.contains(&\"coding_data_sharing\")`. \
|
||
(b) If no, remove 'privacy' from the other setting's haystack — search relevance \
|
||
is more important than tag promiscuity.",
|
||
hits.len(),
|
||
);
|
||
assert_eq!(
|
||
hits[0].key, "coding_data_sharing",
|
||
"search('privacy') unique result must be coding_data_sharing"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mouse path tests for coding_data_sharing
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// First click on unselected row only selects.
|
||
#[test]
|
||
fn pr9_mouse_click_on_unselected_coding_data_sharing_row_only_selects() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "coding_data_sharing") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first body-click on unselected coding_data_sharing row should only select, got: {outcome:?}",
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
}
|
||
|
||
/// Second click on selected row opens picker.
|
||
#[test]
|
||
fn pr9_mouse_click_on_selected_coding_data_sharing_row_opens_picker() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "coding_data_sharing") as u16;
|
||
|
||
// First click: select.
|
||
let _ = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
|
||
// Second click on the focused row: open the picker.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"second click on focused Enum row must open picker, got: {outcome:?}",
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => {
|
||
assert_eq!(*key, "coding_data_sharing");
|
||
}
|
||
_ => panic!("second click on focused coding_data_sharing row must enter PickingEnum"),
|
||
}
|
||
}
|
||
|
||
/// Value-column click opens picker in one click.
|
||
#[test]
|
||
fn pr9_mouse_click_on_coding_data_sharing_indicator_opens_picker_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "coding_data_sharing") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"value click must open picker in one click, got: {outcome:?}",
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => {
|
||
assert_eq!(*key, "coding_data_sharing");
|
||
}
|
||
_ => {
|
||
panic!("value click on coding_data_sharing must enter PickingEnum")
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// default_selected_permission (Agent Enum, no preview — SHELL-owned, persists)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// `default_selected_permission` lives under `Agent` and is SHELL-owned.
|
||
#[test]
|
||
fn default_selected_permission_renders_under_agent_category() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("default_selected_permission")
|
||
.expect("default_selected_permission must be registered");
|
||
assert_eq!(
|
||
meta.category,
|
||
SettingCategory::Agent,
|
||
"default_selected_permission must live under Agent"
|
||
);
|
||
assert_eq!(
|
||
meta.owner,
|
||
SettingOwner::Shell,
|
||
"default_selected_permission is SHELL-owned (persists to [ui].default_selected_permission)"
|
||
);
|
||
}
|
||
|
||
/// `default_selected_permission` must be `supports_preview: false` —
|
||
/// permission prompts aren't open in the modal background, so there is
|
||
/// no live preview surface to drive.
|
||
#[test]
|
||
fn default_selected_permission_does_not_support_preview() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("default_selected_permission")
|
||
.expect("default_selected_permission must be registered");
|
||
match &meta.kind {
|
||
SettingKind::Enum {
|
||
supports_preview, ..
|
||
} => {
|
||
assert!(
|
||
!supports_preview,
|
||
"default_selected_permission MUST be supports_preview: false",
|
||
);
|
||
}
|
||
other => panic!("expected Enum kind for default_selected_permission, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `current_value_for` maps `UiConfig::default()` (None on disk) onto the
|
||
/// `always_allow_all_sessions` canonical (the effective default).
|
||
#[test]
|
||
fn default_selected_permission_current_value_defaults_to_always_allow_all_sessions() {
|
||
use xai_grok_pager::settings::current_value_for;
|
||
let ui = UiConfig::default();
|
||
let pager = PagerLocalSnapshot::default();
|
||
assert_eq!(
|
||
current_value_for("default_selected_permission", &ui, &pager),
|
||
Some(SettingValue::Enum("always_allow_all_sessions")),
|
||
"None on disk → canonical 'always_allow_all_sessions' (effective default)",
|
||
);
|
||
}
|
||
|
||
/// Enter opens the picker seeded to the current ("always_allow_all_sessions") value.
|
||
#[test]
|
||
fn default_selected_permission_enter_enters_picking_enum() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "default_selected_permission");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on default_selected_permission row must transition to PickingEnum, got {outcome:?}"
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
key,
|
||
original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(*key, "default_selected_permission");
|
||
assert_eq!(
|
||
original_value,
|
||
&SettingValue::Enum("always_allow_all_sessions"),
|
||
"default UiConfig → original 'always_allow_all_sessions'"
|
||
);
|
||
}
|
||
other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Nav in the picker must NOT dispatch an Action — there is no preview.
|
||
#[test]
|
||
fn default_selected_permission_picker_nav_does_not_dispatch_preview() {
|
||
for nav_key in &[
|
||
KeyCode::Down,
|
||
KeyCode::Char('j'),
|
||
KeyCode::Up,
|
||
KeyCode::Char('k'),
|
||
] {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "default_selected_permission");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
|
||
if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
}
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(*nav_key));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Nav key {nav_key:?} in default_selected_permission picker MUST NOT dispatch an \
|
||
Action (no preview). Got {outcome:?}",
|
||
);
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
}
|
||
}
|
||
|
||
/// Enter commits `SetDefaultSelectedPermission(canonical)` carrying the
|
||
/// selected choice's canonical string, then returns to Browse.
|
||
#[test]
|
||
fn default_selected_permission_picker_enter_dispatches_set_commit() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("default_selected_permission").unwrap();
|
||
let (default_canonical, choices) = match &meta.kind {
|
||
SettingKind::Enum {
|
||
default, choices, ..
|
||
} => (*default, *choices),
|
||
_ => panic!("default_selected_permission must be Enum"),
|
||
};
|
||
// Picker seeds at the current value ("always_allow_all_sessions"); navigate one row down
|
||
// and resolve that choice's canonical from the registry rather than
|
||
// hardcoding it — robust against future catalog reordering.
|
||
let seed_idx = choices
|
||
.iter()
|
||
.position(|c| c.canonical == default_canonical)
|
||
.expect("default canonical must exist in choices");
|
||
let target_canonical = choices
|
||
.get(seed_idx + 1)
|
||
.map(|c| c.canonical)
|
||
.expect("default_selected_permission must have a choice after the default");
|
||
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "default_selected_permission");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
// Nav to the next choice.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
// Enter → commit.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetDefaultSelectedPermission(canonical)) => {
|
||
assert_eq!(
|
||
canonical, target_canonical,
|
||
"Enter must commit the selected canonical via SetDefaultSelectedPermission"
|
||
);
|
||
}
|
||
other => panic!("expected Action::SetDefaultSelectedPermission commit, got {other:?}"),
|
||
}
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Enter commit must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// Esc in the non-preview picker returns to Browse without an Action.
|
||
#[test]
|
||
fn default_selected_permission_picker_esc_does_not_dispatch_action() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "default_selected_permission");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Esc on non-preview Enum picker must NOT emit an Action. Got {outcome:?}"
|
||
);
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Esc must return to Browse"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mouse path tests for default_selected_permission
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// First click on unselected row only selects.
|
||
#[test]
|
||
fn default_selected_permission_mouse_click_on_unselected_row_only_selects() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "default_selected_permission") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first body-click on unselected default_selected_permission row should only select, got: {outcome:?}",
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
}
|
||
|
||
/// Second click on selected row opens picker.
|
||
#[test]
|
||
fn default_selected_permission_mouse_click_on_selected_row_opens_picker() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "default_selected_permission") as u16;
|
||
|
||
// First click: select.
|
||
let _ = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
|
||
// Second click on the focused row: open the picker.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"second click on focused Enum row must open picker, got: {outcome:?}",
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => {
|
||
assert_eq!(*key, "default_selected_permission");
|
||
}
|
||
_ => {
|
||
panic!("second click on focused default_selected_permission row must enter PickingEnum")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Value-column click opens picker in one click.
|
||
#[test]
|
||
fn default_selected_permission_mouse_click_on_indicator_opens_picker_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "default_selected_permission") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"value click must open picker in one click, got: {outcome:?}",
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => {
|
||
assert_eq!(*key, "default_selected_permission");
|
||
}
|
||
_ => {
|
||
panic!("value click on default_selected_permission must enter PickingEnum")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The `/privacy` slash command's argument parser
|
||
/// is case-insensitive and supports a deliberately-pared-down list of
|
||
/// unambiguous-semantic aliases. The unit-level coverage lives in the
|
||
/// slash command module; this e2e test pins the integration contract
|
||
/// (the parser is reachable from the slash command and produces the
|
||
/// expected `Action`).
|
||
///
|
||
/// Ambiguous aliases
|
||
/// (`on/off/true/false/enable/disable`) were DROPPED because they
|
||
/// could be read either as "turn on privacy" (=opt-out) or "turn on
|
||
/// sharing" (=opt-in). For a privacy-critical setting we err on the
|
||
/// side of explicit, unambiguous arguments. The test below verifies
|
||
/// both the accept list AND the reject list.
|
||
#[test]
|
||
fn pr9_privacy_slash_command_parses_aliases() {
|
||
use xai_grok_pager::slash::commands::privacy::parse_privacy_arg;
|
||
|
||
// Canonical names.
|
||
assert_eq!(parse_privacy_arg("opt-in"), Some(true));
|
||
assert_eq!(parse_privacy_arg("opt-out"), Some(false));
|
||
|
||
// Case-insensitive (sample).
|
||
assert_eq!(parse_privacy_arg("Opt-In"), Some(true));
|
||
assert_eq!(parse_privacy_arg("OPT-OUT"), Some(false));
|
||
|
||
// Unambiguous-semantic aliases (pruned list).
|
||
assert_eq!(parse_privacy_arg("in"), Some(true));
|
||
assert_eq!(parse_privacy_arg("out"), Some(false));
|
||
assert_eq!(parse_privacy_arg("share"), Some(true));
|
||
assert_eq!(parse_privacy_arg("private"), Some(false));
|
||
|
||
// Ambiguous aliases MUST be rejected. `/privacy on`
|
||
// could be read as "turn on privacy" (=opt-out, the OPPOSITE of
|
||
// what an earlier mapping returned). For a privacy
|
||
// setting, ambiguity = silent data-exfiltration risk.
|
||
for ambiguous in &["on", "off", "true", "false", "enable", "disable"] {
|
||
assert_eq!(
|
||
parse_privacy_arg(ambiguous),
|
||
None,
|
||
"ambiguous alias `{ambiguous}` MUST be rejected (PR 9 R1, Security Issue 10)",
|
||
);
|
||
}
|
||
|
||
// Unknown.
|
||
assert_eq!(parse_privacy_arg("maybe"), None);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// `plan_mode` (Agent-category Enum, PAGER-owned + ACP-mediated,
|
||
// supports_preview: false)
|
||
//
|
||
// Migrated from the per-Action `Action::EnterPlanMode` (no-description
|
||
// case) to the typed `Action::SetPlanMode(PlanModeKind)` going through
|
||
// the unified `set_plan_mode` dispatch path. The dispatcher is the
|
||
// single source of truth for idempotency, optimistic mutation
|
||
// (`plan_mode_pending`), modal-snapshot refresh, toast, and the
|
||
// `Effect::SetSessionMode` ACP emit.
|
||
//
|
||
// **Why `supports_preview: false`**: toggling fires an ACP
|
||
// `session/set_mode` request that mutates per-agent state and gates
|
||
// tool dispatch. Per-keystroke preview would either fire N round-trips
|
||
// per nav OR commit on every keystroke. Both are unacceptable.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// `plan_mode` lives under the `Agent` section:
|
||
/// pins the category against drift.
|
||
#[test]
|
||
fn pr10_plan_mode_renders_under_agent_category() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("plan_mode").expect("plan_mode must be registered");
|
||
assert_eq!(
|
||
meta.category,
|
||
SettingCategory::Agent,
|
||
"plan_mode must live under Agent"
|
||
);
|
||
assert_eq!(
|
||
meta.owner,
|
||
SettingOwner::Pager,
|
||
"plan_mode is PAGER-owned (per-session, NOT persisted to config.toml; \
|
||
shell drives transitions via ACP CurrentModeUpdate)"
|
||
);
|
||
}
|
||
|
||
/// `plan_mode` is an Enum with `supports_preview: false`. Toggling
|
||
/// fires an ACP `session/set_mode` request; per-keystroke preview
|
||
/// would either fire N round-trips OR commit on every nav.
|
||
#[test]
|
||
fn pr10_plan_mode_does_not_support_preview() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("plan_mode").expect("plan_mode must be registered");
|
||
match &meta.kind {
|
||
SettingKind::Enum {
|
||
supports_preview, ..
|
||
} => {
|
||
assert!(
|
||
!supports_preview,
|
||
"plan_mode MUST be supports_preview: false — preview would \
|
||
fire an ACP round-trip per nav OR commit on every keystroke; \
|
||
both are unacceptable for an agent-mode transition",
|
||
);
|
||
}
|
||
other => panic!("expected Enum kind for plan_mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `current_value_for("plan_mode", _, pager)` reads from the pager
|
||
/// snapshot's `plan_mode_active` field (NOT from `UiConfig` — there
|
||
/// is no such UiConfig field by design; plan mode is per-session
|
||
/// only). Canonical mapping: `true → "on"`, `false → "off"`.
|
||
#[test]
|
||
fn pr10_current_value_for_reads_pager_snapshot() {
|
||
use xai_grok_pager::settings::current_value_for;
|
||
|
||
let ui = UiConfig::default();
|
||
|
||
let off_snap = PagerLocalSnapshot {
|
||
plan_mode_active: false,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
let on_snap = PagerLocalSnapshot {
|
||
plan_mode_active: true,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
|
||
assert_eq!(
|
||
current_value_for("plan_mode", &ui, &off_snap),
|
||
Some(SettingValue::Enum("off")),
|
||
"plan_mode_active=false → canonical 'off' (default state)",
|
||
);
|
||
assert_eq!(
|
||
current_value_for("plan_mode", &ui, &on_snap),
|
||
Some(SettingValue::Enum("on")),
|
||
"plan_mode_active=true → canonical 'on'",
|
||
);
|
||
}
|
||
|
||
/// Enter on the `plan_mode` row → PickingEnum mode (mirroring the
|
||
/// theme/permission_mode/coding_data_sharing picker), seeded to the
|
||
/// canonical of the current live state.
|
||
#[test]
|
||
fn pr10_enter_on_plan_mode_row_enters_picking_enum() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "plan_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on plan_mode row must transition to PickingEnum, got {outcome:?}"
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
key,
|
||
original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(*key, "plan_mode");
|
||
assert_eq!(
|
||
original_value,
|
||
&SettingValue::Enum("off"),
|
||
"default snapshot plan_mode_active=false → original 'off'"
|
||
);
|
||
}
|
||
other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// **Regression test.** Up/Down/j/k nav in the `plan_mode` picker
|
||
/// MUST NOT dispatch a preview Action — that would fire an ACP
|
||
/// round-trip per keystroke (the ACP path is eager). Mirror of
|
||
/// `pr6_permission_mode_picker_nav_does_not_dispatch_preview` and
|
||
/// `pr9_coding_data_sharing_picker_nav_does_not_dispatch_preview`.
|
||
#[test]
|
||
fn pr10_plan_mode_picker_nav_does_not_dispatch_preview() {
|
||
for nav_key in &[
|
||
KeyCode::Down,
|
||
KeyCode::Char('j'),
|
||
KeyCode::Up,
|
||
KeyCode::Char('k'),
|
||
] {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "plan_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
|
||
if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
}
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(*nav_key));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Nav key {nav_key:?} in plan_mode picker MUST NOT dispatch a preview \
|
||
Action — that would fire an ACP round-trip per keystroke. Got {outcome:?}",
|
||
);
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
}
|
||
}
|
||
|
||
/// Enter on the focused picker choice commits via
|
||
/// `Action::SetPlanMode(PlanModeKind)` — the typed setter, not a
|
||
/// preview variant. Pins the canonical-to-PlanModeKind mapping
|
||
/// (on→On, off→Off).
|
||
#[test]
|
||
fn pr10_plan_mode_picker_enter_dispatches_set_commit() {
|
||
use xai_grok_pager::app::actions::PlanModeKind;
|
||
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "plan_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
// Default snapshot has plan_mode_active=false → picker seeds at
|
||
// "off". Down nav moves to "on".
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
// Enter → commit.
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetPlanMode(kind)) => {
|
||
assert_eq!(
|
||
kind,
|
||
PlanModeKind::On,
|
||
"Enter must commit `on` → SetPlanMode(PlanModeKind::On)"
|
||
);
|
||
}
|
||
other => panic!("expected Action::SetPlanMode commit, got {other:?}"),
|
||
}
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Enter commit must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// Esc inside the picker for a non-preview Enum returns to Browse
|
||
/// without dispatching any Action. Mirror of
|
||
/// `pr9_coding_data_sharing_picker_esc_does_not_dispatch_action`.
|
||
/// Since `plan_mode` has no preview, Esc must NOT re-persist.
|
||
#[test]
|
||
fn pr10_plan_mode_picker_esc_does_not_dispatch_action() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "plan_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Esc on non-preview Enum picker must NOT emit an Action — \
|
||
doing so would fire an ACP round-trip on every Esc. Got {outcome:?}"
|
||
);
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Esc must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// Snapshot-driven seeding: `PagerLocalSnapshot { plan_mode_active:
|
||
/// true }` makes Enter on the row open the picker seeded at the
|
||
/// "on" choice.
|
||
#[test]
|
||
fn pr10_picker_seeds_choices_idx_from_pager_snapshot_plan_mode_active() {
|
||
let snapshot = PagerLocalSnapshot {
|
||
plan_mode_active: true,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
snapshot,
|
||
);
|
||
navigate_to(&mut s, "plan_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
let reg = SettingsRegistry::defaults();
|
||
let on_idx = match ®.find("plan_mode").unwrap().kind {
|
||
SettingKind::Enum { choices, .. } => choices
|
||
.iter()
|
||
.position(|c| c.canonical == "on")
|
||
.expect("plan_mode must have 'on' choice"),
|
||
_ => panic!("plan_mode must be Enum"),
|
||
};
|
||
match s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
choices_idx,
|
||
ref original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(
|
||
choices_idx, on_idx,
|
||
"picker must seed at the 'on' index when snapshot says plan_mode_active=true"
|
||
);
|
||
assert_eq!(
|
||
original_value,
|
||
&SettingValue::Enum("on"),
|
||
"original_value must match the live snapshot"
|
||
);
|
||
}
|
||
ref other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// The choices catalog includes EXACTLY "off" and "on" with canonical
|
||
/// strings matching the dispatcher contract. Pins the canonical
|
||
/// contract — anything else here breaks the `action_for_enum_commit`
|
||
/// arm in `views/settings_modal.rs`.
|
||
#[test]
|
||
fn pr10_plan_mode_choices_use_canonical_strings() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("plan_mode").unwrap();
|
||
let canonicals: Vec<&str> = match &meta.kind {
|
||
SettingKind::Enum { choices, .. } => choices.iter().map(|c| c.canonical).collect(),
|
||
_ => panic!("plan_mode must be Enum"),
|
||
};
|
||
assert_eq!(
|
||
canonicals.len(),
|
||
2,
|
||
"plan_mode catalog must be exactly {{off, on}} — adding a choice requires \
|
||
updating the action_for_enum_commit arm in views/settings_modal.rs AND \
|
||
the action_for_reset arm in dispatch.rs AND PlanModeKind in actions.rs",
|
||
);
|
||
assert!(
|
||
canonicals.contains(&"off"),
|
||
"plan_mode must include 'off' canonical"
|
||
);
|
||
assert!(
|
||
canonicals.contains(&"on"),
|
||
"plan_mode must include 'on' canonical"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mouse path tests for plan_mode (keyboard ↔ mouse parity).
|
||
//
|
||
// Mirrors the permission_mode / coding_data_sharing mouse tests. Every
|
||
// keyboard interaction has a mouse equivalent.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// First mouse-click on a DIFFERENT (non-selected) `plan_mode` row
|
||
/// only SELECTS the row (no picker entry, no Action). Mirrors the
|
||
/// two-stage Bool-row select-then-toggle UX.
|
||
#[test]
|
||
fn pr10_mouse_click_on_unselected_plan_mode_row_only_selects() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "plan_mode") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first body-click on unselected plan_mode row should only select, got: {outcome:?}",
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
}
|
||
|
||
/// Second mouse-click on the selected row opens the picker for the
|
||
/// Enum row — mirroring the keyboard Enter path.
|
||
#[test]
|
||
fn pr10_mouse_click_on_selected_plan_mode_row_opens_picker() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "plan_mode") as u16;
|
||
|
||
// First click: select.
|
||
let _ = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
|
||
// Second click on the focused row: open the picker.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"second click on focused Enum row must open picker, got: {outcome:?}",
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => {
|
||
assert_eq!(*key, "plan_mode");
|
||
}
|
||
_ => panic!("second click on focused plan_mode row must enter PickingEnum"),
|
||
}
|
||
}
|
||
|
||
/// **Value-column layout.** Value-column click on the
|
||
/// plan_mode row opens the picker in ONE click — replaces the
|
||
/// previous left-edge indicator hit-rect.
|
||
#[test]
|
||
fn pr10_mouse_click_on_plan_mode_indicator_opens_picker_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "plan_mode") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"value click must open picker in one click, got: {outcome:?}",
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => {
|
||
assert_eq!(*key, "plan_mode");
|
||
}
|
||
_ => {
|
||
panic!("value click on plan_mode must enter PickingEnum")
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// `render_mermaid` (SHELL-owned Enum, Appearance).
|
||
//
|
||
// Unlike `plan_mode` (PAGER-owned, snapshot-seeded), `render_mermaid` is
|
||
// SHELL-owned: the live value comes from the process-wide cache mirror
|
||
// (`appearance::cache::load_render_mermaid`, default `auto`), mirroring how
|
||
// `vim_mode` reads its cache. The picker commits the typed
|
||
// `Action::SetRenderMermaid(RenderMermaid)` (canonical → enum via
|
||
// `RenderMermaid::from_canonical`). `supports_preview: false`, so picker nav
|
||
// and Esc must never dispatch an Action.
|
||
//
|
||
// These tests honor the `ALL_SETTINGS_EXERCISED` contract — keyboard AND
|
||
// mouse coverage, same rigor as `plan_mode` / `coding_data_sharing`.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// `render_mermaid` lives under `Appearance` and is SHELL-owned (persisted to
|
||
/// `[ui].render_mermaid`). Pins the category + owner against drift.
|
||
#[test]
|
||
fn render_mermaid_renders_under_appearance_category() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("render_mermaid")
|
||
.expect("render_mermaid must be registered");
|
||
assert_eq!(
|
||
meta.category,
|
||
SettingCategory::Appearance,
|
||
"render_mermaid must live under Appearance"
|
||
);
|
||
assert_eq!(
|
||
meta.owner,
|
||
SettingOwner::Shell,
|
||
"render_mermaid is SHELL-owned (persisted to `[ui].render_mermaid`)"
|
||
);
|
||
}
|
||
|
||
/// `render_mermaid` is an Enum with `supports_preview: false` — toggling
|
||
/// flips the render path for every Mermaid block, so per-keystroke preview
|
||
/// would thrash the scrollback. Commit-on-Enter only.
|
||
#[test]
|
||
fn render_mermaid_does_not_support_preview() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("render_mermaid")
|
||
.expect("render_mermaid must be registered");
|
||
match &meta.kind {
|
||
SettingKind::Enum {
|
||
supports_preview, ..
|
||
} => assert!(
|
||
!supports_preview,
|
||
"render_mermaid MUST be supports_preview: false",
|
||
),
|
||
other => panic!("expected Enum kind for render_mermaid, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Enter on the `render_mermaid` row → PickingEnum, seeded to the live cache
|
||
/// value. With a fresh cache (no `[ui].render_mermaid` override) the default
|
||
/// is `auto`.
|
||
#[test]
|
||
fn enter_on_render_mermaid_row_enters_picking_enum() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "render_mermaid");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on render_mermaid row must transition to PickingEnum, got {outcome:?}"
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
key,
|
||
original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(*key, "render_mermaid");
|
||
assert_eq!(
|
||
original_value,
|
||
&SettingValue::Enum("auto"),
|
||
"default cache render_mermaid → original 'auto'"
|
||
);
|
||
}
|
||
other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// **Regression test.** Up/Down/j/k nav in the `render_mermaid` picker MUST
|
||
/// NOT dispatch a preview Action — `supports_preview: false`. Mirror of
|
||
/// `pr10_plan_mode_picker_nav_does_not_dispatch_preview`.
|
||
#[test]
|
||
fn render_mermaid_picker_nav_does_not_dispatch_preview() {
|
||
for nav_key in &[
|
||
KeyCode::Down,
|
||
KeyCode::Char('j'),
|
||
KeyCode::Up,
|
||
KeyCode::Char('k'),
|
||
] {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "render_mermaid");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
|
||
if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
}
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(*nav_key));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Nav key {nav_key:?} in render_mermaid picker MUST NOT dispatch a preview \
|
||
Action. Got {outcome:?}",
|
||
);
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
}
|
||
}
|
||
|
||
/// Enter on the focused picker choice commits via
|
||
/// `Action::SetRenderMermaid(RenderMermaid)` — the typed setter. Default
|
||
/// seed is `auto` (index 0); one Down moves to `on` (index 1). Pins the
|
||
/// canonical-to-RenderMermaid mapping.
|
||
#[test]
|
||
fn render_mermaid_picker_enter_dispatches_set_commit() {
|
||
use xai_grok_pager::appearance::RenderMermaid;
|
||
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "render_mermaid");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
// Fresh cache seeds the picker at "auto"; Down moves to "on".
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetRenderMermaid(kind)) => {
|
||
assert_eq!(
|
||
kind,
|
||
RenderMermaid::On,
|
||
"Enter must commit `on` → SetRenderMermaid(RenderMermaid::On)"
|
||
);
|
||
}
|
||
other => panic!("expected Action::SetRenderMermaid commit, got {other:?}"),
|
||
}
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Enter commit must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// Esc inside the picker for a non-preview Enum returns to Browse without
|
||
/// dispatching any Action. Mirror of
|
||
/// `pr10_plan_mode_picker_esc_does_not_dispatch_action`.
|
||
#[test]
|
||
fn render_mermaid_picker_esc_does_not_dispatch_action() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "render_mermaid");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Esc on non-preview Enum picker must NOT emit an Action. Got {outcome:?}"
|
||
);
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Esc must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// The choices catalog is EXACTLY {auto, on, off} with canonical strings
|
||
/// matching `RenderMermaid::as_canonical` and the `action_for_enum_commit`
|
||
/// arm in `views/settings_modal.rs`.
|
||
#[test]
|
||
fn render_mermaid_choices_use_canonical_strings() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("render_mermaid").unwrap();
|
||
let canonicals: Vec<&str> = match &meta.kind {
|
||
SettingKind::Enum { choices, .. } => choices.iter().map(|c| c.canonical).collect(),
|
||
_ => panic!("render_mermaid must be Enum"),
|
||
};
|
||
assert_eq!(
|
||
canonicals,
|
||
vec!["auto", "on", "off"],
|
||
"render_mermaid catalog must be exactly [auto, on, off] in order — \
|
||
changing it requires updating RenderMermaid::from_canonical AND the \
|
||
action_for_enum_commit arm in views/settings_modal.rs",
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mouse path tests for render_mermaid (keyboard ↔ mouse
|
||
// parity). Mirrors the plan_mode mouse tests.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// First mouse-click on a DIFFERENT (non-selected) `render_mermaid` row only
|
||
/// SELECTS the row (no picker entry, no Action).
|
||
#[test]
|
||
fn mouse_click_on_unselected_render_mermaid_row_only_selects() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "render_mermaid") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first body-click on unselected render_mermaid row should only select, got: {outcome:?}",
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
}
|
||
|
||
/// Second mouse-click on the selected row opens the picker for the Enum row —
|
||
/// mirroring the keyboard Enter path.
|
||
#[test]
|
||
fn mouse_click_on_selected_render_mermaid_row_opens_picker() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "render_mermaid") as u16;
|
||
|
||
let _ = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"second click on focused Enum row must open picker, got: {outcome:?}",
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "render_mermaid"),
|
||
_ => panic!("second click on focused render_mermaid row must enter PickingEnum"),
|
||
}
|
||
}
|
||
|
||
/// Value-column click on the render_mermaid row opens the picker in ONE click.
|
||
#[test]
|
||
fn mouse_click_on_render_mermaid_indicator_opens_picker_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "render_mermaid") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"value click must open picker in one click, got: {outcome:?}",
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "render_mermaid"),
|
||
_ => panic!("value click on render_mermaid must enter PickingEnum"),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// screen_mode (SHELL Enum, Appearance, restart_required, no preview).
|
||
// Catalog [fullscreen, minimal]; product default when unset is fullscreen.
|
||
// Session-only switches stay on /minimal and /fullscreen (do not write config).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Enter on the `screen_mode` row opens the picker seeded at the product
|
||
/// default `fullscreen` (UiConfig.screen_mode is None → canonical fullscreen).
|
||
#[test]
|
||
fn enter_on_screen_mode_row_enters_picking_enum() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "screen_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on screen_mode row must transition to PickingEnum, got {outcome:?}"
|
||
);
|
||
match s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
key,
|
||
original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(key, "screen_mode");
|
||
assert_eq!(
|
||
original_value,
|
||
SettingValue::Enum("fullscreen"),
|
||
"default UiConfig screen_mode=None → original 'fullscreen'"
|
||
);
|
||
}
|
||
other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// **Regression test.** Up/Down/j/k nav in the `screen_mode` picker MUST NOT
|
||
/// dispatch a preview Action — `supports_preview: false` (restart-required).
|
||
#[test]
|
||
fn screen_mode_picker_nav_does_not_dispatch_preview() {
|
||
for nav_key in &[
|
||
KeyCode::Down,
|
||
KeyCode::Char('j'),
|
||
KeyCode::Up,
|
||
KeyCode::Char('k'),
|
||
] {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "screen_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
|
||
if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
}
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(*nav_key));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Nav key {nav_key:?} in screen_mode picker MUST NOT dispatch a preview \
|
||
Action. Got {outcome:?}",
|
||
);
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
}
|
||
}
|
||
|
||
/// Enter on the focused picker choice commits via
|
||
/// `Action::SetScreenMode(String)` carrying the registry canonical. Seed is
|
||
/// `fullscreen` (index 0); one Down moves to `minimal` (index 1).
|
||
#[test]
|
||
fn screen_mode_picker_enter_dispatches_set_commit() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "screen_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetScreenMode(mode)) => {
|
||
assert_eq!(
|
||
mode, "minimal",
|
||
"Enter must commit `minimal` → SetScreenMode(\"minimal\")"
|
||
);
|
||
}
|
||
other => panic!("expected Action::SetScreenMode commit, got {other:?}"),
|
||
}
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Enter commit must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// The choices catalog is EXACTLY {fullscreen, minimal} in order — contract
|
||
/// with `canonical_screen_mode` and the settings UI labels.
|
||
#[test]
|
||
fn screen_mode_choices_use_canonical_strings() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("screen_mode").unwrap();
|
||
let canonicals: Vec<&str> = match &meta.kind {
|
||
SettingKind::Enum { choices, .. } => choices.iter().map(|c| c.canonical).collect(),
|
||
_ => panic!("screen_mode must be Enum"),
|
||
};
|
||
assert_eq!(
|
||
canonicals,
|
||
vec!["fullscreen", "minimal"],
|
||
"screen_mode catalog must be exactly [fullscreen, minimal] in order — \
|
||
changing it requires updating canonical_screen_mode and the chooser",
|
||
);
|
||
match &meta.kind {
|
||
SettingKind::Enum {
|
||
supports_preview, ..
|
||
} => {
|
||
assert!(
|
||
!*supports_preview,
|
||
"screen_mode is restart-required — no live preview"
|
||
);
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
assert!(meta.restart_required, "screen_mode requires restart");
|
||
}
|
||
|
||
/// Value-column click on the screen_mode row opens the picker in ONE click
|
||
/// (mouse ↔ keyboard parity).
|
||
#[test]
|
||
fn mouse_click_on_screen_mode_indicator_opens_picker_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "screen_mode") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"value click must open picker in one click, got: {outcome:?}",
|
||
);
|
||
match s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => assert_eq!(key, "screen_mode"),
|
||
_ => panic!("value click on screen_mode must enter PickingEnum"),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// hunk_tracker_mode (SHELL Enum, Advanced, restart_required, no preview).
|
||
// Catalog [agent_only, all_dirty, off]; `disabled` aliases `off` at parse
|
||
// time. Mirrors the render_mermaid enum tests (keyboard ↔ mouse parity).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Enter on the `hunk_tracker_mode` row opens the picker seeded at the
|
||
/// default `agent_only`.
|
||
#[test]
|
||
fn enter_on_hunk_tracker_mode_row_enters_picking_enum() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "hunk_tracker_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on hunk_tracker_mode row must transition to PickingEnum, got {outcome:?}"
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
key,
|
||
original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(*key, "hunk_tracker_mode");
|
||
assert_eq!(
|
||
original_value,
|
||
&SettingValue::Enum("agent_only"),
|
||
"default UiConfig hunk_tracker_mode → original 'agent_only'"
|
||
);
|
||
}
|
||
other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// **Regression test.** Up/Down/j/k nav in the `hunk_tracker_mode` picker MUST
|
||
/// NOT dispatch a preview Action — `supports_preview: false`. Mirror of
|
||
/// `render_mermaid_picker_nav_does_not_dispatch_preview`.
|
||
#[test]
|
||
fn hunk_tracker_mode_picker_nav_does_not_dispatch_preview() {
|
||
for nav_key in &[
|
||
KeyCode::Down,
|
||
KeyCode::Char('j'),
|
||
KeyCode::Up,
|
||
KeyCode::Char('k'),
|
||
] {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "hunk_tracker_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
|
||
if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
}
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(*nav_key));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Nav key {nav_key:?} in hunk_tracker_mode picker MUST NOT dispatch a preview \
|
||
Action. Got {outcome:?}",
|
||
);
|
||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||
}
|
||
}
|
||
|
||
/// Enter on the focused picker choice commits via
|
||
/// `Action::SetHunkTrackerMode(String)` carrying the registry canonical. Seed
|
||
/// is `agent_only` (index 0); one Down moves to `all_dirty` (index 1). Pins
|
||
/// the canonical-string payload that `action_for_enum_commit` forwards.
|
||
#[test]
|
||
fn hunk_tracker_mode_picker_enter_dispatches_set_commit() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "hunk_tracker_mode");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
// Fresh state seeds the picker at "agent_only"; Down moves to "all_dirty".
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetHunkTrackerMode(mode)) => {
|
||
assert_eq!(
|
||
mode, "all_dirty",
|
||
"Enter must commit `all_dirty` → SetHunkTrackerMode(\"all_dirty\")"
|
||
);
|
||
}
|
||
other => panic!("expected Action::SetHunkTrackerMode commit, got {other:?}"),
|
||
}
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Enter commit must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// The choices catalog is EXACTLY {agent_only, all_dirty, off} in order. The
|
||
/// commit forwards `choice.to_string()` verbatim, so these canonicals are the
|
||
/// contract with the shell-side `canonical_hunk_tracker_mode` parser.
|
||
#[test]
|
||
fn hunk_tracker_mode_choices_use_canonical_strings() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("hunk_tracker_mode").unwrap();
|
||
let canonicals: Vec<&str> = match &meta.kind {
|
||
SettingKind::Enum { choices, .. } => choices.iter().map(|c| c.canonical).collect(),
|
||
_ => panic!("hunk_tracker_mode must be Enum"),
|
||
};
|
||
assert_eq!(
|
||
canonicals,
|
||
vec!["agent_only", "all_dirty", "off"],
|
||
"hunk_tracker_mode catalog must be exactly [agent_only, all_dirty, off] in order — \
|
||
changing it requires updating the shell-side canonical_hunk_tracker_mode parser",
|
||
);
|
||
}
|
||
|
||
/// Value-column click on the hunk_tracker_mode row opens the picker in ONE
|
||
/// click (mouse ↔ keyboard parity).
|
||
#[test]
|
||
fn mouse_click_on_hunk_tracker_mode_indicator_opens_picker_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "hunk_tracker_mode") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"value click must open picker in one click, got: {outcome:?}",
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "hunk_tracker_mode"),
|
||
_ => panic!("value click on hunk_tracker_mode must enter PickingEnum"),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// voice_stt_language (SHELL Enum, Editor)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Enter on the voice_stt_language row opens the picker seeded at the
|
||
/// default `en`.
|
||
#[test]
|
||
fn enter_on_voice_stt_language_row_enters_picking_enum() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "voice_stt_language");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on voice_stt_language row must transition to PickingEnum, got {outcome:?}"
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
key,
|
||
original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(*key, "voice_stt_language");
|
||
assert_eq!(
|
||
original_value,
|
||
&SettingValue::Enum("en"),
|
||
"default UiConfig voice_stt_language → original 'en'"
|
||
);
|
||
}
|
||
other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Enter on a picker choice commits via `Action::SetVoiceSttLanguage(String)`
|
||
/// carrying the canonical code. Seed is `en` (index 0); one Down moves to
|
||
/// `auto` (System).
|
||
#[test]
|
||
fn voice_stt_language_picker_enter_dispatches_set_commit() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "voice_stt_language");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetVoiceSttLanguage(code)) => {
|
||
assert_eq!(code, "auto", "second choice is System (`auto`)");
|
||
}
|
||
other => panic!("expected Action::SetVoiceSttLanguage commit, got {other:?}"),
|
||
}
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::Browse),
|
||
"Enter commit must return to Browse"
|
||
);
|
||
}
|
||
|
||
/// Space-toggle on `voice_keybind_enabled` dispatches the typed setter.
|
||
/// Default is ON (the chord works out of the box), so toggling flips it off.
|
||
#[test]
|
||
fn space_on_voice_keybind_enabled_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "voice_keybind_enabled");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "voice_keybind_enabled", false);
|
||
}
|
||
|
||
/// Value-column click toggles `voice_keybind_enabled` in one click.
|
||
#[test]
|
||
fn mouse_click_on_voice_keybind_enabled_indicator_toggles_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "voice_keybind_enabled") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "voice_keybind_enabled", false);
|
||
}
|
||
|
||
/// Value-column click on the voice_stt_language row opens the picker in ONE
|
||
/// click (mouse ↔ keyboard parity).
|
||
#[test]
|
||
fn mouse_click_on_voice_stt_language_indicator_opens_picker_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "voice_stt_language") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"value click must open picker in one click, got: {outcome:?}",
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "voice_stt_language"),
|
||
_ => panic!("value click on voice_stt_language must enter PickingEnum"),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CLI batch: show_tips, auto_update (SHELL Bool, restart_required)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Space-toggle on `show_tips` dispatches typed setter.
|
||
#[test]
|
||
fn pr13_space_on_show_tips_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "show_tips");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "show_tips", false);
|
||
}
|
||
|
||
#[test]
|
||
fn pr13_space_on_auto_update_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "auto_update");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "auto_update", false);
|
||
}
|
||
|
||
/// Value-column click on `show_tips` toggles in one click.
|
||
#[test]
|
||
fn pr13_mouse_click_on_show_tips_indicator_toggles_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "show_tips") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
72,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "show_tips", false);
|
||
}
|
||
|
||
/// Two-stage select-then-toggle on `auto_update`.
|
||
#[test]
|
||
fn pr13_mouse_click_on_auto_update_two_stage_select_then_toggle() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "auto_update") as u16;
|
||
|
||
// First click: select-only (the focused row was compact_mode).
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first click on a different row body should only select, got: {outcome:?}"
|
||
);
|
||
|
||
// Second click on the SAME row should now toggle.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "auto_update", false);
|
||
}
|
||
|
||
/// CLI-batch settings are all `restart_required: true`.
|
||
#[test]
|
||
fn pr13_cli_batch_all_settings_are_restart_required() {
|
||
let reg = SettingsRegistry::defaults();
|
||
for key in ["show_tips", "auto_update"] {
|
||
let meta = reg
|
||
.find(key)
|
||
.unwrap_or_else(|| panic!("registry must contain `{key}` (PR 13)"));
|
||
assert!(
|
||
meta.restart_required,
|
||
"PR-13 setting `{key}` must have restart_required: true \
|
||
(consumer reads value once at startup; the modal renders \
|
||
the restart pill when this row is expanded)",
|
||
);
|
||
// Sanity: these CLI-batch settings are all SHELL-owned Bool.
|
||
assert_eq!(meta.owner, SettingOwner::Shell);
|
||
assert!(matches!(meta.kind, SettingKind::Bool { .. }));
|
||
}
|
||
}
|
||
|
||
/// CLI-batch defaults round-trip through `current_value_for`.
|
||
#[test]
|
||
fn pr13_cli_batch_defaults_roundtrip_via_current_value_for() {
|
||
use xai_grok_pager::settings::current_value_for;
|
||
let ui = UiConfig::default();
|
||
let pager = PagerLocalSnapshot::default();
|
||
for (key, expected) in [("show_tips", true), ("auto_update", true)] {
|
||
let value = current_value_for(key, &ui, &pager)
|
||
.unwrap_or_else(|| panic!("current_value_for(`{key}`) must resolve"));
|
||
assert_eq!(
|
||
value,
|
||
SettingValue::Bool(expected),
|
||
"PR 13: `{key}` defaults to {expected} (matches the consumer's \
|
||
.unwrap_or(...) at the original read site)",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// CLI-batch settings are discoverable via search.
|
||
#[test]
|
||
fn pr13_cli_batch_settings_are_discoverable_via_search() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let cases = [("tip", "show_tips"), ("auto-update", "auto_update")];
|
||
for (query, expected_key) in cases {
|
||
let hits = reg.search(query);
|
||
assert!(
|
||
hits.iter().any(|m| m.key == expected_key),
|
||
"search(`{query}`) must include `{expected_key}` — hit keys: {:?}",
|
||
hits.iter().map(|m| m.key).collect::<Vec<_>>(),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// fork_secondary_model (DynamicEnum, restart_required: false)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// `fork_secondary_model` lives under Models.
|
||
#[test]
|
||
fn pr14_model_family_renders_under_models_category() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("fork_secondary_model")
|
||
.expect("`fork_secondary_model` must be registered");
|
||
assert_eq!(
|
||
meta.category,
|
||
SettingCategory::Models,
|
||
"`fork_secondary_model` must live under Models"
|
||
);
|
||
assert_eq!(
|
||
meta.owner,
|
||
SettingOwner::Shell,
|
||
"`fork_secondary_model` is SHELL-owned (persisted via util::config)"
|
||
);
|
||
}
|
||
|
||
/// `fork_secondary_model` is `restart_required: false`.
|
||
#[test]
|
||
fn pr14_restart_required_split() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let fork = reg.find("fork_secondary_model").unwrap();
|
||
assert!(
|
||
!fork.restart_required,
|
||
"fork_secondary_model must be restart_required: false — the shell's \
|
||
config_reloader rebroadcasts the new default on the next fork"
|
||
);
|
||
}
|
||
|
||
/// Model settings use `DynamicEnum` with `ActiveModelCatalog`.
|
||
#[test]
|
||
fn pr14_string_settings_use_known_model_validator() {
|
||
use xai_grok_pager::settings::DynamicEnumSource;
|
||
let reg = SettingsRegistry::defaults();
|
||
for key in ["default_model", "fork_secondary_model"] {
|
||
let meta = reg
|
||
.find(key)
|
||
.unwrap_or_else(|| panic!("`{key}` must be registered"));
|
||
match &meta.kind {
|
||
SettingKind::DynamicEnum { source, .. } => {
|
||
assert_eq!(
|
||
*source,
|
||
DynamicEnumSource::ActiveModelCatalog,
|
||
"`{key}` must pull choices from the active model catalog \
|
||
so the picker matches `/model`'s UX"
|
||
);
|
||
}
|
||
other => panic!("expected DynamicEnum kind for `{key}`, got {other:?}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Defaults round-trip through `current_value_for`.
|
||
#[test]
|
||
fn pr14_model_family_defaults_roundtrip_via_current_value_for() {
|
||
use xai_grok_pager::settings::current_value_for;
|
||
let ui = UiConfig::default();
|
||
let pager = PagerLocalSnapshot::default();
|
||
|
||
// Baseline value folds to empty (no-opinion sentinel).
|
||
let value = current_value_for("fork_secondary_model", &ui, &pager).unwrap();
|
||
assert_eq!(
|
||
value,
|
||
SettingValue::String(String::new()),
|
||
"PR 14: `fork_secondary_model` defaults to empty string (no-opinion sentinel)",
|
||
);
|
||
}
|
||
|
||
/// Non-baseline `fork_secondary_model` surfaces verbatim.
|
||
#[test]
|
||
fn pr14_fork_secondary_model_reads_ui_config_non_baseline() {
|
||
use xai_grok_pager::settings::current_value_for;
|
||
let ui = UiConfig {
|
||
fork_secondary_model: "Custom Fork Model".to_string(),
|
||
..UiConfig::default()
|
||
};
|
||
let pager = PagerLocalSnapshot::default();
|
||
assert_eq!(
|
||
current_value_for("fork_secondary_model", &ui, &pager),
|
||
Some(SettingValue::String("Custom Fork Model".to_string())),
|
||
"non-baseline `fork_secondary_model` must surface verbatim — the \
|
||
baseline-equality fold only kicks in for the default value",
|
||
);
|
||
}
|
||
|
||
/// Model-family settings are discoverable via search.
|
||
#[test]
|
||
fn pr14_model_family_settings_are_discoverable_via_search() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let cases = [("fork", "fork_secondary_model")];
|
||
for (query, expected_key) in cases {
|
||
let hits = reg.search(query);
|
||
assert!(
|
||
hits.iter().any(|m| m.key == expected_key),
|
||
"search(`{query}`) must include `{expected_key}` — hit keys: {:?}",
|
||
hits.iter().map(|m| m.key).collect::<Vec<_>>(),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// vim_mode (scrollback navigation) — PAGER-owned, paired with simple_mode
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Keyboard Space on the vim_mode row dispatches the typed setter
|
||
/// with the inverted snapshot value (default false → true). Same
|
||
/// shape as the `multiline_mode` test above; both rows are
|
||
/// PAGER-owned Bool settings.
|
||
#[test]
|
||
fn vim_mode_space_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "vim_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "vim_mode", true);
|
||
}
|
||
|
||
#[test]
|
||
fn vim_mode_enter_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "vim_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert_set_bool_action(outcome, "vim_mode", true);
|
||
}
|
||
|
||
#[test]
|
||
fn vim_mode_mouse_click_two_stage_toggles() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "vim_mode") as u16;
|
||
|
||
// First click: select-only.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first click on a different row body should only select, got: {outcome:?}"
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
|
||
// Second click: toggle.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "vim_mode", true);
|
||
}
|
||
|
||
#[test]
|
||
fn vim_mode_snapshot_on_dispatches_off() {
|
||
let snapshot = PagerLocalSnapshot {
|
||
vim_mode: true,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
snapshot,
|
||
);
|
||
navigate_to(&mut s, "vim_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "vim_mode", false);
|
||
}
|
||
|
||
#[test]
|
||
fn vim_mode_renders_under_appearance_category_pager_owned() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("vim_mode").expect("vim_mode must be registered");
|
||
assert_eq!(
|
||
meta.category,
|
||
SettingCategory::Appearance,
|
||
"vim_mode lives under Appearance (paired with simple_mode)"
|
||
);
|
||
assert_eq!(meta.owner, SettingOwner::Shell, "vim_mode is SHELL-owned");
|
||
}
|
||
|
||
#[test]
|
||
fn simple_mode_label_distinguishes_input_from_scrollback() {
|
||
// The pair (`simple_mode`, `vim_mode`) controls vim behaviour in
|
||
// two different surfaces. The label rename in this rebase makes
|
||
// that explicit so neither row is ambiguous when both are shown.
|
||
let reg = SettingsRegistry::defaults();
|
||
let simple = reg.find("simple_mode").expect("simple_mode registered");
|
||
let vim = reg.find("vim_mode").expect("vim_mode registered");
|
||
assert_eq!(simple.label, "Disable vim input mode");
|
||
assert_eq!(vim.label, "Vim scrollback navigation");
|
||
// Keyword sanity-check so search('vim') still finds both.
|
||
assert!(simple.keywords.contains(&"vim"));
|
||
assert!(vim.keywords.contains(&"vim"));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// keep_text_selection — SHELL-owned Mouse Enum (`flash` | `hold`)
|
||
//
|
||
// Mirrors `render_mermaid`: `supports_preview: false`, Enter opens picker,
|
||
// commit dispatches `Action::SetKeepTextSelection(TextSelection)`.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn keep_text_selection_renders_under_mouse_shell_owned() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("keep_text_selection")
|
||
.expect("keep_text_selection must be registered");
|
||
assert_eq!(meta.category, SettingCategory::Mouse);
|
||
assert_eq!(meta.owner, SettingOwner::Shell);
|
||
assert_eq!(meta.label, "Text selection");
|
||
assert!(
|
||
meta.description.contains("Shift"),
|
||
"description should mention Shift-drag for native terminal copy"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn keep_text_selection_does_not_support_preview() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("keep_text_selection")
|
||
.expect("keep_text_selection must be registered");
|
||
match &meta.kind {
|
||
SettingKind::Enum {
|
||
supports_preview, ..
|
||
} => assert!(
|
||
!supports_preview,
|
||
"keep_text_selection MUST be supports_preview: false",
|
||
),
|
||
other => panic!("expected Enum kind for keep_text_selection, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn enter_on_keep_text_selection_row_enters_picking_enum() {
|
||
// The picker's `original_value` is read from the process-wide cache; pin it
|
||
// to the default so a sibling test's `set_keep_text_selection` can't leak in.
|
||
xai_grok_pager::appearance::cache::set_keep_text_selection(
|
||
xai_grok_pager::appearance::TextSelection::Flash,
|
||
);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "keep_text_selection");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on keep_text_selection row must transition to PickingEnum, got {outcome:?}"
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
key,
|
||
original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(*key, "keep_text_selection");
|
||
assert_eq!(
|
||
original_value,
|
||
&SettingValue::Enum("flash"),
|
||
"default keep_text_selection → original 'flash'"
|
||
);
|
||
}
|
||
other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn keep_text_selection_picker_nav_does_not_dispatch_preview() {
|
||
for nav_key in &[
|
||
KeyCode::Down,
|
||
KeyCode::Up,
|
||
KeyCode::Char('j'),
|
||
KeyCode::Char('k'),
|
||
] {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "keep_text_selection");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
// Retreat keys at choices_idx=0 are clamped → Unchanged; step down first.
|
||
if matches!(nav_key, KeyCode::Up | KeyCode::Char('k')) {
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
}
|
||
let outcome = handle_settings_key(&mut s, &press(*nav_key));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Nav key {nav_key:?} in keep_text_selection picker MUST NOT dispatch a preview \
|
||
Action (supports_preview: false), got {outcome:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn keep_text_selection_picker_enter_dispatches_set_commit() {
|
||
use xai_grok_pager::appearance::TextSelection;
|
||
|
||
// Pin the cache-backed live value so the picker seeds at flash (idx 0)
|
||
// regardless of any sibling test that set hold/word_select on this thread.
|
||
xai_grok_pager::appearance::cache::set_keep_text_selection(TextSelection::Flash);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "keep_text_selection");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
// flash (idx 0); Down → hold (idx 1), Enter commits.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetKeepTextSelection(kind)) => {
|
||
assert_eq!(kind, TextSelection::Hold);
|
||
}
|
||
other => panic!("expected SetKeepTextSelection(Hold), got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn keep_text_selection_picker_esc_does_not_dispatch_action() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "keep_text_selection");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Esc));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Esc with supports_preview: false must not dispatch Action, got {outcome:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn keep_text_selection_choices_use_canonical_strings() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("keep_text_selection").unwrap();
|
||
let SettingKind::Enum { choices, .. } = &meta.kind else {
|
||
panic!("keep_text_selection must be Enum");
|
||
};
|
||
let canonicals: Vec<_> = choices.iter().map(|c| c.canonical).collect();
|
||
assert_eq!(
|
||
canonicals,
|
||
vec!["flash", "hold", "word_select"],
|
||
"keep_text_selection catalog must be exactly [flash, hold, word_select] in order"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_click_on_unselected_keep_text_selection_row_only_selects() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "keep_text_selection") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first body-click on unselected keep_text_selection row should only select, got: {outcome:?}",
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_click_on_selected_keep_text_selection_row_opens_picker() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "keep_text_selection") as u16;
|
||
let _ = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"second click on focused keep_text_selection row must enter PickingEnum, got {outcome:?}"
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "keep_text_selection"),
|
||
_ => panic!("second click on focused keep_text_selection row must enter PickingEnum"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_click_on_keep_text_selection_indicator_opens_picker_in_one_click() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "keep_text_selection") as u16;
|
||
// Value column is on the right; use a high x to hit the indicator.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
70,
|
||
row_y,
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "keep_text_selection"),
|
||
_ => panic!("value click on keep_text_selection must enter PickingEnum, got {outcome:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn keep_text_selection_hold_snapshot_seeds_picker_at_hold() {
|
||
use xai_grok_pager::appearance::TextSelection;
|
||
// Live value is the process-wide cache (like render_mermaid), not UiConfig alone.
|
||
xai_grok_pager::appearance::cache::set_keep_text_selection(TextSelection::Hold);
|
||
let ui = UiConfig {
|
||
keep_text_selection: Some("hold".into()),
|
||
..UiConfig::default()
|
||
};
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
ui,
|
||
PagerLocalSnapshot::default(),
|
||
);
|
||
navigate_to(&mut s, "keep_text_selection");
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
original_value,
|
||
choices_idx,
|
||
..
|
||
} => {
|
||
assert_eq!(original_value, &SettingValue::Enum("hold"));
|
||
assert_eq!(*choices_idx, 1, "hold is the second choice");
|
||
}
|
||
other => panic!("expected PickingEnum, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// scroll_speed — SHELL-owned Int under Mouse, no preview
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Int stepper open/step/commit for scroll_speed. Defaults to 50;
|
||
/// mid-range policy: Up/Down ±1, Left/Right ±5.
|
||
#[test]
|
||
fn scroll_speed_int_stepper_commit_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "scroll_speed");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on Int row must transition to EditingValue, got {outcome:?}"
|
||
);
|
||
assert_eq!(
|
||
s.editing_buffer(),
|
||
Some("50"),
|
||
"buffer must seed from default 50",
|
||
);
|
||
|
||
// Up = +1 → 51. Right = +5 → 56.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Up));
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Right));
|
||
assert_eq!(s.editing_buffer(), Some("56"));
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetScrollSpeed(56)) => {}
|
||
other => panic!("expected SetScrollSpeed(56), got {other:?}"),
|
||
}
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_speed_mouse_click_opens_editor() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "scroll_speed") as u16;
|
||
|
||
// First click — select.
|
||
let _ = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
// Second click on the focused row opens the editor.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"second click on focused Int row must enter the editor, got {outcome:?}"
|
||
);
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. } if key == "scroll_speed"),
|
||
"mode must be EditingValue(scroll_speed) after Enter-equivalent click, got {:?}",
|
||
s.mode(),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_speed_renders_under_mouse_shell_owned_bounds_1_to_100() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("scroll_speed")
|
||
.expect("scroll_speed must be registered");
|
||
assert_eq!(meta.category, SettingCategory::Mouse);
|
||
assert_eq!(meta.owner, SettingOwner::Shell);
|
||
match &meta.kind {
|
||
SettingKind::Int { default, min, max } => {
|
||
assert_eq!(*default, 50);
|
||
assert_eq!(*min, 1);
|
||
assert_eq!(*max, 100);
|
||
}
|
||
other => panic!("expected Int kind for scroll_speed, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// scroll_mode — SHELL-owned Mouse Enum (`auto` | `wheel` | `trackpad`),
|
||
// no preview (mirrors keep_text_selection).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn scroll_mode_renders_under_mouse_shell_owned_no_preview() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("scroll_mode").expect("scroll_mode registered");
|
||
assert_eq!(meta.category, SettingCategory::Mouse);
|
||
assert_eq!(meta.owner, SettingOwner::Shell);
|
||
match &meta.kind {
|
||
SettingKind::Enum {
|
||
default,
|
||
choices,
|
||
supports_preview,
|
||
} => {
|
||
assert_eq!(*default, "auto");
|
||
assert!(!supports_preview, "scroll_mode must not preview");
|
||
let canonicals: Vec<_> = choices.iter().map(|c| c.canonical).collect();
|
||
assert_eq!(
|
||
canonicals,
|
||
vec!["auto", "wheel", "trackpad"],
|
||
"catalog must match ScrollMode::as_canonical order"
|
||
);
|
||
}
|
||
other => panic!("expected Enum kind for scroll_mode, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_mode_picker_enter_dispatches_set_commit() {
|
||
use xai_grok_pager::appearance::ScrollMode;
|
||
|
||
// Pin the cache-backed live value so the picker seeds at auto (idx 0)
|
||
// regardless of sibling tests on this thread.
|
||
xai_grok_pager::appearance::cache::set_scroll_mode(ScrollMode::Auto);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "scroll_mode");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on scroll_mode row must transition to PickingEnum, got {outcome:?}"
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum {
|
||
key,
|
||
original_value,
|
||
..
|
||
} => {
|
||
assert_eq!(*key, "scroll_mode");
|
||
assert_eq!(original_value, &SettingValue::Enum("auto"));
|
||
}
|
||
other => panic!("expected PickingEnum mode, got {other:?}"),
|
||
}
|
||
// auto (idx 0); Down → wheel (idx 1), Enter commits the typed setter.
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Down));
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetScrollMode(mode)) => {
|
||
assert_eq!(mode, ScrollMode::Wheel);
|
||
}
|
||
other => panic!("expected SetScrollMode(Wheel), got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_click_on_selected_scroll_mode_row_opens_picker() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "scroll_mode") as u16;
|
||
// First click selects, second opens the picker.
|
||
let _ = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"second click on focused scroll_mode row must enter PickingEnum, got {outcome:?}"
|
||
);
|
||
match &s.mode() {
|
||
SettingsModalMode::PickingEnum { key, .. } => assert_eq!(*key, "scroll_mode"),
|
||
_ => panic!("second click on focused scroll_mode row must enter PickingEnum"),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// scroll_lines — SHELL-owned Int under Mouse (1-10), no preview
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn scroll_lines_renders_under_mouse_shell_owned_bounds_1_to_10() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("scroll_lines").expect("scroll_lines registered");
|
||
assert_eq!(meta.category, SettingCategory::Mouse);
|
||
assert_eq!(meta.owner, SettingOwner::Shell);
|
||
match &meta.kind {
|
||
SettingKind::Int { default, min, max } => {
|
||
assert_eq!(*default, 3);
|
||
assert_eq!(*min, 1);
|
||
assert_eq!(*max, 10);
|
||
}
|
||
other => panic!("expected Int kind for scroll_lines, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Int stepper open/step/commit for scroll_lines (the scroll_speed pattern).
|
||
#[test]
|
||
fn scroll_lines_int_stepper_commit_dispatches_typed_setter() {
|
||
// Pin the live cache so the buffer seeds at the default 3.
|
||
xai_grok_pager::appearance::cache::set_scroll_lines(3);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "scroll_lines");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"Enter on Int row must transition to EditingValue, got {outcome:?}"
|
||
);
|
||
assert_eq!(
|
||
s.editing_buffer(),
|
||
Some("3"),
|
||
"buffer must seed from default 3",
|
||
);
|
||
|
||
// Narrow-range policy: Up = +1 → 4 (unit steps so every 1..=10 is reachable).
|
||
let _ = handle_settings_key(&mut s, &press(KeyCode::Up));
|
||
assert_eq!(s.editing_buffer(), Some("4"));
|
||
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
match outcome {
|
||
SettingsKeyOutcome::Action(Action::SetScrollLines(4)) => {}
|
||
other => panic!("expected SetScrollLines(4), got {other:?}"),
|
||
}
|
||
assert!(matches!(s.mode(), SettingsModalMode::Browse));
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_lines_mouse_click_opens_editor() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "scroll_lines") as u16;
|
||
let _ = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"second click on focused Int row must enter the editor, got {outcome:?}"
|
||
);
|
||
assert!(
|
||
matches!(s.mode(), SettingsModalMode::EditingValue { key, .. } if key == "scroll_lines"),
|
||
"mode must be EditingValue(scroll_lines), got {:?}",
|
||
s.mode(),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// invert_scroll — SHELL-owned Bool (Mouse, default false)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn invert_scroll_space_dispatches_typed_setter() {
|
||
xai_grok_pager::appearance::cache::set_invert_scroll(false);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "invert_scroll");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "invert_scroll", true);
|
||
xai_grok_pager::appearance::cache::set_invert_scroll(false);
|
||
}
|
||
|
||
#[test]
|
||
fn invert_scroll_enter_dispatches_typed_setter() {
|
||
xai_grok_pager::appearance::cache::set_invert_scroll(true);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "invert_scroll");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert_set_bool_action(outcome, "invert_scroll", false);
|
||
xai_grok_pager::appearance::cache::set_invert_scroll(false);
|
||
}
|
||
|
||
#[test]
|
||
fn invert_scroll_mouse_click_two_stage_toggles() {
|
||
xai_grok_pager::appearance::cache::set_invert_scroll(false);
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "invert_scroll") as u16;
|
||
// First click — select only.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first body-click should only select, got {outcome:?}"
|
||
);
|
||
// Second click — toggles.
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "invert_scroll", true);
|
||
xai_grok_pager::appearance::cache::set_invert_scroll(false);
|
||
}
|
||
|
||
#[test]
|
||
fn invert_scroll_renders_under_mouse_shell_owned_default_false() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg.find("invert_scroll").expect("invert_scroll registered");
|
||
assert_eq!(meta.category, SettingCategory::Mouse);
|
||
assert_eq!(meta.owner, SettingOwner::Shell);
|
||
match &meta.kind {
|
||
SettingKind::Bool { default } => assert!(!default, "invert_scroll must default OFF"),
|
||
other => panic!("expected Bool kind for invert_scroll, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// display_refresh_auto_cadence — SHELL-owned Bool (Appearance, default false)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn display_refresh_auto_cadence_space_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "display_refresh_auto_cadence");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "display_refresh_auto_cadence", true);
|
||
}
|
||
|
||
#[test]
|
||
fn display_refresh_auto_cadence_enter_dispatches_typed_setter() {
|
||
// Seed on so Enter toggles off.
|
||
let mut ui = UiConfig::default();
|
||
ui.display_refresh.auto_cadence_enabled = Some(true);
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
ui,
|
||
PagerLocalSnapshot {
|
||
auto_mode_gate: true,
|
||
..PagerLocalSnapshot::default()
|
||
},
|
||
);
|
||
navigate_to(&mut s, "display_refresh_auto_cadence");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert_set_bool_action(outcome, "display_refresh_auto_cadence", false);
|
||
}
|
||
|
||
#[test]
|
||
fn display_refresh_auto_cadence_mouse_click_two_stage_toggles() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "display_refresh_auto_cadence") as u16;
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first body-click should only select, got {outcome:?}"
|
||
);
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "display_refresh_auto_cadence", true);
|
||
}
|
||
|
||
#[test]
|
||
fn display_refresh_auto_cadence_meta_appearance_shell_restart_hidden_minimal() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("display_refresh_auto_cadence")
|
||
.expect("display_refresh_auto_cadence registered");
|
||
assert_eq!(meta.category, SettingCategory::Appearance);
|
||
assert_eq!(meta.owner, SettingOwner::Shell);
|
||
assert!(meta.restart_required);
|
||
assert!(meta.hidden_in_minimal);
|
||
assert_eq!(meta.label, "Match display refresh rate");
|
||
match &meta.kind {
|
||
SettingKind::Bool { default } => {
|
||
assert!(!default, "display_refresh_auto_cadence must default OFF")
|
||
}
|
||
other => panic!("expected Bool kind for display_refresh_auto_cadence, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn display_refresh_auto_cadence_defaults_roundtrip_via_current_value_for() {
|
||
use xai_grok_pager::settings::current_value_for;
|
||
let ui = UiConfig::default();
|
||
let pager = PagerLocalSnapshot::default();
|
||
let value = current_value_for("display_refresh_auto_cadence", &ui, &pager)
|
||
.expect("current_value_for(display_refresh_auto_cadence) must resolve");
|
||
assert_eq!(value, SettingValue::Bool(false));
|
||
|
||
let mut ui_on = UiConfig::default();
|
||
ui_on.display_refresh.auto_cadence_enabled = Some(true);
|
||
let value = current_value_for("display_refresh_auto_cadence", &ui_on, &pager)
|
||
.expect("current_value_for(display_refresh_auto_cadence) must resolve");
|
||
assert_eq!(value, SettingValue::Bool(true));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// show_thinking_blocks — SHELL-owned Bool (Appearance, default true)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn show_thinking_blocks_space_dispatches_typed_setter() {
|
||
// Pin off so space toggles to true.
|
||
xai_grok_pager::appearance::cache::set_show_thinking_blocks(false);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "show_thinking_blocks");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "show_thinking_blocks", true);
|
||
xai_grok_pager::appearance::cache::set_show_thinking_blocks(true);
|
||
}
|
||
|
||
#[test]
|
||
fn show_thinking_blocks_enter_dispatches_typed_setter() {
|
||
xai_grok_pager::appearance::cache::set_show_thinking_blocks(false);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "show_thinking_blocks");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert_set_bool_action(outcome, "show_thinking_blocks", true);
|
||
xai_grok_pager::appearance::cache::set_show_thinking_blocks(true);
|
||
}
|
||
|
||
#[test]
|
||
fn show_thinking_blocks_mouse_click_two_stage_toggles() {
|
||
xai_grok_pager::appearance::cache::set_show_thinking_blocks(false);
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "show_thinking_blocks") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first click on a different row body should only select, got: {outcome:?}"
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
// Cache pinned off above → toggle dispatches true.
|
||
assert_set_bool_action(outcome, "show_thinking_blocks", true);
|
||
xai_grok_pager::appearance::cache::set_show_thinking_blocks(true);
|
||
}
|
||
|
||
#[test]
|
||
fn show_thinking_blocks_cache_on_dispatches_off() {
|
||
// When the live cache is on, toggle should turn it off.
|
||
xai_grok_pager::appearance::cache::set_show_thinking_blocks(true);
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
PagerLocalSnapshot::default(),
|
||
);
|
||
navigate_to(&mut s, "show_thinking_blocks");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "show_thinking_blocks", false);
|
||
// Restore client default (on) for other tests that share the process cache.
|
||
xai_grok_pager::appearance::cache::set_show_thinking_blocks(true);
|
||
}
|
||
|
||
#[test]
|
||
fn show_thinking_blocks_renders_under_appearance_category_shell_owned() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("show_thinking_blocks")
|
||
.expect("show_thinking_blocks must be registered");
|
||
assert_eq!(meta.category, SettingCategory::Appearance);
|
||
assert_eq!(meta.owner, SettingOwner::Shell);
|
||
match &meta.kind {
|
||
SettingKind::Bool { default } => assert!(*default, "default must be true"),
|
||
other => panic!("expected Bool kind for show_thinking_blocks, got {other:?}"),
|
||
}
|
||
// Must sit immediately above respect_manual_folds in the registry order.
|
||
let keys: Vec<&str> = reg
|
||
.all()
|
||
.iter()
|
||
.filter(|m| m.category == SettingCategory::Appearance)
|
||
.map(|m| m.key)
|
||
.collect();
|
||
let show_idx = keys
|
||
.iter()
|
||
.position(|k| *k == "show_thinking_blocks")
|
||
.expect("show_thinking_blocks in Appearance");
|
||
let respect_idx = keys
|
||
.iter()
|
||
.position(|k| *k == "respect_manual_folds")
|
||
.expect("respect_manual_folds in Appearance");
|
||
assert_eq!(
|
||
show_idx + 1,
|
||
respect_idx,
|
||
"show_thinking_blocks must be immediately above respect_manual_folds; \
|
||
Appearance order: {keys:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// prompt_suggestions — SHELL-owned Bool (Editor, default true)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn prompt_suggestions_space_dispatches_typed_setter() {
|
||
// Pin off so space toggles to true.
|
||
xai_grok_pager::appearance::cache::set_prompt_suggestions(false);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "prompt_suggestions");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "prompt_suggestions", true);
|
||
xai_grok_pager::appearance::cache::set_prompt_suggestions(true);
|
||
}
|
||
|
||
#[test]
|
||
fn prompt_suggestions_enter_dispatches_typed_setter() {
|
||
xai_grok_pager::appearance::cache::set_prompt_suggestions(false);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "prompt_suggestions");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert_set_bool_action(outcome, "prompt_suggestions", true);
|
||
xai_grok_pager::appearance::cache::set_prompt_suggestions(true);
|
||
}
|
||
|
||
#[test]
|
||
fn prompt_suggestions_mouse_click_two_stage_toggles() {
|
||
xai_grok_pager::appearance::cache::set_prompt_suggestions(false);
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "prompt_suggestions") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first click on a different row body should only select, got: {outcome:?}"
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
// Cache pinned off above → toggle dispatches true.
|
||
assert_set_bool_action(outcome, "prompt_suggestions", true);
|
||
xai_grok_pager::appearance::cache::set_prompt_suggestions(true);
|
||
}
|
||
|
||
#[test]
|
||
fn prompt_suggestions_cache_on_dispatches_off() {
|
||
// Default is on; when the live cache is on, toggle should turn it off.
|
||
xai_grok_pager::appearance::cache::set_prompt_suggestions(true);
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
PagerLocalSnapshot::default(),
|
||
);
|
||
navigate_to(&mut s, "prompt_suggestions");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "prompt_suggestions", false);
|
||
// Restore client default (on) for other tests that share the process cache.
|
||
xai_grok_pager::appearance::cache::set_prompt_suggestions(true);
|
||
}
|
||
|
||
#[test]
|
||
fn prompt_suggestions_renders_under_editor_category_shell_owned() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("prompt_suggestions")
|
||
.expect("prompt_suggestions must be registered");
|
||
assert_eq!(meta.category, SettingCategory::Editor);
|
||
assert_eq!(meta.owner, SettingOwner::Shell);
|
||
match &meta.kind {
|
||
SettingKind::Bool { default } => assert!(*default, "default must be true"),
|
||
other => panic!("expected Bool kind for prompt_suggestions, got {other:?}"),
|
||
}
|
||
// Must sit immediately below multiline_mode in the registry order.
|
||
let keys: Vec<&str> = reg
|
||
.all()
|
||
.iter()
|
||
.filter(|m| m.category == SettingCategory::Editor)
|
||
.map(|m| m.key)
|
||
.collect();
|
||
let multiline_idx = keys
|
||
.iter()
|
||
.position(|k| *k == "multiline_mode")
|
||
.expect("multiline_mode in Editor");
|
||
let prompt_idx = keys
|
||
.iter()
|
||
.position(|k| *k == "prompt_suggestions")
|
||
.expect("prompt_suggestions in Editor");
|
||
assert_eq!(
|
||
multiline_idx + 1,
|
||
prompt_idx,
|
||
"prompt_suggestions must be immediately below multiline_mode; \
|
||
Editor order: {keys:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// respect_manual_folds — PAGER-owned Bool
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn respect_manual_folds_space_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "respect_manual_folds");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "respect_manual_folds", true);
|
||
}
|
||
|
||
#[test]
|
||
fn respect_manual_folds_enter_dispatches_typed_setter() {
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "respect_manual_folds");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert_set_bool_action(outcome, "respect_manual_folds", true);
|
||
}
|
||
|
||
#[test]
|
||
fn respect_manual_folds_mouse_click_two_stage_toggles() {
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "respect_manual_folds") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first click on a different row body should only select, got: {outcome:?}"
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert_set_bool_action(outcome, "respect_manual_folds", true);
|
||
}
|
||
|
||
#[test]
|
||
fn respect_manual_folds_snapshot_on_dispatches_off() {
|
||
let snapshot = PagerLocalSnapshot {
|
||
respect_manual_folds: true,
|
||
..PagerLocalSnapshot::default()
|
||
};
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
snapshot,
|
||
);
|
||
navigate_to(&mut s, "respect_manual_folds");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "respect_manual_folds", false);
|
||
}
|
||
|
||
#[test]
|
||
fn respect_manual_folds_renders_under_appearance_category_pager_owned() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("respect_manual_folds")
|
||
.expect("respect_manual_folds must be registered");
|
||
assert_eq!(meta.category, SettingCategory::Appearance);
|
||
assert_eq!(meta.owner, SettingOwner::Pager);
|
||
match &meta.kind {
|
||
SettingKind::Bool { default } => assert!(!default),
|
||
other => panic!("expected Bool kind for respect_manual_folds, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// group_tool_verbs — SHELL-owned Bool (Appearance, default true)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn group_tool_verbs_space_dispatches_typed_setter() {
|
||
// Default is true; space toggles to false.
|
||
xai_grok_pager::appearance::cache::set_group_tool_verbs(true);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "group_tool_verbs");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "group_tool_verbs", false);
|
||
}
|
||
|
||
#[test]
|
||
fn group_tool_verbs_enter_dispatches_typed_setter() {
|
||
xai_grok_pager::appearance::cache::set_group_tool_verbs(true);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "group_tool_verbs");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert_set_bool_action(outcome, "group_tool_verbs", false);
|
||
}
|
||
|
||
#[test]
|
||
fn group_tool_verbs_mouse_click_two_stage_toggles() {
|
||
xai_grok_pager::appearance::cache::set_group_tool_verbs(true);
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "group_tool_verbs") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first click on a different row body should only select, got: {outcome:?}"
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
// Default is true → toggle dispatches false.
|
||
assert_set_bool_action(outcome, "group_tool_verbs", false);
|
||
}
|
||
|
||
#[test]
|
||
fn group_tool_verbs_cache_off_dispatches_on() {
|
||
// When the live cache is off, toggle should turn it on.
|
||
xai_grok_pager::appearance::cache::set_group_tool_verbs(false);
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
PagerLocalSnapshot::default(),
|
||
);
|
||
navigate_to(&mut s, "group_tool_verbs");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "group_tool_verbs", true);
|
||
// Restore default (on) for other tests that share the process cache.
|
||
xai_grok_pager::appearance::cache::set_group_tool_verbs(true);
|
||
}
|
||
|
||
#[test]
|
||
fn group_tool_verbs_renders_under_appearance_category_shell_owned() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("group_tool_verbs")
|
||
.expect("group_tool_verbs must be registered");
|
||
assert_eq!(meta.category, SettingCategory::Appearance);
|
||
assert_eq!(meta.owner, SettingOwner::Shell);
|
||
match &meta.kind {
|
||
SettingKind::Bool { default } => assert!(*default, "default must be true"),
|
||
other => panic!("expected Bool kind for group_tool_verbs, got {other:?}"),
|
||
}
|
||
// Must sit immediately below respect_manual_folds in the registry order.
|
||
let keys: Vec<&str> = reg
|
||
.all()
|
||
.iter()
|
||
.filter(|m| m.category == SettingCategory::Appearance)
|
||
.map(|m| m.key)
|
||
.collect();
|
||
let respect_idx = keys
|
||
.iter()
|
||
.position(|k| *k == "respect_manual_folds")
|
||
.expect("respect_manual_folds in Appearance");
|
||
let group_idx = keys
|
||
.iter()
|
||
.position(|k| *k == "group_tool_verbs")
|
||
.expect("group_tool_verbs in Appearance");
|
||
assert_eq!(
|
||
respect_idx + 1,
|
||
group_idx,
|
||
"group_tool_verbs must be immediately below respect_manual_folds; \
|
||
Appearance order: {keys:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// collapsed_edit_blocks — SHELL-owned Bool (Appearance, default false)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn collapsed_edit_blocks_space_dispatches_typed_setter() {
|
||
// Seed the live cache to the shipped default (bypasses the disk seed so a
|
||
// host [ui] override can't flip the expected toggle direction).
|
||
xai_grok_pager::appearance::cache::set_collapsed_edit_blocks(false);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "collapsed_edit_blocks");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "collapsed_edit_blocks", true);
|
||
}
|
||
|
||
#[test]
|
||
fn collapsed_edit_blocks_enter_dispatches_typed_setter() {
|
||
xai_grok_pager::appearance::cache::set_collapsed_edit_blocks(false);
|
||
let mut s = make_state();
|
||
navigate_to(&mut s, "collapsed_edit_blocks");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Enter));
|
||
assert_set_bool_action(outcome, "collapsed_edit_blocks", true);
|
||
}
|
||
|
||
#[test]
|
||
fn collapsed_edit_blocks_mouse_click_two_stage_toggles() {
|
||
xai_grok_pager::appearance::cache::set_collapsed_edit_blocks(false);
|
||
let mut s = make_state();
|
||
synth_rects(&mut s);
|
||
let row_y = row_idx_for(&s, "collapsed_edit_blocks") as u16;
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
assert!(
|
||
matches!(outcome, SettingsKeyOutcome::Changed),
|
||
"first click on a different row body should only select, got: {outcome:?}"
|
||
);
|
||
assert_eq!(s.selected, row_y as usize);
|
||
|
||
let outcome = handle_settings_mouse(
|
||
&mut s,
|
||
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
10,
|
||
row_y,
|
||
);
|
||
// Default is false → toggle dispatches true.
|
||
assert_set_bool_action(outcome, "collapsed_edit_blocks", true);
|
||
}
|
||
|
||
#[test]
|
||
fn collapsed_edit_blocks_cache_on_dispatches_off() {
|
||
// When the live cache is on (remote settings/team enable), toggle turns it off.
|
||
xai_grok_pager::appearance::cache::set_collapsed_edit_blocks(true);
|
||
let mut s = SettingsModalState::new(
|
||
Arc::new(SettingsRegistry::defaults()),
|
||
UiConfig::default(),
|
||
PagerLocalSnapshot::default(),
|
||
);
|
||
navigate_to(&mut s, "collapsed_edit_blocks");
|
||
let outcome = handle_settings_key(&mut s, &press(KeyCode::Char(' ')));
|
||
assert_set_bool_action(outcome, "collapsed_edit_blocks", false);
|
||
// Restore default (off) for other tests that share the process cache.
|
||
xai_grok_pager::appearance::cache::set_collapsed_edit_blocks(false);
|
||
}
|
||
|
||
#[test]
|
||
fn collapsed_edit_blocks_renders_under_appearance_category_shell_owned() {
|
||
let reg = SettingsRegistry::defaults();
|
||
let meta = reg
|
||
.find("collapsed_edit_blocks")
|
||
.expect("collapsed_edit_blocks must be registered");
|
||
assert_eq!(meta.category, SettingCategory::Appearance);
|
||
assert_eq!(meta.owner, SettingOwner::Shell);
|
||
match &meta.kind {
|
||
SettingKind::Bool { default } => {
|
||
assert!(!*default, "default must be false (rollout flag ships OFF)")
|
||
}
|
||
other => panic!("expected Bool kind for collapsed_edit_blocks, got {other:?}"),
|
||
}
|
||
// Must sit immediately below group_tool_verbs in the registry order.
|
||
let keys: Vec<&str> = reg
|
||
.all()
|
||
.iter()
|
||
.filter(|m| m.category == SettingCategory::Appearance)
|
||
.map(|m| m.key)
|
||
.collect();
|
||
let group_idx = keys
|
||
.iter()
|
||
.position(|k| *k == "group_tool_verbs")
|
||
.expect("group_tool_verbs in Appearance");
|
||
let collapsed_idx = keys
|
||
.iter()
|
||
.position(|k| *k == "collapsed_edit_blocks")
|
||
.expect("collapsed_edit_blocks in Appearance");
|
||
assert_eq!(
|
||
group_idx + 1,
|
||
collapsed_idx,
|
||
"collapsed_edit_blocks must be immediately below group_tool_verbs; \
|
||
Appearance order: {keys:?}"
|
||
);
|
||
}
|