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!(keep_os_layout);
318 fold_option_field!(report_rate);
319 fold_option_field!(custom_name);
323
324 if device.identity.is_none() {
325 device.identity = legacy.identity.take();
326 }
327
328 macro_rules! fold_if_empty {
334 ($field:ident) => {
335 if device.$field.is_empty() {
336 device.$field = legacy.$field;
337 } else if !legacy.$field.is_empty() && device.$field != legacy.$field {
338 tracing::warn!(
339 %route_key,
340 field = stringify!($field),
341 "list differs between merged entries; keeping the canonical one"
342 );
343 }
344 };
345 }
346 fold_if_empty!(dpi_presets);
347 fold_if_empty!(host_switch_targets);
348
349 if device.action_ring.is_default() {
353 device.action_ring = legacy.action_ring.clone();
354 } else if !legacy.action_ring.is_default() && device.action_ring != legacy.action_ring {
355 tracing::warn!(
356 %route_key,
357 field = "action_ring",
358 "ring differs between merged entries; keeping the canonical one"
359 );
360 }
361
362 if device.enabled && !legacy.enabled {
366 device.enabled = false;
367 }
368}
369
370fn fold_maps(device: &mut DeviceConfig, legacy: &mut DeviceConfig, route_key: &str) {
377 macro_rules! fold_map_field {
378 ($field:ident) => {
379 for (key, value) in std::mem::take(&mut legacy.$field) {
380 if let Some(existing) = device.$field.get(&key) {
381 if *existing != value {
382 tracing::warn!(
383 %route_key,
384 field = stringify!($field),
385 key = ?key,
386 "entry differs between merged entries; keeping the canonical one"
387 );
388 }
389 } else {
390 device.$field.insert(key, value);
391 }
392 }
393 };
394 }
395 fold_map_field!(bindings);
396 fold_map_field!(disabled_gestures);
397 fold_map_field!(per_app_bindings);
398 fold_map_field!(camera_profiles);
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::device_order::{DeviceIdentity, DeviceStableId};
405
406 fn cabled() -> DeviceStableId {
407 DeviceStableId::Direct {
408 vendor_id: 0x046d,
409 product_id: 0xc08d,
410 identity: DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]),
411 }
412 }
413
414 fn on_receiver() -> DeviceStableId {
415 DeviceStableId::Bolt {
416 receiver_uid: "82839805".to_string(),
417 slot: 1,
418 }
419 }
420
421 #[test]
422 fn a_known_unit_keys_the_entry_directly() {
423 let config = Config::default();
424 let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
425 let key = config
426 .resolve_device_key(&cabled(), Some(&unit))
427 .expect("a non-zero unit is a physical identity");
428 assert_eq!(key.as_str(), "unit:6be9d300");
429 }
430
431 #[test]
432 fn the_same_unit_resolves_alike_on_either_route() {
433 let config = Config::default();
435 let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
436 assert_eq!(
437 config.resolve_device_key(&cabled(), Some(&unit)),
438 config.resolve_device_key(&on_receiver(), Some(&unit)),
439 );
440 }
441
442 fn litra() -> DeviceStableId {
443 DeviceStableId::RawHid {
444 vendor_id: 0x046d,
445 product_id: 0xc901,
446 usage_page: 0xff43,
447 usage_id: 0x0202,
448 identity: "serial:beam-1".to_string(),
449 }
450 }
451
452 #[test]
453 fn settings_still_under_the_pre_upgrade_key_are_read_from_it() {
454 let mut config = Config::default();
460 config.devices.insert(
461 "receiver:82839805:slot:1".to_string(),
462 DeviceConfig {
463 dpi: Some(Dpi::new(3200)),
464 ..DeviceConfig::default()
465 },
466 );
467
468 let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
469 let key = config
470 .resolve_device_key(&on_receiver(), Some(&unit))
471 .expect("resolves");
472 assert_eq!(key.as_str(), "receiver:82839805:slot:1");
473 assert_eq!(
474 config.devices[key.as_str()].dpi,
475 Some(Dpi::new(3200)),
476 "the settings are where the key says they are"
477 );
478 }
479
480 #[test]
481 fn a_standalone_raw_entry_keeps_its_light_settings() {
482 let mut config = Config::default();
486 config.devices.insert(
487 "raw:046d:c901:ff43:0202:serial:beam-1".to_string(),
488 DeviceConfig {
489 light: Some(LightSettings::default()),
490 ..DeviceConfig::default()
491 },
492 );
493
494 let serial = DeviceIdentity::Serial("beam-1".to_string());
495 let key = config
496 .resolve_device_key(&litra(), Some(&serial))
497 .expect("resolves");
498 assert_eq!(key.as_str(), "raw:046d:c901:ff43:0202:serial:beam-1");
499 }
500
501 #[test]
502 fn a_canonical_entry_holding_only_metadata_does_not_shadow_the_legacy_one() {
503 let mut config = Config::default();
507 config.devices.insert(
508 "receiver:82839805:slot:1".to_string(),
509 DeviceConfig {
510 dpi: Some(Dpi::new(3200)),
511 ..DeviceConfig::default()
512 },
513 );
514 let mut bare = DeviceConfig::default();
515 bare.links.insert(
516 "receiver:82839805:slot:1".to_string(),
517 LinkConfig::default(),
518 );
519 config.devices.insert("unit:6be9d300".to_string(), bare);
520
521 let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
522 let key = config
523 .resolve_device_key(&on_receiver(), Some(&unit))
524 .expect("resolves");
525 assert_eq!(key.as_str(), "receiver:82839805:slot:1");
526 }
527
528 #[test]
529 fn a_canonical_entry_with_settings_wins_over_a_legacy_one() {
530 let mut config = Config::default();
534 config.devices.insert(
535 "receiver:82839805:slot:1".to_string(),
536 DeviceConfig {
537 dpi: Some(Dpi::new(3200)),
538 ..DeviceConfig::default()
539 },
540 );
541 config.devices.insert(
542 "unit:6be9d300".to_string(),
543 DeviceConfig {
544 dpi: Some(Dpi::new(800)),
545 ..DeviceConfig::default()
546 },
547 );
548
549 let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
550 let key = config
551 .resolve_device_key(&on_receiver(), Some(&unit))
552 .expect("resolves");
553 assert_eq!(key.as_str(), "unit:6be9d300");
554 }
555
556 #[test]
557 fn the_route_key_is_the_legacy_entry_key_for_every_variant_but_direct() {
558 for stable in [on_receiver(), litra()] {
565 let physical = stable.physical_key().expect("physical");
566 assert_eq!(
567 stable.route_key(),
568 physical.as_str(),
569 "{stable:?}: the route key must find the legacy entry"
570 );
571 }
572 let unknown = DeviceStableId::Unknown {
573 slot: 2,
574 identity: DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]),
575 };
576 assert_eq!(
577 unknown.route_key(),
578 unknown.physical_key().expect("physical").as_str()
579 );
580
581 let direct = cabled();
582 assert_ne!(
583 direct.route_key(),
584 direct.physical_key().expect("physical").as_str(),
585 "a direct route key drops the identity that keys the entry"
586 );
587 }
588
589 #[test]
590 fn adoption_repoints_the_selection_and_any_host_switch_target() {
591 let mut config = Config::default();
595 config.devices.insert(
596 "receiver:82839805:slot:1".to_string(),
597 DeviceConfig {
598 dpi: Some(Dpi::new(800)),
599 ..DeviceConfig::default()
600 },
601 );
602 config.devices.insert(
603 "receiver:82839805:slot:2".to_string(),
604 DeviceConfig {
605 host_switch_targets: vec!["receiver:82839805:slot:1".to_string()],
606 ..DeviceConfig::default()
607 },
608 );
609 config.selected_device = Some("receiver:82839805:slot:1".to_string());
610
611 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
612 assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
613
614 assert_eq!(config.selected_device.as_deref(), Some("unit:6be9d300"));
615 assert_eq!(
616 config.devices["receiver:82839805:slot:2"].host_switch_targets,
617 vec!["unit:6be9d300".to_string()],
618 "the keyboard still switches the mouse it was linked to"
619 );
620 }
621
622 #[test]
623 fn an_asleep_device_resolves_through_its_indexed_route() {
624 let mut config = Config::default();
628 let mut device = DeviceConfig::default();
629 device.links.insert(
630 "receiver:82839805:slot:1".to_string(),
631 LinkConfig::default(),
632 );
633 config.devices.insert("unit:6be9d300".to_string(), device);
634
635 let key = config
636 .resolve_device_key(&on_receiver(), None)
637 .expect("the indexed route resolves");
638 assert_eq!(key.as_str(), "unit:6be9d300");
639 }
640
641 #[test]
642 fn an_unindexed_route_falls_back_to_todays_key() {
643 let config = Config::default();
644 let key = config
645 .resolve_device_key(&on_receiver(), None)
646 .expect("receiver keys are physical");
647 assert_eq!(key.as_str(), "receiver:82839805:slot:1");
648 }
649
650 #[test]
651 fn an_all_zero_unit_over_a_receiver_keeps_its_route_key() {
652 let config = Config::default();
655 let zero = DeviceIdentity::Unit([0; 4]);
656 let key = config
657 .resolve_device_key(&on_receiver(), Some(&zero))
658 .expect("falls back rather than failing");
659 assert_eq!(key.as_str(), "receiver:82839805:slot:1");
660 }
661
662 #[test]
663 fn a_direct_device_with_no_identity_stays_non_persistent() {
664 let config = Config::default();
665 let anonymous = DeviceStableId::Direct {
666 vendor_id: 0x046d,
667 product_id: 0xc08d,
668 identity: DeviceIdentity::Unit([0; 4]),
669 };
670 assert_eq!(config.resolve_device_key(&anonymous, None), None);
671 }
672
673 #[test]
674 fn folding_takes_a_custom_name_the_canonical_entry_lacks() {
675 let mut config = Config::default();
679 let legacy = DeviceConfig {
680 custom_name: Some("Desk mouse".to_string()),
681 ..DeviceConfig::default()
682 };
683 config
684 .devices
685 .insert("receiver:82839805:slot:1".to_string(), legacy);
686 config
687 .devices
688 .insert("unit:6be9d300".to_string(), DeviceConfig::default());
689
690 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
691 assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
692
693 assert_eq!(
694 config.devices["unit:6be9d300"].custom_name.as_deref(),
695 Some("Desk mouse"),
696 "the user's alias survives the fold"
697 );
698 }
699
700 #[test]
701 fn folding_keeps_the_canonical_custom_name_when_both_are_named() {
702 let mut config = Config::default();
706 let legacy = DeviceConfig {
707 custom_name: Some("Old name".to_string()),
708 ..DeviceConfig::default()
709 };
710 let canonical_entry = DeviceConfig {
711 custom_name: Some("Current name".to_string()),
712 ..DeviceConfig::default()
713 };
714 config
715 .devices
716 .insert("receiver:82839805:slot:1".to_string(), legacy);
717 config
718 .devices
719 .insert("unit:6be9d300".to_string(), canonical_entry);
720
721 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
722 assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
723
724 assert_eq!(
725 config.devices["unit:6be9d300"].custom_name.as_deref(),
726 Some("Current name"),
727 "the canonical alias wins a genuine conflict"
728 );
729 }
730
731 #[test]
732 fn adopting_a_route_folds_the_legacy_entry_in() {
733 let mut config = Config::default();
736 let legacy = DeviceConfig {
737 lighting: Some(Lighting::default()),
738 ..DeviceConfig::default()
739 };
740 config
741 .devices
742 .insert("receiver:82839805:slot:1".to_string(), legacy);
743 config
744 .devices
745 .insert("unit:6be9d300".to_string(), DeviceConfig::default());
746
747 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
748 assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
749
750 let device = &config.devices["unit:6be9d300"];
751 assert!(device.lighting.is_some(), "lighting moved to device level");
752 assert!(device.links.contains_key("receiver:82839805:slot:1"));
753 assert!(
754 !config.devices.contains_key("receiver:82839805:slot:1"),
755 "the legacy entry is consumed, not left behind"
756 );
757 }
758
759 #[test]
760 fn a_sighting_records_what_the_link_measured() {
761 let mut config = Config::default();
765 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
766 let wired = Capabilities {
767 hires_wheel: false,
768 ..Capabilities::default()
769 };
770 let wireless = Capabilities {
771 hires_wheel: true,
772 ..Capabilities::default()
773 };
774
775 assert!(config.adopt_route(&canonical, "direct:046d:c08d", Some(wired)));
776 assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", Some(wireless)));
777
778 let links = &config.devices["unit:6be9d300"].links;
779 assert_eq!(links["direct:046d:c08d"].capabilities, Some(wired));
780 assert_eq!(
781 links["receiver:82839805:slot:1"].capabilities,
782 Some(wireless)
783 );
784
785 assert!(!config.adopt_route(&canonical, "direct:046d:c08d", Some(wired)));
788 assert!(!config.adopt_route(&canonical, "direct:046d:c08d", None));
790 assert_eq!(
791 config.devices["unit:6be9d300"].links["direct:046d:c08d"].capabilities,
792 Some(wired)
793 );
794 }
795
796 #[test]
797 fn a_conflicting_value_becomes_a_per_link_override() {
798 let mut config = Config::default();
802 let legacy = DeviceConfig {
803 dpi: Some(Dpi::new(800)),
804 ..DeviceConfig::default()
805 };
806 config
807 .devices
808 .insert("receiver:82839805:slot:1".to_string(), legacy);
809 let canonical_entry = DeviceConfig {
810 dpi: Some(Dpi::new(1600)),
811 ..DeviceConfig::default()
812 };
813 config
814 .devices
815 .insert("unit:6be9d300".to_string(), canonical_entry);
816
817 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
818 config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
819
820 let device = &config.devices["unit:6be9d300"];
821 assert_eq!(device.dpi, Some(Dpi::new(1600)), "canonical stays default");
822 assert_eq!(
823 device.links["receiver:82839805:slot:1"].overrides.dpi,
824 Some(Dpi::new(800)),
825 "the legacy value survives on its own link"
826 );
827 }
828
829 #[test]
830 fn adopting_an_unseen_route_registers_it_and_reports_a_change() {
831 let mut config = Config::default();
835 config
836 .devices
837 .insert("unit:6be9d300".to_string(), DeviceConfig::default());
838 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
839
840 assert!(config.adopt_route(&canonical, "direct:046d:c08d", None));
841 assert!(
842 config.devices["unit:6be9d300"]
843 .links
844 .contains_key("direct:046d:c08d")
845 );
846 }
847
848 #[test]
849 fn adopting_an_already_indexed_route_reports_no_change() {
850 let mut config = Config::default();
855 let mut device = DeviceConfig::default();
856 device
857 .links
858 .insert("direct:046d:c08d".to_string(), LinkConfig::default());
859 config.devices.insert("unit:6be9d300".to_string(), device);
860 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
861
862 assert!(!config.adopt_route(&canonical, "direct:046d:c08d", None));
863 }
864
865 #[test]
866 fn a_re_paired_slot_points_at_the_new_unit() {
867 let mut config = Config::default();
870 let mut first = DeviceConfig::default();
871 first.links.insert(
872 "receiver:82839805:slot:1".to_string(),
873 LinkConfig::default(),
874 );
875 config.devices.insert("unit:aaaaaaaa".to_string(), first);
876 config
877 .devices
878 .insert("unit:bbbbbbbb".to_string(), DeviceConfig::default());
879
880 let second = PhysicalDeviceKey::parse("unit:bbbbbbbb").expect("valid");
881 config.adopt_route(&second, "receiver:82839805:slot:1", None);
882
883 assert!(
884 !config.devices["unit:aaaaaaaa"]
885 .links
886 .contains_key("receiver:82839805:slot:1"),
887 "the route no longer points at the previous unit"
888 );
889 assert!(
890 config.devices["unit:bbbbbbbb"]
891 .links
892 .contains_key("receiver:82839805:slot:1")
893 );
894 }
895
896 #[test]
897 fn per_app_bindings_merge_key_by_key() {
898 let mut config = Config::default();
901 let legacy = DeviceConfig {
902 per_app_bindings: std::collections::BTreeMap::from([(
903 "com.example.App".to_string(),
904 std::collections::BTreeMap::from([(ButtonId::MiddleClick, Action::Copy)]),
905 )]),
906 ..DeviceConfig::default()
907 };
908 config
909 .devices
910 .insert("receiver:82839805:slot:1".to_string(), legacy);
911 config
912 .devices
913 .insert("unit:6be9d300".to_string(), DeviceConfig::default());
914
915 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
916 config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
917
918 let device = &config.devices["unit:6be9d300"];
919 assert_eq!(
920 device.per_app_bindings.get("com.example.App"),
921 Some(&std::collections::BTreeMap::from([(
922 ButtonId::MiddleClick,
923 Action::Copy
924 )])),
925 "the app overlay survives adoption"
926 );
927 }
928
929 #[test]
930 fn light_is_taken_when_the_canonical_side_has_none() {
931 let mut config = Config::default();
934 let legacy = DeviceConfig {
935 light: Some(LightSettings::default()),
936 ..DeviceConfig::default()
937 };
938 config
939 .devices
940 .insert("receiver:82839805:slot:1".to_string(), legacy);
941 config
942 .devices
943 .insert("unit:6be9d300".to_string(), DeviceConfig::default());
944
945 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
946 config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
947
948 assert_eq!(
949 config.devices["unit:6be9d300"].light,
950 Some(LightSettings::default()),
951 "the standalone-light setting is not silently discarded"
952 );
953 }
954
955 #[test]
956 fn a_legacy_disabled_flag_disables_the_canonical_entry() {
957 let mut config = Config::default();
961 let legacy = DeviceConfig {
962 enabled: false,
963 ..DeviceConfig::default()
964 };
965 config
966 .devices
967 .insert("receiver:82839805:slot:1".to_string(), legacy);
968 config
969 .devices
970 .insert("unit:6be9d300".to_string(), DeviceConfig::default());
971
972 let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
973 config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
974
975 assert!(
976 !config.devices["unit:6be9d300"].enabled,
977 "the legacy opt-out survives adoption"
978 );
979 }
980}