use std::assert_matches;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::*;
#[derive(Serialize, Deserialize)]
struct RoundtripWrapper {
binding: BTreeMap<ButtonId, Action>,
}
#[test]
fn catalog_has_at_least_29_entries() {
let catalog = Action::catalog();
assert!(
catalog.len() >= 29,
"catalog has {} entries, need ≥ 29",
catalog.len()
);
}
#[test]
fn catalog_excludes_custom_shortcut() {
let catalog = Action::catalog();
for action in &catalog {
assert!(
!matches!(action, Action::CustomShortcut(_)),
"catalog must not contain CustomShortcut"
);
}
}
#[test]
fn power_user_action_labels_and_category() {
assert_eq!(Action::TypeText("hi".into()).label(), "Type \"hi\"");
assert_eq!(
Action::RunAppleScript("osascript".into()).label(),
"Run AppleScript"
);
assert_eq!(
Action::RunShellCommand("echo hi".into()).label(),
"Run Command"
);
assert_eq!(Action::TypeText("x".into()).category(), Category::Editing);
assert_eq!(
Action::RunAppleScript("x".into()).category(),
Category::Editing
);
assert_eq!(
Action::RunShellCommand("x".into()).category(),
Category::Editing
);
}
#[test]
fn power_user_actions_excluded_from_catalog() {
let cat = Action::catalog();
assert!(cat.iter().all(|a| !matches!(
a,
Action::TypeText(_) | Action::RunAppleScript(_) | Action::RunShellCommand(_)
)));
}
#[test]
fn power_user_actions_roundtrip_toml() {
for action in [
Action::TypeText("hello".into()),
Action::RunAppleScript("beep".into()),
Action::RunShellCommand("date".into()),
] {
let toml = toml::to_string(&action).expect("serialize");
let back: Action = toml::from_str(&toml).expect("deserialize");
assert_eq!(action, back);
}
}
#[test]
fn workflow_label_category_and_catalog_exclusion() {
let wf = Action::Workflow(vec![
WorkflowStep::TypeText("bite me".into()),
WorkflowStep::Delay { millis: 5000 },
WorkflowStep::PressKey(KeyCombo {
modifiers: 0,
key_code: 0x24, display: String::new(),
}),
]);
assert_eq!(wf.label(), "Workflow (3 steps)");
assert_eq!(wf.category(), Category::Editing);
assert!(
Action::catalog()
.iter()
.all(|a| !matches!(a, Action::Workflow(_)))
);
}
#[test]
fn workflow_roundtrips_toml() {
let wf = Action::Workflow(vec![
WorkflowStep::TypeText("bite me".into()),
WorkflowStep::Delay { millis: 5000 },
WorkflowStep::PressKey(KeyCombo {
modifiers: KeyCombo::MOD_SHIFT,
key_code: 0x24,
display: "⇧↩".into(),
}),
WorkflowStep::RunShellCommand("echo done".into()),
]);
let toml = toml::to_string(&wf).expect("serialize");
let back: Action = toml::from_str(&toml).expect("deserialize");
assert_eq!(wf, back);
}
#[derive(Serialize, Deserialize)]
struct BindingWrapper {
bindings: BTreeMap<ButtonId, Binding>,
}
fn binding_roundtrip(bindings: BTreeMap<ButtonId, Binding>) -> BTreeMap<ButtonId, Binding> {
let toml = toml::to_string_pretty(&BindingWrapper { bindings }).expect("serialize");
toml::from_str::<BindingWrapper>(&toml)
.expect("deserialize")
.bindings
}
#[test]
fn binding_single_roundtrips_including_payload_variants() {
let mut bindings = BTreeMap::new();
bindings.insert(ButtonId::Back, Binding::Single(Action::BrowserBack));
bindings.insert(
ButtonId::DpiToggle,
Binding::Single(Action::SetDpiPreset(2)),
);
bindings.insert(
ButtonId::Forward,
Binding::Single(Action::CustomShortcut(KeyCombo {
modifiers: KeyCombo::MOD_CMD,
key_code: 0x23,
display: "⌘P".into(),
})),
);
let back = binding_roundtrip(bindings);
assert_eq!(back[&ButtonId::Back], Binding::Single(Action::BrowserBack));
assert_eq!(
back[&ButtonId::DpiToggle],
Binding::Single(Action::SetDpiPreset(2))
);
assert_matches!(
back[&ButtonId::Forward],
Binding::Single(Action::CustomShortcut(_))
);
}
#[test]
fn binding_gesture_roundtrips() {
let mut map = BTreeMap::new();
map.insert(GestureDirection::Up, Action::Copy);
map.insert(GestureDirection::Click, Action::Paste);
let mut bindings = BTreeMap::new();
bindings.insert(ButtonId::GestureButton, Binding::Gesture(map.clone()));
let back = binding_roundtrip(bindings);
assert_eq!(back[&ButtonId::GestureButton], Binding::Gesture(map));
}
#[test]
fn binding_direction_keyed_table_routes_to_gesture() {
for dir in GestureDirection::ALL {
let toml = format!("bindings.GestureButton.{dir} = \"None\"");
let parsed = toml::from_str::<BindingWrapper>(&toml).expect("deserialize");
assert!(
matches!(
parsed.bindings[&ButtonId::GestureButton],
Binding::Gesture(_)
),
"a {dir}-keyed table must route to Gesture, not Single"
);
}
}
#[test]
fn binding_payload_action_stays_single() {
let toml = "bindings.DpiToggle.SetDpiPreset = 2";
let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
assert_eq!(
parsed.bindings[&ButtonId::DpiToggle],
Binding::Single(Action::SetDpiPreset(2))
);
}
#[test]
fn binding_capture_region_roundtrips_as_single_string() {
let toml = "bindings.Back = \"CaptureRegion\"";
let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
assert_eq!(
parsed.bindings[&ButtonId::Back],
Binding::Single(Action::CaptureRegion)
);
let back = binding_roundtrip(parsed.bindings);
assert_eq!(
back[&ButtonId::Back],
Binding::Single(Action::CaptureRegion)
);
assert_eq!(Action::CaptureRegion.label(), "Capture Region");
assert_eq!(Action::CaptureRegion.category(), Category::System);
assert!(Action::catalog().contains(&Action::CaptureRegion));
}
fn roundtrip(action: &Action) -> Action {
let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
map.insert(ButtonId::Back, action.clone());
let w = RoundtripWrapper { binding: map };
let s = toml::to_string(&w).expect("serialize");
let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
back.binding
.into_values()
.next()
.expect("binding present after roundtrip")
}
#[test]
fn all_catalog_variants_roundtrip_toml() {
for action in Action::catalog() {
let back = roundtrip(&action);
assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
}
}
#[test]
fn custom_shortcut_roundtrips_toml() {
let action = Action::CustomShortcut(KeyCombo {
modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
key_code: 0x23, display: "⌘⇧P".into(),
});
assert_eq!(roundtrip(&action), action);
}
#[test]
fn key_combo_rendered_label_uses_display_when_set() {
let combo = KeyCombo {
modifiers: 0,
key_code: 0,
display: "preset".into(),
};
assert_eq!(combo.rendered_label(), "preset");
}
#[test]
fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
let combo = KeyCombo {
modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
key_code: 0x23, display: String::new(),
};
assert_eq!(combo.rendered_label(), "⇧⌘P");
}
#[test]
fn category_editing_variants() {
assert_eq!(Action::Copy.category(), Category::Editing);
assert_eq!(Action::Undo.category(), Category::Editing);
assert_eq!(Action::SelectAll.category(), Category::Editing);
assert_eq!(Action::Find.category(), Category::Editing);
assert_eq!(Action::Save.category(), Category::Editing);
assert_eq!(Action::Cut.category(), Category::Editing);
assert_eq!(Action::Redo.category(), Category::Editing);
assert_eq!(Action::Paste.category(), Category::Editing);
}
#[test]
fn category_browser_variants() {
assert_eq!(Action::BrowserBack.category(), Category::Browser);
assert_eq!(Action::BrowserForward.category(), Category::Browser);
assert_eq!(Action::NewTab.category(), Category::Browser);
assert_eq!(Action::CloseTab.category(), Category::Browser);
assert_eq!(Action::ReopenTab.category(), Category::Browser);
assert_eq!(Action::NextTab.category(), Category::Browser);
assert_eq!(Action::PrevTab.category(), Category::Browser);
assert_eq!(Action::ReloadPage.category(), Category::Browser);
}
#[test]
fn category_media_variants() {
assert_eq!(Action::PlayPause.category(), Category::Media);
assert_eq!(Action::NextTrack.category(), Category::Media);
assert_eq!(Action::PrevTrack.category(), Category::Media);
assert_eq!(Action::VolumeUp.category(), Category::Media);
assert_eq!(Action::VolumeDown.category(), Category::Media);
assert_eq!(Action::MuteVolume.category(), Category::Media);
}
#[test]
fn category_mouse_variants() {
assert_eq!(Action::LeftClick.category(), Category::Mouse);
assert_eq!(Action::RightClick.category(), Category::Mouse);
assert_eq!(Action::MiddleClick.category(), Category::Mouse);
}
#[test]
fn category_dpi_variants() {
assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
}
#[test]
fn category_scroll_variants() {
assert_eq!(Action::ScrollUp.category(), Category::Scroll);
assert_eq!(Action::ScrollDown.category(), Category::Scroll);
assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
}
#[test]
fn category_navigation_variants() {
assert_eq!(Action::MissionControl.category(), Category::Navigation);
assert_eq!(Action::AppExpose.category(), Category::Navigation);
assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
assert_eq!(Action::NextDesktop.category(), Category::Navigation);
assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
}
#[test]
fn category_system_variants() {
assert_eq!(Action::LockScreen.category(), Category::System);
assert_eq!(Action::Screenshot.category(), Category::System);
}
#[test]
fn category_labels_are_nonempty() {
let categories = [
Category::Editing,
Category::Browser,
Category::Media,
Category::Mouse,
Category::Dpi,
Category::Scroll,
Category::Navigation,
Category::System,
];
for cat in categories {
assert!(!cat.label().is_empty(), "label empty for {cat:?}");
}
}
#[test]
fn dpi_toggle_default_is_cycle_dpi_presets() {
assert_eq!(
default_binding(ButtonId::DpiToggle),
Action::CycleDpiPresets
);
}