use super::helpers::*;
use super::*;
use crate::features::tools::default_tool_ids;
use crate::shared::config::{FlashAttn, MediaResolution, PythonMode, SpecType, WebProvider};
use crate::shared::embed_prefix::EmbedConvention;
use crate::shared::secrets::{ExternalSlot, SearchSlot};
fn screen() -> SettingsScreen {
let mut p = Profile::new("Базовый", "Ты — ассистент.");
p.enabled_tools = default_tool_ids();
SettingsScreen::new(AppConfig::default(), vec![p], vec![])
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn goto_section(s: &mut SettingsScreen, sec: Section) {
if s.focus == Focus::Fields {
s.handle_key(key(KeyCode::Esc));
}
for _ in 0..SECTIONS.len() {
if s.section() == sec {
return;
}
s.handle_key(key(KeyCode::Tab));
}
assert_eq!(s.section(), sec, "section {sec:?} not found");
}
fn field_desc(s: &SettingsScreen, id: FieldId) -> Option<String> {
let mut found = None;
s.visit_field_sets(&mut |_, _, _, _, fields| {
if found.is_none() {
found = fields
.into_iter()
.find(|r| r.id == id)
.and_then(|r| r.description);
}
});
found
}
fn field_label(s: &SettingsScreen, id: FieldId) -> Option<String> {
let mut found = None;
s.visit_field_sets(&mut |_, _, _, _, fields| {
if found.is_none() {
found = fields.into_iter().find(|r| r.id == id).map(|r| r.label);
}
});
found
}
fn goto_field(s: &mut SettingsScreen, id: FieldId) {
s.handle_key(key(KeyCode::Enter)); for _ in 0..300 {
if s.fields().get(s.field_idx).map(|f| f.id) == Some(id) {
return;
}
s.handle_key(key(KeyCode::Down));
}
panic!("field {id:?} not found in section {:?}", s.section());
}
fn goto_field_again(s: &mut SettingsScreen, id: FieldId) {
s.handle_key(key(KeyCode::Esc));
goto_field(s, id);
}
fn enter_secret(s: &mut SettingsScreen, value: &str) -> Option<SettingsIntent> {
s.handle_key(key(KeyCode::Enter));
let editor = s
.editor
.as_ref()
.expect("the secret field's editor is open");
assert_eq!(editor.input.text(), "", "a stored secret can't be shown");
assert!(editor.input.is_masked(), "a secret field must be masked");
for c in value.chars() {
s.handle_key(key(KeyCode::Char(c)));
}
s.handle_key(key(KeyCode::Enter))
}
#[test]
fn esc_closes_from_the_sections() {
let mut s = screen();
assert_eq!(s.handle_key(key(KeyCode::Esc)), Some(SettingsIntent::Close));
}
#[test]
fn esc_steps_out_of_the_field_pane_then_closes() {
let mut s = screen();
s.handle_key(key(KeyCode::Enter));
assert!(s.focus == Focus::Fields);
assert_eq!(s.handle_key(key(KeyCode::Esc)), None, "must not close yet");
assert!(s.focus == Focus::Menu);
assert_eq!(s.handle_key(key(KeyCode::Esc)), Some(SettingsIntent::Close));
}
#[test]
fn right_does_not_enter_the_field_pane() {
let mut s = screen();
assert_eq!(s.handle_key(key(KeyCode::Right)), None);
assert!(s.focus == Focus::Menu, "`→` must not enter the field pane");
s.handle_key(key(KeyCode::Enter));
assert!(s.focus == Focus::Fields);
}
#[test]
fn left_on_a_choice_field_cycles_and_keeps_focus() {
let mut s = screen();
goto_section(&mut s, Section::Interface);
goto_field(&mut s, FieldId::ITheme);
let before = s.config.interface.theme;
let intent = s.handle_key(key(KeyCode::Left));
assert!(matches!(intent, Some(SettingsIntent::SaveConfig(_))));
assert_ne!(s.config.interface.theme, before);
assert!(s.focus == Focus::Fields, "`←` must not leave the pane");
}
#[test]
fn left_on_a_non_choice_field_is_a_no_op() {
let mut s = screen();
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::TWeb); let idx = s.field_idx;
assert_eq!(s.handle_key(key(KeyCode::Left)), None);
assert!(s.focus == Focus::Fields, "`←` must not leave the pane");
assert_eq!(s.field_idx, idx, "and must not move the cursor");
}
#[test]
fn tab_preserves_focus_and_resets_the_field_index() {
let mut s = screen();
s.handle_key(key(KeyCode::Tab));
assert!(s.focus == Focus::Menu);
s.handle_key(key(KeyCode::Enter));
s.handle_key(key(KeyCode::Down));
let section_before = s.section();
s.handle_key(key(KeyCode::Tab));
assert!(s.focus == Focus::Fields, "Tab must not drop the focus");
assert_ne!(s.section(), section_before);
assert_eq!(s.field_idx, 0);
}
#[test]
fn up_on_the_first_field_stays_in_the_pane() {
let mut s = screen();
s.handle_key(key(KeyCode::Enter));
assert_eq!(s.handle_key(key(KeyCode::Up)), None);
assert!(s.focus == Focus::Fields);
assert_eq!(s.field_idx, 0);
}
#[test]
fn interface_language_field_cycles_and_relocalizes() {
use crate::shared::i18n::Lang;
let mut s = screen();
assert_eq!(s.config.interface.language, Lang::Ru);
assert!(field_desc(&s, FieldId::ILanguage).is_some());
goto_section(&mut s, Section::Interface);
goto_field(&mut s, FieldId::ILanguage);
s.handle_key(key(KeyCode::Right));
assert_eq!(s.config.interface.language, Lang::En);
assert_eq!(s.loc().t("ui.settings.section.interface"), "Interface");
}
#[test]
fn ctrl_q_and_f10_quit() {
let mut s = screen();
assert_eq!(s.handle_key(ctrl('q')), Some(SettingsIntent::Quit));
assert_eq!(
s.handle_key(key(KeyCode::F(10))),
Some(SettingsIntent::Quit)
);
s.editor = Some(Editor {
field: FieldId::XBinary,
input: InputBox::new(),
multiline: false,
error: None,
});
assert_eq!(s.handle_key(ctrl('q')), Some(SettingsIntent::Quit));
}
#[test]
fn tab_cycles_sections() {
let mut s = screen();
assert_eq!(s.section(), Section::Model);
s.handle_key(key(KeyCode::Tab));
assert_eq!(s.section(), Section::Sampling);
s.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE));
assert_eq!(s.section(), Section::Model);
}
#[test]
fn toggle_web_emits_save_with_flipped_value() {
let mut s = screen();
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::TWeb);
let before = s.config.tools.web_enabled;
let intent = s.handle_key(key(KeyCode::Char(' ')));
match intent {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.tools.web_enabled, !before),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn tools_have_confirm_dangerous_toggle() {
let mut s = screen();
goto_section(&mut s, Section::Tools);
let ids: Vec<FieldId> = s.fields().iter().map(|f| f.id).collect();
assert!(ids.contains(&FieldId::TConfirmDangerous));
goto_field(&mut s, FieldId::TConfirmDangerous);
match s.handle_key(key(KeyCode::Char(' '))) {
Some(SettingsIntent::SaveConfig(c)) => assert!(c.tools.confirm_dangerous),
other => panic!("expected SaveConfig, got {other:?}"),
}
assert!(field_desc(&s, FieldId::TConfirmDangerous).is_some());
}
#[test]
fn python_group_visibility_follows_mode() {
let mut s = screen();
goto_section(&mut s, Section::Tools);
let ids: Vec<FieldId> = s.fields().iter().map(|f| f.id).collect();
assert!(ids.contains(&FieldId::TPythonMode));
assert!(ids.contains(&FieldId::TPython));
assert!(ids.contains(&FieldId::TPythonNet));
assert!(ids.contains(&FieldId::TPythonImages));
assert!(ids.contains(&FieldId::TPythonWasmTimeout));
assert!(ids.contains(&FieldId::TPythonWasmMemory));
assert!(!ids.contains(&FieldId::TPythonPath));
assert!(!ids.contains(&FieldId::TPythonLocalMemory));
let mut cfg = AppConfig::default();
cfg.tools.python_mode = PythonMode::Local;
let mut p = Profile::new("Базовый", "Ты — ассистент.");
p.enabled_tools = default_tool_ids();
let mut s = SettingsScreen::new(cfg, vec![p], vec![]);
goto_section(&mut s, Section::Tools);
let ids: Vec<FieldId> = s.fields().iter().map(|f| f.id).collect();
assert!(ids.contains(&FieldId::TPythonMode));
assert!(ids.contains(&FieldId::TPythonPath));
assert!(!ids.contains(&FieldId::TPythonNet));
assert!(!ids.contains(&FieldId::TPythonWasmTimeout));
assert!(!ids.contains(&FieldId::TPythonWasmMemory));
assert!(ids.contains(&FieldId::TPythonLocalMemory));
assert!(ids.contains(&FieldId::TPythonImages));
}
#[test]
fn keyed_search_rows_follow_the_provider_choice() {
let tools_ids = |provider: WebProvider| -> Vec<FieldId> {
let mut cfg = AppConfig::default();
cfg.tools.web_provider = provider;
let mut p = Profile::new("Базовый", "Ты — ассистент.");
p.enabled_tools = default_tool_ids();
let mut s = SettingsScreen::new(cfg, vec![p], vec![]);
goto_section(&mut s, Section::Tools);
s.fields().iter().map(|f| f.id).collect()
};
let auto = tools_ids(WebProvider::Auto);
assert!(auto.contains(&FieldId::TWebProvider));
for id in [FieldId::TWebTavilyKey, FieldId::TWebTavilyKeyEnv] {
assert!(auto.contains(&id), "auto shows the key row: {id:?}");
}
let free = tools_ids(WebProvider::FreeOnly);
assert!(
free.contains(&FieldId::TWebProvider),
"the choice itself stays, or there is no way back"
);
for id in [FieldId::TWebTavilyKey, FieldId::TWebTavilyKeyEnv] {
assert!(!free.contains(&id), "free_only spends no key: {id:?}");
}
}
#[test]
fn keyed_search_rows_address_their_own_secret_slot() {
let mut cfg = AppConfig::default();
cfg.tools.web_provider = WebProvider::Auto;
let mut p = Profile::new("Базовый", "Ты — ассистент.");
p.enabled_tools = default_tool_ids();
let s = SettingsScreen::new(cfg, vec![p], vec![]);
assert_eq!(
s.secret_field_key(FieldId::TWebTavilyKey),
Some(SecretKey::Search(SearchSlot::Tavily))
);
assert_eq!(
SecretKey::Search(SearchSlot::Tavily).storage_name(),
"search-tavily"
);
}
#[test]
fn python_mode_cycles_and_emits_save() {
let mut s = screen();
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::TPythonMode);
let intent = s.handle_key(key(KeyCode::Right));
match intent {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.tools.python_mode, PythonMode::Local)
}
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn video_group_rows_present_grouped_and_ordered() {
let s = screen();
let fields = s.tool_fields();
let video_ids: Vec<FieldId> = fields
.iter()
.filter(|f| {
matches!(
f.id,
FieldId::VideoModel
| FieldId::VideoResolution
| FieldId::VideoMaxMinutes
| FieldId::VideoApiKeyEnv
)
})
.map(|f| f.id)
.collect();
assert_eq!(
video_ids,
vec![
FieldId::VideoModel,
FieldId::VideoResolution,
FieldId::VideoMaxMinutes,
FieldId::VideoApiKeyEnv,
]
);
for id in &video_ids {
let group = fields.iter().find(|f| f.id == *id).unwrap().group;
assert_eq!(
group, "Видео (YouTube)",
"field {id:?} not in the video group"
);
}
for id in video_ids {
assert!(
field_desc(&s, id).is_some(),
"field {id:?} has no description"
);
}
}
#[test]
fn video_resolution_field_cycles_and_saves() {
let mut s = screen();
assert_eq!(s.config.video.media_resolution, MediaResolution::Low);
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::VideoResolution);
let intent = s.handle_key(key(KeyCode::Right));
match intent {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.video.media_resolution, MediaResolution::Medium)
}
other => panic!("expected SaveConfig, got {other:?}"),
}
assert_eq!(s.config.video.media_resolution, MediaResolution::Medium);
}
#[test]
fn video_model_text_field_writes_config() {
let mut s = screen();
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::VideoModel);
s.handle_key(key(KeyCode::Enter)); s.handle_key(ctrl('k')); for c in "gemini-2.5-pro".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.video.model_name.as_deref(), Some("gemini-2.5-pro"))
}
other => panic!("expected SaveConfig, got {other:?}"),
}
let mut s2 = screen();
goto_section(&mut s2, Section::Tools);
goto_field(&mut s2, FieldId::VideoModel);
s2.handle_key(key(KeyCode::Enter));
s2.handle_key(ctrl('k'));
match s2.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.video.model_name, None),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn video_max_minutes_is_a_validated_int_field() {
let mut s = screen();
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::VideoMaxMinutes);
assert_eq!(field_num_kind(FieldId::VideoMaxMinutes), Some(NumKind::Int));
s.handle_key(key(KeyCode::Enter)); s.handle_key(ctrl('k'));
s.handle_key(key(KeyCode::Char('x')));
assert!(s.handle_key(key(KeyCode::Enter)).is_none());
assert!(
s.editor.is_some(),
"the editor stays open on an invalid number"
);
assert!(s.editor.as_ref().unwrap().error.is_some());
s.handle_key(ctrl('k'));
for c in "45".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.video.max_minutes, 45),
other => panic!("expected SaveConfig, got {other:?}"),
}
let mut s3 = screen();
goto_section(&mut s3, Section::Tools);
goto_field(&mut s3, FieldId::VideoMaxMinutes);
s3.handle_key(key(KeyCode::Enter));
s3.handle_key(ctrl('k'));
s3.handle_key(key(KeyCode::Char('0')));
match s3.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.video.max_minutes, 0),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn video_api_key_env_text_field_writes_config() {
let mut s = screen();
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::VideoApiKeyEnv);
s.handle_key(key(KeyCode::Enter));
for c in "GEMINI_API_KEY".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.video.api_key_env.as_deref(), Some("GEMINI_API_KEY"))
}
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn model_section_shows_active_subsection_server_chip() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let render_text = |s: &mut SettingsScreen| -> String {
let mut term = Terminal::new(TestBackend::new(94, 12)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
};
let mut s = screen();
s.set_server_statuses(ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Connecting,
impersonation: ServerStatus::NotConfigured,
});
let t = render_text(&mut s);
assert!(t.contains("чат: готов"), "chat-server chip: {t}");
s.model_sub = ModelTab::Embeddings;
let t = render_text(&mut s);
assert!(
t.contains("эмбеддинги: подключение"),
"embeddings chip: {t}"
);
assert!(!t.contains("чат: готов"), "the other chip isn't shown");
goto_section(&mut s, Section::Interface);
let t = render_text(&mut s);
assert!(!t.contains("готов") && !t.contains("подключение"));
}
#[test]
fn a_long_server_reason_is_cut_not_dropped() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let head = |s: &mut SettingsScreen| -> String {
let mut term = Terminal::new(TestBackend::new(94, 12)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer();
(0..buf.area.width)
.map(|x| buf[(x, 1)].symbol().to_string())
.collect()
};
let mut s = screen();
s.model_sub = ModelTab::Embeddings;
let why = "порт 8001 занят другой программой — выберите другой: Настройки → Модель/сервер → Эмбеддинги → Порт (сервер не запущен: иначе её ответы сошли бы за его)";
s.set_server_statuses(ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Disconnected(why.into()),
impersonation: ServerStatus::NotConfigured,
});
let row = head(&mut s);
assert!(
row.contains("эмбеддинги: нет связи: порт 8001 занят") && row.contains('…'),
"{row}"
);
s.set_server_statuses(ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Disconnected("порт 8001 занят".into()),
impersonation: ServerStatus::NotConfigured,
});
let row = head(&mut s);
assert!(
row.contains("порт 8001 занят") && !row.contains('…'),
"{row}"
);
}
#[test]
fn embeddings_tab_has_the_input_convention_field() {
let mut s = screen();
goto_section(&mut s, Section::Model);
s.model_sub = ModelTab::Embeddings;
assert!(
s.fields().iter().any(|f| f.id == FieldId::EConvention),
"the convention field is present in managed mode"
);
s.config.embed.mode = ServerMode::OpenAi;
assert!(
s.fields().iter().any(|f| f.id == FieldId::EConvention),
"and in a cloud mode too"
);
s.model_sub = ModelTab::Assistant;
assert!(!s.fields().iter().any(|f| f.id == FieldId::EConvention));
}
#[test]
fn cycling_the_input_convention_saves_it() {
let mut s = screen();
assert_eq!(s.config.embed.convention, EmbedConvention::None, "default");
match s.cycle_field(FieldId::EConvention, 1) {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.embed.convention, EmbedConvention::E5)
}
other => panic!("expected a config save, got {other:?}"),
}
let mut s2 = screen();
goto_section(&mut s2, Section::Model);
s2.model_sub = ModelTab::Embeddings;
let row = s2
.fields()
.into_iter()
.find(|f| f.id == FieldId::EConvention)
.unwrap();
assert!(row.description.is_some());
}
#[test]
fn interface_has_terminal_compat_toggle() {
let mut s = screen();
let rows = s.interface_fields();
assert!(rows.iter().any(|r| r.id == FieldId::ICompat));
match s.toggle_field(FieldId::ICompat) {
Some(SettingsIntent::SaveConfig(c)) => assert!(c.interface.terminal_compat),
other => panic!("expected SaveConfig, got {other:?}"),
}
assert!(s.palette().compat);
assert!(field_desc(&s, FieldId::ICompat).is_some());
}
#[test]
fn interface_has_the_colour_mode_choice() {
use crate::shared::config::ThemeMode;
let mut s = screen();
let rows = s.interface_fields();
let ids: Vec<FieldId> = rows.iter().map(|r| r.id).collect();
let at = |id| ids.iter().position(|x| *x == id);
assert!(
at(FieldId::IThemeMode) < at(FieldId::ITheme),
"the mode decides what the theme row is, so it comes first: {ids:?}"
);
assert!(field_desc(&s, FieldId::IThemeMode).is_some());
assert_eq!(s.config.interface.mode(), ThemeMode::System);
assert_eq!(s.config.interface.theme_mode, None);
let mut seen = vec![s.config.interface.theme_mode];
for _ in 0..ThemeMode::ALL.len() {
match s.cycle_field(FieldId::IThemeMode, 1) {
Some(SettingsIntent::SaveConfig(c)) => seen.push(c.interface.theme_mode),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
assert_eq!(
seen,
vec![None, Some(ThemeMode::Full), Some(ThemeMode::Mono), None],
"round to the default is back to absence, not to a pinned `system`"
);
s.cycle_field(FieldId::IThemeMode, -1);
assert_eq!(s.config.interface.mode(), ThemeMode::Mono);
let (options, at) = s.choice_menu(FieldId::IThemeMode).unwrap();
assert_eq!(options.len(), ThemeMode::ALL.len());
assert_eq!(options[at], theme_mode_label(ThemeMode::Mono, s.loc()));
let mut names = options.clone();
names.dedup();
assert_eq!(names, options, "two modes under one label");
}
#[test]
fn the_monochrome_mode_has_no_theme_row() {
use crate::shared::config::{Theme, ThemeMode};
let mut s = screen();
goto_section(&mut s, Section::Interface);
s.config.interface.theme = Theme::Light;
s.config.interface.full_theme = "light".into();
let theme_rows = |s: &SettingsScreen| -> Vec<FieldId> {
s.interface_fields()
.into_iter()
.map(|r| r.id)
.filter(|id| matches!(id, FieldId::ITheme | FieldId::IFullTheme))
.collect()
};
let rows_before = s.interface_fields().len();
s.config.interface.set_mode(ThemeMode::Mono);
assert_eq!(theme_rows(&s), Vec::<FieldId>::new());
assert_eq!(
s.interface_fields().len(),
rows_before - 1,
"only the theme row goes"
);
assert!(s.palette().mono, "the screen draws in the mode it shows");
match s.reset_field(FieldId::IThemeMode) {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.interface.theme_mode, None),
other => panic!("expected SaveConfig, got {other:?}"),
}
assert_eq!(theme_rows(&s), vec![FieldId::ITheme]);
assert_eq!(s.config.interface.theme, Theme::Light, "kept through it");
s.config.interface.set_mode(ThemeMode::Mono);
s.cycle_field(FieldId::IThemeMode, -1);
assert_eq!(theme_rows(&s), vec![FieldId::IFullTheme]);
assert_eq!(s.config.interface.full_theme, "light", "kept through it");
assert!(!s.palette().mono);
}
#[test]
fn the_theme_row_is_the_theme_of_the_colour_mode_in_effect() {
use crate::shared::config::{Theme, ThemeMode};
let mut s = screen();
let theme_row = |s: &SettingsScreen| -> (FieldId, String, String) {
let rows: Vec<FieldRow> = s
.interface_fields()
.into_iter()
.filter(|r| matches!(r.id, FieldId::ITheme | FieldId::IFullTheme))
.collect();
assert_eq!(rows.len(), 1, "exactly one theme row at a time");
let row = rows.into_iter().next().unwrap();
let value = value_text(&row.kind, s.loc());
(row.id, row.label, value)
};
s.config.interface.theme = Theme::Light;
let (id, system_label, value) = theme_row(&s);
assert_eq!(id, FieldId::ITheme);
assert_eq!(value, theme_label(Theme::Light, s.loc()));
s.cycle_field(FieldId::IThemeMode, 1);
assert_eq!(s.config.interface.mode(), ThemeMode::Full);
let (id, full_label, value) = theme_row(&s);
assert_eq!(id, FieldId::IFullTheme);
assert_eq!(full_label, system_label, "one row to the eye");
assert_eq!(value, full_theme_label("dark", s.loc()));
assert_ne!(
field_desc(&s, FieldId::IFullTheme),
None,
"the full theme explains itself"
);
match s.cycle_field(FieldId::IFullTheme, 1) {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.interface.full_theme, "light");
assert_eq!(c.interface.theme, Theme::Light);
}
other => panic!("expected SaveConfig, got {other:?}"),
}
s.cycle_field(FieldId::IThemeMode, -1);
assert_eq!(theme_row(&s).0, FieldId::ITheme);
assert_eq!(s.config.interface.full_theme, "light");
s.cycle_field(FieldId::IThemeMode, 1);
assert_eq!(theme_row(&s).2, full_theme_label("light", s.loc()));
}
#[test]
fn the_working_copy_palette_follows_the_colour_mode() {
use crate::shared::theme::{CANVAS_DARK, CANVAS_LIGHT};
use ratatui::style::Color;
let mut s = screen();
assert_eq!(
s.palette().canvas,
Color::Reset,
"system: the terminal's own"
);
s.cycle_field(FieldId::IThemeMode, 1);
assert_eq!(s.palette().canvas, CANVAS_DARK);
s.cycle_field(FieldId::IFullTheme, 1);
assert_eq!(s.palette().canvas, CANVAS_LIGHT);
assert!(!s.palette().dark);
assert!(!s.palette().mono);
s.cycle_field(FieldId::IThemeMode, 1);
assert_eq!(s.palette().canvas, Color::Reset);
assert!(s.palette().mono);
s.cycle_field(FieldId::IThemeMode, 1);
assert_eq!(s.palette().canvas, Color::Reset);
assert!(!s.palette().mono);
}
#[test]
fn the_full_theme_and_the_colour_mode_reset_to_their_defaults() {
use crate::shared::config::ThemeMode;
let mut s = screen();
goto_section(&mut s, Section::Interface);
s.cycle_field(FieldId::IThemeMode, 1);
s.cycle_field(FieldId::IFullTheme, 1);
assert_eq!(s.config.interface.full_theme, "light");
let defaults = s.default_fields();
let loc = s.loc();
let default_of = |id| {
defaults
.iter()
.find(|r| r.id == id)
.map(|r| value_text(&r.kind, loc))
};
assert_eq!(
default_of(FieldId::IFullTheme),
Some(full_theme_label("dark", loc))
);
assert_eq!(
default_of(FieldId::IThemeMode),
Some(theme_mode_label(ThemeMode::System, loc))
);
match s.reset_field(FieldId::IFullTheme) {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.interface.full_theme, "dark"),
other => panic!("expected SaveConfig, got {other:?}"),
}
assert_eq!(s.reset_field(FieldId::IFullTheme), None, "already there");
match s.reset_field(FieldId::IThemeMode) {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.interface.theme_mode, None),
other => panic!("expected SaveConfig, got {other:?}"),
}
assert_eq!(s.reset_field(FieldId::IThemeMode), None, "already there");
}
#[test]
fn a_full_theme_nothing_answers_to_is_listed_and_can_be_left() {
let mut s = screen();
s.cycle_field(FieldId::IThemeMode, 1);
let lost = |s: &mut SettingsScreen| s.config.interface.full_theme = "gruvbox".into();
lost(&mut s);
let (options, at) = s.choice_menu(FieldId::IFullTheme).unwrap();
assert_eq!(options.last().map(String::as_str), Some("gruvbox"));
assert_eq!(at, options.len() - 1);
assert_eq!(s.palette(), crate::shared::theme::Palette::full("dark"));
s.cycle_field(FieldId::IFullTheme, 1);
assert_eq!(s.config.interface.full_theme, "dark", "forward: the first");
lost(&mut s);
s.cycle_field(FieldId::IFullTheme, -1);
assert_eq!(s.config.interface.full_theme, "light", "backward: the last");
lost(&mut s);
s.apply_choice(FieldId::IFullTheme, 0);
assert_eq!(s.config.interface.full_theme, "dark");
let (options, _) = s.choice_menu(FieldId::IFullTheme).unwrap();
assert!(!options.contains(&"gruvbox".to_string()), "{options:?}");
}
#[test]
fn interface_has_the_osc52_choice() {
let mut s = screen();
let rows = s.interface_fields();
assert!(rows.iter().any(|r| r.id == FieldId::IClipboardOsc52));
assert!(field_desc(&s, FieldId::IClipboardOsc52).is_some());
use crate::shared::osc52::Osc52Mode;
let mut seen = vec![s.config.interface.clipboard_osc52];
for _ in 0..3 {
match s.cycle_field(FieldId::IClipboardOsc52, 1) {
Some(SettingsIntent::SaveConfig(c)) => seen.push(c.interface.clipboard_osc52),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
assert_eq!(
seen,
vec![
Osc52Mode::Auto,
Osc52Mode::Always,
Osc52Mode::Off,
Osc52Mode::Auto
],
"the cycle must return to where it started"
);
}
#[test]
fn interface_has_the_auto_title_choice() {
let mut s = screen();
let rows = s.interface_fields();
assert!(rows.iter().any(|r| r.id == FieldId::IAutoTitle));
assert!(field_desc(&s, FieldId::IAutoTitle).is_some());
use crate::shared::config::AutoTitleMode;
assert_eq!(
s.config.interface.auto_title,
AutoTitleMode::AfterAssistantReply
);
let mut seen = vec![s.config.interface.auto_title];
for _ in 0..3 {
match s.cycle_field(FieldId::IAutoTitle, 1) {
Some(SettingsIntent::SaveConfig(c)) => seen.push(c.interface.auto_title),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
assert_eq!(
seen,
vec![
AutoTitleMode::AfterAssistantReply,
AutoTitleMode::Off,
AutoTitleMode::AfterUserMessage,
AutoTitleMode::AfterAssistantReply,
],
"the cycle must return to where it started"
);
}
#[test]
fn interface_has_the_self_model_note_order_choice() {
let mut s = screen();
let rows = s.interface_fields();
assert!(rows.iter().any(|r| r.id == FieldId::ISmNoteOrder));
assert!(field_desc(&s, FieldId::ISmNoteOrder).is_some());
use crate::shared::config::NoteOrder;
assert_eq!(
s.config.interface.self_model_note_order,
NoteOrder::NewestFirst
);
let mut seen = vec![s.config.interface.self_model_note_order];
for _ in 0..2 {
match s.cycle_field(FieldId::ISmNoteOrder, 1) {
Some(SettingsIntent::SaveConfig(c)) => seen.push(c.interface.self_model_note_order),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
assert_eq!(
seen,
vec![
NoteOrder::NewestFirst,
NoteOrder::OldestFirst,
NoteOrder::NewestFirst,
],
"the cycle must return to where it started"
);
}
#[test]
fn interface_has_table_separators_toggle() {
let mut s = screen();
let rows = s.interface_fields();
assert!(rows.iter().any(|r| r.id == FieldId::ITableSeparators));
match s.toggle_field(FieldId::ITableSeparators) {
Some(SettingsIntent::SaveConfig(c)) => assert!(c.interface.table_row_separators),
other => panic!("expected SaveConfig, got {other:?}"),
}
assert!(field_desc(&s, FieldId::ITableSeparators).is_some());
}
#[test]
fn interface_has_mermaid_toggle() {
let mut s = screen();
let rows = s.interface_fields();
assert!(rows.iter().any(|r| r.id == FieldId::IMermaid));
match s.toggle_field(FieldId::IMermaid) {
Some(SettingsIntent::SaveConfig(c)) => assert!(!c.interface.render_mermaid),
other => panic!("expected SaveConfig, got {other:?}"),
}
assert!(field_desc(&s, FieldId::IMermaid).is_some());
}
#[test]
fn interface_has_model_name_toggle() {
let mut s = screen();
let rows = s.interface_fields();
assert!(rows.iter().any(|r| r.id == FieldId::IModelName));
match s.toggle_field(FieldId::IModelName) {
Some(SettingsIntent::SaveConfig(c)) => assert!(c.interface.show_model_name),
other => panic!("expected SaveConfig, got {other:?}"),
}
assert!(field_desc(&s, FieldId::IModelName).is_some());
}
#[test]
fn interface_input_rows_commits_and_clamps() {
use crate::shared::config::{DEFAULT_INPUT_MAX_ROWS, INPUT_MAX_ROWS_LIMIT};
fn edit(typed: &str) -> (AppConfig, SettingsScreen) {
let mut s = screen();
goto_section(&mut s, Section::Interface);
goto_field(&mut s, FieldId::IInputRows);
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k'));
for c in typed.chars() {
s.handle_key(key(KeyCode::Char(c)));
}
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => (*c, s),
other => panic!("{typed:?}: expected SaveConfig, got {other:?}"),
}
}
let shown = |s: &SettingsScreen| {
s.interface_fields()
.into_iter()
.find(|f| f.id == FieldId::IInputRows)
.map(|f| value_text(&f.kind, s.loc()))
.expect("the input-rows row")
};
let s = screen();
assert!(field_desc(&s, FieldId::IInputRows).is_some());
assert_eq!(field_num_kind(FieldId::IInputRows), Some(NumKind::Int));
assert_eq!(shown(&s), DEFAULT_INPUT_MAX_ROWS.to_string());
let (cfg, s) = edit("12");
assert_eq!(cfg.interface.input_max_rows, 12);
assert_eq!(shown(&s), "12");
assert_eq!(edit("0").0.interface.input_max_rows, 1);
assert_eq!(
edit("1000").0.interface.input_max_rows,
INPUT_MAX_ROWS_LIMIT
);
}
#[test]
fn cycle_mode_changes_server_mode() {
let mut s = screen();
s.handle_key(key(KeyCode::Enter)); s.handle_key(key(KeyCode::Down)); let intent = s.handle_key(key(KeyCode::Right));
match intent {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.engine.mode, ServerMode::External),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn model_subsection_switches_to_impersonation_fields() {
let mut s = screen();
s.handle_key(key(KeyCode::Enter)); assert_eq!(s.handle_key(key(KeyCode::Right)), None);
assert_eq!(s.model_sub, ModelTab::Impersonation);
s.handle_key(key(KeyCode::Down)); let intent = s.handle_key(key(KeyCode::Right));
match intent {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.impersonation_engine.mode, ImpersonationMode::Managed)
}
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn model_subsection_third_tab_is_embeddings() {
let mut s = screen();
s.handle_key(key(KeyCode::Enter)); s.handle_key(key(KeyCode::Right)); s.handle_key(key(KeyCode::Right)); assert_eq!(s.model_sub, ModelTab::Embeddings);
let ids: Vec<FieldId> = s.fields().iter().map(|f| f.id).collect();
assert!(ids.contains(&FieldId::EMode));
assert!(ids.contains(&FieldId::EBinary));
goto_section(&mut s, Section::Tools);
assert!(!s.fields().iter().any(|f| f.id == FieldId::EMode));
}
#[test]
fn model_subsection_fourth_tab_is_tts_with_mode_driven_fields() {
let mut s = screen();
s.handle_key(key(KeyCode::Enter)); for _ in 0..3 {
s.handle_key(key(KeyCode::Right));
}
assert_eq!(s.model_sub, ModelTab::Tts);
let ids: Vec<FieldId> = s.fields().iter().map(|f| f.id).collect();
assert!(ids.contains(&FieldId::TtsMode));
assert!(ids.contains(&FieldId::TtsModelName));
assert!(ids.contains(&FieldId::TtsVoice));
assert!(ids.contains(&FieldId::TtsInstructions));
assert!(ids.contains(&FieldId::TtsApiKey));
assert!(ids.contains(&FieldId::TtsSpeakRoles));
assert!(ids.contains(&FieldId::TtsStopOnSwitch));
assert!(ids.contains(&FieldId::TtsStopOnGeneration));
s.config.tts.mode = crate::shared::config::TtsMode::External;
let ids: Vec<FieldId> = s.fields().iter().map(|f| f.id).collect();
assert!(ids.contains(&FieldId::TtsUrl));
assert!(ids.contains(&FieldId::TtsApiKey));
assert_eq!(
s.secret_field_key(FieldId::TtsApiKey),
Some(SecretKey::External(ExternalSlot::Tts))
);
assert!(
!ids.contains(&FieldId::TtsInstructions),
"instructions — only the OpenAI cloud"
);
}
#[test]
fn tts_toggles_and_mode_are_saved() {
let mut s = screen();
goto_section(&mut s, Section::Model);
s.model_sub = ModelTab::Tts;
goto_field(&mut s, FieldId::TtsMode);
s.handle_key(key(KeyCode::Right));
assert_eq!(
s.config.tts.mode,
crate::shared::config::TtsMode::Gemini,
"the mode cycle follows TtsMode::ALL"
);
let mut s = screen();
goto_section(&mut s, Section::Model);
s.model_sub = ModelTab::Tts;
goto_field(&mut s, FieldId::TtsSpeakRoles);
let intent = s.handle_key(key(KeyCode::Char(' ')));
assert!(matches!(intent, Some(SettingsIntent::SaveConfig(_))));
assert!(
s.config.tts.speak_roles,
"the \"Speak roles\" toggle turned on"
);
assert!(field_desc(&s, FieldId::TtsMode).is_some());
assert!(field_desc(&s, FieldId::TtsSpeakRoles).is_some());
}
#[test]
fn section_counts_sum_matches_search_index() {
let mut s = screen();
for &m in SERVER_MODES.iter() {
s.config.engine.mode = m;
s.config.embed.mode = m;
let total: usize = s.section_counts().iter().sum();
let search_total = s.build_search_index().len();
assert_eq!(total, search_total, "mode {m:?}");
}
}
#[test]
fn section_count_tracks_selected_mode() {
let mut s = screen();
s.config.engine.mode = ServerMode::Managed;
s.config.impersonation_engine.mode = ImpersonationMode::Shared;
s.config.embed.mode = ServerMode::Managed;
let managed = s.section_field_count(Section::Model);
s.config.engine.mode = ServerMode::OpenAi;
let cloud = s.section_field_count(Section::Model);
assert!(
cloud < managed,
"cloud must show fewer fields: managed={managed} cloud={cloud}"
);
let model_from_search = s
.build_search_index()
.iter()
.filter(|h| SECTIONS[h.section_idx] == Section::Model)
.count();
assert_eq!(cloud, model_from_search);
}
#[test]
fn section_field_count_excludes_subsection_selector() {
let s = screen();
assert_eq!(
s.section_field_count(Section::Sampling),
Subsection::ALL.len() * SAMPLING_PARAMS.len(),
"the subsection selector leaked into the sampling count"
);
}
fn goto_impersonation_sub(s: &mut SettingsScreen) {
goto_section(s, Section::Profiles);
goto_field(s, FieldId::ProfileSub); s.handle_key(key(KeyCode::Right)); assert_eq!(s.profile_sub, Subsection::Impersonation);
}
#[test]
fn impersonation_profile_subsection_edits_its_own_list_without_tools() {
let mut s = screen();
s.config.impersonation_profiles = vec![crate::shared::config::ImpersonationProfile::new(
"Юзер",
"Ты — Владимир.",
)];
goto_impersonation_sub(&mut s);
let fields = s.fields();
assert!(
!fields.iter().any(|f| matches!(f.id, FieldId::PTool(_))),
"the impersonation subsection has no tool toggles"
);
assert!(fields.iter().any(|f| f.id == FieldId::IpSelect));
assert!(fields.iter().any(|f| f.id == FieldId::IpName));
assert!(fields.iter().any(|f| f.id == FieldId::IpSystem));
assert!(
!fields
.iter()
.any(|f| matches!(f.id, FieldId::PSelect | FieldId::PName)),
"the assistant profile selector/name don't belong on the impersonation tab"
);
}
#[test]
fn impersonation_profile_create_edit_delete() {
let mut s = screen();
goto_impersonation_sub(&mut s);
assert!(s.fields().iter().all(|f| f.id != FieldId::IpName));
assert!(matches!(
s.handle_key(ctrl('n')),
Some(SettingsIntent::SaveConfig(_))
));
assert_eq!(s.config.impersonation_profiles.len(), 1);
assert_eq!(s.imp_profile_idx, 0);
goto_field(&mut s, FieldId::IpName);
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k')); for c in "Владимир".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
assert!(matches!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::SaveConfig(_))
));
assert_eq!(s.config.impersonation_profiles[0].name, "Владимир");
s.handle_key(key(KeyCode::Esc)); goto_field(&mut s, FieldId::IpSystem);
s.handle_key(key(KeyCode::Enter));
for c in "Ты — я.".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
s.handle_key(key(KeyCode::Enter));
assert_eq!(s.config.impersonation_profiles[0].system_message, "Ты — я.");
assert!(matches!(
s.handle_key(ctrl('d')),
Some(SettingsIntent::SaveConfig(_))
));
assert!(s.config.impersonation_profiles.is_empty());
}
#[test]
fn assistant_profile_references_impersonation_profile() {
let mut s = screen();
let ip = crate::shared::config::ImpersonationProfile::new("Юзер", "Ты — Владимир.");
let ip_id = ip.id;
s.config.impersonation_profiles = vec![ip];
goto_section(&mut s, Section::Profiles);
goto_field(&mut s, FieldId::PImpProfile);
assert_eq!(
s.fields()
.iter()
.find(|f| f.id == FieldId::PImpProfile)
.map(|f| value_text(&f.kind, s.loc())),
Some("(не задан)".to_string())
);
let intent = s.handle_key(key(KeyCode::Right));
assert!(matches!(intent, Some(SettingsIntent::SaveProfile { .. })));
assert_eq!(s.profiles[0].impersonation_profile_id, Some(ip_id));
s.handle_key(key(KeyCode::Right));
assert_eq!(s.profiles[0].impersonation_profile_id, None);
}
#[test]
fn new_profile_is_selected_after_the_settings_reemit() {
let mut s = screen();
goto_section(&mut s, Section::Profiles);
assert_eq!(s.profile_idx, 0);
assert!(matches!(
s.handle_key(ctrl('n')),
Some(SettingsIntent::CreateProfile { .. })
));
let mut profiles = s.profiles.clone();
let created = Profile::new("Новый профиль", "");
let created_id = created.id;
profiles.push(created);
s.refresh(s.config.clone(), profiles, Vec::new());
assert_eq!(s.profiles[s.profile_idx].id, created_id);
let mut profiles = s.profiles.clone();
profiles.push(Profile::new("Ещё один", ""));
s.refresh(s.config.clone(), profiles, Vec::new());
assert_eq!(s.profiles[s.profile_idx].id, created_id);
}
#[test]
fn editing_model_commits_text() {
let mut s = screen();
goto_field(&mut s, FieldId::XModel); s.handle_key(key(KeyCode::Enter)); assert!(s.editor.is_some());
for c in "gemma.gguf".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
let intent = s.handle_key(key(KeyCode::Enter));
match intent {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.engine.managed.model_path.as_deref(), Some("gemma.gguf"))
}
other => panic!("expected SaveConfig, got {other:?}"),
}
assert!(s.editor.is_none());
}
#[test]
fn editor_esc_discards() {
let mut s = screen();
s.handle_key(key(KeyCode::Enter)); s.handle_key(key(KeyCode::Down)); s.handle_key(key(KeyCode::Down)); s.handle_key(key(KeyCode::Enter)); s.handle_key(key(KeyCode::Char('x')));
let intent = s.handle_key(key(KeyCode::Esc));
assert_eq!(intent, None);
assert!(s.editor.is_none());
assert!(s.config.engine.managed.binary.is_none());
}
#[test]
fn create_and_delete_profile_in_profiles_section() {
let mut s = screen();
goto_section(&mut s, Section::Profiles);
assert_eq!(s.section(), Section::Profiles);
let create = s.handle_key(ctrl('n'));
assert!(matches!(create, Some(SettingsIntent::CreateProfile { .. })));
let id = s.profiles[0].id;
let del = s.handle_key(ctrl('d'));
assert_eq!(del, Some(SettingsIntent::DeleteProfile(id)));
}
#[test]
fn toggling_profile_tool_emits_save_profile() {
let mut s = screen();
goto_section(&mut s, Section::Profiles);
let catalog = crate::features::tools::tool_catalog();
let enabled = s.profiles[0].enabled_tools.clone();
let idx = catalog
.iter()
.position(|i| enabled.contains(&i.id))
.expect("the profile has an enabled tool from the catalog");
goto_field(&mut s, FieldId::PTool(idx));
let before = s.profiles[0].enabled_tools.len();
let intent = s.handle_key(key(KeyCode::Char(' ')));
match intent {
Some(SettingsIntent::SaveProfile { edit, .. }) => {
let tools = edit.enabled_tools.unwrap();
assert_eq!(tools.len(), before - 1, "the enabled tool turned off");
}
other => panic!("expected SaveProfile, got {other:?}"),
}
}
#[test]
fn profile_select_cycles() {
let mut p2 = Profile::new("Второй", "sys2");
p2.enabled_tools = default_tool_ids();
let mut s = SettingsScreen::new(
AppConfig::default(),
{
let mut p1 = Profile::new("Первый", "sys1");
p1.enabled_tools = default_tool_ids();
vec![p1, p2]
},
vec![],
);
goto_section(&mut s, Section::Profiles);
goto_field(&mut s, FieldId::PSelect); assert_eq!(s.profile_idx, 0);
s.handle_key(key(KeyCode::Right));
assert_eq!(s.profile_idx, 1);
}
#[test]
fn samplers_list_round_trip() {
let v = parse_list("penalties;dry; temperature ", ';').unwrap();
assert_eq!(v, vec!["penalties", "dry", "temperature"]);
assert_eq!(join_list(Some(&v), ';'), "penalties;dry;temperature");
assert_eq!(parse_list("", ';'), None);
assert_eq!(parse_list(" ; ", ';'), None);
assert_eq!(join_list(None, ';'), "—");
}
#[test]
fn dry_breakers_decode_and_encode_escapes() {
let v = parse_breakers(r#"\n, :, ", *"#).unwrap();
assert_eq!(v, vec!["\n", ":", "\"", "*"]);
assert_eq!(join_breakers(Some(&v)), r#"\n,:,",*"#);
assert_eq!(parse_breakers(""), None);
assert_eq!(join_breakers(None), "—");
}
#[test]
fn system_message_editor_is_multiline_and_keeps_newlines() {
let mut s = screen();
goto_section(&mut s, Section::Profiles);
goto_field(&mut s, FieldId::PSystem);
s.handle_key(key(KeyCode::Enter)); let editor = s.editor.as_ref().expect("the editor is open");
assert!(
editor.multiline,
"the system message is edited as multiline"
);
s.handle_key(key(KeyCode::Char('A')));
s.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
s.handle_key(key(KeyCode::Char('B')));
assert!(s.editor.is_some(), "Shift+Enter doesn't close the editor");
let intent = s.handle_key(key(KeyCode::Enter)); match intent {
Some(SettingsIntent::SaveProfile { edit, .. }) => {
assert_eq!(edit.system_message.unwrap(), "Ты — ассистент.A\nB");
}
other => panic!("expected SaveProfile, got {other:?}"),
}
}
#[test]
fn alt_enter_also_inserts_newline_in_multiline_editor() {
let mut s = screen();
goto_section(&mut s, Section::Profiles);
goto_field(&mut s, FieldId::PSystem);
s.handle_key(key(KeyCode::Enter)); s.handle_key(key(KeyCode::Char('A')));
s.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT));
s.handle_key(key(KeyCode::Char('B')));
s.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
s.handle_key(key(KeyCode::Char('C')));
assert!(s.editor.is_some(), "Alt+Enter doesn't close the editor");
let intent = s.handle_key(key(KeyCode::Enter)); match intent {
Some(SettingsIntent::SaveProfile { edit, .. }) => {
assert_eq!(edit.system_message.unwrap(), "Ты — ассистент.A\nB\nC");
}
other => panic!("expected SaveProfile, got {other:?}"),
}
}
#[test]
fn ctrl_k_clears_and_ctrl_z_restores_multiline_editor() {
let mut s = screen();
goto_section(&mut s, Section::Profiles);
goto_field(&mut s, FieldId::PSystem);
s.handle_key(key(KeyCode::Enter)); s.handle_key(key(KeyCode::Char('A')));
s.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
s.handle_key(key(KeyCode::Char('B')));
let ctrl_k = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL);
let ctrl_z = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL);
s.handle_key(ctrl_k); assert_eq!(s.editor.as_ref().unwrap().input.text(), "");
s.handle_key(ctrl_z); assert_eq!(
s.editor.as_ref().unwrap().input.text(),
"Ты — ассистент.A\nB"
);
assert!(s.editor.is_some(), "Ctrl+K/Ctrl+Z don't close the editor");
}
#[test]
fn greeting_editor_is_multiline_and_keeps_newlines() {
let mut s = screen();
goto_section(&mut s, Section::Profiles);
goto_field(&mut s, FieldId::PGreeting);
s.handle_key(key(KeyCode::Enter)); let editor = s.editor.as_ref().expect("the editor is open");
assert!(editor.multiline, "the greeting is edited as multiline");
s.handle_key(key(KeyCode::Char('A')));
s.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
s.handle_key(key(KeyCode::Char('B')));
assert!(s.editor.is_some(), "Shift+Enter doesn't close the editor");
let intent = s.handle_key(key(KeyCode::Enter)); match intent {
Some(SettingsIntent::SaveProfile { edit, .. }) => {
assert_eq!(edit.greeting.unwrap().as_deref(), Some("A\nB"));
}
other => panic!("expected SaveProfile, got {other:?}"),
}
}
#[test]
fn role_name_fields_edit_character_names() {
let mut s = screen();
goto_section(&mut s, Section::Profiles);
goto_field(&mut s, FieldId::PUserName);
s.handle_key(key(KeyCode::Enter)); for c in "Гайя".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveProfile { edit, .. }) => {
let names = edit
.character_names
.expect("names are part of the snapshot");
assert_eq!(names.user, "Гайя");
assert_eq!(names.assistant, "", "the assistant's name isn't touched");
}
other => panic!("expected SaveProfile, got {other:?}"),
}
assert!(s.fields().iter().any(
|f| f.id == FieldId::PUserName && matches!(&f.kind, FieldKind::Text(t) if t == "Гайя")
));
let mut s2 = screen();
s2.profiles[0].character_names.user = "Гайя".into();
goto_section(&mut s2, Section::Profiles);
goto_field(&mut s2, FieldId::PUserName);
s2.handle_key(key(KeyCode::Enter));
s2.handle_key(ctrl('k')); match s2.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveProfile { edit, .. }) => {
assert_eq!(edit.character_names.unwrap().user, "");
}
other => panic!("expected SaveProfile, got {other:?}"),
}
}
#[test]
fn role_name_fields_are_described_and_grouped_with_persona() {
let s = screen();
let rows = s.profile_fields_for(Subsection::Assistant);
let persona = rows
.iter()
.find(|r| r.id == FieldId::PSystem)
.expect("the system message is in the Persona group")
.group;
for id in [FieldId::PUserName, FieldId::PAssistantName] {
let r = rows.iter().find(|r| r.id == id).expect("field present");
assert_eq!(r.group, persona);
assert!(field_desc(&s, id).is_some(), "{id:?} has no description");
}
}
#[test]
fn other_fields_edit_single_line() {
let mut s = screen();
s.handle_key(key(KeyCode::Enter)); s.handle_key(key(KeyCode::Down)); s.handle_key(key(KeyCode::Down)); s.handle_key(key(KeyCode::Enter)); let editor = s.editor.as_ref().expect("the editor is open");
assert!(
!editor.multiline,
"a regular field is edited as single-line"
);
}
#[test]
fn cloud_mode_reveals_model_and_key_fields() {
let mut s = screen();
s.handle_key(key(KeyCode::Enter)); s.handle_key(key(KeyCode::Down)); s.handle_key(key(KeyCode::Right));
s.handle_key(key(KeyCode::Right));
assert_eq!(s.config.engine.mode, ServerMode::OpenAi);
let ids: Vec<FieldId> = s.fields().iter().map(|f| f.id).collect();
assert!(ids.contains(&FieldId::XModelName));
assert!(ids.contains(&FieldId::XApiKeyEnv));
assert!(!ids.contains(&FieldId::XNgl));
assert!(!ids.contains(&FieldId::XBinary));
}
#[test]
fn render_does_not_panic() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut s = screen();
for (w, h) in [(80u16, 24u16), (40, 12)] {
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| s.render(f)).unwrap();
}
}
#[test]
fn undo_restores_a_config_value_and_emits_save() {
let mut s = screen();
goto_section(&mut s, Section::Interface);
goto_field(&mut s, FieldId::ITheme);
let before = s.config.interface.theme;
s.handle_key(key(KeyCode::Right));
assert_ne!(s.config.interface.theme, before);
let intent = s.handle_key(ctrl('z'));
assert!(matches!(intent, Some(SettingsIntent::SaveConfig(_))));
assert_eq!(s.config.interface.theme, before, "the value must come back");
}
#[test]
fn undo_restores_a_profile_field() {
let mut s = screen();
goto_section(&mut s, Section::Profiles);
goto_field(&mut s, FieldId::PUserName);
s.handle_key(key(KeyCode::Enter));
for c in "Гея".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
s.handle_key(key(KeyCode::Enter));
assert_eq!(s.profiles[0].character_names.user, "Гея");
let intent = s.handle_key(ctrl('z'));
assert!(matches!(intent, Some(SettingsIntent::SaveProfile { .. })));
assert_eq!(s.profiles[0].character_names.user, "");
}
#[test]
fn consecutive_edits_of_one_field_undo_together() {
let mut s = screen();
goto_section(&mut s, Section::Model);
goto_field(&mut s, FieldId::XMode);
let before = s.config.engine.mode;
for _ in 0..3 {
s.handle_key(key(KeyCode::Right));
}
assert_ne!(s.config.engine.mode, before);
s.handle_key(ctrl('z'));
assert_eq!(
s.config.engine.mode, before,
"one press undoes the whole run"
);
}
#[test]
fn edits_of_different_fields_are_separate_steps() {
let mut s = screen();
goto_section(&mut s, Section::Interface);
goto_field(&mut s, FieldId::ITheme);
let theme = s.config.interface.theme;
s.handle_key(key(KeyCode::Right));
goto_field_again(&mut s, FieldId::ICompat);
let compat = s.config.interface.terminal_compat;
s.handle_key(key(KeyCode::Char(' ')));
assert_ne!(s.config.interface.terminal_compat, compat);
s.handle_key(ctrl('z'));
assert_eq!(s.config.interface.terminal_compat, compat, "the last edit");
assert_ne!(s.config.interface.theme, theme, "but not the one before it");
s.handle_key(ctrl('z'));
assert_eq!(s.config.interface.theme, theme);
}
#[test]
fn redo_reapplies_and_a_new_edit_clears_it() {
let mut s = screen();
goto_section(&mut s, Section::Interface);
goto_field(&mut s, FieldId::ITheme);
let before = s.config.interface.theme;
s.handle_key(key(KeyCode::Right));
let after = s.config.interface.theme;
s.handle_key(ctrl('z'));
assert_eq!(s.config.interface.theme, before);
let intent = s.handle_key(ctrl('y'));
assert!(matches!(intent, Some(SettingsIntent::SaveConfig(_))));
assert_eq!(s.config.interface.theme, after, "redo re-applies");
s.handle_key(ctrl('z'));
goto_field_again(&mut s, FieldId::ICompat);
s.handle_key(key(KeyCode::Char(' '))); assert_eq!(s.handle_key(ctrl('y')), None, "redo was invalidated");
}
#[test]
fn undo_on_an_empty_stack_is_a_no_op() {
let mut s = screen();
assert_eq!(s.handle_key(ctrl('z')), None);
assert_eq!(s.handle_key(ctrl('y')), None);
}
#[test]
fn ctrl_z_inside_the_editor_is_text_undo_not_settings_undo() {
let mut s = screen();
goto_section(&mut s, Section::Interface);
goto_field(&mut s, FieldId::ITheme);
let theme_before = s.config.interface.theme;
s.handle_key(key(KeyCode::Right)); let theme_after = s.config.interface.theme;
goto_field_again(&mut s, FieldId::IDicts);
s.handle_key(key(KeyCode::Enter)); for c in "abc".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(s.handle_key(ctrl('z')), None, "consumed by the editor");
assert_eq!(
s.config.interface.theme, theme_after,
"the setting must be untouched"
);
assert_ne!(theme_before, theme_after);
}
#[test]
fn an_api_key_commit_records_no_undo_step() {
let mut s = screen();
s.config.engine.mode = crate::shared::config::ServerMode::OpenAi;
goto_section(&mut s, Section::Interface);
goto_field(&mut s, FieldId::ITheme);
let before = s.config.interface.theme;
s.handle_key(key(KeyCode::Right));
goto_section(&mut s, Section::Model);
goto_field(&mut s, FieldId::XApiKey);
s.handle_key(key(KeyCode::Enter));
for c in "sk-test".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
assert!(matches!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::SetSecret { .. })
));
s.handle_key(ctrl('z'));
assert_eq!(s.config.interface.theme, before);
}
#[test]
fn undo_jumps_to_the_field_it_reverted() {
let mut s = screen();
goto_section(&mut s, Section::Interface);
goto_field(&mut s, FieldId::ITheme);
s.handle_key(key(KeyCode::Right));
goto_section(&mut s, Section::Memory);
assert_eq!(s.section(), Section::Memory);
s.handle_key(ctrl('z'));
assert_eq!(s.section(), Section::Interface, "jumped back to the change");
assert_eq!(
s.fields().get(s.field_idx).map(|f| f.id),
Some(FieldId::ITheme)
);
assert!(s.focus == Focus::Fields);
}
#[test]
fn pane_markers_stay_put_and_swap_colour_with_focus() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let palette = Palette::for_theme(Theme::Auto);
let sections_glyph = palette.glyphs().collapsed;
let params_glyph = palette.glyphs().title_marker;
let marker = |s: &mut SettingsScreen, glyph: &str| -> (ratatui::style::Color, (u16, u16)) {
let mut term = Terminal::new(TestBackend::new(100, 20)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer().clone();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
if buf[(x, y)].symbol() == glyph {
return (buf[(x, y)].fg, (x, y));
}
}
}
panic!("marker {glyph:?} is not drawn at all");
};
let mut s = screen();
let (menu_fg, menu_at) = marker(&mut s, sections_glyph);
let (fields_fg, fields_at) = marker(&mut s, params_glyph);
assert_eq!(menu_fg, palette.success, "sections hold the focus → green");
assert_eq!(fields_fg, palette.muted, "parameters don't → muted");
s.handle_key(key(KeyCode::Enter)); let (menu_fg2, menu_at2) = marker(&mut s, sections_glyph);
let (fields_fg2, fields_at2) = marker(&mut s, params_glyph);
assert_eq!(menu_fg2, palette.muted, "sections lost the focus → muted");
assert_eq!(fields_fg2, palette.success, "parameters hold it → green");
assert_eq!(menu_at, menu_at2, "the `▸` must not move");
assert_eq!(fields_at, fields_at2, "the `◆` must not move");
}
#[test]
fn footer_hints_differ_by_focus() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let footer = |s: &mut SettingsScreen| -> String {
let mut term = Terminal::new(TestBackend::new(120, 24)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer().clone();
let lines: Vec<String> = (0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect();
lines
.iter()
.rev()
.take_while(|l| l.starts_with(' '))
.cloned()
.collect::<Vec<_>>()
.join(" ")
};
let mut s = screen();
let menu = footer(&mut s);
assert!(menu.contains("параметры"), "Enter → the pane: {menu:?}");
assert!(menu.contains("закрыть"), "Esc closes: {menu:?}");
assert!(
!menu.contains("к секциям"),
"nothing to step back to yet: {menu:?}"
);
s.handle_key(key(KeyCode::Enter));
let fields = footer(&mut s);
assert!(fields.contains("к секциям"), "Esc steps back: {fields:?}");
assert!(!fields.contains("закрыть"), "not a close here: {fields:?}");
assert!(
fields.contains("поля"),
"the pane's own navigation: {fields:?}"
);
assert!(
!menu.contains("поля"),
"the pane's keys stay in the pane: {menu:?}"
);
}
#[test]
fn footer_hints_follow_the_selected_field() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let footer = |s: &mut SettingsScreen| -> String {
let mut term = Terminal::new(TestBackend::new(200, 24)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer().clone();
let lines: Vec<String> = (0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect();
lines
.iter()
.rev()
.take_while(|l| l.starts_with(' '))
.cloned()
.collect::<Vec<_>>()
.join(" ")
};
let mut s = screen();
goto_section(&mut s, Section::Interface);
s.handle_key(key(KeyCode::Enter));
let mut saw_choice = false;
let mut saw_toggle = false;
for _ in 0..30 {
let kind = s.fields().get(s.field_idx).map(|f| match f.kind {
FieldKind::Toggle(_) => 'T',
FieldKind::Choice(_) => 'C',
FieldKind::Text(_) => 'X',
});
let f = footer(&mut s);
match kind {
Some('C') => {
saw_choice = true;
assert!(f.contains("выбор"), "a Choice cycles with ←→: {f:?}");
assert!(!f.contains("тумблер"), "…and does not toggle: {f:?}");
}
Some('T') => {
saw_toggle = true;
assert!(f.contains("тумблер"), "a Toggle flips with Space: {f:?}");
assert!(!f.contains("выбор"), "…and does not cycle: {f:?}");
}
_ => {}
}
assert!(!f.contains("сброс"), "nothing is modified yet: {f:?}");
s.handle_key(key(KeyCode::Down));
}
assert!(saw_choice && saw_toggle, "the walk must cover both kinds");
}
#[test]
fn the_reset_hint_follows_a_modified_field() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let footer = |s: &mut SettingsScreen| -> String {
let mut term = Terminal::new(TestBackend::new(200, 24)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.filter(|l| l.starts_with(' '))
.collect::<Vec<_>>()
.join(" ")
};
let mut s = screen();
goto_section(&mut s, Section::Interface);
s.handle_key(key(KeyCode::Enter));
let toggle_idx = s
.fields()
.iter()
.position(|f| matches!(f.kind, FieldKind::Toggle(_)))
.expect("the Interface section has a toggle");
for _ in 0..toggle_idx {
s.handle_key(key(KeyCode::Down));
}
assert!(
!footer(&mut s).contains("сброс"),
"at its default — nothing to reset"
);
s.handle_key(key(KeyCode::Char(' ')));
assert!(
footer(&mut s).contains("сброс"),
"modified — Del now resets it"
);
s.handle_key(key(KeyCode::Down));
assert!(
!footer(&mut s).contains("сброс"),
"the neighbouring row is still at its default"
);
}
#[test]
fn hotkeys_render_below_panel_and_wrap_when_narrow() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let rows = |w: u16, h: u16| -> Vec<String> {
let mut s = screen();
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect()
};
let status_rows = |lines: &[String]| -> usize {
lines
.iter()
.rev()
.take_while(|l| l.starts_with(' '))
.count()
};
let wide = rows(160, 24);
assert_eq!(status_rows(&wide), 1, "wide — one hotkey row");
let last = wide.last().unwrap();
assert!(last.contains("Tab") && last.contains("выход"));
assert!(
last.trim_end().ends_with("выход"),
"right-aligned: {last:?}"
);
assert!(
!wide[wide.len() - 2].starts_with(' '),
"above the hotkeys — the panel's bottom border: {:?}",
wide[wide.len() - 2]
);
let narrow = rows(46, 24);
assert!(
status_rows(&narrow) > 1,
"expected hotkeys to wrap: {}",
status_rows(&narrow)
);
}
#[test]
fn render_with_editor_does_not_panic() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut s = screen();
s.handle_key(key(KeyCode::Enter));
s.handle_key(key(KeyCode::Down));
s.handle_key(key(KeyCode::Down));
s.handle_key(key(KeyCode::Enter)); let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
term.draw(|f| s.render(f)).unwrap();
}
#[test]
fn flag_fields_have_descriptions() {
let mut s = screen();
s.config.engine.managed.spec_type = SpecType::DraftMtp;
assert!(field_desc(&s, FieldId::XNgl).is_some());
assert!(field_desc(&s, FieldId::XJinja).is_some());
assert!(field_desc(&s, FieldId::XNoMmap).is_some());
assert!(field_desc(&s, FieldId::XBatch).is_some());
assert!(field_desc(&s, FieldId::XPort).is_none());
assert!(field_desc(&s, FieldId::XFlashAttn).is_some());
assert!(field_desc(&s, FieldId::XSpecType).is_some());
assert!(field_desc(&s, FieldId::XDraftModel).is_some());
}
#[test]
fn managed_mode_shows_flash_attn_and_spec_type() {
let s = screen();
let ids: Vec<FieldId> = s.fields().iter().map(|f| f.id).collect();
assert!(ids.contains(&FieldId::XFlashAttn));
assert!(ids.contains(&FieldId::XSpecType));
assert!(!ids.contains(&FieldId::XDraftModel));
}
#[test]
fn cycling_spec_type_to_draft_reveals_draft_fields() {
let mut s = screen();
s.handle_key(key(KeyCode::Enter)); while s.fields().get(s.field_idx).map(|f| f.id) != Some(FieldId::XSpecType) {
s.handle_key(key(KeyCode::Down));
}
s.handle_key(key(KeyCode::Right));
s.handle_key(key(KeyCode::Right));
s.handle_key(key(KeyCode::Right));
assert_eq!(s.config.engine.managed.spec_type, SpecType::DraftMtp);
let ids: Vec<FieldId> = s.fields().iter().map(|f| f.id).collect();
assert!(ids.contains(&FieldId::XDraftModel));
assert!(ids.contains(&FieldId::XDraftNMax));
}
#[test]
fn cycling_flash_attn_changes_value_and_saves() {
let mut s = screen();
s.handle_key(key(KeyCode::Enter)); while s.fields().get(s.field_idx).map(|f| f.id) != Some(FieldId::XFlashAttn) {
s.handle_key(key(KeyCode::Down));
}
assert_eq!(s.config.engine.managed.flash_attn, FlashAttn::Auto);
let intent = s.handle_key(key(KeyCode::Right));
assert_eq!(s.config.engine.managed.flash_attn, FlashAttn::On);
assert!(matches!(intent, Some(SettingsIntent::SaveConfig(_))));
}
#[test]
fn render_with_focused_description_does_not_panic() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut s = screen();
s.handle_key(key(KeyCode::Enter)); while s.fields().get(s.field_idx).map(|f| f.id) != Some(FieldId::XNoMmap) {
s.handle_key(key(KeyCode::Down));
}
for (w, h) in [(80u16, 24u16), (40, 12)] {
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| s.render(f)).unwrap();
}
}
fn pane_text(s: &mut SettingsScreen, w: u16, h: u16) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer().clone();
let text: String = (0..buf.area.height)
.map(|y| {
(25..buf.area.width.saturating_sub(1))
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join(" ");
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn norm(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn panel_border_y(buf: &ratatui::buffer::Buffer) -> u16 {
(0..buf.area.height)
.rev()
.find(|&y| {
buf[(0, y)].symbol() == "│"
&& (25..buf.area.width - 1).all(|x| buf[(x, y)].symbol() == "─")
})
.expect("the hint panel's top border is on screen")
}
fn outer_bottom_y(buf: &ratatui::buffer::Buffer) -> u16 {
(0..buf.area.height)
.rev()
.find(|&y| buf[(0, y)].symbol() == "╰")
.expect("the screen's bottom border is on screen")
}
fn panel_rows(s: &mut SettingsScreen, w: u16, h: u16) -> Vec<String> {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer();
(panel_border_y(buf) + 1..outer_bottom_y(buf))
.map(|y| {
(25..buf.area.width - 1)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
#[test]
fn hint_panel_height_is_uniform_across_sections() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let panel_h = |s: &mut SettingsScreen| -> u16 {
let mut term = Terminal::new(TestBackend::new(116, 30)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer();
outer_bottom_y(buf) - panel_border_y(buf)
};
let mut s = screen();
let seen: Vec<(Section, u16)> = SECTIONS
.iter()
.map(|&sec| {
goto_section(&mut s, sec);
(sec, panel_h(&mut s))
})
.collect();
assert!(
seen.iter().all(|&(_, h)| h == seen[0].1),
"the panel height changed between sections: {seen:?}"
);
}
#[test]
fn long_value_preview_ends_with_an_ellipsis_only_when_cut() {
let long = "Ты — ассистент. ".repeat(200);
let mut p = Profile::new("Базовый", long.trim_end());
p.enabled_tools = default_tool_ids();
let mut s = SettingsScreen::new(AppConfig::default(), vec![p], vec![]);
goto_section(&mut s, Section::Profiles);
goto_field(&mut s, FieldId::PSystem);
let rows = panel_rows(&mut s, 116, 30);
let last = rows
.iter()
.rfind(|r| !r.is_empty())
.expect("the preview is in the panel");
assert!(
last.ends_with('…'),
"no cut marker on the last line: {last:?}"
);
let mut p = Profile::new("Базовый", "Ты — ассистент, и этой строки хватает всем.");
p.enabled_tools = default_tool_ids();
let mut s = SettingsScreen::new(AppConfig::default(), vec![p], vec![]);
goto_section(&mut s, Section::Profiles);
goto_field(&mut s, FieldId::PSystem);
let rows = panel_rows(&mut s, 116, 30);
assert!(
rows.iter().any(|r| r.contains("хватает всем")),
"the whole value is previewed: {rows:?}"
);
assert!(
rows.iter().all(|r| !r.contains('…')),
"a value shown whole must not carry the cut marker: {rows:?}"
);
}
#[test]
fn a_hint_clipped_by_the_cap_ends_with_an_ellipsis() {
let mut s = screen();
s.config.engine.mode = ServerMode::OpenAi;
goto_field(&mut s, FieldId::XApiKey);
let desc = norm(&field_desc(&s, FieldId::XApiKey).expect("api key is described"));
let rows = panel_rows(&mut s, 80, 16);
let shown = norm(&rows.join(" "));
assert!(
!shown.contains(&desc),
"the fixture must not fit whole, or the test measures nothing: {shown:?}"
);
let last = rows
.iter()
.rfind(|r| !r.is_empty())
.expect("the hint is in the panel");
assert!(
last.ends_with('…'),
"no cut marker on the last line: {last:?}"
);
}
#[test]
fn cloud_model_field_describes_the_model_not_the_toggle() {
let mut s = screen();
s.config.engine.mode = ServerMode::Grok;
let model = field_desc(&s, FieldId::XModelName).expect("cloud model is described");
let toggle = field_desc(&s, FieldId::IModelName).expect("the toggle is described");
assert!(
model.contains("Enter"),
"the hint should point at the picker: {model}"
);
assert!(
!model.contains("gpt-") && !model.contains("claude-") && !model.contains("grok-"),
"the hint names a model again, and a named model ages: {model}"
);
assert_ne!(
model, toggle,
"two fields share one description — the duplicated-key defect"
);
}
#[test]
fn value_preview_marks_only_what_it_cuts() {
let text = |lines: &[Line<'static>]| -> Vec<String> {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect()
};
let st = Style::new();
let whole = value_preview("alpha beta", 2, 20, st);
assert_eq!(text(&whole), ["alpha beta"]);
let cut = value_preview(&"word ".repeat(30), 2, 10, st);
assert_eq!(cut.len(), 2, "cut at the row budget");
assert!(
text(&cut)[1].ends_with('…'),
"the cut is marked: {:?}",
text(&cut)
);
assert!(
value_preview("anything long enough", 0, 10, st).is_empty(),
"no rows — no lone marker"
);
}
#[test]
fn a_long_hint_is_shown_whole_in_the_bottom_panel() {
for mode in [ServerMode::OpenAi, ServerMode::External] {
let mut s = screen();
s.config.engine.mode = mode;
goto_field(&mut s, FieldId::XApiKey);
let desc = norm(&field_desc(&s, FieldId::XApiKey).expect("api key is described"));
for (w, h) in [(120u16, 40u16), (146, 46), (70, 40)] {
let shown = pane_text(&mut s, w, h);
assert!(
shown.contains(&desc),
"hint clipped at {w}x{h} in {mode:?}:\n want: {desc}\n got: {shown}"
);
}
}
}
#[test]
fn a_long_value_preview_does_not_push_the_hint_out() {
let mut s = screen();
s.config.engine.mode = ServerMode::OpenAi;
s.config.engine.openai.api_key_env = Some("VERY_LONG_ENVIRONMENT_VARIABLE_NAME_".repeat(12));
goto_field(&mut s, FieldId::XApiKeyEnv);
let desc = norm(&field_desc(&s, FieldId::XApiKeyEnv).expect("described"));
let shown = pane_text(&mut s, 120, 40);
assert!(
shown.contains("VERY_LONG_ENVIRONMENT_VARIABLE_NAME_VERY_LONG"),
"the preview is gone entirely: {shown}"
);
assert!(
shown.contains(&desc),
"hint clipped by the preview: {shown}"
);
}
#[test]
fn hint_panel_height_follows_the_longest_hint_within_bounds() {
let s = screen();
let loc = s.loc();
let rows = |fields: &[FieldRow], w: usize, cap: usize| hint_panel_rows(fields, w, cap, loc);
let short = vec![row(FieldId::XPort, "p", FieldKind::Text("1".into()))];
assert_eq!(rows(&short, 80, HINT_MAX_ROWS), HINT_MIN_ROWS, "floor");
let long = vec![
row(FieldId::XPort, "p", FieldKind::Text("1".into())),
row(FieldId::XUrl, "u", FieldKind::Text(String::new())).describe("слово ".repeat(60)),
];
let need = hint_rows(&long[1], 40, loc);
assert!(need > HINT_MIN_ROWS, "the fixture must overflow the floor");
assert_eq!(rows(&long, 40, HINT_MAX_ROWS), need, "grows to the longest");
assert_eq!(rows(&long, 40, 4), 4, "and stops at the cap");
}
#[test]
fn fields_scrollbar_appears_only_on_overflow() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let has_thumb = |term: &Terminal<TestBackend>| {
let buf = term.backend().buffer();
let x = buf.area.right() - 1; (buf.area.top()..buf.area.bottom()).any(|y| buf[(x, y)].symbol() == "█")
};
let mut s = screen();
goto_section(&mut s, Section::Sampling); let mut term = Terminal::new(TestBackend::new(80, 14)).unwrap();
term.draw(|f| s.render(f)).unwrap();
assert!(has_thumb(&term), "an overflowing section — has the thumb");
let mut term = Terminal::new(TestBackend::new(80, 60)).unwrap();
term.draw(|f| s.render(f)).unwrap();
assert!(!has_thumb(&term), "all fields visible — no thumb");
}
#[test]
fn editing_new_sampling_field_commits() {
let mut s = screen();
goto_section(&mut s, Section::Sampling);
goto_field(&mut s, FieldId::S(SamplingParam::MinP));
s.handle_key(key(KeyCode::Enter)); for c in "0.03".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
let intent = s.handle_key(key(KeyCode::Enter)); match intent {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.default_sampling.min_p, Some(0.03))
}
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn cloud_hides_unsupported_sampling_params() {
let mut s = screen();
goto_section(&mut s, Section::Sampling);
let has =
|s: &SettingsScreen, p: SamplingParam| s.fields().iter().any(|f| f.id == FieldId::S(p));
assert!(has(&s, SamplingParam::TopK));
assert!(has(&s, SamplingParam::Thinking));
s.config.engine.mode = ServerMode::Gemini;
assert!(!has(&s, SamplingParam::MinP));
assert!(has(&s, SamplingParam::TopK));
assert!(has(&s, SamplingParam::Thinking));
assert!(has(&s, SamplingParam::Reasoning));
assert!(has(&s, SamplingParam::Temp));
assert!(has(&s, SamplingParam::TopP));
assert!(has(&s, SamplingParam::MaxTokens));
s.config.engine.mode = ServerMode::OpenAi;
assert!(!has(&s, SamplingParam::Temp));
assert!(!has(&s, SamplingParam::TopP));
assert!(!has(&s, SamplingParam::FreqPen));
assert!(has(&s, SamplingParam::MaxTokens));
assert!(has(&s, SamplingParam::Thinking));
assert!(has(&s, SamplingParam::Reasoning));
assert!(has(&s, SamplingParam::Verbosity));
s.config.engine.mode = ServerMode::Gemini;
assert!(!has(&s, SamplingParam::Verbosity));
s.config.engine.mode = ServerMode::Claude;
assert!(!has(&s, SamplingParam::Temp));
assert!(!has(&s, SamplingParam::TopP));
assert!(!has(&s, SamplingParam::FreqPen));
assert!(has(&s, SamplingParam::MaxTokens));
s.config.engine.mode = ServerMode::Grok;
assert!(has(&s, SamplingParam::Temp));
assert!(has(&s, SamplingParam::TopP));
assert!(has(&s, SamplingParam::Seed));
assert!(has(&s, SamplingParam::MaxTokens));
assert!(has(&s, SamplingParam::Reasoning));
assert!(!has(&s, SamplingParam::FreqPen));
assert!(!has(&s, SamplingParam::PresPen));
assert!(!has(&s, SamplingParam::TopK));
assert!(!has(&s, SamplingParam::MinP));
assert!(!has(&s, SamplingParam::Verbosity));
}
#[test]
fn impersonation_shared_inherits_assistant_cloud_filter() {
let mut s = screen();
s.config.engine.mode = ServerMode::OpenAi;
assert_eq!(
s.config.impersonation_engine.mode,
ImpersonationMode::Shared
);
goto_section(&mut s, Section::Sampling);
s.handle_key(key(KeyCode::Enter)); s.handle_key(key(KeyCode::Right)); assert_eq!(s.sampling_sub, Subsection::Impersonation);
let has_topk = s
.fields()
.iter()
.any(|f| f.id == FieldId::IS(SamplingParam::TopK));
assert!(
!has_topk,
"the assistant's cloud filters shared impersonation too"
);
s.config.impersonation_engine.mode = ImpersonationMode::Managed;
let has_topk = s
.fields()
.iter()
.any(|f| f.id == FieldId::IS(SamplingParam::TopK));
assert!(has_topk);
}
#[test]
fn subsection_renders_as_tab_strip_not_list_row() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut s = screen();
s.handle_key(key(KeyCode::Enter)); let mut term = Terminal::new(TestBackend::new(90, 24)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer();
let text: String = (0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("Ассистент"));
assert!(text.contains("Эмбеддинги"));
assert!(
!text.contains("Подсекция"),
"the subsection selector should be a tab strip, not a list row"
);
}
#[test]
fn profile_tools_are_grouped_with_descriptions() {
let mut s = screen();
goto_section(&mut s, Section::Profiles);
let fields = s.profile_fields();
let tool_rows: Vec<&FieldRow> = fields
.iter()
.filter(|r| matches!(r.id, FieldId::PTool(_)))
.collect();
assert!(!tool_rows.is_empty());
let loc = s.loc();
let known: Vec<&str> = crate::features::tools::meta::ToolGroup::ALL
.iter()
.map(|g| loc.get(g.i18n_key()).unwrap_or(g.title()))
.collect();
for r in &tool_rows {
assert!(
known.contains(&r.group),
"a tool outside the known group set: {}",
r.label
);
assert!(r.hint.is_some(), "no inline description for {}", r.label);
}
let groups: Vec<&str> = tool_rows.iter().map(|r| r.group).collect();
let mut seen = std::collections::HashSet::new();
let mut prev = "";
for g in groups {
if g != prev {
assert!(seen.insert(g), "group {g} isn't contiguous");
prev = g;
}
}
}
#[test]
fn globally_disabled_tool_is_marked_gated() {
let mut s = screen();
s.config.tools.python_enabled = false;
s.config.tools.web_enabled = true;
goto_section(&mut s, Section::Profiles);
let fields = s.profile_fields();
let idx_of = |name: &str| -> usize {
crate::features::tools::all_tool_ids()
.iter()
.position(|t| t == name)
.unwrap()
};
let find = |id: FieldId| fields.iter().find(|r| r.id == id).unwrap();
let py = find(FieldId::PTool(idx_of("python_exec")));
assert!(py.warn, "python_exec disabled globally — gated");
assert!(py.hint.unwrap().contains("глобально"));
let web = find(FieldId::PTool(idx_of("web_search")));
assert!(!web.warn, "web enabled globally — not gated");
assert_eq!(web.hint, Some("поиск в интернете"));
}
#[test]
fn mcp_tools_extend_profile_toggles_with_honest_gate() {
use crate::features::tools::meta::{ToolGate, ToolGroup, ToolInfo};
let mut s = screen();
s.config.mcp.enabled = false;
s.profiles[0]
.enabled_tools
.push("mcp__fs__read_text_file".into());
s.set_mcp(crate::features::tools::mcp::McpSnapshot {
tools: vec![ToolInfo {
id: "mcp__fs__read_text_file".into(),
group: ToolGroup::Plugins,
label: "read_text_file",
gate: Some(ToolGate::Mcp),
enabled_by_default: false,
concurrent: false,
description: Some("Read the complete contents of a file".into()),
}],
servers: Vec::new(),
});
goto_section(&mut s, Section::Profiles);
let fields = s.profile_fields();
let idx = s
.tool_catalog()
.iter()
.position(|i| i.id == "mcp__fs__read_text_file")
.unwrap();
let row = fields.iter().find(|r| r.id == FieldId::PTool(idx)).unwrap();
assert!(matches!(row.kind, FieldKind::Toggle(true)));
assert!(row.warn, "MCP disabled globally — an honest gate");
assert!(row.hint.unwrap().contains("MCP"));
s.config.mcp.enabled = true;
let fields = s.profile_fields();
let row = fields.iter().find(|r| r.id == FieldId::PTool(idx)).unwrap();
assert!(!row.warn);
assert_eq!(row.hint, Some("read_text_file"));
assert_eq!(
row.description.as_deref(),
Some("Read the complete contents of a file")
);
s.toggle_profile_tool(idx).unwrap();
assert!(
!s.profiles[0]
.enabled_tools
.iter()
.any(|t| t == "mcp__fs__read_text_file")
);
s.toggle_profile_tool(idx).unwrap();
assert!(
s.profiles[0]
.enabled_tools
.iter()
.any(|t| t == "mcp__fs__read_text_file")
);
}
#[test]
fn mcp_server_rows_show_status_and_confirm_changed_catalog() {
use crate::features::tools::mcp::{McpServerSnapshot, McpSnapshot};
use crate::shared::server::ServerStatus;
let mut s = screen();
s.config.mcp.enabled = true;
s.set_mcp(McpSnapshot {
tools: Vec::new(),
servers: vec![
McpServerSnapshot {
id: "fs".into(),
status: ServerStatus::Ready,
tool_count: 14,
pending_catalog: false,
},
McpServerSnapshot {
id: "github".into(),
status: ServerStatus::Disconnected("каталог изменился".into()),
tool_count: 0,
pending_catalog: true,
},
],
});
goto_section(&mut s, Section::Plugins);
let fields = s.plugin_fields();
let fs = fields
.iter()
.find(|r| r.id == FieldId::TMcpServer(0))
.unwrap();
assert!(matches!(&fs.kind, FieldKind::Text(v) if v.contains("14")));
assert!(!fs.warn);
let gh = fields
.iter()
.find(|r| r.id == FieldId::TMcpServer(1))
.unwrap();
assert!(gh.warn, "a changed catalog — a warning");
assert!(gh.hint.unwrap().contains("Enter"));
assert_eq!(
s.mcp_server_action(0),
Some(SettingsIntent::ReconnectMcpServer("fs".into()))
);
assert_eq!(
s.mcp_server_action(1),
Some(SettingsIntent::ConfirmMcpCatalog("github".into()))
);
goto_field(&mut s, FieldId::TMcpServer(1));
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::ConfirmMcpCatalog("github".into()))
);
}
#[test]
fn mcp_master_toggle_lives_in_plugins_section() {
let mut s = screen();
assert!(!s.config.mcp.enabled);
assert!(
!s.tool_fields().iter().any(|r| r.id == FieldId::TMcpEnabled),
"the MCP switch left the Tools section"
);
goto_section(&mut s, Section::Plugins);
goto_field(&mut s, FieldId::TMcpEnabled);
s.handle_key(key(KeyCode::Char(' ')));
assert!(s.config.mcp.enabled, "Space enables the MCP master gate");
assert!(field_desc(&s, FieldId::TMcpEnabled).is_some());
}
#[test]
fn group_header_shows_toggle_count() {
let palette = Palette::default();
let line = header_line("Веб-поиск", Some((1, 2)), 60, &palette);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("1/2"), "no counter: {text:?}");
let plain = header_line("Сервер", None, 60, &palette);
let ptext: String = plain.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
!ptext.contains('/'),
"a group with no counter shouldn't have one"
);
}
#[test]
fn group_headers_same_length_with_and_without_count() {
let palette = Palette::default();
let line_width = |line: Line<'static>| -> usize {
line.spans
.iter()
.map(|s| label_width(s.content.as_ref()))
.sum()
};
for w in [40usize, 60, 74, 100] {
let counted = line_width(header_line("Интроспекция", Some((5, 5)), w, &palette));
let plain = line_width(header_line("Персона", None, w, &palette));
assert_eq!(
counted, plain,
"width {w}: with the counter {counted} ≠ without {plain}"
);
}
}
#[test]
fn choice_popup_opens_and_applies_selection() {
let mut s = screen();
goto_field(&mut s, FieldId::XSpecType);
s.handle_key(key(KeyCode::Enter));
assert!(s.choice.is_some(), "Enter on Choice opens the popup");
assert_eq!(s.config.engine.managed.spec_type, SpecType::None);
s.handle_key(key(KeyCode::Down)); let intent = s.handle_key(key(KeyCode::Enter));
assert!(s.choice.is_none(), "Enter applies and closes the popup");
assert_eq!(s.config.engine.managed.spec_type, SpecType::DraftSimple);
assert!(matches!(intent, Some(SettingsIntent::SaveConfig(_))));
}
#[test]
fn choice_popup_esc_cancels() {
let mut s = screen();
goto_field(&mut s, FieldId::XMode);
s.handle_key(key(KeyCode::Enter));
assert!(s.choice.is_some());
s.handle_key(key(KeyCode::Down));
s.handle_key(key(KeyCode::Esc));
assert!(s.choice.is_none());
assert_eq!(
s.config.engine.mode,
ServerMode::Managed,
"Esc doesn't change the value"
);
}
#[test]
fn invalid_number_keeps_editor_open() {
let mut s = screen();
goto_field(&mut s, FieldId::XNgl);
s.handle_key(key(KeyCode::Enter)); for c in "abc".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
let intent = s.handle_key(key(KeyCode::Enter)); assert_eq!(intent, None);
assert!(s.editor.is_some(), "invalid input doesn't close the editor");
assert!(s.editor.as_ref().unwrap().error.is_some());
s.handle_key(ctrl('k')); for c in "42".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
let intent = s.handle_key(key(KeyCode::Enter));
assert!(s.editor.is_none());
match intent {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.engine.managed.gpu_layers, 42),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn field_validation_error_classifies_numbers() {
assert!(field_validation_error(FieldId::XNgl, "abc").is_some());
assert!(field_validation_error(FieldId::XNgl, "12").is_none());
assert!(field_validation_error(FieldId::XNgl, "").is_none()); assert!(field_validation_error(FieldId::S(SamplingParam::Temp), "x").is_some());
assert!(field_validation_error(FieldId::S(SamplingParam::Temp), "0.7").is_none());
assert!(field_validation_error(FieldId::XBinary, "любой текст").is_none());
assert!(field_validation_error(FieldId::S(SamplingParam::Samplers), "top_k;top_p").is_none());
}
#[test]
fn del_resets_field_to_default() {
let mut s = screen();
s.config.engine.managed.gpu_layers = 40; let default_ngl = AppConfig::default().engine.managed.gpu_layers;
goto_field(&mut s, FieldId::XNgl);
let intent = s.handle_key(key(KeyCode::Delete));
match intent {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.engine.managed.gpu_layers, default_ngl)
}
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn del_on_default_field_is_noop() {
let mut s = screen();
goto_field(&mut s, FieldId::XNgl);
assert_eq!(s.handle_key(key(KeyCode::Delete)), None);
}
#[test]
fn modified_field_shows_marker() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let render_text = |s: &mut SettingsScreen| -> String {
let mut term = Terminal::new(TestBackend::new(92, 34)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer();
(0..buf.area.height)
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
.map(|(x, y)| buf[(x, y)].symbol().to_string())
.collect()
};
let mut s = screen();
s.handle_key(key(KeyCode::Enter));
assert!(
!render_text(&mut s).contains('•'),
"no markers at the default"
);
s.config.engine.managed.gpu_layers = 40;
assert!(
render_text(&mut s).contains('•'),
"a modified field is marked with •"
);
}
#[test]
fn search_filters_and_jumps_to_field() {
let mut s = screen();
s.handle_key(key(KeyCode::Char('/')));
assert!(s.search.is_some(), "`/` opens the search overlay");
for c in "приветствие".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
{
let st = s.search.as_ref().unwrap();
assert!(!st.results.is_empty());
assert!(
st.results
.iter()
.all(|&i| st.all[i].haystack.contains("приветствие")),
"all results contain the query"
);
}
s.handle_key(key(KeyCode::Enter));
assert!(s.search.is_none());
assert_eq!(s.section(), Section::Profiles);
assert!(s.focus == Focus::Fields);
assert_eq!(
s.fields().get(s.field_idx).map(|f| f.id),
Some(FieldId::PGreeting)
);
}
#[test]
fn search_opens_on_cyrillic_slash_key() {
let mut s = screen();
s.handle_key(key(KeyCode::Char('.')));
assert!(s.search.is_some(), "`.` (Russian layout) opens search");
}
#[test]
fn search_jump_switches_subsection() {
let mut s = screen();
assert_eq!(s.model_sub, ModelTab::Assistant);
s.handle_key(key(KeyCode::Char('/')));
for c in "эмбеддинги порт".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
assert!(!s.search.as_ref().unwrap().results.is_empty());
s.handle_key(key(KeyCode::Enter));
assert_eq!(s.section(), Section::Model);
assert_eq!(s.model_sub, ModelTab::Embeddings);
assert_eq!(
s.fields().get(s.field_idx).map(|f| f.id),
Some(FieldId::EPort)
);
}
#[test]
fn search_esc_cancels_without_jump() {
let mut s = screen();
let before = (s.section_idx, s.field_idx);
s.handle_key(key(KeyCode::Char('/')));
for c in "порт".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
s.handle_key(key(KeyCode::Esc));
assert!(s.search.is_none());
assert_eq!(
(s.section_idx, s.field_idx),
before,
"Esc doesn't move navigation"
);
}
#[test]
fn search_index_covers_all_subsections() {
let s = screen();
let idx = s.build_search_index();
assert!(
idx.len() > 100,
"the index covers all sections: {}",
idx.len()
);
assert!(
idx.iter().any(|h| h.crumb.contains("Имперсонация")),
"the index has impersonation-subsection fields"
);
}
#[test]
fn memory_section_gathers_rag_notes_self_model() {
let mut s = screen();
goto_section(&mut s, Section::Memory);
let ids: Vec<FieldId> = s.fields().iter().map(|f| f.id).collect();
for id in [
FieldId::RagTarget,
FieldId::NotesAutoConsolidate,
FieldId::SmMaxNarrative,
FieldId::SmProtocol,
] {
assert!(ids.contains(&id), "\"Memory\" is missing {id:?}");
}
goto_section(&mut s, Section::Tools);
let tool_ids: Vec<FieldId> = s.fields().iter().map(|f| f.id).collect();
assert!(!tool_ids.contains(&FieldId::RagTarget));
assert!(!tool_ids.contains(&FieldId::SmMaxNarrative));
assert!(tool_ids.contains(&FieldId::MaxToolRounds));
}
#[test]
fn memory_section_opens_with_the_context_group() {
let mut s = screen();
goto_section(&mut s, Section::Memory);
let rows = s.fields();
let ids: Vec<FieldId> = rows.iter().map(|f| f.id).collect();
for id in [
FieldId::CompactEnabled,
FieldId::CompactWords,
FieldId::CompactTail,
] {
assert!(ids.contains(&id), "\"Memory\" is missing {id:?}");
assert!(
field_desc(&s, id).is_some(),
"{id:?} carries no description"
);
}
let group = rows[0].group;
assert_eq!(group, "Контекст", "the Context group opens the section");
let ctx: Vec<FieldId> = rows
.iter()
.filter(|f| f.group == group)
.map(|f| f.id)
.collect();
assert_eq!(
ctx,
vec![
FieldId::CompactEnabled,
FieldId::CompactWords,
FieldId::CompactTail,
FieldId::CompactThreshold,
FieldId::CompactContext,
FieldId::CompactPage
]
);
let rag_at = rows
.iter()
.position(|f| f.id == FieldId::RagTarget)
.unwrap();
let last_ctx = rows
.iter()
.rposition(|f| f.id == FieldId::CompactContext)
.unwrap();
assert!(last_ctx < rag_at, "Context sits ahead of the RAG group");
}
#[test]
fn compaction_toggle_persists() {
let mut s = screen();
assert!(s.config.compaction.enabled, "on by default");
match s.toggle_field(FieldId::CompactEnabled) {
Some(SettingsIntent::SaveConfig(c)) => assert!(!c.compaction.enabled),
other => panic!("expected SaveConfig, got {other:?}"),
}
assert!(!s.config.compaction.enabled);
}
#[test]
fn compaction_numeric_fields_commit_and_validate() {
fn edit(id: FieldId, typed: &str) -> AppConfig {
let mut s = screen();
goto_section(&mut s, Section::Memory);
goto_field(&mut s, id);
assert_eq!(field_num_kind(id), Some(NumKind::Int), "{id:?} is an int");
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k'));
s.handle_key(key(KeyCode::Char('x')));
assert!(s.handle_key(key(KeyCode::Enter)).is_none(), "{id:?}");
assert!(s.editor.as_ref().is_some_and(|e| e.error.is_some()));
s.handle_key(ctrl('k'));
for c in typed.chars() {
s.handle_key(key(KeyCode::Char(c)));
}
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => *c,
other => panic!("{id:?}: expected SaveConfig, got {other:?}"),
}
}
assert_eq!(
edit(FieldId::CompactWords, "400").compaction.summary_words,
400
);
assert_eq!(
edit(FieldId::CompactTail, "4096").compaction.tail_tokens,
4096
);
assert_eq!(
edit(FieldId::CompactThreshold, "60")
.compaction
.threshold_pct,
60
);
assert_eq!(
edit(FieldId::CompactThreshold, "150")
.compaction
.threshold_pct,
100
);
assert_eq!(
edit(FieldId::CompactContext, "0").compaction.context_tokens,
None
);
assert_eq!(
edit(FieldId::CompactContext, "32768")
.compaction
.context_tokens,
Some(32768)
);
}
#[test]
fn compaction_page_size_commits() {
let mut s = screen();
goto_section(&mut s, Section::Memory);
goto_field(&mut s, FieldId::CompactPage);
assert_eq!(field_num_kind(FieldId::CompactPage), Some(NumKind::Int));
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k'));
for c in "1200".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.compaction.page_tokens, 1200),
other => panic!("expected SaveConfig, got {other:?}"),
}
assert_eq!(s.config.compaction.page_tokens, 1200);
}
#[test]
fn managed_model_group_pairs_the_projector_with_the_gguf() {
let mut s = screen();
s.config.impersonation_engine.mode = ImpersonationMode::Managed;
for (tab, model, mmproj) in [
(ModelTab::Assistant, FieldId::XModel, FieldId::XMmproj),
(ModelTab::Impersonation, FieldId::IxModel, FieldId::IxMmproj),
] {
let fields = s.model_fields_for(tab);
let at = |id: FieldId| fields.iter().position(|f| f.id == id);
let (mi, pi) = (
at(model).unwrap_or_else(|| panic!("{model:?} missing")),
at(mmproj).unwrap_or_else(|| panic!("{mmproj:?} missing")),
);
assert_eq!(pi, mi + 1, "{mmproj:?} must follow {model:?}");
assert_eq!(
fields[pi].group, fields[mi].group,
"the projector must share the GGUF's group"
);
assert!(
field_desc(&s, mmproj).is_some(),
"{mmproj:?} has no description"
);
}
}
#[test]
fn projector_path_commits_and_clears() {
let mut s = screen();
goto_section(&mut s, Section::Model);
goto_field(&mut s, FieldId::XMmproj);
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k'));
for c in "mmproj-gemma.gguf".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(
c.engine.managed.mmproj.as_deref(),
Some("mmproj-gemma.gguf")
)
}
other => panic!("expected SaveConfig, got {other:?}"),
}
goto_field_again(&mut s, FieldId::XMmproj);
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k'));
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.engine.managed.mmproj, None),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn image_group_follows_the_attachment_budgets() {
let s = screen();
let fields = s.memory_fields();
let ids: Vec<FieldId> = fields
.iter()
.filter(|f| {
matches!(
f.id,
FieldId::AttachPage
| FieldId::ImageMaxCount
| FieldId::ImageMaxBytes
| FieldId::ImageDownscale
)
})
.map(|f| f.id)
.collect();
assert_eq!(
ids,
vec![
FieldId::AttachPage,
FieldId::ImageMaxCount,
FieldId::ImageMaxBytes,
FieldId::ImageDownscale,
],
"the image rows follow the attachment budgets"
);
let group = |id: FieldId| fields.iter().find(|f| f.id == id).unwrap().group;
let images = group(FieldId::ImageMaxCount);
assert_ne!(
images,
group(FieldId::AttachPage),
"images are their own group — tokens and megabytes are not one scale"
);
for id in [
FieldId::ImageMaxCount,
FieldId::ImageMaxBytes,
FieldId::ImageDownscale,
] {
assert_eq!(group(id), images, "{id:?} left the images group");
assert!(field_desc(&s, id).is_some(), "{id:?} has no description");
}
}
#[test]
fn image_budgets_commit_and_the_size_limit_round_trips_through_mb() {
fn edit(id: FieldId, typed: &str) -> (AppConfig, SettingsScreen) {
let mut s = screen();
goto_section(&mut s, Section::Memory);
goto_field(&mut s, id);
assert_eq!(field_num_kind(id), Some(NumKind::Int), "{id:?} is an int");
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k'));
s.handle_key(key(KeyCode::Char('x')));
assert!(s.handle_key(key(KeyCode::Enter)).is_none(), "{id:?}");
assert!(s.editor.as_ref().is_some_and(|e| e.error.is_some()));
s.handle_key(ctrl('k'));
for c in typed.chars() {
s.handle_key(key(KeyCode::Char(c)));
}
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => (*c, s),
other => panic!("{id:?}: expected SaveConfig, got {other:?}"),
}
}
assert_eq!(edit(FieldId::ImageMaxCount, "3").0.images.max_count, 3);
assert_eq!(
edit(FieldId::ImageDownscale, "1024").0.images.downscale_px,
1024
);
let (cfg, s) = edit(FieldId::ImageMaxBytes, "5");
assert_eq!(cfg.images.max_bytes, 5 * BYTES_PER_MB);
let shown = s
.memory_fields()
.into_iter()
.find(|f| f.id == FieldId::ImageMaxBytes)
.map(|f| value_text(&f.kind, s.loc()))
.expect("the size-limit row");
assert_eq!(shown, "5");
assert_eq!(
edit(FieldId::ImageMaxBytes, "0").0.images.max_bytes,
BYTES_PER_MB
);
}
#[test]
fn the_default_size_limit_is_shown_in_megabytes() {
assert_eq!(
bytes_to_mb(crate::shared::config::DEFAULT_IMAGE_MAX_BYTES),
10
);
assert_eq!(
mb_to_bytes(10),
crate::shared::config::DEFAULT_IMAGE_MAX_BYTES
);
}
#[test]
fn fields_carry_group_headers() {
let s = screen();
let groups: Vec<&str> = s.model_fields().iter().map(|f| f.group).collect();
assert!(groups.iter().any(|g| g.is_empty()));
assert!(groups.contains(&"Сервер"));
let sg: Vec<&str> = s.sampling_fields().iter().map(|f| f.group).collect();
assert!(sg.contains(&"Основные"));
assert!(sg.contains(&"Рассуждения"));
}
#[test]
fn long_value_is_truncated_with_ellipsis() {
let palette = Palette::default();
let f = FieldRow {
id: FieldId::XModel,
label: "GGUF-модель (-m)".into(),
kind: FieldKind::Text("D:\\LLM\\GGUF\\very-long-model-name-".repeat(4)),
group: "Модель",
hint: None,
description: None,
warn: false,
warn_note: None,
};
let line = render_field_line(&f, 20, 24, false, false, &palette);
let rendered: String = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
rendered.contains('…'),
"the long value is truncated: {rendered:?}"
);
}
#[test]
fn selected_field_shows_green_rail() {
let palette = Palette::default();
let f = FieldRow {
id: FieldId::XModel,
label: "GGUF-модель (-m)".into(),
kind: FieldKind::Text("model".into()),
group: "Модель",
hint: None,
description: None,
warn: false,
warn_note: None,
};
let sel = render_field_line(&f, 20, 24, false, true, &palette);
assert_eq!(sel.spans[0].content.as_ref(), "▌ ");
assert_eq!(sel.spans[0].style.fg, Some(palette.success));
let modf = render_field_line(&f, 20, 24, true, false, &palette);
assert_eq!(modf.spans[0].content.as_ref(), "• ");
let plain = render_field_line(&f, 20, 24, false, false, &palette);
assert_eq!(plain.spans[0].content.as_ref(), " ");
let both = render_field_line(&f, 20, 24, true, true, &palette);
assert_eq!(both.spans[0].content.as_ref(), "▌ ");
}
#[test]
fn overlong_label_keeps_the_value_column() {
let palette = Palette::default();
let toggle = |label: &str| FieldRow {
id: FieldId::PTool(0),
label: label.into(),
kind: FieldKind::Toggle(true),
group: "Плагины (MCP)",
hint: None,
description: None,
warn: false,
warn_note: None,
};
let rows = vec![
toggle("mcp__filesystem__read_file"),
toggle("mcp__filesystem__read_multiple_files"),
toggle("mcp__filesystem__list_directory_with_sizes"),
toggle("mcp__filesystem__list_allowed_directories"),
];
let label_col = section_label_col(&rows);
assert_eq!(label_col, LABEL_CAP, "the column stops at the cap");
let value_col = |f: &FieldRow| -> usize {
let line = render_field_line(f, label_col, 24, false, false, &palette);
let head: String = line.spans[..3]
.iter()
.map(|sp| sp.content.as_ref())
.collect();
assert_eq!(line.spans[3].content.as_ref(), "[x]", "the value span");
label_width(&head)
};
let first = value_col(&rows[0]);
for f in &rows[1..] {
assert_eq!(
value_col(f),
first,
"the toggle of {:?} is off the column",
f.label
);
}
let long = render_field_line(&rows[2], label_col, 24, false, false, &palette);
assert!(
long.spans[1].content.ends_with('…'),
"the clipped label is marked: {:?}",
long.spans[1].content
);
}
#[test]
fn mcp_tool_toggles_share_the_column() {
use crate::features::tools::meta::{ToolGate, ToolGroup, ToolInfo};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut s = screen();
s.config.mcp.enabled = true;
let mcp_tool = |name: &'static str| ToolInfo {
id: format!("mcp__fs__{name}"),
group: ToolGroup::Plugins,
label: name,
gate: Some(ToolGate::Mcp),
enabled_by_default: false,
concurrent: false,
description: Some("A server-provided description".into()),
};
s.set_mcp(crate::features::tools::mcp::McpSnapshot {
tools: vec![
mcp_tool("read_file"),
mcp_tool("read_multiple_files"),
mcp_tool("list_directory_with_sizes"),
mcp_tool("list_allowed_directories"),
],
servers: Vec::new(),
});
goto_section(&mut s, Section::Profiles);
s.handle_key(key(KeyCode::Enter)); let mut term = Terminal::new(TestBackend::new(100, 40)).unwrap();
term.draw(|f| s.render(f)).unwrap();
for _ in 0..120 {
s.handle_key(key(KeyCode::Down));
term.draw(|f| s.render(f)).unwrap();
}
let buf = term.backend().buffer();
let mut columns: Vec<(String, usize)> = Vec::new();
for y in 0..buf.area.height {
let line: String = (0..buf.area.width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect();
let cells: Vec<char> = line.chars().collect();
if let Some(col) =
(0..cells.len().saturating_sub(2)).find(|&i| cells[i] == '[' && cells[i + 2] == ']')
{
columns.push((line.trim_end().to_string(), col));
}
}
assert!(
columns.iter().any(|(l, _)| l.contains("mcp__fs__list_")),
"no long MCP row on screen: {columns:#?}"
);
let (_, first) = &columns[0];
for (line, col) in &columns {
assert_eq!(col, first, "the toggle is off the column: {line:?}");
}
}
#[test]
fn section_label_col_has_floor_cap_and_skips_subsection() {
let text = |s: &str| FieldKind::Text(s.into());
let short = vec![row(FieldId::PName, "Имя", text("x"))];
assert_eq!(section_label_col(&short), MIN_LABEL_COL);
let medium = vec![
row(FieldId::PName, "Имя", text("x")),
row(FieldId::PGreeting, "Подпись средней длины!", text("y")),
];
assert_eq!(section_label_col(&medium), 22);
let mut with_outlier = medium;
with_outlier.push(row(
FieldId::PSystem,
&"а".repeat(LABEL_CAP + 12),
text("z"),
));
assert_eq!(section_label_col(&with_outlier), LABEL_CAP);
let with_sub = vec![
row(FieldId::PName, "Имя", text("x")),
row(FieldId::ModelSub, &"б".repeat(25), text("s")),
];
assert_eq!(section_label_col(&with_sub), MIN_LABEL_COL);
}
#[test]
fn all_labels_fit_alignment_cap() {
let mut cfg = AppConfig::default();
cfg.engine.managed.spec_type = SpecType::DraftMtp;
let mut p = Profile::new("Базовый", "Ты — ассистент.");
p.enabled_tools = default_tool_ids();
let s = SettingsScreen::new(cfg, vec![p], vec![]);
let mut all: Vec<(&str, Vec<FieldRow>)> = vec![
("Инструменты", s.tool_fields()),
("Память", s.memory_fields()),
("Интерфейс", s.interface_fields()),
];
for tab in ModelTab::ALL {
all.push(("Модель", s.model_fields_for(tab)));
}
for sub in Subsection::ALL {
all.push(("Семплинг", s.sampling_fields_for(sub)));
all.push(("Профили", s.profile_fields_for(sub)));
}
for (section, fields) in all {
for f in fields {
assert!(
label_width(&f.label) <= LABEL_CAP,
"label \"{}\" ({section}) is wider than LABEL_CAP={LABEL_CAP} — shorten \
it or move the context into the group header",
f.label
);
}
}
}
#[test]
fn value_column_is_shared_across_groups() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut s = screen();
goto_section(&mut s, Section::Tools);
let mut term = Terminal::new(TestBackend::new(100, 52)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer();
let lines: Vec<String> = (0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect()
})
.collect();
let value_cell = |label: &str| -> usize {
let line = lines
.iter()
.find(|l| l.contains(label))
.unwrap_or_else(|| panic!("no row with the label {label:?}"));
let chars: Vec<char> = line.chars().collect();
let needle: Vec<char> = label.chars().collect();
let start = (0..=chars.len() - needle.len())
.find(|&i| chars[i..i + needle.len()] == needle[..])
.unwrap();
let after = start + needle.len();
after + chars[after..].iter().take_while(|c| **c == ' ').count()
};
let a = value_cell("Лимит раундов инструментов");
let b = value_cell("Web-поиск");
let c = value_cell("Доступ к файлам");
assert_eq!(
a, b,
"the \"Agentic loop\" and \"Web search\" group values are in one column"
);
assert_eq!(b, c, "the \"Files\" group values are in the same column");
}
#[test]
fn sampling_extensions_have_descriptions() {
let s = screen();
for &p in SAMPLING_PARAMS {
assert!(
field_desc(&s, FieldId::S(p)).is_some(),
"no hint for {p:?} (Assistant)",
);
assert!(
field_desc(&s, FieldId::IS(p)).is_some(),
"no hint for {p:?} (Impersonation)",
);
}
}
#[test]
fn api_key_field_shows_status_in_cloud_modes_only() {
let mut s = screen();
goto_section(&mut s, Section::Model);
let has_key_field = |s: &SettingsScreen| s.fields().iter().any(|f| f.id == FieldId::XApiKey);
assert!(!has_key_field(&s));
s.config.engine.mode = ServerMode::OpenAi;
assert!(has_key_field(&s));
let value = |s: &SettingsScreen| {
s.fields()
.into_iter()
.find(|f| f.id == FieldId::XApiKey)
.map(|f| value_text(&f.kind, s.loc()))
.unwrap()
};
let unset = value(&s);
s.set_secrets_present(vec![SecretKey::Provider(CloudProvider::OpenAi)]);
let set = value(&s);
assert_ne!(
unset, set,
"the field's status doesn't reflect the key's presence"
);
s.set_secrets_present(vec![SecretKey::Provider(CloudProvider::Claude)]);
assert_eq!(value(&s), unset);
assert!(!set.contains("sk-"));
}
#[test]
fn backup_password_field_is_a_masked_secret_with_its_own_intent() {
let mut s = screen();
goto_section(&mut s, Section::Data);
goto_field(&mut s, FieldId::BackupPassword);
let value = |s: &SettingsScreen| {
s.fields()
.into_iter()
.find(|f| f.id == FieldId::BackupPassword)
.map(|f| match f.kind {
FieldKind::Text(v) => v,
_ => panic!("the backup password should be a text field"),
})
.expect("the field exists in the Data section")
};
let unset = value(&s);
s.set_secrets_present(vec![SecretKey::BackupPassword]);
assert_ne!(unset, value(&s), "the status doesn't follow the flag");
s.handle_key(key(KeyCode::Enter));
let editor = s.editor.as_ref().expect("the editor is open");
assert_eq!(editor.input.text(), "");
assert!(editor.input.is_masked());
for c in "hunter2".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::SetSecret {
key: SecretKey::BackupPassword,
value: "hunter2".into()
})
);
let json = serde_json::to_string(&s.config).unwrap();
assert!(!json.contains("hunter2"), "the password leaked: {json}");
assert_eq!(
s.handle_key(key(KeyCode::Delete)),
Some(SettingsIntent::SetSecret {
key: SecretKey::BackupPassword,
value: String::new()
})
);
s.set_secrets_present(Vec::new());
assert_eq!(s.handle_key(key(KeyCode::Delete)), None);
}
#[test]
fn api_key_editor_is_masked_empty_and_commits_intent() {
let mut s = screen();
s.config.engine.mode = ServerMode::Claude;
s.set_secrets_present(vec![SecretKey::Provider(CloudProvider::Claude)]); goto_section(&mut s, Section::Model);
goto_field(&mut s, FieldId::XApiKey);
s.handle_key(key(KeyCode::Enter));
let editor = s.editor.as_ref().expect("the key field's editor is open");
assert_eq!(editor.input.text(), "", "the seed must be empty");
assert!(editor.input.is_masked(), "the secret field must be masked");
for c in "sk-ant-123".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
let intent = s.handle_key(key(KeyCode::Enter));
assert_eq!(
intent,
Some(SettingsIntent::SetSecret {
key: SecretKey::Provider(CloudProvider::Claude),
value: "sk-ant-123".into()
})
);
let json = serde_json::to_string(&s.config).unwrap();
assert!(
!json.contains("sk-ant-123"),
"the key leaked into the screen's config: {json}"
);
}
#[test]
fn del_on_api_key_field_removes_stored_key() {
let mut s = screen();
s.config.engine.mode = ServerMode::Gemini;
goto_section(&mut s, Section::Model);
goto_field(&mut s, FieldId::XApiKey);
assert_eq!(s.handle_key(key(KeyCode::Delete)), None);
s.set_secrets_present(vec![SecretKey::Provider(CloudProvider::Gemini)]);
assert_eq!(
s.handle_key(key(KeyCode::Delete)),
Some(SettingsIntent::SetSecret {
key: SecretKey::Provider(CloudProvider::Gemini),
value: String::new()
})
);
}
#[test]
fn api_key_field_targets_provider_of_its_slot() {
let mut s = screen();
s.config.engine.mode = ServerMode::OpenAi;
s.config.impersonation_engine.mode = ImpersonationMode::Claude;
s.config.embed.mode = ServerMode::Gemini;
assert_eq!(
s.secret_field_key(FieldId::XApiKey),
Some(SecretKey::Provider(CloudProvider::OpenAi))
);
assert_eq!(
s.secret_field_key(FieldId::IxApiKey),
Some(SecretKey::Provider(CloudProvider::Claude))
);
assert_eq!(
s.secret_field_key(FieldId::EApiKey),
Some(SecretKey::Provider(CloudProvider::Gemini))
);
assert_eq!(s.secret_field_key(FieldId::XUrl), None);
}
#[test]
fn api_key_rows_name_their_provider() {
let mut s = screen();
s.config.engine.mode = ServerMode::OpenAi;
s.config.impersonation_engine.mode = ImpersonationMode::Claude;
s.config.embed.mode = ServerMode::Gemini;
s.config.tts.mode = crate::shared::config::TtsMode::OpenAi;
s.config.tools.web_provider = WebProvider::Auto;
for (field, name) in [
(FieldId::XApiKey, "OpenAI"),
(FieldId::XApiKeyEnv, "OpenAI"),
(FieldId::IxApiKey, "Claude"),
(FieldId::IxApiKeyEnv, "Claude"),
(FieldId::EApiKey, "Gemini"),
(FieldId::EApiKeyEnv, "Gemini"),
(FieldId::TtsApiKey, "OpenAI"),
(FieldId::TtsApiKeyEnv, "OpenAI"),
(FieldId::TWebTavilyKey, "Tavily"),
(FieldId::TWebTavilyKeyEnv, "Tavily"),
(FieldId::VideoApiKey, "Gemini"),
(FieldId::VideoApiKeyEnv, "Gemini"),
] {
let label = field_label(&s, field).unwrap_or_else(|| panic!("no row for {field:?}"));
assert!(
label.contains(name),
"{field:?} must name its provider: {label:?} doesn't contain {name:?}"
);
}
s.config.engine.mode = ServerMode::External;
for field in [FieldId::XApiKey, FieldId::XApiKeyEnv] {
let label = field_label(&s, field).unwrap_or_else(|| panic!("no row for {field:?}"));
let named = CloudProvider::ALL
.iter()
.find(|p| label.contains(p.display_name()));
assert!(named.is_none(), "{field:?} in external mode: {label:?}");
}
}
#[test]
fn external_key_field_targets_its_own_slot() {
let mut s = screen();
s.config.engine.mode = ServerMode::External;
s.config.impersonation_engine.mode = ImpersonationMode::External;
s.config.embed.mode = ServerMode::External;
s.config.tts.mode = crate::shared::config::TtsMode::External;
for (field, slot) in [
(FieldId::XApiKey, ExternalSlot::Chat),
(FieldId::IxApiKey, ExternalSlot::Impersonation),
(FieldId::EApiKey, ExternalSlot::Embed),
(FieldId::TtsApiKey, ExternalSlot::Tts),
] {
assert_eq!(
s.secret_field_key(field),
Some(SecretKey::External(slot)),
"{field:?} must address its own external slot"
);
}
goto_section(&mut s, Section::Model);
let value = |s: &SettingsScreen| {
s.fields()
.into_iter()
.find(|f| f.id == FieldId::XApiKey)
.map(|f| value_text(&f.kind, s.loc()))
.expect("external mode shows the key row")
};
let unset = value(&s);
s.set_secrets_present(vec![SecretKey::External(ExternalSlot::Embed)]);
assert_eq!(value(&s), unset, "another slot's key is not this slot's");
s.set_secrets_present(vec![SecretKey::External(ExternalSlot::Chat)]);
assert_ne!(value(&s), unset, "the status must follow this slot's key");
goto_field(&mut s, FieldId::XApiKey);
assert_eq!(
enter_secret(&mut s, "sk-gateway"),
Some(SettingsIntent::SetSecret {
key: SecretKey::External(ExternalSlot::Chat),
value: "sk-gateway".into()
})
);
let json = serde_json::to_string(&s.config).unwrap();
assert!(
!json.contains("sk-gateway"),
"the key leaked into the screen's config: {json}"
);
assert!(s.fields().iter().any(|f| f.id == FieldId::XApiKeyEnv));
}
#[test]
fn video_key_field_targets_gemini_whatever_the_engines_are() {
let mut s = screen();
s.config.engine.mode = ServerMode::OpenAi;
s.config.impersonation_engine.mode = ImpersonationMode::Shared;
s.config.embed.mode = ServerMode::External;
assert_eq!(
s.secret_field_key(FieldId::VideoApiKey),
Some(SecretKey::Provider(CloudProvider::Gemini)),
"no engine is on Gemini, yet the video key must still address Gemini"
);
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::VideoApiKey);
s.handle_key(key(KeyCode::Enter));
let editor = s.editor.as_ref().expect("the key field's editor is open");
assert_eq!(editor.input.text(), "", "a stored key can't be shown");
assert!(editor.input.is_masked(), "the secret field must be masked");
for c in "AIzaSy-test".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::SetSecret {
key: SecretKey::Provider(CloudProvider::Gemini),
value: "AIzaSy-test".into()
})
);
let json = serde_json::to_string(&s.config).unwrap();
assert!(
!json.contains("AIzaSy-test"),
"the key leaked into the screen's config: {json}"
);
}
#[test]
fn video_key_row_shows_status_and_del_removes_it() {
let mut s = screen();
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::VideoApiKey);
assert_eq!(s.handle_key(key(KeyCode::Delete)), None);
s.set_secrets_present(vec![SecretKey::Provider(CloudProvider::Gemini)]);
let want =
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru).t("ui.settings.value.key_set");
assert!(
s.fields()
.iter()
.any(|f| f.id == FieldId::VideoApiKey
&& matches!(&f.kind, FieldKind::Text(v) if v == want)),
"the row must report the key as stored"
);
assert_eq!(
s.handle_key(key(KeyCode::Delete)),
Some(SettingsIntent::SetSecret {
key: SecretKey::Provider(CloudProvider::Gemini),
value: String::new()
})
);
}
#[test]
fn profile_and_persona_rows_are_never_marked_modified() {
use ratatui::{Terminal, backend::TestBackend};
let mut s = screen();
s.config.impersonation_profiles = vec![crate::shared::config::ImpersonationProfile::new(
"Владимир",
"Ты — Владимир.",
)];
s.profiles[0].impersonation_profile_id = Some(s.config.impersonation_profiles[0].id);
goto_section(&mut s, Section::Profiles);
s.handle_key(key(KeyCode::Enter));
let marked = |s: &mut SettingsScreen| {
let mut term = Terminal::new(TestBackend::new(96, 26)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buf = term.backend().buffer().clone();
(0..26).any(|y| (0..96).any(|x| buf[(x, y)].symbol() == "•"))
};
assert!(
!marked(&mut s),
"the assistant subsection marked user data as modified"
);
goto_impersonation_sub(&mut s);
assert!(
!marked(&mut s),
"the impersonation subsection marked user data as modified"
);
}
fn type_into(s: &mut SettingsScreen, id: FieldId, text: &str) -> Option<SettingsIntent> {
goto_field_again(s, id);
s.handle_key(key(KeyCode::Enter)); s.handle_key(ctrl('k')); s.handle_paste(text);
s.handle_key(key(KeyCode::Enter))
}
#[test]
fn mcp_env_secret_rows_follow_the_declared_variables() {
let mut s = screen();
goto_section(&mut s, Section::Plugins);
s.handle_key(ctrl('n'));
assert!(type_into(&mut s, FieldId::McpId, "gh").is_some());
assert!(
!s.plugin_fields()
.iter()
.any(|r| matches!(r.id, FieldId::McpEnvSecret(_))),
"a server with no env has nothing to store"
);
assert!(type_into(&mut s, FieldId::McpEnv, "GITHUB_TOKEN, OTHER, FROM_OS=SRC").is_some());
let all = s.plugin_fields();
let rows: Vec<&FieldRow> = all
.iter()
.filter(|r| matches!(r.id, FieldId::McpEnvSecret(_)))
.collect();
assert_eq!(
rows.len(),
2,
"a row per variable declared by name alone — the one naming a source has \
its answer already"
);
assert_eq!(rows[0].label, "GITHUB_TOKEN");
assert_eq!(rows[1].label, "OTHER");
assert!(
!rows.iter().any(|r| r.label == "FROM_OS"),
"offering to store a value for a variable that names a source would be two \
answers to one question (§9.5b)"
);
let ru = crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru);
assert!(
matches!(&rows[0].kind, FieldKind::Text(v) if v == ru.t("ui.settings.value.key_unset")),
"nothing stored yet"
);
s.set_secrets_present(vec![SecretKey::McpEnv {
server: "gh".into(),
var: "GITHUB_TOKEN".into(),
}]);
let rows = s.plugin_fields();
let stored = rows
.iter()
.find(|r| r.id == FieldId::McpEnvSecret(1))
.unwrap();
assert!(
matches!(&stored.kind, FieldKind::Text(v) if v == ru.t("ui.settings.value.key_set")),
"the row must report the value as stored"
);
goto_field_again(&mut s, FieldId::McpEnvSecret(2));
s.handle_key(key(KeyCode::Enter));
let editor = s.editor.as_ref().expect("the editor is open");
assert_eq!(editor.input.text(), "", "a stored value cannot be shown");
assert!(editor.input.is_masked());
for c in "ghp-live-token".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::SetSecret {
key: SecretKey::McpEnv {
server: "gh".into(),
var: "OTHER".into()
},
value: "ghp-live-token".into()
})
);
let json = serde_json::to_string(&s.config).unwrap();
assert!(
!json.contains("ghp-live-token"),
"the secret leaked into the screen's config: {json}"
);
}
#[test]
fn the_gate_explanation_is_only_on_gated_tools_and_names_its_section() {
let mut s = screen();
goto_section(&mut s, Section::Plugins);
s.handle_key(ctrl('n'));
assert!(type_into(&mut s, FieldId::McpEnv, "ABSENT=MINDFORK_TEST_NO_SUCH_VAR").is_some());
let rows = s.plugin_fields();
let source_row = rows
.iter()
.find(|r| matches!(r.id, FieldId::McpEnvSource(_)))
.expect("the status row");
assert!(source_row.warn, "a missing source is worth flagging");
assert!(
source_row.warn_note.is_none(),
"…but it has nothing to do with a global tool gate"
);
let desc = source_row.description.as_deref().unwrap_or_default();
assert!(
desc.contains("MINDFORK_TEST_NO_SUCH_VAR") && !desc.contains("{src}"),
"the description must name the source: {desc}"
);
s.config.mcp.enabled = false;
let mcp_tool = crate::features::tools::meta::ToolInfo {
id: "mcp__gh__issues".to_string(),
group: crate::features::tools::meta::ToolGroup::Plugins,
label: "issues",
gate: Some(ToolGate::Mcp),
enabled_by_default: false,
concurrent: false,
description: None,
};
s.set_mcp(crate::features::tools::mcp::McpSnapshot {
tools: vec![mcp_tool],
servers: Vec::new(),
});
let idx = s
.tool_catalog()
.iter()
.position(|i| i.id == "mcp__gh__issues")
.unwrap();
s.profiles[0].enabled_tools.push("mcp__gh__issues".into());
goto_section(&mut s, Section::Profiles);
let gated = s
.profile_fields()
.into_iter()
.find(|r| r.id == FieldId::PTool(idx))
.expect("the tool toggle");
let note = gated.warn_note.expect("a gated tool explains itself");
let ru = crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru);
assert!(
note.contains(ru.t("ui.settings.section.plugins")),
"the MCP switch lives in \"Plugins\", not \"Tools\": {note}"
);
assert!(
!note.contains("{section}"),
"unsubstituted placeholder: {note}"
);
s.config.tools.web_enabled = false;
let web = s
.tool_catalog()
.iter()
.position(|i| i.id == "web_search")
.unwrap();
s.profiles[0].enabled_tools.push("web_search".into());
let note = s
.profile_fields()
.into_iter()
.find(|r| r.id == FieldId::PTool(web))
.and_then(|r| r.warn_note)
.expect("a gated tool explains itself");
assert!(note.contains(ru.t("ui.settings.section.tools")), "{note}");
}
#[test]
fn a_sourced_variable_shows_whether_its_source_exists() {
let mut s = screen();
goto_section(&mut s, Section::Plugins);
s.handle_key(ctrl('n'));
unsafe { std::env::set_var("MINDFORK_TEST_SETTINGS_SRC", "x") };
assert!(
type_into(
&mut s,
FieldId::McpEnv,
"PRESENT=MINDFORK_TEST_SETTINGS_SRC, ABSENT=MINDFORK_TEST_NO_SUCH_VAR",
)
.is_some()
);
let rows = s.plugin_fields();
let row_of = |idx: usize| {
rows.iter()
.find(|r| r.id == FieldId::McpEnvSource(idx))
.unwrap_or_else(|| panic!("no status row for variable {idx}"))
};
let absent = row_of(0);
let present = row_of(1);
assert_eq!(absent.label, "ABSENT");
assert_eq!(present.label, "PRESENT");
assert!(
matches!(&present.kind, FieldKind::Text(v) if v.contains("MINDFORK_TEST_SETTINGS_SRC")
&& v.contains(&ru_t("ui.settings.value.mcp_source_found"))),
"the row must name the source and say it was found"
);
assert!(!present.warn);
assert!(
matches!(&absent.kind, FieldKind::Text(v)
if v.contains(&ru_t("ui.settings.value.mcp_source_missing"))),
"a missing source must be visible"
);
assert!(absent.warn, "a missing source is worth flagging");
assert!(
!rows
.iter()
.any(|r| matches!(r.id, FieldId::McpEnvSecret(_))),
"a variable that names a source has its origin already"
);
goto_field_again(&mut s, FieldId::McpEnvSource(1));
assert_eq!(s.handle_key(key(KeyCode::Enter)), None);
assert!(s.editor.is_none(), "there is nothing to edit here");
}
fn ru_t(key: &str) -> String {
let ru = crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru);
ru.t(key).rsplit("{src}").next().unwrap_or("").to_string()
}
#[test]
fn del_on_an_env_secret_row_removes_the_stored_value() {
let mut s = screen();
goto_section(&mut s, Section::Plugins);
s.handle_key(ctrl('n'));
assert!(type_into(&mut s, FieldId::McpId, "gh").is_some());
assert!(type_into(&mut s, FieldId::McpEnv, "TOKEN=").is_some());
goto_field_again(&mut s, FieldId::McpEnvSecret(0));
assert_eq!(s.handle_key(key(KeyCode::Delete)), None);
s.set_secrets_present(vec![SecretKey::McpEnv {
server: "gh".into(),
var: "TOKEN".into(),
}]);
assert_eq!(
s.handle_key(key(KeyCode::Delete)),
Some(SettingsIntent::SetSecret {
key: SecretKey::McpEnv {
server: "gh".into(),
var: "TOKEN".into()
},
value: String::new()
})
);
}
#[test]
fn env_row_drops_names_it_cannot_carry() {
let mut s = screen();
goto_section(&mut s, Section::Plugins);
s.handle_key(ctrl('n'));
assert!(type_into(&mut s, FieldId::McpEnv, "OK_1=A, has-dash=B, 1BAD=C").is_some());
let keys: Vec<&String> = s.config.mcp.servers[0].env.keys().collect();
assert_eq!(keys, vec!["OK_1"]);
}
#[test]
fn mcp_import_row_commits_a_path_and_shows_the_outcome() {
let mut s = screen();
goto_section(&mut s, Section::Plugins);
assert_eq!(
type_into(
&mut s,
FieldId::McpImport,
"D:/cfg/claude_desktop_config.json"
),
Some(SettingsIntent::ImportMcpServers(
"D:/cfg/claude_desktop_config.json".into()
))
);
assert_eq!(type_into(&mut s, FieldId::McpImport, ""), None);
s.set_mcp_import_result("Импортировано серверов: 2".into());
let row = s
.plugin_fields()
.into_iter()
.find(|r| r.id == FieldId::McpImport)
.unwrap();
assert!(matches!(&row.kind, FieldKind::Text(v) if v.contains("2")));
goto_field_again(&mut s, FieldId::McpImport);
s.handle_key(key(KeyCode::Enter));
assert_eq!(s.editor.as_ref().unwrap().input.text(), "");
}
#[test]
fn mcp_server_create_edit_delete_round_trip() {
let mut s = screen();
goto_section(&mut s, Section::Plugins);
assert!(s.config.mcp.servers.is_empty());
assert!(
s.plugin_fields()
.iter()
.all(|r| r.id != FieldId::McpCommand)
);
assert!(matches!(
s.handle_key(ctrl('n')),
Some(SettingsIntent::SaveConfig(_))
));
let srv = &s.config.mcp.servers[0];
assert_eq!(srv.id, "server-1");
assert!(
!srv.enabled,
"a new server is off — nothing spawns while the command is half-typed (F5)"
);
assert_eq!(s.mcp_server_idx, 0);
assert!(type_into(&mut s, FieldId::McpId, "fs").is_some());
assert!(type_into(&mut s, FieldId::McpCommand, "cmd").is_some());
assert!(
type_into(
&mut s,
FieldId::McpArgs,
r#"/c npx -y @modelcontextprotocol/server-filesystem "D:/my work""#,
)
.is_some()
);
assert!(type_into(&mut s, FieldId::McpEnv, "GITHUB_TOKEN=MINDFORK_PAT").is_some());
assert!(type_into(&mut s, FieldId::McpTimeout, "90").is_some());
let srv = &s.config.mcp.servers[0];
assert_eq!(srv.id, "fs");
assert_eq!(srv.command, "cmd");
assert_eq!(
srv.args,
[
"/c",
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"D:/my work"
]
);
assert_eq!(srv.env["GITHUB_TOKEN"], "MINDFORK_PAT");
assert_eq!(srv.tool_timeout_secs, 90);
goto_field_again(&mut s, FieldId::McpEnabled);
s.handle_key(key(KeyCode::Char(' ')));
assert!(s.config.mcp.servers[0].enabled);
s.handle_key(ctrl('n'));
assert_eq!(s.config.mcp.servers[1].id, "server-1");
assert!(matches!(
s.handle_key(ctrl('d')),
Some(SettingsIntent::SaveConfig(_))
));
assert_eq!(s.config.mcp.servers.len(), 1);
assert_eq!(s.mcp_server_idx, 0);
s.handle_key(ctrl('d'));
assert!(s.config.mcp.servers.is_empty());
assert!(s.handle_key(ctrl('d')).is_none(), "nothing left to delete");
}
#[test]
fn mcp_id_must_be_a_unique_slug() {
let mut s = screen();
goto_section(&mut s, Section::Plugins);
s.handle_key(ctrl('n'));
type_into(&mut s, FieldId::McpId, "fs");
s.handle_key(ctrl('n'));
for bad in ["Files", "my server", ""] {
assert!(
type_into(&mut s, FieldId::McpId, bad).is_none(),
"{bad:?} committed"
);
assert!(s.editor.is_some(), "{bad:?}: the editor must stay open");
assert!(s.editor.as_ref().unwrap().error.is_some());
s.handle_key(key(KeyCode::Esc));
}
assert!(type_into(&mut s, FieldId::McpId, "fs").is_none());
let err = s.editor.as_ref().unwrap().error.clone().unwrap();
assert!(err.contains("занят"), "{err}");
s.handle_key(key(KeyCode::Esc));
assert!(type_into(&mut s, FieldId::McpId, "gh").is_some());
assert!(type_into(&mut s, FieldId::McpId, "gh").is_some());
assert_eq!(s.config.mcp.servers[1].id, "gh");
}
#[test]
fn mcp_command_accepts_a_shim_and_a_plain_name() {
let mut s = screen();
goto_section(&mut s, Section::Plugins);
s.handle_key(ctrl('n'));
for command in ["npx", "run-server.cmd", "C:/tools/server.exe"] {
assert!(
type_into(&mut s, FieldId::McpCommand, command).is_some(),
"{command} was refused"
);
assert!(s.editor.is_none(), "{command} left the editor open");
assert_eq!(s.config.mcp.servers[0].command, command);
}
}
#[test]
fn mcp_selector_switches_the_edited_server() {
let mut s = screen();
goto_section(&mut s, Section::Plugins);
s.handle_key(ctrl('n'));
type_into(&mut s, FieldId::McpId, "fs");
s.handle_key(ctrl('n'));
type_into(&mut s, FieldId::McpId, "gh");
goto_field_again(&mut s, FieldId::McpSelect);
assert!(
s.handle_key(key(KeyCode::Left)).is_none(),
"selection is navigation, not an edit"
);
assert_eq!(s.mcp_server_idx, 0);
let shown = s.fields();
let id_row = shown.iter().find(|r| r.id == FieldId::McpId).unwrap();
assert!(matches!(&id_row.kind, FieldKind::Text(v) if v == "fs"));
s.handle_key(key(KeyCode::Enter));
let (opts, cur) = s.choice_menu(FieldId::McpSelect).unwrap();
assert_eq!(opts, ["fs", "gh"]);
assert_eq!(cur, 0);
s.handle_key(key(KeyCode::Down));
s.handle_key(key(KeyCode::Enter));
assert_eq!(s.mcp_server_idx, 1);
}
#[test]
fn mcp_server_fields_are_user_data() {
let mut s = screen();
goto_section(&mut s, Section::Plugins);
s.handle_key(ctrl('n'));
type_into(&mut s, FieldId::McpId, "fs");
goto_field_again(&mut s, FieldId::McpId);
assert!(s.handle_key(key(KeyCode::Delete)).is_none());
assert_eq!(s.config.mcp.servers[0].id, "fs");
for id in [
FieldId::McpSelect,
FieldId::McpId,
FieldId::McpCommand,
FieldId::McpArgs,
FieldId::McpEnv,
FieldId::McpEnvSecret(0),
FieldId::McpImport,
FieldId::McpEnabled,
FieldId::McpTimeout,
FieldId::McpMaxResult,
] {
assert!(is_profile_field(id), "{id:?}");
}
}
#[test]
fn mcp_undo_restores_a_deleted_server() {
let mut s = screen();
goto_section(&mut s, Section::Plugins);
s.handle_key(ctrl('n'));
type_into(&mut s, FieldId::McpId, "fs");
s.handle_key(ctrl('d'));
assert!(s.config.mcp.servers.is_empty());
assert!(matches!(
s.handle_key(ctrl('z')),
Some(SettingsIntent::SaveConfig(_))
));
assert_eq!(s.config.mcp.servers[0].id, "fs");
}
#[test]
fn mcp_args_and_env_round_trip_as_text() {
for args in [
vec!["-y".to_string(), "@scope/pkg".to_string()],
vec!["D:/my work".to_string()],
vec![r#"say "hi""#.to_string()],
vec!["it's".to_string(), String::new()],
vec![r"C:\Program Files\srv.exe".to_string()],
] {
assert_eq!(parse_args(&join_args(&args)), args, "{args:?}");
}
assert_eq!(parse_args(" a 'b c' d "), ["a", "b c", "d"]);
assert_eq!(parse_args(""), Vec::<String>::new());
let declared = std::collections::BTreeMap::from([
("GITHUB_TOKEN".to_string(), String::new()),
("SLACK_TOKEN".to_string(), String::new()),
]);
assert_eq!(join_env_map(&declared), "GITHUB_TOKEN, SLACK_TOKEN");
assert_eq!(parse_env_map(&join_env_map(&declared)), declared);
let mapped = std::collections::BTreeMap::from([
("GITHUB_TOKEN".to_string(), "MINDFORK_PAT".to_string()),
("HOME".to_string(), "MY_HOME".to_string()),
]);
assert_eq!(parse_env_map(&join_env_map(&mapped)), mapped);
assert_eq!(
parse_env_map(" A = B , C "),
std::collections::BTreeMap::from([
("A".to_string(), "B".to_string()),
("C".to_string(), String::new()),
])
);
assert_eq!(parse_env_map("A=B, =C, has-dash").len(), 1);
}
#[test]
fn mcp_status_says_how_many_tools_the_profile_enabled() {
use crate::features::tools::mcp::{McpServerSnapshot, McpSnapshot};
let mut s = screen();
s.config.mcp.enabled = true;
s.set_mcp(McpSnapshot {
tools: Vec::new(),
servers: vec![McpServerSnapshot {
id: "fs".into(),
status: ServerStatus::Ready,
tool_count: 2,
pending_catalog: false,
}],
});
let row = |s: &SettingsScreen| {
s.plugin_fields()
.into_iter()
.find(|r| r.id == FieldId::TMcpServer(0))
.unwrap()
};
let off = row(&s);
assert!(
matches!(&off.kind, FieldKind::Text(v) if v.contains('2') && v.contains('0')),
"the count of enabled tools is missing"
);
assert!(
off.hint.is_some_and(|h| h.contains("Профил")),
"no pointer to where they are enabled"
);
s.profiles[0].enabled_tools.push("mcp__fs__read".into());
let on = row(&s);
assert!(matches!(&on.kind, FieldKind::Text(v) if v.contains("1")));
assert!(on.hint.is_none(), "the hint should go once tools are on");
s.profiles[0].enabled_tools.push("mcp__other__x".into());
assert!(matches!(&row(&s).kind, FieldKind::Text(v) if v.contains("1")));
}
#[test]
fn the_field_pane_scrolls_only_once_the_selection_leaves_the_window() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut term = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut s = screen();
goto_section(&mut s, Section::Sampling); s.handle_key(key(KeyCode::Enter)); let mut draw = |s: &mut SettingsScreen| {
term.draw(|f| s.render(f)).unwrap();
};
draw(&mut s);
for _ in 0..60 {
s.handle_key(key(KeyCode::Down));
draw(&mut s);
}
let bottom = s.fields_scroll.offset();
assert!(bottom > 0, "the section must be longer than the pane");
s.handle_key(key(KeyCode::Up));
draw(&mut s);
assert_eq!(
s.fields_scroll.offset(),
bottom,
"the rows must stay put while the selection can still move inside them"
);
for _ in 0..60 {
s.handle_key(key(KeyCode::Up));
draw(&mut s);
}
assert_eq!(s.fields_scroll.offset(), 0);
}
const CLOUD_MODES: [ServerMode; 5] = [
ServerMode::OpenAi,
ServerMode::Gemini,
ServerMode::Claude,
ServerMode::Grok,
ServerMode::OpenRouter,
];
fn active_sessions(c: &AppConfig) -> u32 {
match c.engine.mode {
ServerMode::Managed => c.engine.managed.sessions,
ServerMode::External => c.engine.external.sessions,
_ => c.engine.cloud().map_or(0, |cl| cl.sessions),
}
}
fn edit_sessions(s: &mut SettingsScreen, text: &str) -> Option<SettingsIntent> {
goto_section(s, Section::Model);
goto_field(s, FieldId::XSessions);
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k')); for c in text.chars() {
s.handle_key(key(KeyCode::Char(c)));
}
s.handle_key(key(KeyCode::Enter))
}
#[test]
fn sessions_row_is_on_the_assistant_tab_in_every_mode() {
let mut s = screen();
s.config.engine.managed.sessions = 2;
s.config.engine.external.sessions = 3;
s.config.engine.openai.sessions = 4;
s.config.engine.gemini.sessions = 5;
s.config.engine.claude.sessions = 6;
s.config.engine.grok.sessions = 7;
let modes = [ServerMode::Managed, ServerMode::External]
.into_iter()
.chain(CLOUD_MODES);
for mode in modes {
s.config.engine.mode = mode;
let rows = s.model_fields_for(ModelTab::Assistant);
let row = rows
.iter()
.find(|r| r.id == FieldId::XSessions)
.unwrap_or_else(|| panic!("{mode:?}: no sessions row"));
let want = active_sessions(&s.config).to_string();
assert!(
matches!(&row.kind, FieldKind::Text(v) if *v == want),
"{mode:?}: the value is the active section's"
);
assert_eq!(row.group, "Параллельные сессии", "{mode:?}: its own group");
assert_eq!(
rows.last().map(|r| r.id),
Some(FieldId::XConcurrent),
"{mode:?}: the group closes the tab, the width row last"
);
assert!(
row.description.is_some(),
"{mode:?}: the row carries a description"
);
}
}
fn active_concurrent(c: &AppConfig) -> u32 {
match c.engine.mode {
ServerMode::Managed => c.engine.managed.concurrent_calls,
ServerMode::External => c.engine.external.concurrent_calls,
_ => c.engine.cloud().map_or(0, |cl| cl.concurrent_calls),
}
}
fn edit_concurrent(s: &mut SettingsScreen, text: &str) -> Option<SettingsIntent> {
goto_section(s, Section::Model);
goto_field(s, FieldId::XConcurrent);
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k'));
for c in text.chars() {
s.handle_key(key(KeyCode::Char(c)));
}
s.handle_key(key(KeyCode::Enter))
}
#[test]
fn concurrent_row_follows_the_section_and_its_hint_names_the_marked_tools() {
let mut s = screen();
s.config.engine.external.concurrent_calls = 3;
let modes = [ServerMode::Managed, ServerMode::External]
.into_iter()
.chain(CLOUD_MODES);
for mode in modes {
s.config.engine.mode = mode;
let rows = s.model_fields_for(ModelTab::Assistant);
let row = rows
.iter()
.find(|r| r.id == FieldId::XConcurrent)
.unwrap_or_else(|| panic!("{mode:?}: no width row"));
let want = active_concurrent(&s.config);
assert!(
matches!(&row.kind, FieldKind::Text(v) if *v == want.to_string()),
"{mode:?}: the value is the active section's ({want})"
);
let expected_default = match mode {
ServerMode::Managed => 1,
ServerMode::External => 3,
_ => 4,
};
assert_eq!(want, expected_default, "{mode:?}");
let desc = row.description.as_deref().unwrap_or_default();
assert!(
desc.contains("fs_read"),
"{mode:?}: the hint names a read: {desc}"
);
assert!(desc.contains("fetch_url"), "{mode:?}: {desc}");
assert!(
!desc.contains("fs_write") && !desc.contains("python_exec"),
"{mode:?}: the hint must not name a tool the rule keeps sequential: {desc}"
);
}
}
#[test]
fn editing_concurrent_writes_the_active_section_with_a_floor_of_one() {
let modes = [ServerMode::Managed, ServerMode::External]
.into_iter()
.chain(CLOUD_MODES);
for mode in modes {
let mut s = screen();
s.config.engine.mode = mode;
match edit_concurrent(&mut s, "2") {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(active_concurrent(&c), 2, "{mode:?}: the active section");
let others = [
(ServerMode::Managed, c.engine.managed.concurrent_calls, 1),
(ServerMode::External, c.engine.external.concurrent_calls, 1),
(ServerMode::OpenAi, c.engine.openai.concurrent_calls, 4),
(ServerMode::Gemini, c.engine.gemini.concurrent_calls, 4),
(ServerMode::Claude, c.engine.claude.concurrent_calls, 4),
(ServerMode::Grok, c.engine.grok.concurrent_calls, 4),
];
for (m, v, default) in others {
if m != mode {
assert_eq!(v, default, "{mode:?}: {m:?} must stay at its default");
}
}
}
other => panic!("{mode:?}: expected SaveConfig, got {other:?}"),
}
}
let mut s = screen();
s.config.engine.mode = ServerMode::Grok;
match edit_concurrent(&mut s, "0") {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.engine.grok.concurrent_calls, 1),
other => panic!("expected SaveConfig, got {other:?}"),
}
use super::spec::{Access, FieldSpec};
let Some(FieldSpec {
access: Access::Text(set),
..
}) = super::spec::field_spec(FieldId::XConcurrent)
else {
panic!("XConcurrent is a text field of the config table");
};
let mut c = AppConfig::default();
c.engine.mode = ServerMode::Managed;
set(&mut c, "abc");
set(&mut c, "-1");
assert_eq!(c.engine.managed.concurrent_calls, 1);
set(&mut c, "6");
assert_eq!(c.engine.managed.concurrent_calls, 6);
}
#[test]
fn sessions_row_is_absent_from_the_impersonation_tab() {
let mut s = screen();
s.config.engine.mode = ServerMode::Managed;
for mode in [
ImpersonationMode::Shared,
ImpersonationMode::Managed,
ImpersonationMode::External,
ImpersonationMode::OpenAi,
] {
s.config.impersonation_engine.mode = mode;
let ids: Vec<FieldId> = s
.model_fields_for(ModelTab::Impersonation)
.iter()
.map(|r| r.id)
.collect();
assert!(
!ids.contains(&FieldId::XSessions) && !ids.contains(&FieldId::XConcurrent),
"{mode:?}: the impersonation tab must not offer sessions"
);
}
let ids: Vec<FieldId> = s
.model_fields_for(ModelTab::Embeddings)
.iter()
.map(|r| r.id)
.collect();
assert!(!ids.contains(&FieldId::XSessions) && !ids.contains(&FieldId::XConcurrent));
}
#[test]
fn editing_sessions_writes_the_active_section() {
let modes = [ServerMode::Managed, ServerMode::External]
.into_iter()
.chain(CLOUD_MODES);
for mode in modes {
let mut s = screen();
s.config.engine.mode = mode;
match edit_sessions(&mut s, "3") {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(active_sessions(&c), 3, "{mode:?}: the active section");
let mut untouched = vec![
(ServerMode::Managed, c.engine.managed.sessions),
(ServerMode::External, c.engine.external.sessions),
(ServerMode::OpenAi, c.engine.openai.sessions),
(ServerMode::Gemini, c.engine.gemini.sessions),
(ServerMode::Claude, c.engine.claude.sessions),
(ServerMode::Grok, c.engine.grok.sessions),
];
untouched.retain(|(m, _)| *m != mode);
for (m, v) in untouched {
assert_eq!(v, 1, "{mode:?}: {m:?} must stay at the default");
}
}
other => panic!("{mode:?}: expected SaveConfig, got {other:?}"),
}
}
}
#[test]
fn sessions_below_one_is_stored_as_one() {
let mut s = screen();
s.config.engine.mode = ServerMode::External;
s.config.engine.external.sessions = 3;
match edit_sessions(&mut s, "0") {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.engine.external.sessions, 1),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn unparsable_sessions_leaves_the_value_unchanged() {
let mut s = screen();
s.config.engine.mode = ServerMode::Managed;
s.config.engine.managed.sessions = 2;
assert_eq!(edit_sessions(&mut s, "abc"), None);
assert!(s.editor.is_some(), "invalid input doesn't close the editor");
assert_eq!(s.config.engine.managed.sessions, 2);
use super::spec::{Access, FieldSpec};
let Some(FieldSpec {
access: Access::Text(set),
..
}) = super::spec::field_spec(FieldId::XSessions)
else {
panic!("XSessions is a text field of the config table");
};
let mut c = AppConfig::default();
c.engine.mode = ServerMode::Managed;
c.engine.managed.sessions = 2;
set(&mut c, "abc");
set(&mut c, "-1");
set(&mut c, "");
assert_eq!(c.engine.managed.sessions, 2, "unparsable input is a no-op");
set(&mut c, "5");
assert_eq!(c.engine.managed.sessions, 5);
c.engine.mode = ServerMode::Claude;
set(&mut c, "4");
assert_eq!(c.engine.claude.sessions, 4);
assert_eq!(
c.engine.managed.sessions, 5,
"the other sections are untouched"
);
}
#[test]
fn sessions_description_names_the_reported_slots_for_local_servers_only() {
let base = |s: &SettingsScreen| s.loc().t("ui.settings.desc.sessions").to_string();
let mut s = screen();
s.config.engine.mode = ServerMode::External;
s.set_engine_slots(Some(4));
let desc = field_desc(&s, FieldId::XSessions).unwrap();
assert!(
desc.contains('4'),
"external: the hint names the slots: {desc}"
);
assert!(
desc.starts_with(&base(&s)),
"the hint follows the description"
);
assert_ne!(
s.config.engine.external.sessions, 4,
"a hint is never written into the value"
);
s.config.engine.mode = ServerMode::Managed;
let desc = field_desc(&s, FieldId::XSessions).unwrap();
assert!(
desc.contains('4'),
"managed: the hint names the slots: {desc}"
);
for mode in CLOUD_MODES {
s.config.engine.mode = mode;
let desc = field_desc(&s, FieldId::XSessions).unwrap();
assert_eq!(
desc,
base(&s),
"{mode:?}: a cloud never gets the slots hint"
);
}
s.config.engine.mode = ServerMode::External;
s.set_engine_slots(None);
let desc = field_desc(&s, FieldId::XSessions).unwrap();
assert_eq!(desc, base(&s), "no answer — no hint");
}
fn edit_sub_parallel(s: &mut SettingsScreen, text: &str) -> Option<SettingsIntent> {
goto_section(s, Section::Tools);
goto_field(s, FieldId::TSubParallel);
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k')); for c in text.chars() {
s.handle_key(key(KeyCode::Char(c)));
}
s.handle_key(key(KeyCode::Enter))
}
#[test]
fn sub_parallel_row_follows_the_run_time_limit_in_tools() {
let mut s = screen();
s.config.tools.subagent_parallel = 4;
let rows = s.tool_fields();
let pos = rows
.iter()
.position(|r| r.id == FieldId::TSubParallel)
.expect("no sub_parallel row");
assert_eq!(
rows[pos - 1].id,
FieldId::TSubTimeout,
"it follows the run time limit"
);
assert_eq!(
rows[pos].group,
rows[pos - 1].group,
"in the same group as its sibling"
);
assert!(
matches!(&rows[pos].kind, FieldKind::Text(v) if v == "4"),
"the value is tools.subagent_parallel"
);
assert!(rows[pos].description.is_some(), "the row is described");
}
#[test]
fn editing_sub_parallel_saves_the_value_with_a_floor_of_one() {
let mut s = screen();
match edit_sub_parallel(&mut s, "3") {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.tools.subagent_parallel, 3),
other => panic!("expected SaveConfig, got {other:?}"),
}
let mut s = screen();
s.config.tools.subagent_parallel = 3;
match edit_sub_parallel(&mut s, "0") {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.tools.subagent_parallel, 1),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn sub_parallel_ignores_what_it_cannot_parse() {
let mut s = screen();
s.config.tools.subagent_parallel = 2;
assert_eq!(edit_sub_parallel(&mut s, "abc"), None);
assert!(s.editor.is_some(), "invalid input doesn't close the editor");
assert_eq!(s.config.tools.subagent_parallel, 2);
use super::spec::{Access, FieldSpec};
let Some(FieldSpec {
access: Access::Text(set),
..
}) = super::spec::field_spec(FieldId::TSubParallel)
else {
panic!("TSubParallel is a text field of the config table");
};
let mut c = AppConfig::default();
c.tools.subagent_parallel = 2;
set(&mut c, "abc");
set(&mut c, "-1");
set(&mut c, "");
assert_eq!(c.tools.subagent_parallel, 2, "unparsable input is a no-op");
set(&mut c, "0");
assert_eq!(c.tools.subagent_parallel, 1, "below 1 is stored as 1");
set(&mut c, "5");
assert_eq!(c.tools.subagent_parallel, 5);
}
#[test]
fn background_rows_follow_the_parallel_runs_row_in_tools() {
let mut s = screen();
s.config.tools.subagent_background = true;
s.config.tools.subagent_background_wake = false;
s.config.tools.subagent_background_max = 3;
let rows = s.tool_fields();
let pos = rows
.iter()
.position(|r| r.id == FieldId::TSubBackground)
.expect("no background row");
assert_eq!(rows[pos - 1].id, FieldId::TSubParallel);
assert_eq!(rows[pos + 1].id, FieldId::TSubBackgroundWake);
assert_eq!(rows[pos + 2].id, FieldId::TSubBackgroundMax);
for r in &rows[pos..pos + 3] {
assert_eq!(
r.group,
rows[pos - 1].group,
"{:?} in the loop's group",
r.id
);
assert!(r.description.is_some(), "{:?} is described", r.id);
}
assert!(matches!(rows[pos].kind, FieldKind::Toggle(true)));
assert!(matches!(rows[pos + 1].kind, FieldKind::Toggle(false)));
assert!(matches!(&rows[pos + 2].kind, FieldKind::Text(v) if v == "3"));
}
#[test]
fn background_rows_save_their_fields() {
let mut s = screen();
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::TSubBackground);
match s.handle_key(key(KeyCode::Char(' '))) {
Some(SettingsIntent::SaveConfig(c)) => assert!(c.tools.subagent_background),
other => panic!("expected SaveConfig, got {other:?}"),
}
goto_field(&mut s, FieldId::TSubBackgroundWake);
match s.handle_key(key(KeyCode::Char(' '))) {
Some(SettingsIntent::SaveConfig(c)) => assert!(!c.tools.subagent_background_wake),
other => panic!("expected SaveConfig, got {other:?}"),
}
let edit_max = |s: &mut SettingsScreen, text: &str| {
goto_section(s, Section::Tools);
goto_field(s, FieldId::TSubBackgroundMax);
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k'));
for c in text.chars() {
s.handle_key(key(KeyCode::Char(c)));
}
s.handle_key(key(KeyCode::Enter))
};
let mut s = screen();
match edit_max(&mut s, "4") {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.tools.subagent_background_max, 4),
other => panic!("expected SaveConfig, got {other:?}"),
}
let mut s = screen();
s.config.tools.subagent_background_max = 3;
match edit_max(&mut s, "0") {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.tools.subagent_background_max, 1),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn editing_the_local_memory_limit_stores_its_own_field_and_zero_reads_none() {
let edit = |s: &mut SettingsScreen, text: &str| {
goto_section(s, Section::Tools);
goto_field(s, FieldId::TPythonLocalMemory);
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k'));
for c in text.chars() {
s.handle_key(key(KeyCode::Char(c)));
}
s.handle_key(key(KeyCode::Enter))
};
let mut cfg = AppConfig::default();
cfg.tools.python_mode = PythonMode::Local;
let mut p = Profile::new("Базовый", "Ты — ассистент.");
p.enabled_tools = default_tool_ids();
let mut s = SettingsScreen::new(cfg, vec![p], vec![]);
match edit(&mut s, "512") {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.tools.python_local_memory_mb, Some(512));
assert_eq!(
c.tools.python_wasm_memory_mb, None,
"the sandbox's limit is not touched"
);
}
other => panic!("expected SaveConfig, got {other:?}"),
}
s.config.tools.python_local_memory_mb = Some(512);
match edit(&mut s, "0") {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.tools.python_local_memory_mb, None),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn editing_the_batch_stores_a_number_and_an_empty_field_reads_auto() {
let edit = |s: &mut SettingsScreen, text: &str| {
goto_section(s, Section::Model);
goto_field(s, FieldId::XBatch);
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k'));
for c in text.chars() {
s.handle_key(key(KeyCode::Char(c)));
}
s.handle_key(key(KeyCode::Enter))
};
let mut s = screen();
s.config.engine.mode = ServerMode::Managed;
match edit(&mut s, "256") {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.engine.managed.batch_size, Some(256));
assert_eq!(c.impersonation_engine.managed.batch_size, None);
}
other => panic!("expected SaveConfig, got {other:?}"),
}
s.config.engine.managed.batch_size = Some(256);
match edit(&mut s, "") {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.engine.managed.batch_size, None),
other => panic!("expected SaveConfig, got {other:?}"),
}
let hint = field_desc(&s, FieldId::XBatch).expect("described");
let ceiling = crate::shared::i18n::locale(crate::shared::i18n::Lang::En)
.t("ui.settings.desc.sessions")
.chars()
.count();
assert!(
hint.chars().count() <= ceiling,
"the batch hint ({}) must not outgrow the sessions hint ({ceiling})",
hint.chars().count()
);
}
#[test]
fn quit_settle_row_follows_the_dialogue_time_limit_and_stores_seconds() {
let mut s = screen();
let rows = s.tool_fields();
let pos = rows
.iter()
.position(|r| r.id == FieldId::TQuitSettle)
.expect("no quit_settle row");
assert_eq!(rows[pos - 1].id, FieldId::TDialogueTimeout);
assert_eq!(rows[pos].group, rows[pos - 1].group);
assert!(
matches!(&rows[pos].kind, FieldKind::Text(v) if v.is_empty()),
"empty by default: no cap"
);
assert!(field_desc(&s, FieldId::TQuitSettle).is_some());
let edit = |s: &mut SettingsScreen, text: &str| {
goto_section(s, Section::Tools);
goto_field(s, FieldId::TQuitSettle);
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k'));
for c in text.chars() {
s.handle_key(key(KeyCode::Char(c)));
}
s.handle_key(key(KeyCode::Enter));
};
edit(&mut s, "5");
assert_eq!(s.config.tools.quit_settle_secs, Some(5));
edit(&mut s, "0");
assert_eq!(s.config.tools.quit_settle_secs, Some(0), "zero is a cap");
edit(&mut s, "");
assert_eq!(s.config.tools.quit_settle_secs, None, "emptied: no cap");
}
use crate::shared::api::catalogue::{CatalogModel, CatalogueError, ModelRole, ModelSlot};
fn cat(id: &str, display: Option<&str>, role: ModelRole) -> CatalogModel {
CatalogModel {
id: id.to_string(),
display: display.map(str::to_string),
role,
retiring: None,
facts: Default::default(),
voices: Vec::new(),
}
}
fn on_the_model_row(mode: ServerMode) -> SettingsScreen {
let mut s = screen();
s.config.engine.mode = mode;
goto_section(&mut s, Section::Model);
goto_field(&mut s, FieldId::XModelName);
s
}
#[test]
fn enter_on_a_model_row_asks_the_provider_and_opens_the_picker() {
let mut s = on_the_model_row(ServerMode::Claude);
assert!(
s.picker.is_none() && s.catalogues.is_empty(),
"opening the screen asks nothing"
);
let intent = s.handle_key(key(KeyCode::Enter));
assert_eq!(
intent,
Some(SettingsIntent::ListModels(ModelSlot::Assistant))
);
let st = s.picker.as_ref().expect("the picker is open");
assert_eq!(st.status, super::picker::PickerStatus::Fetching);
assert!(
s.editor.is_none(),
"the editor is the picker's first row, not what Enter opens"
);
}
#[test]
fn every_model_row_asks_for_its_own_slot() {
for (tab, field, slot) in [
(
ModelTab::Assistant,
FieldId::XModelName,
ModelSlot::Assistant,
),
(
ModelTab::Impersonation,
FieldId::IxModelName,
ModelSlot::Impersonation,
),
(
ModelTab::Embeddings,
FieldId::EModelName,
ModelSlot::Embedder,
),
] {
let mut s = screen();
s.config.engine.mode = ServerMode::OpenAi;
s.config.impersonation_engine.mode = ImpersonationMode::OpenAi;
s.config.embed.mode = ServerMode::OpenAi;
goto_section(&mut s, Section::Model);
s.model_sub = tab;
goto_field(&mut s, field);
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::ListModels(slot)),
"{field:?}"
);
}
}
#[test]
fn a_picked_id_is_written_verbatim() {
let mut s = on_the_model_row(ServerMode::External);
s.handle_key(key(KeyCode::Enter));
s.set_model_catalogue(
ModelSlot::Assistant,
Ok(vec![
cat(
r"D:\LLM\GGUF\gemma-4-31B_q4_0-it.gguf",
None,
ModelRole::Unstated,
),
cat("qwen-3.6-27b", None, ModelRole::Unstated),
]
.into()),
);
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(
c.engine.external.model_name.as_deref(),
Some(r"D:\LLM\GGUF\gemma-4-31B_q4_0-it.gguf")
),
other => panic!("expected SaveConfig, got {other:?}"),
}
assert!(s.picker.is_none(), "the picker closes on a pick");
}
#[test]
fn the_first_row_is_typing_by_hand_even_after_a_failure() {
let mut s = on_the_model_row(ServerMode::Gemini);
s.handle_key(key(KeyCode::Enter));
s.set_model_catalogue(ModelSlot::Assistant, Err(CatalogueError::Unreachable));
assert_eq!(
s.picker.as_ref().map(|st| st.status.clone()),
Some(super::picker::PickerStatus::Failed(
CatalogueError::Unreachable
))
);
s.handle_key(key(KeyCode::Up)); assert_eq!(s.handle_key(key(KeyCode::Enter)), None, "no config write");
assert!(
s.editor.is_some() && s.picker.is_none(),
"the row's own editor opens"
);
}
#[test]
fn after_a_refusal_enter_opens_the_editor_directly() {
let mut s = on_the_model_row(ServerMode::Gemini);
s.handle_key(key(KeyCode::Enter));
s.set_model_catalogue(ModelSlot::Assistant, Err(CatalogueError::NoKey));
s.handle_key(key(KeyCode::Esc));
assert_eq!(s.handle_key(key(KeyCode::Enter)), None, "nothing is asked");
assert!(s.editor.is_some() && s.picker.is_none());
}
#[test]
fn the_answer_is_kept_for_the_visit_and_ctrl_r_asks_again() {
let mut s = on_the_model_row(ServerMode::Claude);
s.handle_key(key(KeyCode::Enter));
s.set_model_catalogue(
ModelSlot::Assistant,
Ok(vec![cat("claude-opus-5", Some("Claude Opus 5"), ModelRole::Chat)].into()),
);
s.handle_key(key(KeyCode::Esc));
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
None,
"the list is already here"
);
assert_eq!(s.picker.as_ref().map(|st| st.all.len()), Some(1));
assert_eq!(
s.handle_key(ctrl('r')),
Some(SettingsIntent::ListModels(ModelSlot::Assistant)),
"Ctrl+R asks the provider again"
);
assert!(
s.catalogues.is_empty(),
"and drops what it had, so a late answer cannot be mistaken for the new one"
);
}
#[test]
fn the_filter_narrows_the_list_and_never_hides_the_way_out() {
let mut s = on_the_model_row(ServerMode::OpenAi);
s.handle_key(key(KeyCode::Enter));
s.set_model_catalogue(
ModelSlot::Assistant,
Ok(vec![
cat("gpt-6-astra", None, ModelRole::Unstated),
cat("whisper-1", None, ModelRole::Unstated),
cat("text-embedding-3-small", None, ModelRole::Unstated),
]
.into()),
);
for c in "embed".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
let st = s.picker.as_ref().expect("still open");
assert_eq!(st.results.len(), 1);
assert_eq!(st.rows(), 2, "the match plus the way out");
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(
c.engine.openai.model_name.as_deref(),
Some("text-embedding-3-small")
),
other => panic!("expected SaveConfig, got {other:?}"),
}
let mut s = on_the_model_row(ServerMode::OpenAi);
s.handle_key(key(KeyCode::Enter));
s.set_model_catalogue(
ModelSlot::Assistant,
Ok(vec![cat("gpt-6-astra", None, ModelRole::Unstated)].into()),
);
for c in "nothing-matches-this".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
let st = s.picker.as_ref().expect("still open");
assert_eq!(st.rows(), 1, "only the way out is left");
assert_eq!(st.selected, 0, "and the highlight is on it");
}
#[test]
fn switching_the_provider_never_shows_the_previous_ones_models() {
let mut s = on_the_model_row(ServerMode::OpenAi);
s.handle_key(key(KeyCode::Enter));
s.set_model_catalogue(
ModelSlot::Assistant,
Ok(vec![cat("gpt-6-astra", None, ModelRole::Unstated)].into()),
);
s.handle_key(key(KeyCode::Esc));
s.config.engine.mode = ServerMode::Gemini;
goto_field_again(&mut s, FieldId::XModelName);
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::ListModels(ModelSlot::Assistant)),
"another provider is another question"
);
let st = s.picker.as_ref().expect("the picker is open");
assert!(
st.all.is_empty() && st.status == super::picker::PickerStatus::Fetching,
"and until it is answered, nothing is offered"
);
}
#[test]
fn correcting_the_key_source_asks_again_instead_of_repeating_the_refusal() {
let mut s = on_the_model_row(ServerMode::Claude);
s.config.engine.claude.api_key_env = Some("1ANTHROPIC_API_KEY".into());
goto_field_again(&mut s, FieldId::XModelName);
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::ListModels(ModelSlot::Assistant))
);
s.set_model_catalogue(ModelSlot::Assistant, Err(CatalogueError::NoKey));
s.handle_key(key(KeyCode::Esc));
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
None,
"the same mistake is not asked about twice"
);
assert!(s.editor.is_some());
s.handle_key(key(KeyCode::Esc));
s.config.engine.claude.api_key_env = Some("ANTHROPIC_API_KEY".into());
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::ListModels(ModelSlot::Assistant)),
"a corrected key source is a new question"
);
}
#[test]
fn a_late_answer_is_filed_under_the_provider_it_was_asked_about() {
let mut s = on_the_model_row(ServerMode::OpenAi);
s.handle_key(key(KeyCode::Enter));
s.handle_key(key(KeyCode::Esc));
s.config.engine.mode = ServerMode::Gemini;
s.set_model_catalogue(
ModelSlot::Assistant,
Ok(vec![cat("gpt-6-astra", None, ModelRole::Unstated)].into()),
);
goto_field_again(&mut s, FieldId::XModelName);
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::ListModels(ModelSlot::Assistant)),
"the answer belonged to openai, and gemini has not been asked"
);
assert!(s.picker.as_ref().expect("open").all.is_empty());
}
fn managed_everywhere() -> SettingsScreen {
let mut s = screen();
s.config.engine.mode = ServerMode::Managed;
s.config.impersonation_engine.mode = ImpersonationMode::Managed;
s.config.embed.mode = ServerMode::Managed;
goto_section(&mut s, Section::Model);
s
}
fn extra_args_refusal(s: &mut SettingsScreen, id: FieldId, line: &str) -> Option<String> {
let intent = type_into(s, id, line);
let error = s.editor.as_ref().and_then(|e| e.error.clone());
if error.is_some() {
s.handle_key(key(KeyCode::Esc));
} else {
assert!(
matches!(intent, Some(SettingsIntent::SaveConfig(_))),
"{line}: {intent:?}"
);
}
error
}
#[test]
fn extra_arguments_commit_as_argv_and_a_fields_flag_is_refused() {
let mut s = managed_everywhere();
let line = r#"--n-cpu-moe 20 -ot "blk\.(1[0-9])\.ffn_.*_exps\.=CPU""#;
assert_eq!(extra_args_refusal(&mut s, FieldId::XExtraArgs, line), None);
let stored = vec![
"--n-cpu-moe".to_string(),
"20".into(),
"-ot".into(),
r"blk\.(1[0-9])\.ffn_.*_exps\.=CPU".into(),
];
assert_eq!(s.config.engine.managed.extra_args, stored);
assert!(s.config.impersonation_engine.managed.extra_args.is_empty());
let err = extra_args_refusal(&mut s, FieldId::XExtraArgs, "-t 8 --ctx-size 8192")
.expect("a field's flag is refused");
assert!(
err.contains("--ctx-size") && err.contains(s.loc().t("ui.settings.field.context")),
"{err}"
);
assert_eq!(s.config.engine.managed.extra_args, stored, "nothing stored");
assert_eq!(extra_args_refusal(&mut s, FieldId::XExtraArgs, ""), None);
assert!(s.config.engine.managed.extra_args.is_empty());
}
#[test]
fn each_section_judges_extra_arguments_against_its_own_fields() {
let mut s = managed_everywhere();
assert!(extra_args_refusal(&mut s, FieldId::XExtraArgs, "-np 2").is_some());
s.model_sub = ModelTab::Impersonation;
assert_eq!(
extra_args_refusal(&mut s, FieldId::IxExtraArgs, "-np 2"),
None
);
assert_eq!(
s.config.impersonation_engine.managed.extra_args,
["-np", "2"]
);
assert!(extra_args_refusal(&mut s, FieldId::IxExtraArgs, "--tools all").is_some());
s.model_sub = ModelTab::Embeddings;
assert_eq!(
extra_args_refusal(&mut s, FieldId::EExtraArgs, "-c 4096 -ngl 0"),
None
);
assert_eq!(
s.config.embed.managed.extra_args,
["-c", "4096", "-ngl", "0"]
);
assert!(extra_args_refusal(&mut s, FieldId::EExtraArgs, "--port 9001").is_some());
assert!(extra_args_refusal(&mut s, FieldId::EExtraArgs, "--api-key k").is_some());
}
#[test]
fn every_field_a_refusal_names_is_a_row_of_that_section() {
use crate::shared::api::llama_args::{ManagedRole, OwnField};
let mut s = managed_everywhere();
s.config.engine.managed.spec_type = SpecType::DraftSimple;
s.config.impersonation_engine.managed.spec_type = SpecType::DraftSimple;
let loc = s.loc();
for (tab, role) in [
(ModelTab::Assistant, ManagedRole::Assistant),
(ModelTab::Impersonation, ManagedRole::Impersonation),
(ModelTab::Embeddings, ManagedRole::Embedder),
] {
let labels: Vec<String> = s
.model_fields_for(tab)
.into_iter()
.map(|r| r.label)
.collect();
for field in OwnField::ALL {
if !field.shown_in(role) {
continue;
}
for part in field.label(loc).split(" → ") {
assert!(
labels.iter().any(|l| l == part),
"{role:?}: {field:?} names «{part}», which is not a row there"
);
}
}
}
}
#[test]
fn the_extra_arguments_row_follows_the_servers_own_fields() {
let mut s = managed_everywhere();
s.config.engine.managed.extra_args = vec!["-ot".into(), "a b=CPU".into()];
for (tab, id, before) in [
(ModelTab::Assistant, FieldId::XExtraArgs, FieldId::XSpecType),
(
ModelTab::Impersonation,
FieldId::IxExtraArgs,
FieldId::IxSpecType,
),
(ModelTab::Embeddings, FieldId::EExtraArgs, FieldId::EPort),
] {
let fields = s.model_fields_for(tab);
let at = |id: FieldId| {
fields
.iter()
.position(|f| f.id == id)
.unwrap_or_else(|| panic!("{tab:?}: {id:?} missing"))
};
assert!(at(id) > at(before), "{tab:?}");
assert_eq!(
fields[at(id)].group,
s.loc().t("ui.settings.group.advanced")
);
assert!(field_desc(&s, id).is_some(), "{id:?} has no hint");
}
let row = s
.model_fields_for(ModelTab::Assistant)
.into_iter()
.find(|r| r.id == FieldId::XExtraArgs)
.unwrap();
assert!(matches!(&row.kind, FieldKind::Text(t) if t == r#"-ot "a b=CPU""#));
for lang in [crate::shared::i18n::Lang::En, crate::shared::i18n::Lang::Ru] {
let loc = crate::shared::i18n::locale(lang);
let len = |key: &str| loc.t(key).chars().count();
assert!(
len("ui.settings.desc.extra_args") < len("ui.settings.desc.sub_background"),
"{lang:?}: the extra-arguments hint outgrew the tallest hint"
);
}
}
#[test]
fn a_default_config_shows_no_changed_row_in_any_interface_language() {
use crate::shared::i18n::Lang;
for lang in Lang::ALL.iter().copied() {
let mut config = AppConfig::default();
config.interface.language = lang;
let mut s = SettingsScreen::new(config, vec![], vec![]);
let mut localized = 0usize;
for section in SECTIONS {
goto_section(&mut s, section);
let loc = s.loc();
let defaults = s.default_fields();
for row in s.fields() {
let Some(default) = defaults.iter().find(|d| d.id == row.id) else {
continue;
};
let (now, was) = (value_text(&row.kind, loc), value_text(&default.kind, loc));
if row.id == FieldId::ILanguage {
assert_eq!(
was,
Lang::default().label(),
"the language row's default is the default language"
);
continue;
}
assert_eq!(now, was, "{lang:?} {section:?} {:?}", row.id);
localized += usize::from(!now.is_ascii());
}
}
if lang == Lang::Ru {
assert!(localized > 5, "only {localized} localized values were seen");
}
}
}
#[test]
fn reset_finds_the_default_in_any_interface_language() {
use crate::shared::config::{AutoTitleMode, Theme, ThemeMode};
use crate::shared::i18n::Lang;
for lang in Lang::ALL.iter().copied() {
let mut config = AppConfig::default();
config.interface.language = lang;
config.interface.theme = Theme::Light;
config.interface.auto_title = AutoTitleMode::Off;
config.interface.set_mode(ThemeMode::Full);
config.interface.full_theme = "light".into();
let mut s = SettingsScreen::new(config, vec![], vec![]);
goto_section(&mut s, Section::Interface);
for id in [
FieldId::IFullTheme,
FieldId::IAutoTitle,
FieldId::IThemeMode,
] {
assert!(
matches!(s.reset_field(id), Some(SettingsIntent::SaveConfig(_))),
"{lang:?}: {id:?} did not reset"
);
}
assert!(
matches!(
s.reset_field(FieldId::ITheme),
Some(SettingsIntent::SaveConfig(_))
),
"{lang:?}: the theme did not reset"
);
let i = &s.config.interface;
assert_eq!(i.full_theme, "dark", "{lang:?}");
assert_eq!(i.auto_title, AutoTitleMode::default(), "{lang:?}");
assert_eq!(i.theme_mode, None, "{lang:?}");
assert_eq!(i.theme, Theme::default(), "{lang:?}");
assert_eq!(
i.language, lang,
"{lang:?}: a reset is not a language change"
);
}
}
#[test]
fn the_active_subsection_tab_is_bracketed_in_the_monochrome_mode() {
use crate::shared::theme::Palette;
let text =
|line: &Line<'_>| -> String { line.spans.iter().map(|s| s.content.as_ref()).collect() };
let tabs = ["Assistant", "Impersonation", "Embeddings"];
let mono = Palette::mono();
for focused in [true, false] {
let plain = text(&tab_strip_line(&tabs, 1, focused, &Palette::default()));
assert!(!plain.contains('['), "{plain}");
for active in 0..tabs.len() {
let strip = text(&tab_strip_line(&tabs, active, focused, &mono));
for (i, tab) in tabs.iter().enumerate() {
assert_eq!(
strip.contains(&format!("[{tab}]")),
i == active,
"{tab} in {strip}"
);
}
assert_eq!(strip.replace(['[', ']'], " "), plain);
}
}
}
#[test]
fn the_selected_search_result_is_marked_in_the_monochrome_mode() {
use crate::shared::config::ThemeMode;
let drawn = |mode: ThemeMode| -> Vec<String> {
let mut s = screen();
s.config.interface.set_mode(mode);
s.handle_key(key(KeyCode::Char('/')));
for c in "тем".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
assert!(s.search.as_ref().unwrap().results.len() > 1, "the premise");
s.handle_key(key(KeyCode::Down));
let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
term.draw(|f| s.render(f)).unwrap();
crate::shared::ui::tests::buffer_rows(term.backend().buffer())
};
let mono = drawn(ThemeMode::Mono);
let marked: Vec<&String> = mono.iter().filter(|r| r.contains("│› ")).collect();
assert_eq!(marked.len(), 1, "one selected result: {mono:#?}");
let at = mono.iter().position(|r| r.contains("│› ")).unwrap();
assert!(
mono[at - 1].contains("│ "),
"the result above it is indented by the marker's width: {:?}",
mono[at - 1]
);
let system = drawn(ThemeMode::System);
assert!(!system.iter().any(|r| r.contains("│› ")), "{system:#?}");
let values = |rows: &[String]| -> Vec<String> {
rows.iter()
.filter_map(|r| r.split(['│', '█']).find(|part| part.contains(" › ")))
.filter_map(|inside| inside.split_whitespace().last().map(str::to_string))
.collect()
};
assert!(values(&mono).len() > 1, "{mono:#?}");
assert_eq!(values(&mono), values(&system));
}
fn two_user_themes() -> crate::shared::user_theme::Registry {
use crate::shared::user_theme::{Registry, parse};
Registry::of([
parse("solarized", r##"{"canvas": "#002b36"}"##).unwrap(),
parse("sepia", r##"{"canvas": "#f4ecd8"}"##).unwrap(),
])
}
#[test]
fn the_full_theme_row_lists_the_users_themes_after_the_built_in_ones() {
use crate::shared::config::ThemeMode;
use crate::shared::theme::Palette;
crate::shared::user_theme::with_registry(two_user_themes(), || {
let mut s = screen();
goto_section(&mut s, Section::Interface);
s.config.interface.set_mode(ThemeMode::Full);
let loc = s.loc();
let (options, at) = s.choice_menu(FieldId::IFullTheme).unwrap();
assert_eq!(
options,
[
full_theme_label("dark", loc),
full_theme_label("light", loc),
"sepia".to_string(),
"solarized".to_string(),
],
"a user's theme is shown by the name of its file"
);
assert_eq!(at, 0);
let mut seen = Vec::new();
for _ in 0..options.len() {
match s.cycle_field(FieldId::IFullTheme, 1) {
Some(SettingsIntent::SaveConfig(c)) => seen.push(c.interface.full_theme),
other => panic!("expected SaveConfig, got {other:?}"),
}
let name = &s.config.interface.full_theme;
assert_eq!(s.palette(), Palette::full(name), "{name}");
}
assert_eq!(seen, ["light", "sepia", "solarized", "dark"]);
s.cycle_field(FieldId::IFullTheme, -1);
assert_eq!(s.config.interface.full_theme, "solarized", "and backward");
let palette = s.palette();
assert_eq!(palette.canvas, ratatui::style::Color::Rgb(0, 43, 54));
assert_ne!(palette, Palette::full("dark"));
assert!(palette.dark);
match s.reset_field(FieldId::IFullTheme) {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(c.interface.full_theme, "dark"),
other => panic!("expected SaveConfig, got {other:?}"),
}
});
}
#[test]
fn a_lost_theme_is_listed_after_the_users_themes() {
crate::shared::user_theme::with_registry(two_user_themes(), || {
assert_eq!(
full_theme_names("gruvbox"),
["dark", "light", "sepia", "solarized", "gruvbox"]
);
assert_eq!(
full_theme_names("sepia"),
["dark", "light", "sepia", "solarized"],
"a theme that is there is listed once"
);
assert_eq!(cycle_full_theme("gruvbox", 1), "dark");
assert_eq!(cycle_full_theme("gruvbox", -1), "solarized");
assert_eq!(cycle_full_theme("light", 1), "sepia");
});
assert_eq!(full_theme_names("dark"), ["dark", "light"]);
assert_eq!(full_theme_names("sepia"), ["dark", "light", "sepia"]);
}
const GATEWAY_TABS: [(ModelTab, FieldId, [FieldId; 4]); 3] = {
use FieldId::*;
[
(
ModelTab::Assistant,
XMode,
[XModelName, XApiKey, XApiKeyEnv, XUrl],
),
(
ModelTab::Impersonation,
IxMode,
[IxModelName, IxApiKey, IxApiKeyEnv, IxUrl],
),
(
ModelTab::Embeddings,
EMode,
[EModelName, EApiKey, EApiKeyEnv, EUrl],
),
]
};
fn slot_mode(c: &AppConfig, tab: ModelTab) -> String {
let mode = match tab {
ModelTab::Assistant => serde_json::to_value(c.engine.mode),
ModelTab::Impersonation => serde_json::to_value(c.impersonation_engine.mode),
_ => serde_json::to_value(c.embed.mode),
};
let mode = mode.ok().and_then(|m| m.as_str().map(str::to_string));
mode.unwrap_or_default()
}
fn gateway_everywhere() -> SettingsScreen {
let mut s = screen();
s.config.engine.mode = ServerMode::OpenRouter;
s.config.impersonation_engine.mode = ImpersonationMode::OpenRouter;
s.config.embed.mode = ServerMode::OpenRouter;
goto_section(&mut s, Section::Model);
s
}
fn goto_model_row(s: &mut SettingsScreen, tab: ModelTab, id: FieldId) {
goto_section(s, Section::Model);
s.model_sub = tab;
s.field_idx = 0;
goto_field(s, id);
}
#[test]
fn the_mode_row_reaches_the_gateway_from_either_side_on_every_tab() {
for (tab, mode_row, _) in GATEWAY_TABS {
let mut s = screen();
goto_model_row(&mut s, tab, mode_row);
let first = slot_mode(&s.config, tab);
match s.handle_key(key(KeyCode::Left)) {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(slot_mode(&c, tab), "openrouter", "{tab:?}: ← wraps onto it")
}
other => panic!("{tab:?}: expected SaveConfig, got {other:?}"),
}
s.handle_key(key(KeyCode::Right));
assert_eq!(slot_mode(&s.config, tab), first, "{tab:?}: → wraps back");
let mut walked = Vec::new();
for _ in 0..16 {
s.handle_key(key(KeyCode::Right));
walked.push(slot_mode(&s.config, tab));
if walked.last() == Some(&first) {
break;
}
}
let tail: Vec<&str> = walked.iter().rev().take(3).map(String::as_str).collect();
assert_eq!(
tail,
[first.as_str(), "openrouter", "grok"],
"{tab:?}: {walked:?}"
);
let met = walked.iter().filter(|m| *m == "openrouter").count();
assert_eq!(met, 1, "{tab:?}: {walked:?}");
}
}
#[test]
fn the_gateway_mode_shows_its_section_and_the_attribution_switch_on_every_tab() {
use crate::shared::api::openai::client::{ATTRIBUTION_REFERER, ATTRIBUTION_TITLE};
use crate::shared::i18n::Lang;
for lang in Lang::ALL.iter().copied() {
let mut s = gateway_everywhere();
s.config.interface.language = lang;
for (tab, _, section) in GATEWAY_TABS {
let rows = s.model_fields_for(tab);
let shown: Vec<FieldId> = rows
.iter()
.map(|r| r.id)
.filter(|id| section.contains(id) || *id == FieldId::GatewayAttribution)
.collect();
let mut want = section.to_vec();
want.push(FieldId::GatewayAttribution);
assert_eq!(shown, want, "{lang:?} {tab:?}");
let switch = rows
.iter()
.find(|r| r.id == FieldId::GatewayAttribution)
.expect("asserted above");
assert!(
matches!(switch.kind, FieldKind::Toggle(true)),
"{lang:?} {tab:?}: on by default"
);
let hint = switch.description.as_deref().unwrap_or_default();
assert!(
hint.contains(ATTRIBUTION_REFERER) && hint.contains(ATTRIBUTION_TITLE),
"{lang:?} {tab:?}: the hint must name what is sent: {hint:?}"
);
}
}
}
#[test]
fn the_attribution_switch_belongs_to_the_gateway_mode_alone() {
let mut s = gateway_everywhere();
let shown = |s: &SettingsScreen, tab| {
let rows = s.model_fields_for(tab);
rows.iter().any(|r| r.id == FieldId::GatewayAttribution)
};
for mode in ServerMode::ALL {
s.config.engine.mode = mode;
s.config.embed.mode = mode;
let gateway = mode == ServerMode::OpenRouter;
assert_eq!(shown(&s, ModelTab::Assistant), gateway, "{mode:?}");
assert_eq!(shown(&s, ModelTab::Embeddings), gateway, "{mode:?}");
}
for mode in IMP_MODES {
s.config.impersonation_engine.mode = mode;
assert_eq!(
shown(&s, ModelTab::Impersonation),
mode == ImpersonationMode::OpenRouter,
"{mode:?}"
);
}
for mode in TtsMode::ALL {
s.config.tts.mode = mode;
assert_eq!(
shown(&s, ModelTab::Tts),
mode == TtsMode::OpenRouter,
"{mode:?}"
);
}
}
#[test]
fn toggling_attribution_on_any_tab_saves_the_one_provider_wide_value() {
let mut s = gateway_everywhere();
let mut on = s.config.openrouter.attribution;
for (tab, ..) in GATEWAY_TABS {
goto_model_row(&mut s, tab, FieldId::GatewayAttribution);
on = !on;
match s.handle_key(key(KeyCode::Char(' '))) {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.openrouter.attribution, on, "{tab:?}")
}
other => panic!("{tab:?}: expected SaveConfig, got {other:?}"),
}
for (other, ..) in GATEWAY_TABS {
let rows = s.model_fields_for(other);
let row = rows.iter().find(|r| r.id == FieldId::GatewayAttribution);
assert!(
matches!(row.map(|r| &r.kind), Some(FieldKind::Toggle(v)) if *v == on),
"flipped on {tab:?}, read on {other:?}"
);
}
}
}
#[test]
fn the_gateways_key_rows_name_the_provider_and_address_its_one_key() {
let gateway = SecretKey::Provider(CloudProvider::OpenRouter);
let mut s = gateway_everywhere();
for (tab, _, [_, key_row, env_row, _]) in GATEWAY_TABS {
for row in [key_row, env_row] {
let label = field_label(&s, row).unwrap_or_else(|| panic!("no row for {row:?}"));
assert!(label.contains("OpenRouter"), "{row:?}: {label:?}");
}
assert_eq!(s.secret_field_key(key_row), Some(gateway.clone()));
goto_model_row(&mut s, tab, key_row);
assert_eq!(
enter_secret(&mut s, "sk-or-v1-test"),
Some(SettingsIntent::SetSecret {
key: gateway.clone(),
value: "sk-or-v1-test".into()
}),
"{tab:?}"
);
}
let json = serde_json::to_string(&s.config).unwrap();
assert!(!json.contains("sk-or-v1-test"), "the key leaked: {json}");
let status = |s: &SettingsScreen| {
let rows = s.model_fields_for(ModelTab::Assistant);
let row = rows.iter().find(|r| r.id == FieldId::XApiKey);
row.map(|r| value_text(&r.kind, s.loc()))
.unwrap_or_default()
};
let unset = status(&s);
s.set_secrets_present(vec![SecretKey::Provider(CloudProvider::Grok)]);
assert_eq!(status(&s), unset, "xAI's key is not the gateway's");
s.set_secrets_present(vec![gateway]);
assert_ne!(status(&s), unset);
}
#[test]
fn an_external_address_that_is_the_gateway_says_there_is_a_mode_for_it() {
let mut s = screen();
s.config.engine.mode = ServerMode::External;
s.config.impersonation_engine.mode = ImpersonationMode::External;
s.config.embed.mode = ServerMode::External;
let said = |s: &SettingsScreen, tab, id| {
let rows = s.model_fields_for(tab);
let row = rows.into_iter().find(|r| r.id == id);
row.expect("the address row").description
};
for (tab, _, [.., url_row]) in GATEWAY_TABS {
for (address, hinted) in [
(Some("https://openrouter.ai/api/v1"), true),
(Some("http://127.0.0.1:8000/v1"), false),
(None, false),
] {
let address = address.map(str::to_string);
s.config.engine.external.url = address.clone();
s.config.impersonation_engine.external.url = address.clone();
s.config.embed.external.url = address.clone();
let hint = hinted.then(|| s.loc().t(DESC_EXTERNAL_IS_GATEWAY).to_string());
assert_eq!(said(&s, tab, url_row), hint, "{tab:?} {address:?}");
}
}
let mut s = gateway_everywhere();
s.config.engine.openrouter.url = Some("https://eu.openrouter.ai/api/v1".into());
assert_eq!(said(&s, ModelTab::Assistant, FieldId::XUrl), None);
}
const GATEWAY_ADDRESSES: &str = "
https://openrouter.ai/api/v1 yes | the address the docs give
http://OPENROUTER.AI yes | a host is not case-sensitive
https://eu.openrouter.ai/api/v1 yes | a subdomain of it
https://user:pw@openrouter.ai:443/api/v1 yes | credentials and a port around the host
openrouter.ai/api/v1 yes | typed without a scheme
https://openrouter.ai?x=1 yes | a query straight after the host
https://example.com/openrouter.ai no | its name in a path
https://example.com/?next=openrouter.ai no | ...or in a query
https://notopenrouter.ai/v1 no | a longer name that ends the same
https://openrouter.ai.example.com/v1 no | its name as a subdomain of another
https://openrouter.ai@example.com/v1 no | its name as the credentials
http://127.0.0.1:8000/v1 no | a local server
- no | nothing typed
";
#[test]
fn an_address_is_the_gateways_by_its_host_alone() {
let mut wrong = Vec::new();
for row in GATEWAY_ADDRESSES.lines().filter(|l| !l.trim().is_empty()) {
let (columns, why) = row.split_once('|').unwrap();
let c: Vec<&str> = columns.split_whitespace().collect();
let address = c[0].trim_start_matches('-');
if is_gateway_url(address) != (c[1] == "yes") {
wrong.push(format!("{address:?} — {}", why.trim()));
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
for padded in [" openrouter.ai/api/v1", "https://openrouter.ai \t"] {
assert!(is_gateway_url(padded), "{padded:?}");
}
}
#[test]
fn a_gateway_url_inside_a_schemeless_address_is_not_its_host() {
for address in [
"localhost:8080/v1?upstream=https://openrouter.ai/api/v1",
"127.0.0.1:8080/proxy/https://openrouter.ai/api/v1",
] {
assert!(
!is_gateway_url(address),
"{address} is a local server that mentions the gateway"
);
}
assert!(!is_gateway_url(
"http://localhost:8080/v1?upstream=https://openrouter.ai/api/v1"
));
}
#[test]
fn the_filter_matches_a_name_the_row_no_longer_shows() {
let mut s = on_the_model_row(ServerMode::OpenRouter);
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::ListModels(ModelSlot::Assistant)),
"asked with no key entered: the gateway's catalogue is public"
);
let with_facts = |id: &str, name: &str| CatalogModel {
facts: crate::shared::api::catalogue::ModelFacts {
context_length: Some(200_000),
..Default::default()
},
..cat(id, Some(name), ModelRole::Chat)
};
s.set_model_catalogue(
ModelSlot::Assistant,
Ok(vec![
with_facts("google/gemini-3.5-flash", "Google: Gemini 3.5 Flash"),
with_facts("anthropic/claude-haiku-4.5", "Anthropic: Claude Haiku 4.5"),
]
.into()),
);
for c in "Anthropic: Claude".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
let st = s.picker.as_ref().expect("still open");
let matched: Vec<&CatalogModel> = st.results.iter().map(|&i| &st.all[i]).collect();
assert_eq!(matched.len(), 1, "{matched:?}");
let label = s.picker_label(matched[0]);
assert!(
!label.contains("Anthropic: Claude Haiku"),
"the row shows facts, not the name: {label}"
);
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => assert_eq!(
c.engine.openrouter.model_name.as_deref(),
Some("anthropic/claude-haiku-4.5")
),
other => panic!("expected SaveConfig, got {other:?}"),
}
}
const GROK_VOICE: &str = "x-ai/grok-voice-tts-1.0";
fn speaking(id: &str, voices: &[&str]) -> CatalogModel {
CatalogModel {
voices: voices.iter().copied().map(str::to_string).collect(),
..cat(id, Some("A Vendor: A Voice"), ModelRole::Speech)
}
}
fn speech_catalogue() -> crate::shared::api::catalogue::CatalogueAnswer {
Ok(vec![
speaking(GROK_VOICE, &["eve", "ara", "rex", "sal", "leo"]),
speaking("google/gemini-3.8-flash-tts", &["Zephyr", "Puck", "Kore"]),
speaking("fish-audio/s1", &[]),
]
.into())
}
fn speech_on_the_gateway(model: Option<&str>) -> SettingsScreen {
let mut s = screen();
s.config.tts.mode = TtsMode::OpenRouter;
s.config.tts.openrouter.model_name = model.map(str::to_string);
s
}
fn offered(s: &SettingsScreen) -> Vec<String> {
let st = s.picker.as_ref().expect("the picker is open");
st.all.iter().map(|m| m.id.clone()).collect()
}
#[test]
fn the_speech_tab_has_the_gateway_mode_and_its_section_without_instructions() {
let mut s = screen();
goto_model_row(&mut s, ModelTab::Tts, FieldId::TtsMode);
let mut walked = vec![s.config.tts.mode.label()];
for _ in 0..TtsMode::ALL.len() {
s.handle_key(key(KeyCode::Right));
walked.push(s.config.tts.mode.label());
}
assert_eq!(
walked,
["openai", "gemini", "openrouter", "external", "openai"]
);
let section = |mode| {
let mut s = screen();
s.config.tts.mode = mode;
let rows = s.model_fields_for(ModelTab::Tts);
let ids: Vec<FieldId> = rows.iter().map(|r| r.id).collect();
let at = |id| ids.iter().position(|row| *row == id).expect("the row");
ids[at(FieldId::TtsMode) + 1..at(FieldId::TtsSpeed)].to_vec()
};
use FieldId as F;
assert_eq!(
section(TtsMode::OpenRouter),
[
F::TtsModelName,
F::TtsVoice,
F::TtsUserVoice,
F::TtsApiKey,
F::TtsApiKeyEnv,
F::TtsUrl,
F::GatewayAttribution
]
);
for cloud in [TtsMode::OpenAi, TtsMode::Gemini] {
assert!(section(cloud).contains(&F::TtsInstructions), "{cloud:?}");
}
let s = speech_on_the_gateway(None);
for row in [F::TtsApiKey, F::TtsApiKeyEnv] {
let label = field_label(&s, row).unwrap_or_default();
assert!(label.contains("OpenRouter"), "{row:?}: {label:?}");
}
assert_eq!(
s.secret_field_key(F::TtsApiKey),
Some(SecretKey::Provider(CloudProvider::OpenRouter)),
"the key the chat slot stored"
);
let rows = s.model_fields_for(ModelTab::Tts);
let model = rows
.iter()
.find(|r| r.id == F::TtsModelName)
.expect("a row");
let FieldKind::Text(unset) = text_row(F::TtsModelName, "", &None).kind else {
unreachable!("a text row")
};
assert!(matches!(&model.kind, FieldKind::Text(shown) if *shown == unset));
}
#[test]
fn the_speech_rows_have_a_list_behind_them_in_the_gateways_mode_alone() {
for mode in TtsMode::ALL {
for row in [
FieldId::TtsModelName,
FieldId::TtsVoice,
FieldId::TtsUserVoice,
] {
let mut s = screen();
s.config.tts.mode = mode;
goto_model_row(&mut s, ModelTab::Tts, row);
let intent = s.handle_key(key(KeyCode::Enter));
if mode == TtsMode::OpenRouter {
assert_eq!(
intent,
Some(SettingsIntent::ListModels(ModelSlot::Speech)),
"{row:?}"
);
assert!(s.picker.is_some() && s.editor.is_none(), "{row:?}");
} else {
assert_eq!(intent, None, "{mode:?} {row:?}");
assert!(s.picker.is_none() && s.editor.is_some(), "{mode:?} {row:?}");
}
}
}
}
#[test]
fn a_picked_speech_model_is_written_into_the_gateways_section() {
let mut s = speech_on_the_gateway(None);
goto_model_row(&mut s, ModelTab::Tts, FieldId::TtsModelName);
s.handle_key(key(KeyCode::Enter));
s.set_model_catalogue(ModelSlot::Speech, speech_catalogue());
assert_eq!(
offered(&s),
[GROK_VOICE, "google/gemini-3.8-flash-tts", "fish-audio/s1"]
);
s.handle_key(key(KeyCode::Down));
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(
c.tts.openrouter.model_name.as_deref(),
Some("google/gemini-3.8-flash-tts")
);
assert_eq!(c.tts.openai, AppConfig::default().tts.openai);
}
other => panic!("expected SaveConfig, got {other:?}"),
}
}
#[test]
fn a_voice_row_offers_the_voices_of_the_model_the_slot_names() {
let mut s = speech_on_the_gateway(Some(GROK_VOICE));
goto_model_row(&mut s, ModelTab::Tts, FieldId::TtsVoice);
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::ListModels(ModelSlot::Speech))
);
assert!(offered(&s).is_empty(), "nothing before the answer");
s.set_model_catalogue(ModelSlot::Speech, speech_catalogue());
assert_eq!(offered(&s), ["eve", "ara", "rex", "sal", "leo"]);
assert_eq!(s.picker_label(&s.picker.as_ref().unwrap().all[0]), "eve");
s.handle_key(key(KeyCode::Down));
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.tts.openrouter.voice.as_deref(), Some("ara"))
}
other => panic!("expected SaveConfig, got {other:?}"),
}
goto_model_row(&mut s, ModelTab::Tts, FieldId::TtsUserVoice);
assert_eq!(s.handle_key(key(KeyCode::Enter)), None);
assert_eq!(offered(&s), ["eve", "ara", "rex", "sal", "leo"]);
for c in "le".chars() {
s.handle_key(key(KeyCode::Char(c)));
}
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.tts.openrouter.user_voice.as_deref(), Some("leo"));
assert_eq!(c.tts.openrouter.voice.as_deref(), Some("ara"));
}
other => panic!("expected SaveConfig, got {other:?}"),
}
s.config.tts.openrouter.model_name = Some(" google/gemini-3.8-flash-tts ".into());
goto_model_row(&mut s, ModelTab::Tts, FieldId::TtsVoice);
assert_eq!(s.handle_key(key(KeyCode::Enter)), None);
assert_eq!(offered(&s), ["Zephyr", "Puck", "Kore"]);
}
#[test]
fn a_model_that_lists_no_voice_gets_the_editor() {
for model in [Some("fish-audio/s1"), Some("typed/by-hand"), None] {
let mut s = speech_on_the_gateway(model);
goto_model_row(&mut s, ModelTab::Tts, FieldId::TtsVoice);
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::ListModels(ModelSlot::Speech)),
"{model:?}: not known yet"
);
s.set_model_catalogue(ModelSlot::Speech, speech_catalogue());
let st = s.picker.as_ref().expect("the picker stays open");
assert_eq!(
(st.all.len(), st.rows(), &st.status),
(0, 1, &super::picker::PickerStatus::Listed),
"{model:?}"
);
s.handle_key(key(KeyCode::Esc));
assert_eq!(s.handle_key(key(KeyCode::Enter)), None, "{model:?}");
assert!(s.picker.is_none() && s.editor.is_some(), "{model:?}");
s.handle_key(key(KeyCode::Esc));
goto_model_row(&mut s, ModelTab::Tts, FieldId::TtsModelName);
s.handle_key(key(KeyCode::Enter));
assert_eq!(offered(&s).len(), 3, "{model:?}");
s.handle_key(key(KeyCode::Esc));
assert!(s.no_voice_to_pick(FieldId::TtsVoice), "{model:?}");
s.config.tts.mode = TtsMode::Gemini;
s.config.tts.gemini.model_name = model.map(str::to_string);
assert!(!s.no_voice_to_pick(FieldId::TtsVoice), "{model:?}");
}
}
#[test]
fn a_refused_speech_catalogue_leaves_the_voice_rows_their_editor() {
let mut s = speech_on_the_gateway(Some(GROK_VOICE));
goto_model_row(&mut s, ModelTab::Tts, FieldId::TtsVoice);
s.handle_key(key(KeyCode::Enter));
s.set_model_catalogue(ModelSlot::Speech, Err(CatalogueError::Unreachable));
s.handle_key(key(KeyCode::Esc));
assert_eq!(s.handle_key(key(KeyCode::Enter)), None);
assert!(s.picker.is_none() && s.editor.is_some());
}
#[test]
fn a_speech_models_row_counts_its_voices_and_names_no_price() {
use crate::shared::api::catalogue::{CatalogueShape, parse};
let mut config = AppConfig::default();
config.interface.language = crate::shared::i18n::Lang::En;
let s = SettingsScreen::new(config, vec![], vec![]);
let listed = parse(
CatalogueShape::OpenRouterSpeech,
r#"{"data":[
{"id":"x-ai/grok-voice-tts-1.0","name":"xAI: Grok Voice TTS 1.0","created":2,
"context_length":15000,"architecture":{"output_modalities":["speech"]},
"pricing":{"prompt":"0.000015","completion":"0"},"supported_parameters":[],
"supported_voices":["eve","ara","rex","sal","leo"]},
{"id":"fish-audio/s1","name":"Fish Audio: S1","created":1,"context_length":0,
"architecture":{"output_modalities":["speech"]},
"pricing":{"prompt":"0.000015","completion":"0"},"supported_voices":null,
"expiration_date":"2026-12-01"}]}"#,
)
.expect("a list");
let rows: Vec<String> = listed.iter().map(|m| s.picker_label(m)).collect();
assert_eq!(
rows,
[
"x-ai/grok-voice-tts-1.0 · voices: 5",
"fish-audio/s1 · retiring 2026-12-01"
]
);
}
#[test]
fn an_external_speech_address_that_is_the_gateway_says_the_mode_is_needed() {
let mut s = screen();
s.config.tts.mode = TtsMode::External;
let said = |s: &SettingsScreen| {
let rows = s.model_fields_for(ModelTab::Tts);
let row = rows.into_iter().find(|r| r.id == FieldId::TtsUrl);
row.expect("the address row").description
};
for (address, hinted) in [
(Some("https://openrouter.ai/api/v1"), true),
(Some("http://127.0.0.1:8880/v1"), false),
(None, false),
] {
s.config.tts.external.url = address.map(str::to_string);
let hint = hinted.then(|| s.loc().t(DESC_SPEECH_EXTERNAL_IS_GATEWAY).to_string());
assert_eq!(said(&s), hint, "{address:?}");
}
assert_ne!(
s.loc().t(DESC_SPEECH_EXTERNAL_IS_GATEWAY),
s.loc().t(DESC_EXTERNAL_IS_GATEWAY),
"the other slots' hint promises what this slot cannot"
);
let mut s = speech_on_the_gateway(None);
s.config.tts.openrouter.url = Some("https://eu.openrouter.ai/api/v1".into());
assert_eq!(said(&s), None);
}
#[test]
fn a_speech_answer_is_kept_for_the_address_and_the_key_it_was_asked_under() {
let mut s = speech_on_the_gateway(Some(GROK_VOICE));
let first = s.slot_source(ModelSlot::Speech);
goto_model_row(&mut s, ModelTab::Tts, FieldId::TtsModelName);
s.handle_key(key(KeyCode::Enter));
s.set_model_catalogue(ModelSlot::Speech, speech_catalogue());
s.handle_key(key(KeyCode::Esc));
s.config.tts.openrouter.url = Some("https://eu.openrouter.ai/api/v1".into());
assert_ne!(s.slot_source(ModelSlot::Speech), first);
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::ListModels(ModelSlot::Speech)),
"another address is asked anew"
);
s.handle_key(key(KeyCode::Esc));
s.config.tts.openrouter.url = None;
s.config.tts.openrouter.api_key_env = Some("MY_ROUTER_VOICE".into());
assert_ne!(s.slot_source(ModelSlot::Speech), first);
s.config.tts.openrouter.api_key_env = None;
assert_eq!(s.slot_source(ModelSlot::Speech), first);
s.set_secrets_present(vec![SecretKey::Provider(CloudProvider::OpenRouter)]);
assert_ne!(
s.slot_source(ModelSlot::Speech),
first,
"a key moves the list to the account's own"
);
let keyed = s.slot_source(ModelSlot::Speech);
s.config.tts.openrouter.model_name = Some("fish-audio/s1".into());
assert_eq!(s.slot_source(ModelSlot::Speech), keyed);
}
#[test]
fn the_picker_names_the_list_it_shows() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut s = speech_on_the_gateway(Some(GROK_VOICE));
s.config.interface.language = crate::shared::i18n::Lang::En;
let drawn = |s: &mut SettingsScreen| {
let mut term = Terminal::new(TestBackend::new(120, 30)).unwrap();
term.draw(|f| s.render(f)).unwrap();
let buffer = term.backend().buffer().clone();
let cells = buffer.content().iter().map(|c| c.symbol());
cells.collect::<String>()
};
goto_model_row(&mut s, ModelTab::Tts, FieldId::TtsModelName);
s.handle_key(key(KeyCode::Enter));
s.set_model_catalogue(ModelSlot::Speech, speech_catalogue());
let models = drawn(&mut s);
assert!(models.contains("the provider's models"), "{models}");
assert!(models.contains("voices: 5"), "{models}");
s.handle_key(key(KeyCode::Esc));
goto_model_row(&mut s, ModelTab::Tts, FieldId::TtsVoice);
s.handle_key(key(KeyCode::Enter));
let voices = drawn(&mut s);
assert!(voices.contains("the model's voices"), "{voices}");
assert!(!voices.contains("the provider's models"), "{voices}");
assert!(voices.contains("eve") && voices.contains("leo"), "{voices}");
}
use crate::shared::config::VideoProvider;
const GEMINI_FLASH: &str = "google/gemini-3.5-flash";
fn video_through_the_gateway(model: Option<&str>) -> SettingsScreen {
let mut s = screen();
s.config.video.provider = VideoProvider::OpenRouter;
s.config.video.openrouter.model_name = model.map(str::to_string);
s
}
fn video_group(s: &SettingsScreen) -> Vec<FieldId> {
let fields = s.tool_fields();
let group = fields
.iter()
.find(|f| f.id == FieldId::VideoProvider)
.map(|f| f.group)
.expect("the provider's row");
let shown = fields.iter().filter(|f| f.group == group);
shown.map(|f| f.id).collect()
}
fn watching(id: &str, tools: Option<bool>) -> CatalogModel {
CatalogModel {
facts: crate::shared::api::catalogue::ModelFacts {
context_length: Some(1_048_576),
prompt_price: Some(300_000),
completion_price: Some(2_500_000),
tools,
video: Some(true),
},
..cat(id, Some("A Vendor: A Model"), ModelRole::Chat)
}
}
#[test]
fn the_video_group_shows_the_rows_of_whoever_watches() {
use FieldId as F;
let s = screen();
assert_eq!(
video_group(&s),
[
F::VideoProvider,
F::VideoModel,
F::VideoResolution,
F::VideoMaxMinutes,
F::VideoApiKey,
F::VideoApiKeyEnv
]
);
for lang in crate::shared::i18n::Lang::ALL.iter().copied() {
for provider in VideoProvider::ALL {
let mut s = video_through_the_gateway(None);
s.config.video.provider = provider;
s.config.interface.language = lang;
let gateway = provider == VideoProvider::OpenRouter;
if gateway {
assert_eq!(
video_group(&s),
[
F::VideoProvider,
F::VideoModel,
F::VideoMaxMinutes,
F::VideoApiKey,
F::VideoApiKeyEnv,
F::GatewayAttribution
]
);
}
for row in video_group(&s) {
let hint = field_desc(&s, row).unwrap_or_default();
assert!(!hint.is_empty(), "{lang:?} {provider:?} {row:?}");
}
for row in [F::VideoApiKey, F::VideoApiKeyEnv] {
let label = field_label(&s, row).unwrap_or_default();
assert_eq!(
label.contains("OpenRouter"),
gateway,
"{lang:?} {provider:?} {row:?}: {label}"
);
let hint = field_desc(&s, row).unwrap_or_default();
assert_eq!(
hint.contains("OpenRouter"),
gateway,
"{lang:?} {provider:?} {row:?}: {hint}"
);
}
}
}
let gemini = screen();
let gateway = video_through_the_gateway(None);
for row in [F::VideoModel, F::VideoMaxMinutes] {
let of_gemini = field_desc(&gemini, row).unwrap_or_default();
let of_the_gateway = field_desc(&gateway, row).unwrap_or_default();
assert!(!of_gemini.contains("OpenRouter"), "{row:?}: {of_gemini}");
assert!(
of_the_gateway.contains("OpenRouter"),
"{row:?}: {of_the_gateway}"
);
}
}
#[test]
fn each_video_provider_keeps_its_model_and_its_variable() {
let typed = |s: &mut SettingsScreen, row: FieldId, text: &str| {
goto_section(s, Section::Tools);
goto_field(s, row);
s.handle_key(key(KeyCode::Enter));
s.handle_key(ctrl('k'));
for c in text.chars() {
s.handle_key(key(KeyCode::Char(c)));
}
s.handle_key(key(KeyCode::Enter));
};
let mut s = screen();
typed(&mut s, FieldId::VideoApiKeyEnv, "MY_GEMINI");
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::VideoProvider);
match s.handle_key(key(KeyCode::Right)) {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.video.provider, VideoProvider::OpenRouter)
}
other => panic!("expected SaveConfig, got {other:?}"),
}
goto_field_again(&mut s, FieldId::VideoModel);
s.handle_key(key(KeyCode::Enter));
s.handle_key(key(KeyCode::Home));
s.handle_key(key(KeyCode::Enter));
assert!(s.editor.is_some(), "typing a name by hand");
for c in GEMINI_FLASH.chars() {
s.handle_key(key(KeyCode::Char(c)));
}
s.handle_key(key(KeyCode::Enter));
typed(&mut s, FieldId::VideoApiKeyEnv, "MY_ROUTER");
let video = &s.config.video;
assert_eq!(video.openrouter.model_name.as_deref(), Some(GEMINI_FLASH));
assert_eq!(video.openrouter.api_key_env.as_deref(), Some("MY_ROUTER"));
assert_eq!(
video.model_name.as_deref(),
Some(crate::shared::config::DEFAULT_VIDEO_MODEL)
);
assert_eq!(video.api_key_env.as_deref(), Some("MY_GEMINI"));
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::VideoProvider);
s.handle_key(key(KeyCode::Left));
assert_eq!(s.config.video.provider, VideoProvider::Gemini);
let shown = |s: &SettingsScreen, row: FieldId| {
let fields = s.tool_fields();
let field = fields.into_iter().find(|f| f.id == row);
field
.map(|f| value_text(&f.kind, s.loc()))
.unwrap_or_default()
};
assert_eq!(
shown(&s, FieldId::VideoModel),
crate::shared::config::DEFAULT_VIDEO_MODEL
);
assert_eq!(shown(&s, FieldId::VideoApiKeyEnv), "MY_GEMINI");
typed(&mut s, FieldId::VideoModel, "gemini-2.5-pro");
assert_eq!(s.config.video.model_name.as_deref(), Some("gemini-2.5-pro"));
assert_eq!(
s.config.video.openrouter.model_name.as_deref(),
Some(GEMINI_FLASH),
"what was typed for Gemini is not the gateway's"
);
}
#[test]
fn the_video_key_row_addresses_the_key_of_whoever_watches() {
let gateway = SecretKey::Provider(CloudProvider::OpenRouter);
let mut s = video_through_the_gateway(Some(GEMINI_FLASH));
s.config.engine.mode = ServerMode::Managed;
assert_eq!(
s.secret_field_key(FieldId::VideoApiKey),
Some(gateway.clone())
);
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::VideoApiKey);
assert_eq!(
enter_secret(&mut s, "sk-or-v1-test"),
Some(SettingsIntent::SetSecret {
key: gateway,
value: "sk-or-v1-test".into()
})
);
let json = serde_json::to_string(&s.config).unwrap();
assert!(!json.contains("sk-or-v1-test"), "the key leaked: {json}");
s.config.video.provider = VideoProvider::Gemini;
assert_eq!(
s.secret_field_key(FieldId::VideoApiKey),
Some(SecretKey::Provider(CloudProvider::Gemini))
);
}
#[test]
fn the_video_model_row_has_the_gateways_list_behind_it() {
let mut s = screen();
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::VideoModel);
assert_eq!(s.handle_key(key(KeyCode::Enter)), None);
assert!(
s.picker.is_none() && s.editor.is_some(),
"Gemini: the editor"
);
let mut s = video_through_the_gateway(None);
s.config.interface.language = crate::shared::i18n::Lang::En;
goto_section(&mut s, Section::Tools);
goto_field(&mut s, FieldId::VideoModel);
assert_eq!(
s.handle_key(key(KeyCode::Enter)),
Some(SettingsIntent::ListModels(ModelSlot::Video))
);
s.set_model_catalogue(
ModelSlot::Video,
Ok(vec![
watching(GEMINI_FLASH, Some(false)),
watching("qwen/qwen3.6-flash", Some(true)),
]
.into()),
);
let rows: Vec<String> = {
let st = s.picker.as_ref().expect("the picker is open");
st.all.iter().map(|m| s.picker_label(m)).collect()
};
assert_eq!(
rows,
[
"google/gemini-3.5-flash · 1.04M context · $0.30 in / $2.50 out per 1M tokens",
"qwen/qwen3.6-flash · 1.04M context · $0.30 in / $2.50 out per 1M tokens"
]
);
match s.handle_key(key(KeyCode::Enter)) {
Some(SettingsIntent::SaveConfig(c)) => {
assert_eq!(c.video.openrouter.model_name.as_deref(), Some(GEMINI_FLASH));
assert_eq!(c.video.model_name, AppConfig::default().video.model_name);
}
other => panic!("expected SaveConfig, got {other:?}"),
}
let mut chat = on_the_model_row(ServerMode::OpenRouter);
chat.config.interface.language = crate::shared::i18n::Lang::En;
chat.handle_key(key(KeyCode::Enter));
let label = chat.picker_label(&watching(GEMINI_FLASH, Some(false)));
assert!(label.ends_with("no tools"), "{label}");
}
#[test]
fn a_video_answer_is_kept_for_what_it_was_asked_under() {
let mut s = video_through_the_gateway(Some(GEMINI_FLASH));
let first = s.slot_source(ModelSlot::Video);
s.config.video.openrouter.url = Some("https://eu.openrouter.ai/api/v1".into());
assert_ne!(s.slot_source(ModelSlot::Video), first, "the address");
s.config.video.openrouter.url = None;
s.config.video.openrouter.api_key_env = Some("MY_ROUTER".into());
assert_ne!(s.slot_source(ModelSlot::Video), first, "the variable");
s.config.video.openrouter.api_key_env = None;
assert_eq!(s.slot_source(ModelSlot::Video), first);
s.set_secrets_present(vec![SecretKey::Provider(CloudProvider::Gemini)]);
assert_eq!(
s.slot_source(ModelSlot::Video),
first,
"Gemini's key is not the gateway's"
);
s.set_secrets_present(vec![SecretKey::Provider(CloudProvider::OpenRouter)]);
assert_ne!(s.slot_source(ModelSlot::Video), first, "a stored key");
let keyed = s.slot_source(ModelSlot::Video);
s.config.video.url = Some("https://gemini.example/v1beta".into());
s.config.video.api_key_env = Some("MY_GEMINI".into());
assert_eq!(s.slot_source(ModelSlot::Video), keyed);
s.set_secrets_present(vec![]);
let of_the_gateway = s.slot_source(ModelSlot::Video);
s.config.video.provider = VideoProvider::Gemini;
assert_ne!(
s.slot_source(ModelSlot::Video),
of_the_gateway,
"the provider"
);
}
#[test]
fn the_effort_cycle_reaches_max_and_wraps() {
let mut effort = None;
let mut seen = vec![reasoning_label(effort)];
for _ in 0..REASONING_ORDER.len() - 1 {
effort = cycle_reasoning(effort);
seen.push(reasoning_label(effort));
}
assert_eq!(
seen,
[
"—", "none", "minimal", "low", "medium", "high", "xhigh", "max"
]
);
assert_eq!(cycle_reasoning(effort), None, "after `max`, not set again");
let en = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
let (menu, at) = sampling_choice_menu(
&SamplingConfig {
reasoning_effort: Some(ReasoningEffort::Max),
..Default::default()
},
SamplingParam::Reasoning,
en,
);
assert_eq!(menu, seen);
assert_eq!(at, 7);
}