openlogi-core 0.6.25

Core types, config, and paths for OpenLogi. No I/O specifics.
Documentation
//! Binding catalog / serde roundtrip tests.

use std::assert_matches;
use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use super::*;

// ── Roundtrip wrapper: defined here so it precedes any `let` statements ──

/// Minimal TOML-serializable wrapper used by `roundtrip`.
/// Defined at module scope to satisfy `clippy::items_after_statements`.
#[derive(Serialize, Deserialize)]
struct RoundtripWrapper {
    binding: BTreeMap<ButtonId, Action>,
}

// ── Catalog tests ─────────────────────────────────────────────────────────

#[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"
    );
    // All three are power-user escape hatches: classed as Editing so a
    // hand-authored binding has a home group, but never in the default
    // catalog (asserted below).
    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, // Return
            display: String::new(),
        }),
    ]);
    assert_eq!(wf.label(), "Workflow (3 steps)");
    assert_eq!(wf.category(), Category::Editing);
    // Excluded from the default catalog like the other power-user actions.
    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);
}

// ── Binding (merged model) serde routing ──────────────────────────────────

/// On-disk shape: a `ButtonId` → [`Binding`] map, as `DeviceConfig.bindings`
/// serializes it.
#[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));
}

/// The untagged-routing safety guard. A TOML table keyed by ANY
/// [`GestureDirection`] name must deserialize as [`Binding::Gesture`], never
/// [`Binding::Single`]. If a future [`Action`] payload variant is ever named
/// `Up`/`Down`/`Left`/`Right`/`Click`, the table would parse as `Single`
/// first and this test fails — catching the silent mis-route at CI time.
#[test]
fn binding_direction_keyed_table_routes_to_gesture() {
    for dir in GestureDirection::ALL {
        // `GestureDirection`'s serde key equals its `Display`/variant name.
        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"
        );
    }
}

/// The collision case: a payload [`Action`] also serializes as a single-key
/// table, but untagged must keep it [`Binding::Single`] (it parses as a valid
/// externally-tagged `Action` before the `Gesture` arm is tried).
#[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));
}

// ── TOML roundtrip ────────────────────────────────────────────────────────

/// Serialize then deserialize `action` through TOML, using a wrapper
/// struct because TOML requires a top-level table.
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, // kVK_ANSI_P
        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, // P
        display: String::new(),
    };
    assert_eq!(combo.rendered_label(), "⇧⌘P");
}

// ── Category tests ────────────────────────────────────────────────────────

#[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);
}

// ── Category label smoke test ─────────────────────────────────────────────

#[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:?}");
    }
}

// ── Default binding ───────────────────────────────────────────────────────

#[test]
fn dpi_toggle_default_is_cycle_dpi_presets() {
    assert_eq!(
        default_binding(ButtonId::DpiToggle),
        Action::CycleDpiPresets
    );
}