#![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 first_save_preserves_the_previous_config_for_recovery() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
let backup = dir.path().join("config.toml.backup.1");
let original = b"schema_version = 3\nselected_device = \"original\"\n";
fs::write(&path, original).expect("write original config");
let mut config = Config {
selected_device: Some("replacement".to_string()),
..Config::default()
};
config.save_to_path(&path).expect("save replacement");
assert_eq!(fs::read(&backup).expect("read backup"), original);
config.selected_device = Some("second-save".to_string());
config.save_to_path(&path).expect("save again");
assert_eq!(
fs::read(&backup).expect("read original backup"),
original,
"later saves in one process must not replace the recovery copy"
);
}
#[test]
fn config_backups_rotate_between_generations() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, b"first").expect("write first generation");
super::backup_existing_config(&path).expect("back up first generation");
fs::write(&path, b"second").expect("write second generation");
super::backup_existing_config(&path).expect("back up second generation");
assert_eq!(
fs::read(super::config_backup_path(&path, 1).expect("backup path"))
.expect("read newest backup"),
b"second"
);
assert_eq!(
fs::read(super::config_backup_path(&path, 2).expect("backup path"))
.expect("read older backup"),
b"first"
);
}
#[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(
"Cmd+P"
.parse()
.unwrap_or_else(|error| panic!("valid shortcut failed: {error}")),
)),
);
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(
"Cmd+P"
.parse()
.unwrap_or_else(|error| panic!("valid shortcut failed: {error}"))
)))
);
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 = 4"), "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,
haptic_feedback: false,
haptic_panel: 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 windows_exe_selector_matches_versioned_path() {
let mut cfg = Config::default();
cfg.set_binding(
"2b042",
ButtonId::Back,
Binding::Single(Action::BrowserBack),
);
cfg.set_per_app_binding(
"2b042",
"exe:sharex.exe",
ButtonId::Back,
Some(Action::Copy),
);
cfg.set_per_app_binding(
"2b042",
"exe:sharex.exe",
ButtonId::Forward,
Some(Action::Paste),
);
let store_path = r"c:\program files\windowsapps\sharex_14.0.0.0_x64__abc\sharex.exe";
let effective = cfg.effective_bindings("2b042", Some(store_path));
assert_eq!(
effective.get(&ButtonId::Back),
Some(&Binding::Single(Action::Copy))
);
assert_eq!(
effective.get(&ButtonId::Forward),
Some(&Binding::Single(Action::Paste))
);
assert!(cfg.has_app_override("2b042", store_path));
let unixish = r"c:/tools/sharex/sharex.exe";
assert_eq!(
cfg.effective_bindings("2b042", Some(unixish))
.get(&ButtonId::Back),
Some(&Binding::Single(Action::Copy))
);
let mixed = r"C:\Tools\ShareX\ShareX.EXE";
assert_eq!(
cfg.effective_bindings("2b042", Some(mixed))
.get(&ButtonId::Back),
Some(&Binding::Single(Action::Copy))
);
}
#[test]
fn windows_exe_selector_exact_path_takes_precedence() {
let mut cfg = Config::default();
let exact = r"c:\program files\windowsapps\sharex_14.0.0.0_x64__abc\sharex.exe";
cfg.set_per_app_binding(
"2b042",
"exe:sharex.exe",
ButtonId::Back,
Some(Action::Copy),
);
cfg.set_per_app_binding("2b042", exact, ButtonId::Back, Some(Action::Undo));
assert_eq!(
cfg.effective_bindings("2b042", Some(exact))
.get(&ButtonId::Back),
Some(&Binding::Single(Action::Undo))
);
let other = r"c:\program files\windowsapps\sharex_15.0.0.0_x64__abc\sharex.exe";
assert_eq!(
cfg.effective_bindings("2b042", Some(other))
.get(&ButtonId::Back),
Some(&Binding::Single(Action::Copy))
);
}
#[test]
fn windows_exe_selector_ignores_non_exe_identifiers() {
let mut cfg = Config::default();
cfg.set_binding(
"2b042",
ButtonId::Back,
Binding::Single(Action::BrowserBack),
);
cfg.set_per_app_binding("2b042", "exe:code.exe", ButtonId::Back, Some(Action::Undo));
assert_eq!(
cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"))
.get(&ButtonId::Back),
Some(&Binding::Single(Action::BrowserBack))
);
assert!(!cfg.has_app_override("2b042", "com.microsoft.VSCode"));
}
#[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 = 4"), "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 set_gesture_mode_seeds_a_fresh_button_with_full_directions() {
let mut cfg = Config::default();
cfg.set_gesture_mode("2b042", ButtonId::GestureButton, true);
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_mode("2b042", ButtonId::Forward, true);
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:?}"),
}
assert!(cfg.is_gesture_mode("2b042", ButtonId::GestureButton));
assert!(cfg.is_gesture_mode("2b042", ButtonId::Forward));
}
#[test]
fn gesture_state_roundtrips_through_shapes_without_the_owner_field() {
let mut cfg = Config::default();
cfg.set_gesture_mode("2b042", ButtonId::Back, true);
cfg.set_gesture_mode("4082d", ButtonId::GestureButton, false);
let parsed = write_and_read(&cfg);
assert!(parsed.is_gesture_mode("2b042", ButtonId::Back));
assert!(
parsed.is_gesture_mode("2b042", ButtonId::GestureButton),
"the dedicated button's default gesture mode is untouched by Back's promotion"
);
assert!(parsed.gesture_mode_buttons("4082d").is_empty());
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(!body.contains("gesture_owner"), "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!(cfg.is_gesture_mode("2b042", ButtonId::GestureButton));
}
#[test]
fn gesture_mode_is_per_button_and_not_exclusive() {
let mut cfg = Config::default();
cfg.set_gesture_mode("2b042", ButtonId::MiddleClick, true);
assert!(cfg.is_gesture_mode("2b042", ButtonId::GestureButton));
assert!(cfg.is_gesture_mode("2b042", ButtonId::MiddleClick));
let buttons = cfg.gesture_mode_buttons("2b042");
assert!(
buttons.contains(&ButtonId::GestureButton),
"got: {buttons:?}"
);
assert!(buttons.contains(&ButtonId::MiddleClick), "got: {buttons:?}");
}
#[test]
fn set_gesture_mode_on_keeps_click_and_seeds_directions() {
let mut cfg = Config::default();
cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
cfg.set_gesture_mode("2b042", ButtonId::Back, true);
let bindings = cfg.bindings_for("2b042");
let Some(Binding::Gesture(map)) = bindings.get(&ButtonId::Back) else {
panic!(
"expected a gesture binding, got {:?}",
bindings.get(&ButtonId::Back)
);
};
assert_eq!(map.get(&GestureDirection::Click), Some(&Action::Copy));
for dir in [
GestureDirection::Up,
GestureDirection::Down,
GestureDirection::Left,
GestureDirection::Right,
] {
assert_eq!(
map.get(&dir),
Some(&default_gesture_binding(dir)),
"unseeded arm {dir:?}"
);
}
}
#[test]
fn set_gesture_mode_off_demotes_to_the_click_action() {
let mut cfg = Config::default();
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
GestureDirection::Click,
Action::Paste,
);
cfg.set_gesture_mode("2b042", ButtonId::GestureButton, false);
assert!(!cfg.is_gesture_mode("2b042", ButtonId::GestureButton));
assert_eq!(
cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
Some(&Binding::Single(Action::Paste))
);
}
#[test]
fn set_gesture_mode_off_without_click_falls_back_to_the_default() {
let mut cfg = Config::default();
let mut map = BTreeMap::new();
map.insert(GestureDirection::Up, Action::Copy);
cfg.set_binding("2b042", ButtonId::Back, Binding::Gesture(map));
cfg.set_gesture_mode("2b042", ButtonId::Back, false);
assert_eq!(
cfg.bindings_for("2b042").get(&ButtonId::Back),
Some(&Binding::Single(Action::BrowserBack))
);
}
#[test]
fn migration_demotes_the_dormant_non_owner_gesture_maps() {
let toml = "\
schema_version = 3
[devices.2b042]
gesture_owner = \"MiddleClick\"
[devices.2b042.bindings]
GestureButton = { Up = \"Copy\", Click = \"Paste\" }
MiddleClick = { Up = \"MissionControl\", Click = \"MiddleClick\" }
";
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("load");
assert!(cfg.is_gesture_mode("2b042", ButtonId::MiddleClick));
assert!(!cfg.is_gesture_mode("2b042", ButtonId::GestureButton));
assert_eq!(
cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
Some(&Binding::Single(Action::Paste)),
"the dormant map demotes to its Click choice"
);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(!body.contains("gesture_owner"), "got: {body}");
}
#[test]
fn migration_off_pins_the_dedicated_button_out_of_gesture_mode() {
let toml = "\
schema_version = 3
[devices.2b042]
gesture_owner = \"Off\"
";
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("load");
assert!(!cfg.is_gesture_mode("2b042", ButtonId::GestureButton));
assert!(cfg.gesture_mode_buttons("2b042").is_empty());
assert_eq!(
cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
Some(&Binding::Single(default_binding(ButtonId::GestureButton)))
);
}
#[test]
fn migration_materializes_a_hidpp_owners_missing_map() {
let toml = "\
schema_version = 3
[devices.2b042]
gesture_owner = \"HapticPanel\"
";
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("load");
assert!(cfg.is_gesture_mode("2b042", ButtonId::HapticPanel));
match cfg.bindings_for("2b042").get(&ButtonId::HapticPanel) {
Some(Binding::Gesture(map)) => {
for dir in GestureDirection::ALL {
assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
}
}
other => panic!("expected the owner's materialized default map, got {other:?}"),
}
assert!(!cfg.is_gesture_mode("2b042", ButtonId::GestureButton));
}
#[test]
fn migration_replaces_a_hidpp_owners_single_with_the_default_map() {
let toml = "\
schema_version = 3
[devices.2b042]
gesture_owner = \"GestureButton\"
[devices.2b042.bindings]
GestureButton = \"CycleDpiPresets\"
";
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("load");
assert!(cfg.is_gesture_mode("2b042", ButtonId::GestureButton));
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(
map.get(&GestureDirection::Click),
Some(&default_gesture_binding(GestureDirection::Click)),
"v3 dispatched the seeded default Click, not the stored Single"
);
}
other => panic!("expected the owner's materialized default map, got {other:?}"),
}
}
#[test]
fn off_then_on_restores_customized_swipe_arms() {
let mut cfg = Config::default();
cfg.set_gesture_mode("2b042", ButtonId::GestureButton, true);
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
GestureDirection::Up,
Action::Copy,
);
cfg.set_gesture_mode("2b042", ButtonId::GestureButton, false);
assert!(!cfg.is_gesture_mode("2b042", ButtonId::GestureButton));
cfg.set_gesture_mode("2b042", ButtonId::GestureButton, true);
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(
map.get(&GestureDirection::Up),
Some(&Action::Copy),
"the customized arm survives an off/on round trip"
);
}
other => panic!("expected the restored gesture map, got {other:?}"),
}
}
#[test]
fn re_promoting_a_genuine_single_keeps_it_as_click() {
let mut cfg = Config::default();
cfg.set_binding(
"2b042",
ButtonId::GestureButton,
Binding::Single(default_binding(ButtonId::GestureButton)),
);
cfg.set_gesture_mode("2b042", ButtonId::GestureButton, true);
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(
map.get(&GestureDirection::Click),
Some(&default_binding(ButtonId::GestureButton)),
"the user's explicit action stays as Click"
);
}
other => panic!("expected a gesture binding, got {other:?}"),
}
}
#[test]
fn disabled_gesture_maps_survive_a_save_load_cycle() {
let mut cfg = Config::default();
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
GestureDirection::Down,
Action::Paste,
);
cfg.set_gesture_mode("2b042", ButtonId::GestureButton, false);
let mut restored = write_and_read(&cfg);
restored.set_gesture_mode("2b042", ButtonId::GestureButton, true);
match restored.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(map.get(&GestureDirection::Down), Some(&Action::Paste));
}
other => panic!("expected the persisted stash restored, got {other:?}"),
}
}
#[test]
fn migration_stashes_dormant_maps_for_re_enabling() {
let toml = "\
schema_version = 3
[devices.2b042]
gesture_owner = \"MiddleClick\"
[devices.2b042.bindings]
GestureButton = { Up = \"Copy\", Click = \"Paste\" }
MiddleClick = { Up = \"MissionControl\", Click = \"MiddleClick\" }
";
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, toml).expect("write");
let mut cfg = Config::load_from_path(&path).expect("load");
assert!(!cfg.is_gesture_mode("2b042", ButtonId::GestureButton));
cfg.set_gesture_mode("2b042", ButtonId::GestureButton, true);
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(
map.get(&GestureDirection::Up),
Some(&Action::Copy),
"the dormant map's arms come back on re-enable"
);
}
other => panic!("expected the stashed dormant map restored, got {other:?}"),
}
}
#[test]
fn migration_infers_the_owner_for_pre_field_configs() {
let toml = "\
schema_version = 2
[devices.2b042.bindings]
Back = { Up = \"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("load");
assert!(cfg.is_gesture_mode("2b042", ButtonId::Back));
assert!(!cfg.is_gesture_mode("2b042", ButtonId::GestureButton));
assert_eq!(
cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
Some(&Binding::Single(default_binding(ButtonId::GestureButton)))
);
}