use super::*;
use ratatui::{Terminal, backend::TestBackend};
mod model_picker;
fn draw(frame: &mut ratatui::Frame<'_>, area: ratatui::layout::Rect, editor: &SettingsEditor) {
super::draw(
frame,
area,
editor,
crate::tui::state::MissionControlState::default().theme,
);
}
fn ready_editor() -> SettingsEditor {
let mut editor = SettingsEditor::loading(SettingsScope::Global);
editor.loaded(&Settings::default());
editor
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn settings_editor_exposes_all_backend_fields_and_command_is_idle_only() {
let editor = ready_editor();
let actual: std::collections::BTreeSet<_> = editor.fields.iter().map(|f| f.path).collect();
let expected: std::collections::BTreeSet<_> = crate::config::EDITABLE_SETTINGS_PATHS
.iter()
.copied()
.filter(|path| *path != "capabilities.tools.view_image.vision_model")
.collect();
assert_eq!(actual, expected);
assert_eq!(actual.len(), editor.fields.len());
let command = crate::tui::input::submit_command::tui_submit_command(
"/settings",
&crate::commands::CommandRegistry::mvp(),
);
assert!(matches!(
command,
crate::tui::input::submit_command::TuiSubmitCommand::Settings
));
assert!(command.requires_idle());
assert!(command.active_run_error().is_some());
}
#[test]
fn settings_editor_requires_explicit_save_and_confirms_discard() {
let mut editor = ready_editor();
editor.key(key(KeyCode::Enter));
assert_eq!(
editor.changes(),
vec![("agent.compaction.auto.enabled", json!(true))]
);
assert!(matches!(
editor.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)),
EditorRequest::Save
));
assert!(!editor.changes().is_empty());
assert!(matches!(editor.key(key(KeyCode::Esc)), EditorRequest::None));
assert!(matches!(
editor.key(key(KeyCode::Esc)),
EditorRequest::Close
));
editor.mark_saved();
assert!(editor.changes().is_empty());
}
#[test]
fn settings_editor_scope_changes_do_not_discard_drafts() {
let mut editor = ready_editor();
let switch = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL);
assert!(matches!(
editor.key(switch),
EditorRequest::Load(SettingsScope::Project)
));
editor.key(key(KeyCode::Enter));
assert!(matches!(editor.key(switch), EditorRequest::None));
assert!(editor.notice.contains("Save or discard"));
}
#[test]
fn settings_editor_rejects_invalid_numbers_without_losing_text() {
let mut editor = ready_editor();
editor.selected = 1;
let original = editor.visible_fields()[1].value.clone();
editor.key(key(KeyCode::Enter));
editor.editing.as_mut().unwrap().set_value("abc");
editor.key(key(KeyCode::Enter));
assert!(editor.editing.is_some());
assert!(editor.notice.contains("whole number"));
assert_eq!(editor.visible_fields()[1].value, original);
editor.editing.as_mut().unwrap().set_value("80");
editor.key(key(KeyCode::Enter));
assert_eq!(
editor.changes(),
vec![("agent.compaction.auto.threshold_percent", json!(80))]
);
}
#[test]
fn settings_editor_edits_paths_as_text_and_clears_image_selection_atomically() {
let mut editor = ready_editor();
editor.selected = editor.visible_fields().len() - 1;
editor.key(key(KeyCode::Enter));
editor.insert_paste("/one; /two with spaces");
editor.key(key(KeyCode::Enter));
assert!(editor.changes().contains(&(
"knowledge.skills.additional_paths",
json!(["/one", "/two with spaces"])
)));
for field in &mut editor.fields {
if field
.path
.starts_with("capabilities.tools.view_image.vision_model.")
{
field.value = json!("example");
}
}
editor.mark_saved();
editor.tab = 3;
editor.selected = 3;
editor.key(key(KeyCode::Enter));
editor.key(key(KeyCode::Enter));
assert_eq!(
editor.changes(),
vec![("capabilities.tools.view_image.vision_model", Value::Null)]
);
}
#[test]
fn settings_editor_loading_blocks_changes_and_paste_does_not_reach_prompt() {
let mut editor = SettingsEditor::loading(SettingsScope::Global);
assert!(matches!(editor.key(key(KeyCode::Esc)), EditorRequest::None));
editor.insert_paste("ignored");
assert!(editor.changes().is_empty());
let mut state = crate::tui::state::MissionControlState::default();
state.modals.settings_editor = Some(editor);
assert!(!crate::tui::input::prompt_accepts_paste(&state));
}
#[test]
fn settings_editor_restart_notice_requires_saved_changes() {
let mut editor = ready_editor();
let save = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
terminal.draw(|f| draw(f, f.area(), &editor)).unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect();
assert!(!text.to_lowercase().contains("restart"));
assert!(text.contains("You are editing global settings."));
assert!(!text.contains("inherited global settings"));
assert!(matches!(editor.key(save), EditorRequest::None));
editor.mark_saved();
assert_eq!(editor.notice, "No changes to save.");
editor.key(key(KeyCode::Enter));
assert!(matches!(editor.key(save), EditorRequest::Save));
assert!(!editor.notice.to_lowercase().contains("restart"));
editor.mark_saved();
terminal.draw(|f| draw(f, f.area(), &editor)).unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("Settings saved. Restart magi-code"));
assert!(!text.contains("Restart required"));
assert!(matches!(editor.key(save), EditorRequest::None));
assert!(editor.notice.contains("previously saved changes"));
}
#[test]
fn settings_editor_renders_tabs_draft_status_and_small_terminals() {
let mut editor = ready_editor();
editor.key(key(KeyCode::Enter));
let mut terminal = Terminal::new(TestBackend::new(120, 28)).unwrap();
terminal.draw(|f| draw(f, f.area(), &editor)).unwrap();
let text = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect::<String>();
for label in [
"Settings",
"Context",
"Agent settings",
"unsaved",
"[Ctrl-S] Save",
] {
assert!(text.contains(label), "missing {label}");
}
assert!(!text.to_lowercase().contains("restart"));
for (width, height) in [(1, 1), (20, 5), (50, 15)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
for tab in 0..TABS.len() {
editor.tab = tab;
terminal.draw(|f| draw(f, f.area(), &editor)).unwrap();
}
}
}
#[test]
fn settings_editor_save_attempt_resets_discard_confirmation() {
let mut editor = ready_editor();
editor.key(key(KeyCode::Enter));
assert!(matches!(editor.key(key(KeyCode::Esc)), EditorRequest::None));
assert!(matches!(
editor.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)),
EditorRequest::Save
));
assert!(matches!(editor.key(key(KeyCode::Esc)), EditorRequest::None));
assert!(matches!(
editor.key(key(KeyCode::Esc)),
EditorRequest::Close
));
}
#[test]
fn settings_editor_narrow_window_keeps_selected_tab_and_field_visible() {
let mut editor = ready_editor();
editor.tab = 1;
editor.selected = editor.visible_fields().len() - 1;
for editing in [false, true] {
if editing {
editor.key(key(KeyCode::Enter));
}
let mut terminal = Terminal::new(TestBackend::new(50, 15)).unwrap();
terminal.draw(|f| draw(f, f.area(), &editor)).unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("[Agent settings]"), "{text}");
assert!(text.contains("Subagent stream timeout"), "{text}");
assert!(
text.contains(if editing {
"[Enter] Accept"
} else {
"[Ctrl-S] Save"
}),
"{text}"
);
}
}
#[test]
fn settings_editor_keeps_centered_bounds_across_tabs_editing_and_notices() {
let mut editor = ready_editor();
for (width, height) in [(200, 60), (80, 24), (50, 15)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
let mut expected_bounds = None;
for tab in 0..TABS.len() {
editor.tab = tab;
for editing in [false, true] {
editor.editing = editing.then(SingleLineField::plain);
for notice in [
"",
"Saved.",
"A long notice that wraps over several rows. "
.repeat(8)
.as_str(),
] {
editor.notice = notice.into();
terminal
.draw(|frame| draw(frame, frame.area(), &editor))
.unwrap();
let buffer = terminal.backend().buffer();
let occupied: Vec<_> = (0..height)
.flat_map(|y| (0..width).map(move |x| (x, y)))
.filter(|&(x, y)| buffer[(x, y)].symbol() != " ")
.collect();
let left = occupied.iter().map(|p| p.0).min().unwrap();
let right = occupied.iter().map(|p| p.0).max().unwrap();
let top = occupied.iter().map(|p| p.1).min().unwrap();
let bottom = occupied.iter().map(|p| p.1).max().unwrap();
let bounds = (left, right, top, bottom);
assert_eq!(bounds, *expected_bounds.get_or_insert(bounds));
assert!([width * 60 / 100, width * 80 / 100].contains(&(right - left + 1)));
assert!(bottom - top < height - 2);
assert_eq!(left, width - right - 1);
assert!((i32::from(top) - i32::from(height - bottom - 1)).abs() <= 1);
}
}
}
}
}
#[test]
fn settings_editor_long_unicode_text_scrolls_with_edit_cursor() {
let mut editor = ready_editor();
editor.selected = editor.visible_fields().len() - 1;
editor.key(key(KeyCode::Enter));
editor.insert_paste(&format!("/start/{}尾", "界".repeat(100)));
let mut terminal = Terminal::new(TestBackend::new(50, 18)).unwrap();
for (key_code, expected) in [(KeyCode::End, "å°¾"), (KeyCode::Home, "/start/")] {
editor.key(key(key_code));
let frame = terminal
.draw(|frame| draw(frame, frame.area(), &editor))
.unwrap();
let text: String = frame
.buffer
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains(expected), "{text}");
assert!(text.contains("[Enter] Accept"), "{text}");
assert!(text.contains("[Esc] cancel"), "{text}");
assert!(text.contains("editing"), "{text}");
let cursor =
ratatui::backend::Backend::get_cursor_position(terminal.backend_mut()).unwrap();
assert!(cursor.x > 1 && cursor.x < 48);
assert!(cursor.y > 1 && cursor.y < 16);
}
editor.key(key(KeyCode::Enter));
assert!(editor.editing.is_none());
assert!(editor.changes()[0].1[0].as_str().unwrap().ends_with('å°¾'));
}
#[test]
fn settings_editor_every_tab_keeps_last_field_visible_when_short() {
let mut editor = ready_editor();
for (tab, name) in TABS
.iter()
.enumerate()
.filter(|(tab, _)| *tab != MODELS_TAB)
{
editor.tab = tab;
editor.selected = editor.visible_fields().len() - 1;
let label = editor.visible_fields()[editor.selected].label;
let mut terminal = Terminal::new(TestBackend::new(64, 14)).unwrap();
terminal
.draw(|frame| draw(frame, frame.area(), &editor))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains(&format!("[{name}]")), "{text}");
assert!(text.contains(label), "{text}");
assert!(text.contains("[Ctrl-S]"), "{text}");
assert!(text.contains("[Esc] close"), "{text}");
}
}
#[test]
fn settings_editor_agent_settings_merges_fields_and_tab_navigation() {
let mut editor = ready_editor();
editor.key(key(KeyCode::Tab));
assert_eq!(TABS[editor.tab], "Agent settings");
let expected = [
"agent.subagents.execution.absolute_paths",
"agent.subagents.execution.max_depth",
"agent.side.model",
"agent.summarizer.auto_start",
"agent.summarizer.model",
"agent.summarizer.reasoning",
"sessions.titles.enabled",
"sessions.titles.model",
"providers.streaming.semantic_progress_timeout_seconds",
"providers.streaming.subagent_semantic_progress_timeout_seconds",
];
assert_eq!(
editor
.visible_fields()
.iter()
.map(|field| field.path)
.collect::<Vec<_>>(),
expected
);
let mut terminal = Terminal::new(TestBackend::new(200, 60)).unwrap();
terminal
.draw(|frame| draw(frame, frame.area(), &editor))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
for field in editor.visible_fields() {
assert!(text.contains(field.label), "missing {}", field.label);
}
for heading in [
"Subagents",
"Side agent",
"Summary agent",
"Session titles",
"Streaming",
] {
assert!(text.contains(heading), "missing {heading}");
}
for name in ["Models", "Tools", "Other", "Context"] {
assert!(matches!(editor.key(key(KeyCode::Tab)), EditorRequest::None));
assert_eq!(TABS[editor.tab], name);
assert_eq!(editor.selected, 0);
}
editor.key(key(KeyCode::BackTab));
assert_eq!(TABS[editor.tab], "Context");
editor.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT));
assert_eq!(TABS[editor.tab], "Context");
}
#[test]
fn settings_editor_other_groups_preserve_field_membership() {
let mut editor = ready_editor();
editor.tab = 4;
assert_eq!(TABS[editor.tab], "Other");
assert_eq!(
editor
.visible_fields()
.iter()
.map(|field| field.path)
.collect::<Vec<_>>(),
[
"automation.integrations.herdr.enabled",
"interface.tui.subagent_card_rows",
"interface.tui.autocomplete.respects_gitignore",
]
);
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
terminal
.draw(|frame| draw(frame, frame.area(), &editor))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(!text.contains("Knowledge"));
assert!(!text.contains("Additional skill directories"));
for heading in ["Integrations", "Appearance"] {
assert!(text.contains(heading), "missing {heading}");
}
for field in editor.visible_fields() {
assert!(text.contains(field.label), "missing {}", field.label);
}
}
#[test]
fn settings_editor_navigation_skips_headings_and_highlights_fields_when_scrolling() {
let theme = crate::tui::state::MissionControlState::default().theme;
for tab in (0..TABS.len()).filter(|tab| *tab != MODELS_TAB) {
let mut editor = ready_editor();
editor.tab = tab;
let count = editor.visible_fields().len();
for height in [14, 40] {
let mut terminal = Terminal::new(TestBackend::new(84, height)).unwrap();
editor.selected = 0;
for (step, expected) in (0..count).chain((0..count - 1).rev()).enumerate() {
assert_eq!(editor.selected, expected);
let label = editor.visible_fields()[expected].label;
terminal
.draw(|frame| draw(frame, frame.area(), &editor))
.unwrap();
let buffer = terminal.backend().buffer();
let selected_lines: Vec<String> = (0..height)
.filter_map(|y| {
let line: String = (0..84).map(|x| buffer[(x, y)].symbol()).collect();
line.contains("› ").then_some(line)
})
.collect();
assert_eq!(selected_lines.len(), 1);
assert!(selected_lines[0].contains(label), "{}", selected_lines[0]);
let selected_y = (0..height)
.find(|&y| (0..84).any(|x| buffer[(x, y)].symbol() == "›"))
.unwrap();
assert!((0..84).any(|x| buffer[(x, selected_y)].bg == theme.surface_selection()));
editor.key(key(if step >= count - 1 {
KeyCode::Up
} else {
KeyCode::Down
}));
}
}
}
}
#[test]
fn settings_editor_context_and_tools_sections_preserve_field_membership() {
let mut editor = ready_editor();
assert_eq!(
TABS,
["Context", "Agent settings", "Models", "Tools", "Other"]
);
for (tab, sections) in [
(
0,
vec![
(
"Compaction",
vec![
"agent.compaction.auto.enabled",
"agent.compaction.auto.threshold_percent",
"agent.compaction.auto.threshold_tokens",
"agent.compaction.auto.max_compactions_per_run",
"agent.compaction.model",
],
),
(
"Fallback context",
vec![
"agent.context.enabled",
"agent.context.max_tokens",
"agent.context.reserve_tokens",
"agent.context.keep_recent_tokens",
],
),
(
"Skill directories",
vec!["knowledge.skills.additional_paths"],
),
],
),
(
3,
vec![
(
"Output compression",
vec!["capabilities.tools.output_compression.enabled"],
),
(
"View image",
vec![
"capabilities.tools.view_image.absolute_paths",
"capabilities.tools.view_image.max_image_bytes",
"capabilities.tools.view_image.vision_model.model",
],
),
],
),
] {
editor.tab = tab;
let mut heading = "";
let actual: Vec<_> = editor
.visible_fields()
.iter()
.map(|field| {
if let Some(section) = field.section_heading {
heading = section;
}
(heading, field.path)
})
.collect();
let expected: Vec<_> = sections
.iter()
.flat_map(|(heading, paths)| paths.iter().map(move |path| (*heading, *path)))
.collect();
assert_eq!(actual, expected);
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
terminal
.draw(|frame| draw(frame, frame.area(), &editor))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
for (heading, _) in sections {
assert!(text.contains(heading), "missing {heading}");
}
for field in editor.visible_fields() {
assert!(text.contains(field.label), "missing {}", field.label);
}
}
}
#[test]
fn settings_editor_initial_notice_matches_scope() {
let editor = ready_editor();
assert_eq!(editor.notice, "You are editing global settings.");
let mut project = SettingsEditor::loading(SettingsScope::Project);
project.loaded(&Settings::default());
assert_eq!(
project.notice,
"Project values include inherited global settings."
);
}
#[test]
fn settings_editor_description_and_actions_stay_inside_shared_shell() {
let editor = ready_editor();
for width in [50, 88, 200] {
let mut terminal = Terminal::new(TestBackend::new(width, 80)).unwrap();
terminal
.draw(|frame| draw(frame, frame.area(), &editor))
.unwrap();
let buffer = terminal.backend().buffer();
let rows: Vec<String> = (0..80)
.map(|y| (0..width).map(|x| buffer[(x, y)].symbol()).collect())
.collect();
let top = rows
.iter()
.position(|row| row.contains("Settings"))
.unwrap();
let bottom = rows.iter().rposition(|row| row.contains('â””')).unwrap();
assert!(rows[top].contains('┌'));
assert!(rows[top].contains("[Esc] close"));
assert!(rows[bottom].contains("[Ctrl-S] Save"));
assert!(
rows[top + 1..bottom]
.iter()
.all(|row| !row.contains("[Ctrl-S]"))
);
assert!(
rows[top + 1..bottom]
.iter()
.any(|row| row.contains("Description"))
);
let theme = crate::tui::state::MissionControlState::default().theme;
assert_eq!(
buffer[(width / 2, (top + 1) as u16)].bg,
theme.app().bg.unwrap()
);
}
}
#[test]
fn settings_editor_scope_shortcut_is_only_in_bottom_border() {
let mut editor = ready_editor();
for scope in [SettingsScope::Global, SettingsScope::Project] {
assert_eq!(editor.scope, scope);
let mut terminal = Terminal::new(TestBackend::new(100, 80)).unwrap();
terminal.draw(|f| draw(f, f.area(), &editor)).unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains(&format!("Settings • {}", scope.label())));
assert_eq!(text.matches("[Ctrl-G]").count(), 1);
let buffer = terminal.backend().buffer();
let row = (0..80)
.map(|y| {
(0..100)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.find(|row| row.contains("[Ctrl-G]"))
.unwrap();
assert!(row.contains('â””'));
let EditorRequest::Load(next) =
editor.key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL))
else {
panic!("scope shortcut must load the other scope");
};
editor = SettingsEditor::loading(next);
editor.loaded(&Settings::default());
}
}
#[test]
fn settings_editor_status_is_centered_with_semantic_foreground_and_modal_background() {
let mut editor = ready_editor();
let theme = crate::tui::state::MissionControlState::default().theme;
for notice in [
"Loading settings…",
"Settings saved. Restart magi-code to apply changes.",
"Save failed: permission denied.",
] {
editor.notice = notice.into();
let mut terminal = Terminal::new(TestBackend::new(100, 80)).unwrap();
terminal.draw(|f| draw(f, f.area(), &editor)).unwrap();
let buffer = terminal.backend().buffer();
let y = (0..80)
.find(|&y| {
(0..100)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.contains(notice)
})
.unwrap();
let start = (11..89).find(|&x| buffer[(x, y)].symbol() != " ").unwrap();
let end = (11..89).rfind(|&x| buffer[(x, y)].symbol() != " ").unwrap();
assert!((start - 11).abs_diff(88 - end) <= 1);
for x in start..=end {
let cell = &buffer[(x, y)];
assert_eq!(cell.fg, theme.text_status());
assert_eq!(cell.bg, theme.app().bg.unwrap());
assert!(cell.modifier.contains(ratatui::style::Modifier::BOLD));
}
}
}
#[test]
fn settings_editor_save_hint_starts_footer_and_preserves_active_tab_when_narrow() {
let mut editor = ready_editor();
for width in [30, 50, 64, 80, 120] {
for (tab, name) in TABS.iter().enumerate() {
editor.tab = tab;
let mut terminal = Terminal::new(TestBackend::new(width, 60)).unwrap();
terminal
.draw(|frame| draw(frame, frame.area(), &editor))
.unwrap();
let buffer = terminal.backend().buffer();
let lines: Vec<String> = (0..60)
.map(|y| (0..width).map(|x| buffer[(x, y)].symbol()).collect())
.collect();
let tab_row = lines
.iter()
.position(|line| line.contains(&format!("[{name}]")))
.unwrap();
assert!(!lines[tab_row].contains("[Tab]"));
assert!(lines.iter().all(|line| !line.contains("Shift-Tab")));
let footer = lines
.iter()
.position(|line| line.contains("[Ctrl-S] Save"))
.unwrap();
assert!(footer > tab_row);
assert!(
lines[footer]
.trim_matches(|c: char| c.is_whitespace() || c == 'â””')
.starts_with("[Ctrl-S] Save")
);
if width >= 120 {
for label in TABS {
assert!(lines[tab_row].contains(label));
}
}
}
}
}
#[test]
fn settings_editor_compaction_overrides_persist_and_clear_as_a_pair() {
let temp = tempfile::tempdir().unwrap();
let paths = crate::config::McPaths::from_root(temp.path().join("mc"));
let mut editor = ready_editor();
editor.model_choices = vec![ModelChoice::from(
&crate::model_catalog::ModelCatalogEntry::new("openai-codex", "gpt-5.4"),
)];
for values in [["openai-codex", "gpt-5.4"], ["", ""]] {
editor.selected = editor
.visible_fields()
.iter()
.position(|field| field.path == "agent.compaction.model")
.unwrap();
editor.key(key(KeyCode::Enter));
editor.model_picker.as_mut().unwrap().selected = usize::from(!values[0].is_empty());
editor.key(key(KeyCode::Enter));
assert!(matches!(
editor.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)),
EditorRequest::Save
));
crate::config::save_settings_editor(&paths, SettingsScope::Global, &editor.changes())
.unwrap();
let loaded = crate::config::load_settings_editor(&paths, SettingsScope::Global).unwrap();
assert_eq!(
loaded.compaction.provider.as_deref(),
(!values[0].is_empty()).then_some(values[0])
);
assert_eq!(
loaded.compaction.model.as_deref(),
(!values[1].is_empty()).then_some(values[1])
);
editor.loaded(&loaded);
}
}
#[test]
fn models_settings_toggle_keeps_other_drafts_and_refresh_uses_keys_and_mouse() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut editor = ready_editor();
editor.key(key(KeyCode::Enter));
editor.tab = MODELS_TAB;
editor.models = vec![("openai-codex/test".into(), "Test".into())];
assert!(matches!(
editor.key(key(KeyCode::Enter)),
EditorRequest::RefreshCatalog
));
editor.key(key(KeyCode::Down));
editor.key(key(KeyCode::Enter));
assert!(!editor.model_enabled("openai-codex/test"));
assert_eq!(editor.changes().len(), 2);
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let (button, _) = render::models_geometry(area, &editor);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: button.x,
row: button.y,
modifiers: KeyModifiers::NONE,
};
assert!(matches!(
editor.mouse(mouse, area),
EditorRequest::RefreshCatalog
));
editor.catalog_loading = true;
assert!(matches!(editor.mouse(mouse, area), EditorRequest::None));
assert!(matches!(
editor.key(key(KeyCode::Enter)),
EditorRequest::None
));
assert_eq!(editor.changes().len(), 2);
}
#[test]
fn models_settings_scroll_and_mouse_share_rendered_rows_without_growing_modal() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut editor = ready_editor();
editor.tab = MODELS_TAB;
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let geometry = render::models_geometry(area, &editor);
editor.models = (0..100)
.map(|i| (format!("provider/model-{i}"), format!("Model {i}")))
.collect();
for _ in 0..100 {
editor.key(key(KeyCode::Down));
}
assert_eq!(editor.selected, 100);
assert_eq!(render::models_geometry(area, &editor), geometry);
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
terminal.draw(|f| draw(f, area, &editor)).unwrap();
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("Refresh catalog"));
assert!(text.contains("Model 99"));
assert!(!text.contains("[x] Model 0 "));
let list = geometry.1;
let first = models_scroll_offset(editor.selected, list.height.saturating_sub(2));
editor.mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: list.x,
row: list.y,
modifiers: KeyModifiers::NONE,
},
area,
);
assert!(!editor.model_enabled(&format!("provider/model-{first}")));
}
#[test]
fn models_settings_tab_entry_uses_cached_rows_without_refreshing() {
for models in [
vec![],
vec![("example/model".into(), "Cached model".into())],
] {
let mut editor = ready_editor();
editor.models = models.clone();
for _ in 0..2 {
assert!(matches!(editor.key(key(KeyCode::Tab)), EditorRequest::None));
}
assert_eq!(editor.tab, MODELS_TAB);
assert_eq!(editor.selected, 0);
assert_eq!(editor.models, models);
assert!(!editor.catalog_loading);
let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap();
terminal
.draw(|frame| draw(frame, frame.area(), &editor))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains(if models.is_empty() {
"No models available. Try Refresh catalog."
} else {
"Cached model"
}));
assert!(matches!(
editor.key(key(KeyCode::Char(' '))),
EditorRequest::None
));
for _ in 0..TABS.len() {
assert!(matches!(editor.key(key(KeyCode::Tab)), EditorRequest::None));
}
assert_eq!(editor.tab, MODELS_TAB);
assert_eq!(editor.models, models);
assert!(matches!(
editor.key(key(KeyCode::Enter)),
EditorRequest::RefreshCatalog
));
}
}
#[test]
fn settings_escape_border_matches_busy_dirty_and_editing_actions() {
let mut editor = ready_editor();
let render = |editor: &SettingsEditor| {
let mut terminal = Terminal::new(TestBackend::new(140, 50)).unwrap();
terminal
.draw(|frame| draw(frame, frame.area(), editor))
.unwrap();
terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect::<String>()
};
assert!(render(&editor).contains("[Esc] close"));
editor.key(key(KeyCode::Enter));
assert!(render(&editor).contains("[Esc] confirm discard"));
editor.key(key(KeyCode::Esc));
assert!(render(&editor).contains("[Esc] discard"));
editor.editing = Some(SingleLineField::plain());
assert!(render(&editor).contains("[Esc] cancel"));
editor.busy = true;
assert!(!render(&editor).contains("[Esc]"));
}