use super::*;
use teksilo_core::shortcut::Shortcut;
use teksilo_core::widget_tree::WidgetTree;
fn ranked<'a>(needle: &str, candidates: &[&'a str]) -> Vec<&'a str> {
let mut scored: Vec<(i32, &str)> = candidates
.iter()
.filter_map(|c| Some((fuzzy_score(needle, c)?, *c)))
.collect();
scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
scored.into_iter().map(|(_, c)| c).collect()
}
#[test]
fn an_empty_needle_matches_everything_without_reordering() {
let all = ["File New", "Edit Copy", "View Zoom"];
assert_eq!(
ranked("", &all),
all,
"an empty query must leave the registry's own order alone"
);
}
#[test]
fn a_subsequence_matches_where_a_substring_would_not() {
assert!(
fuzzy_score("nwd", "New Window").is_some(),
"letters scattered in order through the name must match"
);
assert!(
fuzzy_score("zqx", "New Window").is_none(),
"letters that are not present must not match"
);
}
#[test]
fn letters_must_appear_in_order() {
assert!(
fuzzy_score("wn", "Window New").is_some(),
"`w` then `n` appears in that order"
);
assert!(
fuzzy_score("wn", "New").is_none(),
"there is no `w` in `New` at all"
);
}
#[test]
fn a_consecutive_run_outranks_the_same_letters_scattered() {
assert_eq!(
ranked("exp", &["Export", "Edit XML Properties"]).first(),
Some(&"Export"),
"a literal run must beat scattered initials"
);
}
#[test]
fn word_starts_outrank_mid_word_matches() {
assert_eq!(
ranked("nw", &["New Window", "Unwrap lines"]).first(),
Some(&"New Window"),
"two word-initials must beat a mid-word run of the same letters"
);
}
#[test]
fn an_early_match_outranks_a_late_one() {
assert_eq!(
ranked("save", &["Save", "Autosave"]).first(),
Some(&"Save"),
"the same run earlier in the string must rank higher"
);
}
#[test]
fn matching_folds_case_in_both_directions() {
assert!(fuzzy_score("new", "NEW WINDOW").is_some());
assert!(fuzzy_score("nw", "new window").is_some());
}
#[test]
fn a_needle_longer_than_the_haystack_cannot_match() {
assert!(fuzzy_score("abcdefghij", "abc").is_none());
}
#[test]
fn non_ascii_names_match_without_panicking() {
assert!(fuzzy_score("exp", "Exporter").is_some());
assert!(fuzzy_score("écr", "Écrire un chapitre").is_some());
let _ = fuzzy_score("s", "GROSSE STRAßE");
}
#[test]
fn the_category_takes_part_in_matching() {
let cmd = PaletteCommand {
id: "work.new",
name: "New Work".into(),
category: Some("File"),
description: None,
keystroke: None,
enabled: true,
intent: "work.new",
};
assert_eq!(cmd.haystack(), "File New Work");
assert!(
fuzzy_score("filenew", &cmd.haystack()).is_some(),
"a query naming the category then the command must match the composed haystack"
);
}
fn tree_with_commands() -> WidgetTree {
let mut tree = WidgetTree::new();
tree.shortcut_registry_mut().register(
Shortcut::new("file.new")
.name("New Work")
.category("File")
.primary(teksilo_core::shortcut::KeyStroke::ctrl(
teksilo_core::event::Key::N,
))
.build(),
);
tree.shortcut_registry_mut().register(
Shortcut::new("file.export")
.name("Export")
.category("File")
.build(),
);
tree.shortcut_registry_mut().register(
Shortcut::new("view.zoom")
.name("Zoom In")
.category("View")
.build(),
);
tree
}
#[test]
fn a_command_with_no_keystroke_is_still_listed() {
let mut tree = tree_with_commands();
let palette = CommandPalette::new();
let state = palette.state.clone();
let _ = tree.add(palette);
tree.layout(SizeProposal::exact(560.0, 420.0));
let listed: Vec<&str> = state.rows.borrow().iter().map(|c| c.id).collect();
assert!(
listed.contains(&"file.export"),
"an unbound command must appear; got {listed:?}"
);
assert_eq!(listed.len(), 3, "every registered command should be listed");
}
#[test]
fn typing_filters_and_ranks_the_rows() {
let mut tree = tree_with_commands();
let palette = CommandPalette::new();
let state = palette.state.clone();
let _ = tree.add(palette);
tree.layout(SizeProposal::exact(560.0, 420.0));
state.query.set("exp".to_string());
tree.layout(SizeProposal::exact(560.0, 420.0));
let listed: Vec<&str> = state.rows.borrow().iter().map(|c| c.id).collect();
assert_eq!(
listed,
vec!["file.export"],
"only the matching command should survive the query"
);
}
#[test]
fn a_changed_query_sends_the_highlight_back_to_the_best_match() {
let mut tree = tree_with_commands();
let palette = CommandPalette::new();
let state = palette.state.clone();
let _ = tree.add(palette);
tree.layout(SizeProposal::exact(560.0, 420.0));
state.step_selection(2);
assert_eq!(state.selected.get(), 2, "precondition: highlight moved");
state.query.set("zoom".to_string());
tree.layout(SizeProposal::exact(560.0, 420.0));
assert_eq!(
state.selected.get(),
0,
"a new query must reset the highlight to the top match"
);
}
#[test]
fn the_highlight_never_points_past_the_end_of_the_list() {
let mut tree = tree_with_commands();
let palette = CommandPalette::new();
let state = palette.state.clone();
let _ = tree.add(palette);
tree.layout(SizeProposal::exact(560.0, 420.0));
state.step_selection(99);
assert_eq!(
state.selected.get(),
2,
"stepping past the end must clamp to the last row"
);
state.step_selection(-99);
assert_eq!(state.selected.get(), 0, "stepping before the start clamps");
}
#[test]
fn stepping_an_empty_list_is_a_no_op() {
let mut tree = tree_with_commands();
let palette = CommandPalette::new();
let state = palette.state.clone();
let _ = tree.add(palette);
tree.layout(SizeProposal::exact(560.0, 420.0));
state.query.set("nothingmatchesthis".to_string());
tree.layout(SizeProposal::exact(560.0, 420.0));
assert!(
state.rows.borrow().is_empty(),
"precondition: nothing matches"
);
state.step_selection(1);
assert_eq!(
state.selected.get(),
0,
"an empty list must not move or panic"
);
}
#[test]
fn the_include_predicate_removes_a_command_entirely() {
let mut tree = tree_with_commands();
let palette = CommandPalette::new().include(|cmd| cmd.id != "view.zoom");
let state = palette.state.clone();
let _ = tree.add(palette);
tree.layout(SizeProposal::exact(560.0, 420.0));
let listed: Vec<&str> = state.rows.borrow().iter().map(|c| c.id).collect();
assert!(
!listed.contains(&"view.zoom"),
"an excluded command must not be listed; got {listed:?}"
);
assert_eq!(listed.len(), 2);
}
#[test]
fn a_disabled_command_is_hidden_unless_asked_for() {
let mut tree = WidgetTree::new();
tree.shortcut_registry_mut().register(
Shortcut::new("file.save")
.name("Save")
.category("File")
.enabled_when(Signal::new(false))
.build(),
);
let palette = CommandPalette::new();
let state = palette.state.clone();
let _ = tree.add(palette);
tree.layout(SizeProposal::exact(560.0, 420.0));
assert!(
state.rows.borrow().is_empty(),
"a command that cannot run now is not an answer to \"what can I do\""
);
let mut tree = WidgetTree::new();
tree.shortcut_registry_mut().register(
Shortcut::new("file.save")
.name("Save")
.category("File")
.enabled_when(Signal::new(false))
.build(),
);
let palette = CommandPalette::new().show_disabled(true);
let state = palette.state.clone();
let _ = tree.add(palette);
tree.layout(SizeProposal::exact(560.0, 420.0));
assert_eq!(
state.rows.borrow().len(),
1,
"show_disabled must bring it back"
);
assert!(!state.rows.borrow()[0].enabled, "and mark it disabled");
}
#[test]
fn the_intent_falls_back_to_the_id_when_none_was_declared() {
let mut tree = tree_with_commands();
let palette = CommandPalette::new();
let state = palette.state.clone();
let _ = tree.add(palette);
tree.layout(SizeProposal::exact(560.0, 420.0));
let rows = state.rows.borrow();
let export = rows.iter().find(|c| c.id == "file.export").unwrap();
assert_eq!(
export.intent, "file.export",
"a command declaring no explicit intent must send its own id, exactly as the \
keystroke dispatcher does"
);
}
#[test]
fn revealing_scrolls_only_far_enough_to_show_the_row() {
let state = PaletteState::new();
*state.rows.borrow_mut() = (0..40)
.map(|i| PaletteCommand {
id: "x",
name: format!("Command {i}"),
category: None,
description: None,
keystroke: None,
enabled: true,
intent: "x",
})
.collect();
for _ in 0..VISIBLE_ROWS {
state.step_selection(1);
}
assert_eq!(
state.top_index.get(),
1,
"reaching one row past the fold must scroll one row, not jump"
);
for _ in 0..VISIBLE_ROWS {
state.step_selection(-1);
}
assert_eq!(state.top_index.get(), 0, "returning to row 0 shows row 0");
}
fn a11y_nodes(
t: &mut WidgetTree,
) -> std::collections::HashMap<teksilo_core::accesskit::NodeId, teksilo_core::accesskit::Node> {
t.sync_accessibility().nodes.into_iter().collect()
}
fn node_of(
nodes: &std::collections::HashMap<
teksilo_core::accesskit::NodeId,
teksilo_core::accesskit::Node,
>,
id: WidgetId,
) -> teksilo_core::accesskit::Node {
nodes
.get(&teksilo_core::accessibility::widget_id_to_node_id(id))
.cloned()
.unwrap_or_else(|| panic!("{id:?} published no AccessKit node"))
}
#[test]
fn the_dialog_is_named_and_reports_its_match_count() {
use teksilo_i18n::{
I18nConfig, I18nManager,
thread_local::{clear, install},
};
clear();
install(I18nManager::from_config(
&I18nConfig::new().framework_locales(crate::framework_locales()),
));
let mut tree = tree_with_commands();
let palette = CommandPalette::new();
let state = palette.state.clone();
let id = tree.add(palette);
tree.layout(SizeProposal::exact(560.0, 420.0));
let info = tree.accessibility_node(id);
assert_eq!(info.role(), teksilo_core::accesskit::Role::Dialog);
assert!(
info.name().is_some_and(|n| !n.is_empty()),
"an unnamed dialog announces that *something* opened, not what"
);
let nodes = a11y_nodes(&mut tree);
assert_eq!(
node_of(&nodes, id).description(),
Some("3 commands"),
"the match count must be announced, not left to be arrowed through"
);
state.query.set("exp".to_string());
tree.layout(SizeProposal::exact(560.0, 420.0));
let nodes = a11y_nodes(&mut tree);
assert_eq!(
node_of(&nodes, id).description(),
Some("1 command"),
"the count must track the query"
);
teksilo_i18n::thread_local::clear();
}
#[test]
fn the_highlighted_row_is_reported_as_selected() {
let mut tree = tree_with_commands();
let palette = CommandPalette::new();
let state = palette.state.clone();
let _ = tree.add(palette);
tree.layout(SizeProposal::exact(560.0, 420.0));
assert!(
state.selection.is_selected(0),
"the first row must be selected on open"
);
state.step_selection(1);
tree.layout(SizeProposal::exact(560.0, 420.0));
assert!(
state.selection.is_selected(1),
"an arrow key must move the AT-visible selection, not only the tint"
);
assert!(
!state.selection.is_selected(0),
"the previous row must stop reporting itself as selected"
);
}
#[test]
fn the_search_field_publishes_the_highlighted_row_as_its_active_descendant() {
let mut tree = tree_with_commands();
let palette = CommandPalette::new();
let state = palette.state.clone();
let _ = tree.add(palette);
tree.layout(SizeProposal::exact(560.0, 420.0));
let first = state
.active_row
.get()
.expect("the highlighted row must resolve to a live node");
assert!(
state.listbox_id.get().is_some(),
"the result list must be published for the field's `controls` relation"
);
state.step_selection(1);
tree.layout(SizeProposal::exact(560.0, 420.0));
let second = state
.active_row
.get()
.expect("the highlight must still resolve after moving");
assert_ne!(
first, second,
"moving the highlight must move the announced active descendant"
);
let want = teksilo_core::accessibility::widget_id_to_node_id(second);
let nodes = a11y_nodes(&mut tree);
let publisher = nodes
.values()
.find(|n| n.active_descendant() == Some(want))
.expect("the highlighted row must be published as an active descendant");
assert_eq!(
publisher.role(),
teksilo_core::accesskit::Role::TextInput,
"the relation must sit on the focusable text field, not on a composite \
ancestor — AT follows the focused node's active descendant"
);
}
#[test]
fn an_empty_result_set_publishes_no_stale_active_descendant() {
let mut tree = tree_with_commands();
let palette = CommandPalette::new();
let state = palette.state.clone();
let _ = tree.add(palette);
tree.layout(SizeProposal::exact(560.0, 420.0));
assert!(state.active_row.get().is_some(), "precondition");
state.query.set("zzzzz".to_string());
tree.layout(SizeProposal::exact(560.0, 420.0));
assert_eq!(
state.active_row.get(),
None,
"with no rows there is no node to point at — a stale id would name a \
destroyed widget"
);
assert_eq!(state.listbox_id.get(), None, "and no listbox either");
}