#![allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
use std::assert_matches;
use super::*;
use crate::binding::{default_binding, default_gesture_binding};
fn write_and_read(config: &Config) -> Config {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
config.save_to_path(&path).expect("save");
Config::load_from_path(&path).expect("load")
}
#[test]
fn key_trigger_parses_bare_and_modified() {
let t: KeyTrigger = "f1".parse().expect("parse key trigger");
assert_eq!(t.keycode, 0x7A);
assert!(t.modifiers.is_empty());
let t: KeyTrigger = "shift+cmd+f5".parse().expect("parse key trigger");
assert_eq!(t.keycode, 0x60); assert!(t.modifiers.shift && t.modifiers.command);
assert!(!t.modifiers.control && !t.modifiers.option);
let t: KeyTrigger = "ctrl+alt+f2".parse().expect("parse key trigger");
assert!(t.modifiers.control && t.modifiers.option);
assert_eq!(
"esc"
.parse::<KeyTrigger>()
.expect("parse key trigger")
.keycode,
0x35
);
}
#[test]
fn key_trigger_parses_and_displays_extended_function_keys() {
let f13: KeyTrigger = "f13".parse().expect("parse key trigger");
let f17: KeyTrigger = "command+f17".parse().expect("parse key trigger");
let f19: KeyTrigger = "f19".parse().expect("parse key trigger");
assert_eq!(f13.keycode, 0x69);
assert_eq!(f17.keycode, 0x40);
assert_eq!(f17.to_string(), "command+f17");
assert_eq!(f19.keycode, 0x50);
assert_eq!(f19.to_string(), "f19");
}
#[test]
fn key_trigger_rejects_unknown() {
assert!("f99".parse::<KeyTrigger>().is_err());
assert!("shift+".parse::<KeyTrigger>().is_err());
assert!("".parse::<KeyTrigger>().is_err());
}
#[test]
fn keyboard_section_roundtrips_through_config() {
let mut config = Config::default();
config.keyboard.bindings.insert(
"f1".parse().expect("parse key trigger"),
Action::TypeText("hello".into()),
);
config.keyboard.bindings.insert(
"shift+f2".parse().expect("parse key trigger"),
Action::VolumeUp,
);
config.keyboard.bindings.insert(
"f17".parse().expect("parse key trigger"),
Action::MissionControl,
);
let roundtripped = write_and_read(&config);
assert_eq!(roundtripped.keyboard.bindings.len(), 3);
assert_eq!(
roundtripped
.keyboard
.bindings
.get(&"f1".parse::<KeyTrigger>().expect("parse key trigger")),
Some(&Action::TypeText("hello".into()))
);
assert_eq!(
roundtripped
.keyboard
.bindings
.get(&"f17".parse::<KeyTrigger>().expect("parse key trigger")),
Some(&Action::MissionControl)
);
}
#[test]
fn set_keyboard_binding_inserts_and_clears() {
let mut config = Config::default();
let f1: KeyTrigger = "f1".parse().expect("parse key trigger");
config.set_keyboard_binding(f1.clone(), Some(Action::VolumeUp));
assert_eq!(config.keyboard_bindings().get(&f1), Some(&Action::VolumeUp));
assert_eq!(config.keyboard_bindings().len(), 1);
config.set_keyboard_binding(f1.clone(), Some(Action::MuteVolume));
assert_eq!(
config.keyboard_bindings().get(&f1),
Some(&Action::MuteVolume)
);
assert_eq!(config.keyboard_bindings().len(), 1);
config.set_keyboard_binding(f1.clone(), None);
assert!(config.keyboard_bindings().get(&f1).is_none());
assert!(config.keyboard_bindings().is_empty());
}
#[test]
fn missing_file_yields_default() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("nonexistent.toml");
let cfg = Config::load_from_path(&path).expect("load");
assert_eq!(cfg.schema_version, SCHEMA_VERSION);
assert!(cfg.devices.is_empty());
}
#[test]
fn lighting_roundtrips_per_device() {
let mut cfg = Config::default();
cfg.set_lighting(
"g513",
Lighting {
enabled: true,
color: "00aabb".parse().expect("valid hex"),
brightness: 75,
},
);
let restored = write_and_read(&cfg);
assert_eq!(
restored.lighting("g513"),
Some(Lighting {
enabled: true,
color: "00aabb".parse().expect("valid hex"),
brightness: 75,
})
);
assert_eq!(restored.lighting("absent"), None);
}
#[test]
fn standalone_light_settings_roundtrip_per_device() {
let mut cfg = Config::default();
cfg.set_light(
"raw:046d:c900:ff43:0202:serial:glow",
LightSettings {
enabled: false,
auto_camera: false,
brightness_percent: 65,
temperature_kelvin: Some(4600),
color: None,
},
);
let restored = write_and_read(&cfg);
assert_eq!(
restored.light("raw:046d:c900:ff43:0202:serial:glow"),
Some(LightSettings {
enabled: false,
auto_camera: false,
brightness_percent: 65,
temperature_kelvin: Some(4600),
color: None,
})
);
assert_eq!(restored.light("absent"), None);
}
#[test]
fn standalone_light_brightness_is_clamped_on_load() {
let cfg: Config = toml::from_str(
r"
schema_version = 3
[devices.glow.light]
enabled = true
brightness_percent = 255
",
)
.expect("light config loads");
assert_eq!(
cfg.light("glow").map(|light| light.brightness_percent),
Some(100)
);
}
#[test]
fn standalone_light_camera_automation_roundtrips() {
let mut cfg = Config::default();
cfg.set_light(
"raw:046d:c900:ff43:0202:serial:glow",
LightSettings {
enabled: true,
auto_camera: true,
brightness_percent: 80,
temperature_kelvin: Some(5000),
color: None,
},
);
let restored = write_and_read(&cfg);
assert_eq!(
restored
.light("raw:046d:c900:ff43:0202:serial:glow")
.map(|light| light.auto_camera),
Some(true)
);
}
#[test]
fn unparseable_lighting_color_falls_back_to_white() {
let cfg: Config = toml::from_str(
r#"
schema_version = 3
[devices.g513.lighting]
enabled = true
color = "red"
brightness = 50
"#,
)
.expect("config with a bad color still loads");
assert_eq!(
cfg.lighting("g513").map(|l| l.color),
Some(crate::color::Rgb::WHITE)
);
}
#[test]
fn hash_prefixed_lighting_color_migrates_to_canonical_hex() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(
&path,
r##"
schema_version = 3
[devices.g513.lighting]
enabled = true
color = "#ff0000"
brightness = 50
"##,
)
.expect("write config");
let cfg = Config::load_from_path(&path).expect("load hash-prefixed color");
assert_eq!(
cfg.lighting("g513").map(|lighting| lighting.color),
Some(crate::color::Rgb::new(0xff, 0x00, 0x00))
);
cfg.save_to_path(&path).expect("save canonical color");
let saved = fs::read_to_string(path).expect("read saved config");
assert!(saved.contains("color = \"ff0000\""));
assert!(!saved.contains("color = \"#"));
}
#[test]
fn dpi_roundtrips_per_device() {
let mut cfg = Config::default();
cfg.set_dpi("2b042", 1600);
let restored = write_and_read(&cfg);
assert_eq!(restored.dpi("2b042"), Some(1600));
assert_eq!(restored.dpi("absent"), None);
}
#[test]
fn smartshift_roundtrips_per_device() {
let mut cfg = Config::default();
cfg.set_smartshift(
"2b042",
SmartShift {
mode: WheelMode::Ratchet,
auto_disengage: 16,
tunable_torque: 30,
},
);
let restored = write_and_read(&cfg);
assert_eq!(
restored.smartshift("2b042"),
Some(SmartShift {
mode: WheelMode::Ratchet,
auto_disengage: 16,
tunable_torque: 30,
})
);
assert_eq!(restored.smartshift("absent"), None);
}
#[test]
fn invert_scroll_roundtrips_per_device() {
let mut cfg = Config::default();
assert!(!cfg.invert_scroll("2b042"));
cfg.set_invert_scroll("2b042", true);
let restored = write_and_read(&cfg);
assert!(restored.invert_scroll("2b042"));
assert!(!restored.invert_scroll("absent"));
}
#[test]
fn default_invert_scroll_is_omitted_from_toml() {
let mut cfg = Config::default();
cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
cfg.set_invert_scroll("2b042", false);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(
!body.contains("invert_scroll"),
"default invert_scroll should be omitted: {body}"
);
}
#[test]
fn scroll_resolution_roundtrips_all_three_states() {
let mut cfg = Config::default();
assert_eq!(cfg.scroll_resolution("mouse"), None);
cfg.set_scroll_resolution("mouse", Some(ScrollResolution::Low));
let low = write_and_read(&cfg);
assert_eq!(low.scroll_resolution("mouse"), Some(ScrollResolution::Low));
cfg.set_scroll_resolution("mouse", Some(ScrollResolution::High));
let high = write_and_read(&cfg);
assert_eq!(
high.scroll_resolution("mouse"),
Some(ScrollResolution::High)
);
cfg.set_scroll_resolution("mouse", None);
let unmanaged = write_and_read(&cfg);
assert_eq!(unmanaged.scroll_resolution("mouse"), None);
}
#[test]
fn unset_scroll_resolution_is_omitted_from_toml() {
let mut cfg = Config::default();
cfg.set_binding("mouse", ButtonId::Back, Binding::Single(Action::Copy));
cfg.set_scroll_resolution("mouse", Some(ScrollResolution::Low));
cfg.set_scroll_resolution("mouse", None);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(
!body.contains("scroll_resolution"),
"unset scroll resolution should be omitted: {body}"
);
}
#[test]
fn config_without_scroll_resolution_loads_as_unmanaged() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(
&path,
r"
schema_version = 3
[devices.mouse]
invert_scroll = true
",
)
.expect("write config");
let cfg = Config::load_from_path(&path).expect("load existing config");
assert_eq!(cfg.scroll_resolution("mouse"), None);
assert!(cfg.invert_scroll("mouse"));
}
#[test]
fn bindings_roundtrip_per_device() {
let mut cfg = Config::default();
cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
cfg.set_binding(
"2b042",
ButtonId::DpiToggle,
Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
modifiers: crate::binding::KeyCombo::MOD_CMD,
key_code: 0x23, display: "⌘P".into(),
})),
);
cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
let parsed = write_and_read(&cfg);
let a = parsed.bindings_for("2b042");
assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
assert_eq!(
a.get(&ButtonId::DpiToggle),
Some(&Binding::Single(Action::CustomShortcut(
crate::binding::KeyCombo {
modifiers: crate::binding::KeyCombo::MOD_CMD,
key_code: 0x23,
display: "⌘P".into(),
}
)))
);
let b = parsed.bindings_for("4082d");
assert_eq!(
b.get(&ButtonId::Back),
Some(&Binding::Single(Action::Paste))
);
assert_eq!(b.len(), 1, "device b should only see its own bindings");
assert!(parsed.bindings_for("deadbeef").is_empty());
}
#[test]
fn human_readable_toml_layout() {
let mut cfg = Config::default();
cfg.set_binding(
"2b042",
ButtonId::Back,
Binding::Single(Action::BrowserBack),
);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(body.contains("schema_version = 3"), "got: {body}");
assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
}
#[test]
fn dpi_presets_roundtrip_per_device() {
let mut cfg = Config::default();
cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
cfg.set_dpi_presets("4082d", vec![400, 1600]);
let parsed = write_and_read(&cfg);
assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
assert!(parsed.dpi_presets("unknown").is_empty());
}
#[test]
fn empty_dpi_presets_skip_serialization() {
let mut cfg = Config::default();
cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
cfg.set_dpi_presets("2b042", vec![800]);
cfg.set_dpi_presets("2b042", vec![]);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(
!body.contains("dpi_presets"),
"empty dpi_presets should be omitted: {body}"
);
}
#[test]
fn device_identity_roundtrips_and_is_iterable() {
use crate::device::{Capabilities, DeviceKind};
let mut cfg = Config::default();
let mouse = DeviceIdentity {
display_name: "MX Master 3S".to_string(),
model_info: None,
codename: None,
kind: DeviceKind::Mouse,
capabilities: Capabilities {
buttons: true,
pointer: true,
lighting: false,
scroll_inversion: false,
hires_wheel: true,
thumbwheel: false,
},
light_capabilities: None,
driver_id: None,
registry_model_id: None,
};
cfg.set_device_identity("2b034", mouse.clone());
cfg.set_binding(
"2b034",
ButtonId::Back,
Binding::Single(Action::BrowserBack),
);
let parsed = write_and_read(&cfg);
assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
assert_eq!(parsed.device_identity("absent"), None);
assert_eq!(
parsed.bindings_for("2b034").get(&ButtonId::Back),
Some(&Binding::Single(Action::BrowserBack)),
"identity must coexist with bindings on the same device block"
);
assert_eq!(
parsed.known_identities().collect::<Vec<_>>(),
vec![("2b034", &mouse)]
);
}
#[test]
fn selected_device_roundtrips() {
let mut cfg = Config::default();
assert_eq!(cfg.selected_device(), None);
cfg.set_selected_device(Some("2b042".into()));
let parsed = write_and_read(&cfg);
assert_eq!(parsed.selected_device(), Some("2b042"));
}
#[test]
fn per_app_overlay_takes_precedence() {
let mut cfg = Config::default();
cfg.set_binding(
"2b042",
ButtonId::Back,
Binding::Single(Action::BrowserBack),
);
cfg.set_binding(
"2b042",
ButtonId::Forward,
Binding::Single(Action::BrowserForward),
);
cfg.set_per_app_binding(
"2b042",
"com.microsoft.VSCode",
ButtonId::Back,
Some(Action::Undo),
);
let global = cfg.effective_bindings("2b042", None);
assert_eq!(
global.get(&ButtonId::Back),
Some(&Binding::Single(Action::BrowserBack))
);
assert_eq!(
global.get(&ButtonId::Forward),
Some(&Binding::Single(Action::BrowserForward))
);
let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
assert_eq!(
vscode.get(&ButtonId::Back),
Some(&Binding::Single(Action::Undo))
);
assert_eq!(
vscode.get(&ButtonId::Forward),
Some(&Binding::Single(Action::BrowserForward))
);
let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
assert_eq!(
other.get(&ButtonId::Back),
Some(&Binding::Single(Action::BrowserBack))
);
}
#[test]
fn per_app_binding_removal_prunes_empty_app() {
let mut cfg = Config::default();
cfg.set_per_app_binding(
"2b042",
"com.example.App",
ButtonId::Back,
Some(Action::Copy),
);
cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
assert!(
cfg.devices["2b042"].per_app_bindings.is_empty(),
"removing last override should prune the app entry"
);
}
#[test]
fn app_settings_default_omits_block() {
let cfg = Config::default();
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(
!body.contains("app_settings"),
"default app_settings should be omitted: {body}"
);
}
#[test]
fn app_settings_launch_at_login_roundtrips() {
let mut cfg = Config::default();
cfg.app_settings.launch_at_login = true;
let parsed = write_and_read(&cfg);
assert!(parsed.app_settings.launch_at_login);
}
#[test]
fn asset_source_preference_roundtrips() {
let mut cfg = Config::default();
cfg.app_settings.asset_source = AssetSourcePreference::OpenLogi;
let body = toml::to_string_pretty(&cfg).expect("serialize");
let parsed = write_and_read(&cfg);
assert!(body.contains("asset_source = \"openlogi\""));
assert_eq!(
parsed.app_settings.asset_source,
AssetSourcePreference::OpenLogi
);
}
#[test]
fn config_without_asset_source_keeps_automatic_selection() {
let parsed: Config = toml::from_str(
r"
schema_version = 3
[app_settings]
auto_download_assets = false
",
)
.expect("config predating the asset-source setting loads");
assert_eq!(
parsed.app_settings.asset_source,
AssetSourcePreference::Automatic
);
}
#[test]
fn cleared_selected_device_omits_field() {
let mut cfg = Config::default();
cfg.set_selected_device(Some("2b042".into()));
cfg.set_selected_device(None);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(
!body.contains("selected_device"),
"cleared selection should not appear: {body}"
);
}
#[test]
fn empty_device_block_is_skipped_in_output() {
let mut cfg = Config::default();
cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
cfg.devices
.get_mut("2b042")
.expect("entry")
.bindings
.clear();
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(
!body.contains("Back"),
"cleared bindings should not appear: {body}"
);
}
#[test]
fn migrates_v1_button_and_gesture_bindings() {
let v1 = "\
schema_version = 1
[devices.2b042.button_bindings]
Back = \"BrowserBack\"
[devices.2b042.gesture_bindings]
Up = \"Copy\"
Click = \"Paste\"
";
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, v1).expect("write");
let cfg = Config::load_from_path(&path).expect("load v1");
let bindings = cfg.bindings_for("2b042");
assert_eq!(
bindings.get(&ButtonId::Back),
Some(&Binding::Single(Action::BrowserBack))
);
let mut gesture = BTreeMap::new();
gesture.insert(GestureDirection::Up, Action::Copy);
gesture.insert(GestureDirection::Click, Action::Paste);
assert_eq!(
bindings.get(&ButtonId::GestureButton),
Some(&Binding::Gesture(gesture))
);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(body.contains("schema_version = 3"), "got: {body}");
assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
assert!(!body.contains("button_bindings"), "got: {body}");
assert!(!body.contains("gesture_bindings"), "got: {body}");
}
#[test]
fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
let v1 = "\
schema_version = 1
[devices.2b042.button_bindings]
GestureButton = \"MissionControl\"
[devices.2b042.gesture_bindings]
Up = \"Copy\"
Down = \"Paste\"
";
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, v1).expect("write");
let cfg = Config::load_from_path(&path).expect("load v1");
let mut gesture = BTreeMap::new();
gesture.insert(GestureDirection::Up, Action::Copy);
gesture.insert(GestureDirection::Down, Action::Paste);
assert_eq!(
cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
Some(&Binding::Gesture(gesture)),
"gesture map must win over the legacy single GestureButton entry"
);
}
#[test]
fn migration_drops_vestigial_lone_gesture_button_single() {
let v1 = "\
schema_version = 1
[devices.2b042.button_bindings]
GestureButton = \"MissionControl\"
Back = \"BrowserBack\"
";
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, v1).expect("write");
let bindings = Config::load_from_path(&path)
.expect("load v1")
.bindings_for("2b042");
assert_eq!(
bindings.get(&ButtonId::Back),
Some(&Binding::Single(Action::BrowserBack))
);
assert_eq!(bindings.get(&ButtonId::GestureButton), None);
}
#[test]
fn rejects_newer_schema_version_but_accepts_v1() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, "schema_version = 99\n").expect("write");
assert_matches!(
Config::load_from_path(&path).expect_err("v99 should fail"),
ConfigError::UnsupportedSchemaVersion { found: 99, .. }
);
fs::write(&path, "schema_version = 1\n").expect("write");
assert!(
Config::load_from_path(&path).is_ok(),
"v1 should still load"
);
}
#[test]
fn set_gesture_direction_upgrades_single_to_gesture() {
let mut cfg = Config::default();
cfg.set_binding(
"2b042",
ButtonId::Back,
Binding::Single(Action::BrowserBack),
);
cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
match cfg.bindings_for("2b042").get(&ButtonId::Back) {
Some(Binding::Gesture(map)) => {
assert_eq!(
map.get(&GestureDirection::Click),
Some(&Action::BrowserBack)
);
assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
}
other => panic!("expected Gesture after upgrade, got {other:?}"),
}
}
#[test]
fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
let mut cfg = Config::default();
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
GestureDirection::Up,
Action::Copy,
);
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
assert_eq!(
map.get(&GestureDirection::Click),
Some(&crate::binding::default_gesture_binding(
GestureDirection::Click
)),
"a fresh gesture button must seed a Click from its default"
);
}
other => panic!("expected Gesture, got {other:?}"),
}
}
#[test]
fn gesture_owner_defaults_to_hidpp_button_yields_to_oshook_and_can_be_off() {
let mut cfg = Config::default();
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
GestureDirection::Up,
Action::MissionControl,
);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
cfg.set_binding(
"2b042",
ButtonId::Forward,
Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
let mut off = Config::default();
off.disable_gestures("2b042");
assert_eq!(off.gesture_owner("2b042"), None);
}
#[test]
fn set_gesture_owner_records_owner_without_destroying_other_maps() {
let mut cfg = Config::default();
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
GestureDirection::Up,
Action::Copy,
);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
cfg.set_gesture_owner("2b042", ButtonId::Back);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
let bindings = cfg.bindings_for("2b042");
match bindings.get(&ButtonId::Back) {
Some(Binding::Gesture(map)) => {
assert_eq!(
map.get(&GestureDirection::Click),
Some(&Action::BrowserBack)
);
assert_eq!(
map.get(&GestureDirection::Up),
Some(&default_gesture_binding(GestureDirection::Up)),
"a promoted button gets full default arms"
);
}
other => panic!("expected Back to be a gesture binding, got {other:?}"),
}
match bindings.get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
}
other => panic!("expected the HID++ gesture button map preserved, got {other:?}"),
}
cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
}
other => panic!("expected preserved gesture map, got {other:?}"),
}
}
#[test]
fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
let mut cfg = Config::default();
cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
for dir in GestureDirection::ALL {
assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
}
}
other => panic!("expected full default gesture map, got {other:?}"),
}
cfg.set_gesture_owner("2b042", ButtonId::Forward);
match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
Some(Binding::Gesture(map)) => {
assert_eq!(
map.get(&GestureDirection::Click),
Some(&default_binding(ButtonId::Forward))
);
for dir in [
GestureDirection::Up,
GestureDirection::Down,
GestureDirection::Left,
GestureDirection::Right,
] {
assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
}
}
other => panic!("expected full gesture map for Forward, got {other:?}"),
}
}
#[test]
fn disable_gestures_turns_off_without_destroying_maps() {
let mut cfg = Config::default();
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
GestureDirection::Up,
Action::Copy,
);
cfg.disable_gestures("2b042");
assert_eq!(cfg.gesture_owner("2b042"), None);
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
}
other => panic!("expected the gesture map preserved while off, got {other:?}"),
}
}
#[test]
fn gesture_owner_field_roundtrips_as_a_scalar() {
let mut cfg = Config::default();
cfg.set_gesture_owner("2b042", ButtonId::Back); cfg.disable_gestures("4082d");
let parsed = write_and_read(&cfg);
assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
assert_eq!(parsed.gesture_owner("4082d"), None);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
}
#[test]
fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
let toml = "\
schema_version = 2
[devices.2b042]
gesture_owner = \"bogus\"
[devices.2b042.bindings]
Back = \"Copy\"
";
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, toml).expect("write");
let cfg =
Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
assert_eq!(
cfg.bindings_for("2b042").get(&ButtonId::Back),
Some(&Binding::Single(Action::Copy))
);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
}