1#[cfg(test)]
9use crate::binding::{Action, ButtonId};
10use crate::config::{Config, DeviceConfig};
11#[cfg(test)]
12use crate::config::{LightSettings, Lighting, LinkConfig};
13use crate::device::Capabilities;
14use crate::device_order::{DeviceIdentity, DeviceStableId, PhysicalDeviceKey};
15#[cfg(test)]
16use crate::hid::Dpi;
17
18impl DeviceIdentity {
19 #[must_use]
22 pub fn config_key(&self) -> Option<String> {
23 self.is_physical().then(|| self.key())
24 }
25}
26
27#[must_use]
36pub fn canonical_device_key(
37 stable: &DeviceStableId,
38 identity: Option<&DeviceIdentity>,
39) -> Option<PhysicalDeviceKey> {
40 identity
41 .and_then(DeviceIdentity::config_key)
42 .and_then(|key| PhysicalDeviceKey::parse(&key))
43 .or_else(|| stable.physical_key())
44}
45
46impl Config {
47 #[cfg(feature = "fs")]
64 pub(super) fn repair_duplicate_routes(&mut self) {
65 let mut owners = std::collections::BTreeMap::<String, String>::new();
66 for (device_key, device) in &mut self.devices {
67 device.links.retain(|route_key, _| {
68 let Some(owner) = owners.get(route_key) else {
69 owners.insert(route_key.clone(), device_key.clone());
70 return true;
71 };
72 tracing::warn!(
73 %route_key,
74 %owner,
75 duplicate_owner = %device_key,
76 "route is indexed by multiple devices; keeping the first owner"
77 );
78 false
79 });
80 }
81 }
82
83 #[must_use]
108 pub fn resolve_device_key(
109 &self,
110 stable: &DeviceStableId,
111 identity: Option<&DeviceIdentity>,
112 ) -> Option<PhysicalDeviceKey> {
113 if let Some(key) = identity
114 .and_then(DeviceIdentity::config_key)
115 .and_then(|key| PhysicalDeviceKey::parse(&key))
116 {
117 let holds_settings = |key: &str| {
118 self.devices
119 .get(key)
120 .is_some_and(DeviceConfig::holds_settings)
121 };
122 let legacy = stable
123 .physical_key()
124 .filter(|legacy| legacy.as_str() != key.as_str());
125 if let Some(legacy) = legacy
126 && !holds_settings(key.as_str())
127 && holds_settings(legacy.as_str())
128 {
129 return Some(legacy);
130 }
131 return Some(key);
132 }
133 let route = stable.route_key();
134 if let Some(key) = self
135 .devices
136 .iter()
137 .find(|(_, device)| device.links.contains_key(&route))
138 .map(|(key, _)| key.as_str())
139 .and_then(PhysicalDeviceKey::parse)
140 {
141 return Some(key);
142 }
143 stable.physical_key()
144 }
145
146 pub fn adopt_route(
176 &mut self,
177 canonical: &PhysicalDeviceKey,
178 route_key: &str,
179 capabilities: Option<Capabilities>,
180 ) -> bool {
181 let mut changed = false;
182 for (key, device) in &mut self.devices {
185 if key != canonical.as_str() && device.links.remove(route_key).is_some() {
186 changed = true;
187 }
188 }
189 let legacy = (route_key != canonical.as_str())
190 .then(|| self.devices.remove(route_key))
191 .flatten();
192 let device = self
193 .devices
194 .entry(canonical.as_str().to_string())
195 .or_default();
196 if !device.links.contains_key(route_key) {
197 changed = true;
198 }
199 let link = device.links.entry(route_key.to_string()).or_default();
200 if capabilities.is_some() && link.capabilities != capabilities {
204 link.capabilities = capabilities;
205 changed = true;
206 }
207 let Some(legacy) = legacy else {
208 return changed;
209 };
210 fold(device, legacy, route_key);
211 self.repoint_references(route_key, canonical.as_str());
212 true
213 }
214
215 fn repoint_references(&mut self, old: &str, new: &str) {
218 if self.selected_device.as_deref() == Some(old) {
219 self.selected_device = Some(new.to_string());
220 }
221 for device in self.devices.values_mut() {
222 for target in &mut device.host_switch_targets {
223 if target == old {
224 *target = new.to_string();
225 }
226 }
227 }
228 }
229}
230
231pub(super) fn fold(device: &mut DeviceConfig, mut legacy: DeviceConfig, route_key: &str) {
248 for (key, value) in std::mem::take(&mut legacy.links) {
249 device.links.entry(key).or_insert(value);
250 }
251 fold_maps(device, &mut legacy, route_key);
252
253 let link = device.links.entry(route_key.to_string()).or_default();
254 if let Some(capabilities) = legacy
255 .identity
256 .as_ref()
257 .map(|identity| identity.capabilities)
258 {
259 link.capabilities.get_or_insert(capabilities);
263 }
264
265 macro_rules! fold_field {
269 ($field:ident) => {
270 match (&device.$field, legacy.$field) {
271 (None, Some(value)) => device.$field = Some(value),
272 (Some(ours), Some(theirs)) if *ours != theirs => {
273 link.overrides.$field = Some(theirs);
274 }
275 _ => {}
276 }
277 };
278 }
279 fold_field!(dpi);
280 fold_field!(scroll_resolution);
281 fold_field!(lighting);
282 fold_field!(smartshift);
283
284 if device.invert_scroll != legacy.invert_scroll && legacy.invert_scroll {
290 link.overrides.invert_scroll = Some(legacy.invert_scroll);
291 }
292
293 macro_rules! fold_option_field {
298 ($field:ident) => {
299 match (&device.$field, legacy.$field) {
300 (None, Some(value)) => device.$field = Some(value),
301 (Some(ours), Some(theirs)) if *ours != theirs => {
302 tracing::warn!(
303 %route_key,
304 field = stringify!($field),
305 "value differs between merged entries; keeping the canonical one"
306 );
307 }
308 _ => {}
309 }
310 };
311 }
312 fold_option_field!(light);
313 fold_option_field!(camera_controls);
314 fold_option_field!(camera_profile);
315 fold_option_field!(thumbwheel_sensitivity);
316 fold_option_field!(fn_lock);
317 fold_option_field!(custom_name);
321
322 if device.identity.is_none() {
323 device.identity = legacy.identity.take();
324 }
325
326 macro_rules! fold_if_empty {
332 ($field:ident) => {
333 if device.$field.is_empty() {
334 device.$field = legacy.$field;
335 } else if !legacy.$field.is_empty() && device.$field != legacy.$field {
336 tracing::warn!(
337 %route_key,
338 field = stringify!($field),
339 "list differs between merged entries; keeping the canonical one"
340 );
341 }
342 };
343 }
344 fold_if_empty!(dpi_presets);
345 fold_if_empty!(host_switch_targets);
346
347 if device.action_ring.is_default() {
351 device.action_ring = legacy.action_ring.clone();
352 } else if !legacy.action_ring.is_default() && device.action_ring != legacy.action_ring {
353 tracing::warn!(
354 %route_key,
355 field = "action_ring",
356 "ring differs between merged entries; keeping the canonical one"
357 );
358 }
359
360 if device.enabled && !legacy.enabled {
364 device.enabled = false;
365 }
366}
367
368fn fold_maps(device: &mut DeviceConfig, legacy: &mut DeviceConfig, route_key: &str) {
375 macro_rules! fold_map_field {
376 ($field:ident) => {
377 for (key, value) in std::mem::take(&mut legacy.$field) {
378 if let Some(existing) = device.$field.get(&key) {
379 if *existing != value {
380 tracing::warn!(
381 %route_key,
382 field = stringify!($field),
383 key = ?key,
384 "entry differs between merged entries; keeping the canonical one"
385 );
386 }
387 } else {
388 device.$field.insert(key, value);
389 }
390 }
391 };
392 }
393 fold_map_field!(bindings);
394 fold_map_field!(disabled_gestures);
395 fold_map_field!(per_app_bindings);
396 fold_map_field!(camera_profiles);
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use crate::device_order::{DeviceIdentity, DeviceStableId};
403
404 fn cabled() -> DeviceStableId {
405 DeviceStableId::Direct {
406 vendor_id: 0x046d,
407 product_id: 0xc08d,
408 identity: DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]),
409 }
410 }
411
412 fn on_receiver() -> DeviceStableId {
413 DeviceStableId::Bolt {
414 receiver_uid: "82839805".to_string(),
415 slot: 1,
416 }
417 }
418
419 #[test]
420 fn a_known_unit_keys_the_entry_directly() {
421 let config = Config::default();
422 let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
423 let key = config
424 .resolve_device_key(&cabled(), Some(&unit))
425 .expect("a non-zero unit is a physical identity");
426 assert_eq!(key.as_str(), "unit:6be9d300");
427 }
428
429 #[test]
430 fn the_same_unit_resolves_alike_on_either_route() {
431 let config = Config::default();
433 let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
434 assert_eq!(
435 config.resolve_device_key(&cabled(), Some(&unit)),
436 config.resolve_device_key(&on_receiver(), Some(&unit)),
437 );
438 }
439
440 fn litra() -> DeviceStableId {
441 DeviceStableId::RawHid {
442 vendor_id: 0x046d,
443 product_id: 0xc901,
444 usage_page: 0xff43,
445 usage_id: 0x0202,
446 identity: "serial:beam-1".to_string(),
447 }
448 }
449
450 #[test]
451 fn settings_still_under_the_pre_upgrade_key_are_read_from_it() {
452 let mut config = Config::default();
458 config.devices.insert(
459 "receiver:82839805:slot:1".to_string(),
460 DeviceConfig {
461 dpi: Some(Dpi::new(3200)),
462 ..DeviceConfig::default()
463 },
464 );
465
466 let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
467 let key = config
468 .resolve_device_key(&on_receiver(), Some(&unit))
469 .expect("resolves");
470 assert_eq!(key.as_str(), "receiver:82839805:slot:1");
471 assert_eq!(
472 config.devices[key.as_str()].dpi,
473 Some(Dpi::new(3200)),
474 "the settings are where the key says they are"
475 );
476 }
477
478 #[test]
479 fn a_standalone_raw_entry_keeps_its_light_settings() {
480 let mut config = Config::default();
484 config.devices.insert(
485 "raw:046d:c901:ff43:0202:serial:beam-1".to_string(),
486 DeviceConfig {
487 light: Some(LightSettings::default()),
488 ..DeviceConfig::default()
489 },
490 );
491
492 let serial = DeviceIdentity::Serial("beam-1".to_string());
493 let key = config
494 .resolve_device_key(&litra(), Some(&serial))
495 .expect("resolves");
496 assert_eq!(key.as_str(), "raw:046d:c901:ff43:0202:serial:beam-1");
497 }
498
499 #[test]
500 fn a_canonical_entry_holding_only_metadata_does_not_shadow_the_legacy_one() {
501 let mut config = Config::default();
505 config.devices.insert(
506 "receiver:82839805:slot:1".to_string(),
507 DeviceConfig {
508 dpi: Some(Dpi::new(3200)),
509 ..DeviceConfig::default()
510 },
511 );
512 let mut bare = DeviceConfig::default();
513 bare.links.insert(
514 "receiver:82839805:slot:1".to_string(),
515 LinkConfig::default(),
516 );
517 config.devices.insert("unit:6be9d300".to_string(), bare);
518
519 let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
520 let key = config
521 .resolve_device_key(&on_receiver(), Some(&unit))
522 .expect("resolves");
523 assert_eq!(key.as_str(), "receiver:82839805:slot:1");
524 }
525
526 #[test]
527 fn a_canonical_entry_with_settings_wins_over_a_legacy_one() {
528 let mut config = Config::default();
532 config.devices.insert(
533 "receiver:82839805:slot:1".to_string(),
534 DeviceConfig {
535 dpi: Some(Dpi::new(3200)),
536 ..DeviceConfig::default()
537 },
538 );
539 config.devices.insert(
540 "unit:6be9d300".to_string(),
541 DeviceConfig {
542 dpi: Some(Dpi::new(800)),
543 ..DeviceConfig::default()
544 },
545 );
546
547 let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
548 let key = config
549 .resolve_device_key(&on_receiver(), Some(&unit))
550 .expect("resolves");
551 assert_eq!(key.as_str(), "unit:6be9d300");
552 }
553
554 #[test]
555 fn the_route_key_is_the_legacy_entry_key_for_every_variant_but_direct() {
556 for stable in [on_receiver(), litra()] {
563 let physical = stable.physical_key().expect("physical");
564 assert_eq!(
565 stable.route_key(),
566 physical.as_str(),
567 "{stable:?}: the route key must find the legacy entry"
568 );
569 }
570 let unknown = DeviceStableId::Unknown {
571 slot: 2,
572 identity: DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]),
573 };
574 assert_eq!(
575 unknown.route_key(),
576 unknown.physical_key().expect("physical").as_str()
577 );
578
579 let direct = cabled();
580 assert_ne!(
581 direct.route_key(),
582 direct.physical_key().expect("physical").as_str(),
583 "a direct route key drops the identity that keys the entry"
584 );
585 }
586
587 #[test]
588 fn adoption_repoints_the_selection_and_any_host_switch_target() {
589 let mut config = Config::default();
593 config.devices.insert(
594 "receiver:82839805:slot:1".to_string(),
595 DeviceConfig {
596 dpi: Some(Dpi::new(800)),
597 ..DeviceConfig::default()
598 },
599 );
600 config.devices.insert(
601 "receiver:82839805:slot:2".to_string(),
602 DeviceConfig {
603 host_switch_targets: vec!["receiver:82839805:slot:1".to_string()],
604 ..DeviceConfig::default()
605 },
606 );
607 config.selected_device = Some("receiver:82839805:slot:1".to_string());
608
609 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
610 assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
611
612 assert_eq!(config.selected_device.as_deref(), Some("unit:6be9d300"));
613 assert_eq!(
614 config.devices["receiver:82839805:slot:2"].host_switch_targets,
615 vec!["unit:6be9d300".to_string()],
616 "the keyboard still switches the mouse it was linked to"
617 );
618 }
619
620 #[test]
621 fn an_asleep_device_resolves_through_its_indexed_route() {
622 let mut config = Config::default();
626 let mut device = DeviceConfig::default();
627 device.links.insert(
628 "receiver:82839805:slot:1".to_string(),
629 LinkConfig::default(),
630 );
631 config.devices.insert("unit:6be9d300".to_string(), device);
632
633 let key = config
634 .resolve_device_key(&on_receiver(), None)
635 .expect("the indexed route resolves");
636 assert_eq!(key.as_str(), "unit:6be9d300");
637 }
638
639 #[test]
640 fn an_unindexed_route_falls_back_to_todays_key() {
641 let config = Config::default();
642 let key = config
643 .resolve_device_key(&on_receiver(), None)
644 .expect("receiver keys are physical");
645 assert_eq!(key.as_str(), "receiver:82839805:slot:1");
646 }
647
648 #[test]
649 fn an_all_zero_unit_over_a_receiver_keeps_its_route_key() {
650 let config = Config::default();
653 let zero = DeviceIdentity::Unit([0; 4]);
654 let key = config
655 .resolve_device_key(&on_receiver(), Some(&zero))
656 .expect("falls back rather than failing");
657 assert_eq!(key.as_str(), "receiver:82839805:slot:1");
658 }
659
660 #[test]
661 fn a_direct_device_with_no_identity_stays_non_persistent() {
662 let config = Config::default();
663 let anonymous = DeviceStableId::Direct {
664 vendor_id: 0x046d,
665 product_id: 0xc08d,
666 identity: DeviceIdentity::Unit([0; 4]),
667 };
668 assert_eq!(config.resolve_device_key(&anonymous, None), None);
669 }
670
671 #[test]
672 fn folding_takes_a_custom_name_the_canonical_entry_lacks() {
673 let mut config = Config::default();
677 let legacy = DeviceConfig {
678 custom_name: Some("Desk mouse".to_string()),
679 ..DeviceConfig::default()
680 };
681 config
682 .devices
683 .insert("receiver:82839805:slot:1".to_string(), legacy);
684 config
685 .devices
686 .insert("unit:6be9d300".to_string(), DeviceConfig::default());
687
688 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
689 assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
690
691 assert_eq!(
692 config.devices["unit:6be9d300"].custom_name.as_deref(),
693 Some("Desk mouse"),
694 "the user's alias survives the fold"
695 );
696 }
697
698 #[test]
699 fn folding_keeps_the_canonical_custom_name_when_both_are_named() {
700 let mut config = Config::default();
704 let legacy = DeviceConfig {
705 custom_name: Some("Old name".to_string()),
706 ..DeviceConfig::default()
707 };
708 let canonical_entry = DeviceConfig {
709 custom_name: Some("Current name".to_string()),
710 ..DeviceConfig::default()
711 };
712 config
713 .devices
714 .insert("receiver:82839805:slot:1".to_string(), legacy);
715 config
716 .devices
717 .insert("unit:6be9d300".to_string(), canonical_entry);
718
719 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
720 assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
721
722 assert_eq!(
723 config.devices["unit:6be9d300"].custom_name.as_deref(),
724 Some("Current name"),
725 "the canonical alias wins a genuine conflict"
726 );
727 }
728
729 #[test]
730 fn adopting_a_route_folds_the_legacy_entry_in() {
731 let mut config = Config::default();
734 let legacy = DeviceConfig {
735 lighting: Some(Lighting::default()),
736 ..DeviceConfig::default()
737 };
738 config
739 .devices
740 .insert("receiver:82839805:slot:1".to_string(), legacy);
741 config
742 .devices
743 .insert("unit:6be9d300".to_string(), DeviceConfig::default());
744
745 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
746 assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
747
748 let device = &config.devices["unit:6be9d300"];
749 assert!(device.lighting.is_some(), "lighting moved to device level");
750 assert!(device.links.contains_key("receiver:82839805:slot:1"));
751 assert!(
752 !config.devices.contains_key("receiver:82839805:slot:1"),
753 "the legacy entry is consumed, not left behind"
754 );
755 }
756
757 #[test]
758 fn a_sighting_records_what_the_link_measured() {
759 let mut config = Config::default();
763 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
764 let wired = Capabilities {
765 hires_wheel: false,
766 ..Capabilities::default()
767 };
768 let wireless = Capabilities {
769 hires_wheel: true,
770 ..Capabilities::default()
771 };
772
773 assert!(config.adopt_route(&canonical, "direct:046d:c08d", Some(wired)));
774 assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", Some(wireless)));
775
776 let links = &config.devices["unit:6be9d300"].links;
777 assert_eq!(links["direct:046d:c08d"].capabilities, Some(wired));
778 assert_eq!(
779 links["receiver:82839805:slot:1"].capabilities,
780 Some(wireless)
781 );
782
783 assert!(!config.adopt_route(&canonical, "direct:046d:c08d", Some(wired)));
786 assert!(!config.adopt_route(&canonical, "direct:046d:c08d", None));
788 assert_eq!(
789 config.devices["unit:6be9d300"].links["direct:046d:c08d"].capabilities,
790 Some(wired)
791 );
792 }
793
794 #[test]
795 fn a_conflicting_value_becomes_a_per_link_override() {
796 let mut config = Config::default();
800 let legacy = DeviceConfig {
801 dpi: Some(Dpi::new(800)),
802 ..DeviceConfig::default()
803 };
804 config
805 .devices
806 .insert("receiver:82839805:slot:1".to_string(), legacy);
807 let canonical_entry = DeviceConfig {
808 dpi: Some(Dpi::new(1600)),
809 ..DeviceConfig::default()
810 };
811 config
812 .devices
813 .insert("unit:6be9d300".to_string(), canonical_entry);
814
815 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
816 config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
817
818 let device = &config.devices["unit:6be9d300"];
819 assert_eq!(device.dpi, Some(Dpi::new(1600)), "canonical stays default");
820 assert_eq!(
821 device.links["receiver:82839805:slot:1"].overrides.dpi,
822 Some(Dpi::new(800)),
823 "the legacy value survives on its own link"
824 );
825 }
826
827 #[test]
828 fn adopting_an_unseen_route_registers_it_and_reports_a_change() {
829 let mut config = Config::default();
833 config
834 .devices
835 .insert("unit:6be9d300".to_string(), DeviceConfig::default());
836 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
837
838 assert!(config.adopt_route(&canonical, "direct:046d:c08d", None));
839 assert!(
840 config.devices["unit:6be9d300"]
841 .links
842 .contains_key("direct:046d:c08d")
843 );
844 }
845
846 #[test]
847 fn adopting_an_already_indexed_route_reports_no_change() {
848 let mut config = Config::default();
853 let mut device = DeviceConfig::default();
854 device
855 .links
856 .insert("direct:046d:c08d".to_string(), LinkConfig::default());
857 config.devices.insert("unit:6be9d300".to_string(), device);
858 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
859
860 assert!(!config.adopt_route(&canonical, "direct:046d:c08d", None));
861 }
862
863 #[test]
864 fn a_re_paired_slot_points_at_the_new_unit() {
865 let mut config = Config::default();
868 let mut first = DeviceConfig::default();
869 first.links.insert(
870 "receiver:82839805:slot:1".to_string(),
871 LinkConfig::default(),
872 );
873 config.devices.insert("unit:aaaaaaaa".to_string(), first);
874 config
875 .devices
876 .insert("unit:bbbbbbbb".to_string(), DeviceConfig::default());
877
878 let second = PhysicalDeviceKey::parse("unit:bbbbbbbb").expect("valid");
879 config.adopt_route(&second, "receiver:82839805:slot:1", None);
880
881 assert!(
882 !config.devices["unit:aaaaaaaa"]
883 .links
884 .contains_key("receiver:82839805:slot:1"),
885 "the route no longer points at the previous unit"
886 );
887 assert!(
888 config.devices["unit:bbbbbbbb"]
889 .links
890 .contains_key("receiver:82839805:slot:1")
891 );
892 }
893
894 #[test]
895 fn per_app_bindings_merge_key_by_key() {
896 let mut config = Config::default();
899 let legacy = DeviceConfig {
900 per_app_bindings: std::collections::BTreeMap::from([(
901 "com.example.App".to_string(),
902 std::collections::BTreeMap::from([(ButtonId::MiddleClick, Action::Copy)]),
903 )]),
904 ..DeviceConfig::default()
905 };
906 config
907 .devices
908 .insert("receiver:82839805:slot:1".to_string(), legacy);
909 config
910 .devices
911 .insert("unit:6be9d300".to_string(), DeviceConfig::default());
912
913 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
914 config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
915
916 let device = &config.devices["unit:6be9d300"];
917 assert_eq!(
918 device.per_app_bindings.get("com.example.App"),
919 Some(&std::collections::BTreeMap::from([(
920 ButtonId::MiddleClick,
921 Action::Copy
922 )])),
923 "the app overlay survives adoption"
924 );
925 }
926
927 #[test]
928 fn light_is_taken_when_the_canonical_side_has_none() {
929 let mut config = Config::default();
932 let legacy = DeviceConfig {
933 light: Some(LightSettings::default()),
934 ..DeviceConfig::default()
935 };
936 config
937 .devices
938 .insert("receiver:82839805:slot:1".to_string(), legacy);
939 config
940 .devices
941 .insert("unit:6be9d300".to_string(), DeviceConfig::default());
942
943 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
944 config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
945
946 assert_eq!(
947 config.devices["unit:6be9d300"].light,
948 Some(LightSettings::default()),
949 "the standalone-light setting is not silently discarded"
950 );
951 }
952
953 #[test]
954 fn a_legacy_disabled_flag_disables_the_canonical_entry() {
955 let mut config = Config::default();
959 let legacy = DeviceConfig {
960 enabled: false,
961 ..DeviceConfig::default()
962 };
963 config
964 .devices
965 .insert("receiver:82839805:slot:1".to_string(), legacy);
966 config
967 .devices
968 .insert("unit:6be9d300".to_string(), DeviceConfig::default());
969
970 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
971 config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
972
973 assert!(
974 !config.devices["unit:6be9d300"].enabled,
975 "the legacy opt-out survives adoption"
976 );
977 }
978}