feat: finalize search UX and release hardening

This commit is contained in:
stoorps 2026-03-21 16:53:33 +00:00
parent c63b2917da
commit 34f9543a78
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
44 changed files with 4983 additions and 94 deletions

View file

@ -7,6 +7,7 @@ fn help_lists_expected_commands() {
cmd.arg("--help")
.assert()
.success()
.stdout(contains("search"))
.stdout(contains("remove"))
.stdout(contains("list"))
.stdout(contains("update"));

View file

@ -0,0 +1,64 @@
use aim_cli::config::{CliConfig, ConfigError, SearchConfig, load_from_path};
use tempfile::tempdir;
#[test]
fn missing_config_file_returns_defaults() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = load_from_path(&path).unwrap();
assert_eq!(config, CliConfig::default());
assert_eq!(config.search, SearchConfig::default());
assert!(config.search.bottom_to_top);
assert!(!config.search.skip_confirmation);
assert_eq!(config.theme.accent, "#b388ff");
assert_eq!(config.theme.accent_secondary, "#d5c2ff");
assert_eq!(config.theme.dim, "#7f7396");
}
#[test]
fn search_section_overrides_defaults() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"[search]\nbottom_to_top = false\nskip_confirmation = true\n\n[theme]\naccent = \"#9f6bff\"\naccent_secondary = \"#efe7ff\"\ndim = \"#6b6480\"\n",
)
.unwrap();
let config = load_from_path(&path).unwrap();
assert_eq!(
config,
CliConfig {
search: SearchConfig {
bottom_to_top: false,
skip_confirmation: true,
},
theme: aim_cli::config::ThemeConfig {
accent: "#9f6bff".to_owned(),
accent_secondary: "#efe7ff".to_owned(),
dim: "#6b6480".to_owned(),
},
}
);
}
#[test]
fn malformed_toml_returns_path_aware_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[search\nskip_confirmation = true\n").unwrap();
let error = load_from_path(&path).unwrap_err();
match error {
ConfigError::Parse {
path: error_path, ..
} => {
assert_eq!(error_path, path);
}
other => panic!("expected parse error, got {other:?}"),
}
}

View file

@ -0,0 +1,234 @@
use aim_cli::config::SearchConfig;
use aim_cli::ui::search_browser::{BrowserPhase, SearchBrowserState, SubmitAction};
use aim_core::domain::search::{SearchInstallStatus, SearchResult};
#[test]
fn browser_defaults_to_bottom_to_top_ordering() {
let state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
assert_eq!(
visible_names(&state),
vec!["charlie/app", "bravo/app", "alpha/app"]
);
}
#[test]
fn browser_moves_cursor_and_pages() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 2);
state.move_next();
assert_eq!(state.cursor_position(), 1);
state.page_down();
assert_eq!(state.cursor_position(), 2);
state.page_up();
assert_eq!(state.cursor_position(), 0);
}
#[test]
fn browser_supports_single_and_multiple_numeric_selection() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.apply_numeric_selection("1,3").unwrap();
assert_eq!(selected_names(&state), vec!["charlie/app", "alpha/app"]);
}
#[test]
fn browser_supports_numeric_ranges() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.apply_numeric_selection("1-2").unwrap();
assert_eq!(selected_names(&state), vec!["charlie/app", "bravo/app"]);
}
#[test]
fn browser_supports_space_separated_numeric_selection() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.apply_numeric_selection("1 3").unwrap();
assert_eq!(selected_names(&state), vec!["charlie/app", "alpha/app"]);
}
#[test]
fn typing_numeric_input_updates_selection_immediately() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.push_numeric_input('1');
assert_eq!(selected_names(&state), vec!["charlie/app"]);
state.push_numeric_input(' ');
state.push_numeric_input('3');
assert_eq!(selected_names(&state), vec!["charlie/app", "alpha/app"]);
}
#[test]
fn invalid_numeric_input_keeps_last_good_selection_visible() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.push_numeric_input('1');
assert_eq!(selected_names(&state), vec!["charlie/app"]);
state.push_numeric_input('-');
assert_eq!(selected_names(&state), vec!["charlie/app"]);
assert_eq!(state.numeric_buffer(), "1-");
}
#[test]
fn highlight_segments_marks_matching_query_fragments() {
let fragments = aim_cli::ui::search_browser::highlight_segments("pingdotgg/t3code", "dotgg");
assert_eq!(fragments.len(), 3);
assert_eq!(fragments[1].text, "dotgg");
assert!(fragments[1].is_match);
}
#[test]
fn invalid_numeric_selection_preserves_existing_selection() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.apply_numeric_selection("2").unwrap();
let error = state.apply_numeric_selection("2-z").unwrap_err();
assert!(error.contains("2-z"));
assert_eq!(selected_names(&state), vec!["bravo/app"]);
}
#[test]
fn confirmation_requires_selection_before_transition() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
assert!(!state.enter_confirmation());
assert_eq!(state.phase(), BrowserPhase::Browsing);
state.toggle_current_selection();
assert!(state.enter_confirmation());
assert_eq!(state.phase(), BrowserPhase::Confirming);
state.cancel_confirmation();
assert_eq!(state.phase(), BrowserPhase::Browsing);
}
#[test]
fn submit_selection_can_skip_confirmation_from_config() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.toggle_current_selection();
let action = state.submit_selection(true);
assert_eq!(
action,
SubmitAction::Confirmed(aim_cli::ui::search_browser::SearchSelection {
rows: vec![aim_cli::ui::search_browser::SearchRow {
status: SearchInstallStatus::Available,
provider_id: "github".to_owned(),
display_name: "charlie/app".to_owned(),
description: None,
install_query: "charlie/app".to_owned(),
version: Some("1.0.0".to_owned()),
selectable: true,
}],
})
);
}
#[test]
fn installed_rows_are_visible_but_not_selectable() {
let mut state = SearchBrowserState::new(installed_first_results(), SearchConfig::default(), 3);
state.toggle_current_selection();
assert!(state.selected_rows().is_empty());
assert_eq!(
state.status_message(),
Some("installed result is not selectable")
);
}
#[test]
fn update_rows_remain_selectable() {
let mut state = SearchBrowserState::new(update_first_results(), SearchConfig::default(), 3);
state.toggle_current_selection();
assert_eq!(selected_names(&state), vec!["charlie/app"]);
}
#[test]
fn selection_expression_prefills_from_checklist_selection() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 5);
state.toggle_current_selection();
state.move_to_bottom();
state.toggle_current_selection();
assert_eq!(state.selection_expression(), "1,3");
}
#[test]
fn selection_expression_compacts_adjacent_ranges() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 5);
state.apply_numeric_selection("1-3").unwrap();
assert_eq!(state.selection_expression(), "1-3");
}
fn sample_results() -> Vec<SearchResult> {
vec![
sample_result("alpha/app"),
sample_result("bravo/app"),
sample_result("charlie/app"),
]
}
fn sample_result(name: &str) -> SearchResult {
SearchResult {
provider_id: "github".to_owned(),
display_name: name.to_owned(),
description: None,
source_locator: name.to_owned(),
install_query: name.to_owned(),
canonical_locator: name.to_owned(),
version: Some("1.0.0".to_owned()),
install_status: SearchInstallStatus::Available,
}
}
fn installed_first_results() -> Vec<SearchResult> {
let mut results = sample_results();
results[2].install_status = SearchInstallStatus::Installed {
installed_version: Some("1.0.0".to_owned()),
};
results
}
fn update_first_results() -> Vec<SearchResult> {
let mut results = sample_results();
results[2].install_status = SearchInstallStatus::UpdateAvailable {
installed_version: Some("0.9.0".to_owned()),
latest_version: Some("1.0.0".to_owned()),
};
results
}
fn visible_names(state: &SearchBrowserState) -> Vec<&str> {
state
.ordered_rows()
.iter()
.map(|row| row.display_name.as_str())
.collect()
}
fn selected_names(state: &SearchBrowserState) -> Vec<&str> {
state
.selected_rows()
.iter()
.map(|row| row.display_name.as_str())
.collect()
}

View file

@ -0,0 +1,153 @@
use assert_cmd::Command;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use tempfile::tempdir;
const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
#[test]
fn search_command_renders_remote_github_results() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "bat"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Search Results"))
.stdout(contains("Remote Results"))
.stdout(contains("[github] sharkdp/bat"))
.stdout(contains("Install query: sharkdp/bat"));
}
#[test]
fn search_command_renders_local_matches_in_deterministic_order() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
std::fs::write(
&registry_path,
concat!(
"version = 1\n",
"[[apps]]\n",
"stable_id = \"bat\"\n",
"display_name = \"Bat\"\n",
"[[apps]]\n",
"stable_id = \"bat-tools\"\n",
"display_name = \"Bat Tools\"\n",
"[[apps]]\n",
"stable_id = \"acrobat-reader\"\n",
"display_name = \"Acrobat Reader\"\n",
"[[apps]]\n",
"stable_id = \"combat-viewer\"\n",
"display_name = \"Combat Viewer\"\n"
),
)
.unwrap();
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "bat"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Installed Matches"))
.stdout(
contains("- Bat (bat)")
.and(contains("- Bat Tools (bat-tools)"))
.and(contains("- Acrobat Reader (acrobat-reader)"))
.and(contains("- Combat Viewer (combat-viewer)")),
);
}
#[test]
fn search_command_is_read_only_for_registry_contents() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let original = "version = 1\n[[apps]]\nstable_id = \"bat\"\ndisplay_name = \"Bat\"\n";
std::fs::write(&registry_path, original).unwrap();
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "bat"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success();
let persisted = std::fs::read_to_string(&registry_path).unwrap();
assert_eq!(persisted, original);
}
#[test]
fn search_command_fails_fast_on_malformed_config() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "[search\nskip_confirmation = true\n").unwrap();
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "bat"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env("AIM_CONFIG_PATH", &config_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.failure()
.stderr(contains(config_path.to_string_lossy().as_ref()));
}
#[test]
fn search_command_uses_plain_text_output_when_not_on_a_tty() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[search]\nbottom_to_top = false\nskip_confirmation = true\n",
)
.unwrap();
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "bat"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env("AIM_CONFIG_PATH", &config_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Search Results"))
.stdout(contains("Remote Results"))
.stdout(contains("[github] sharkdp/bat"));
}
#[test]
fn search_command_reports_loading_status_to_stderr() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "bat"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stderr(contains("Searching bat"));
}
#[test]
fn search_command_keeps_empty_results_in_plain_text_mode() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "no-such-app-image-query"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Search Results"))
.stdout(contains("No remote matches"));
}

View file

@ -1,7 +1,9 @@
use aim_cli::DispatchResult;
use aim_cli::ui::prompt::render_interaction;
use aim_cli::ui::render::{render_dispatch_result, render_update_summary};
use aim_cli::ui::search_browser::{SearchRow, format_search_row, render_confirmation_summary};
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
use aim_core::domain::search::SearchInstallStatus;
use aim_core::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan};
#[test]
@ -68,3 +70,78 @@ fn tracking_prompt_uses_explicit_question_copy() {
assert!(output.contains("Choose update tracking"));
}
#[test]
fn search_browser_row_uses_status_tag_version_and_description_layout() {
let row = SearchRow {
status: SearchInstallStatus::Installed {
installed_version: Some("0.0.12".to_owned()),
},
provider_id: "github".to_owned(),
display_name: "pingdotgg/t3code".to_owned(),
description: Some("The T3 desktop app.".to_owned()),
install_query: "pingdotgg/t3code".to_owned(),
version: Some("0.0.12".to_owned()),
selectable: false,
};
let output = format_search_row(1, &row, true, true, 120);
assert!(output.contains('\n'));
assert!(output.contains("[installed]"));
assert!(output.contains("v0.0.12"));
assert!(output.contains("pingdotgg/t3code"));
assert!(output.contains("github - The T3 desktop app."));
}
#[test]
fn search_browser_row_without_description_shows_provider_only() {
let row = SearchRow {
status: SearchInstallStatus::Available,
provider_id: "github".to_owned(),
display_name: "pingdotgg/t3code".to_owned(),
description: None,
install_query: "pingdotgg/t3code".to_owned(),
version: Some("0.0.12".to_owned()),
selectable: true,
};
let output = format_search_row(1, &row, false, false, 120);
assert!(output.contains("github"));
assert!(!output.contains(" - "));
assert!(!output.contains("No description available"));
}
#[test]
fn search_confirmation_summary_lists_selected_rows() {
let rows = vec![
SearchRow {
status: SearchInstallStatus::UpdateAvailable {
installed_version: Some("0.0.11".to_owned()),
latest_version: Some("0.0.12".to_owned()),
},
provider_id: "github".to_owned(),
display_name: "pingdotgg/t3code".to_owned(),
description: Some("The T3 desktop app.".to_owned()),
install_query: "pingdotgg/t3code".to_owned(),
version: Some("0.0.12".to_owned()),
selectable: true,
},
SearchRow {
status: SearchInstallStatus::Available,
provider_id: "github".to_owned(),
display_name: "sharkdp/bat".to_owned(),
description: Some("A cat(1) clone with wings.".to_owned()),
install_query: "sharkdp/bat".to_owned(),
version: Some("1.0.0".to_owned()),
selectable: true,
},
];
let output = render_confirmation_summary(&rows);
assert!(output.contains("Confirm Search Selection"));
assert!(output.contains("pingdotgg/t3code"));
assert!(output.contains("sharkdp/bat"));
}