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 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_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 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')));
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");
}
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.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; 4] = [
ServerMode::OpenAi,
ServerMode::Gemini,
ServerMode::Claude,
ServerMode::Grok,
];
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,
}
}
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());
}