1use std::{future::Future, sync::Arc, time::Duration};
10
11use hidpp::{
12 channel::HidppChannel,
13 device::Device,
14 feature::{
15 CreatableFeature,
16 change_host::ChangeHostFeature,
17 hosts_info::{HostIndex, HostSlotStatus, HostsInfoFeature},
18 },
19 protocol::v20,
20};
21use thiserror::Error;
22use tokio::{
23 sync::{mpsc, oneshot},
24 time::timeout,
25};
26use tracing::{debug, info};
27
28use crate::{
29 ChannelPool, DeviceIoGate, DeviceRoute,
30 backend::BackendError,
31 reprog_controls::{self, ReprogControlsV4},
32};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum HostSwitchStopReason {
37 Graceful,
39 DeviceLost,
41}
42
43const HOST_CONTROL_IDS: [(reprog_controls::ControlId, u8); 3] = [
44 (reprog_controls::control_ids::HOST_SWITCH_CHANNEL_1, 0),
45 (reprog_controls::control_ids::HOST_SWITCH_CHANNEL_2, 1),
46 (reprog_controls::control_ids::HOST_SWITCH_CHANNEL_3, 2),
47];
48const HOST_TASK_IDS: [(reprog_controls::TaskId, u8); 3] = [
49 (reprog_controls::task_ids::HOST_SWITCH_CHANNEL_1, 0),
50 (reprog_controls::task_ids::HOST_SWITCH_CHANNEL_2, 1),
51 (reprog_controls::task_ids::HOST_SWITCH_CHANNEL_3, 2),
52];
53const HIDPP_OPERATION_TIMEOUT: Duration = Duration::from_secs(5);
54
55#[derive(Clone, Copy)]
56enum ReportingMode {
57 Diverted,
58 Analytics,
59}
60
61#[derive(Clone, Copy)]
62struct ArmedControl {
63 cid: u16,
64 host: u8,
65 mode: ReportingMode,
66 original: reprog_controls::CidReporting,
67}
68
69#[derive(Debug, Error)]
71pub enum HostSwitchError {
72 #[error("HID transport error")]
74 Hid(#[from] BackendError),
75 #[error("configured keyboard is not connected")]
77 KeyboardNotFound,
78 #[error("configured linked device is not connected")]
80 TargetNotFound,
81 #[error("HID++ protocol error: {0}")]
83 Hidpp(String),
84 #[error("HID++ operation timed out while {operation}")]
86 TimedOut {
87 operation: &'static str,
89 },
90 #[error("keyboard exposes no reportable host switch controls")]
92 UnsupportedKeyboard,
93 #[error("host {host} is not paired on this device")]
96 HostSlotEmpty {
97 host: u8,
99 },
100}
101
102pub async fn run_host_switch_session(
105 keyboard: DeviceRoute,
106 shutdown: oneshot::Receiver<HostSwitchStopReason>,
107 channel_pool: ChannelPool,
108 mut device_io: DeviceIoGate,
109) -> Result<Option<u8>, HostSwitchError> {
110 if !device_io.allows_io() {
111 return Err(HostSwitchError::Hid(BackendError::Backend(
112 "host device I/O is suspended".into(),
113 )));
114 }
115 let channel = open_channel(&channel_pool, &keyboard, "opening keyboard channel")
116 .await?
117 .ok_or(HostSwitchError::KeyboardNotFound)?;
118 let keyboard_index = keyboard.device_index();
119 let device = timed_hidpp(
120 "opening keyboard device",
121 Device::new(Arc::clone(&channel), keyboard_index),
122 )
123 .await?;
124 let feature = timed_hidpp(
125 "locating host controls",
126 device.root().get_feature(reprog_controls::FEATURE_ID),
127 )
128 .await?
129 .ok_or(HostSwitchError::UnsupportedKeyboard)?;
130 let controls = ReprogControlsV4::new(Arc::clone(&channel), keyboard_index, feature.index);
131
132 let armed = arm_host_controls(&controls).await?;
133 if armed.is_empty() {
134 return Err(HostSwitchError::UnsupportedKeyboard);
135 }
136
137 let (press_tx, mut press_rx) = mpsc::unbounded_channel();
138 let feature_index = controls.feature_index();
139 let event_controls = armed.clone();
140 let listener = channel.add_msg_listener_guarded(move |raw, matched| {
141 if matched {
142 return;
143 }
144 let message = v20::Message::from(raw);
145 let Some(event) =
146 reprog_controls::decode_full_event(&message, keyboard_index, feature_index)
147 else {
148 return;
149 };
150 if let Some(host) = event_host(&event_controls, event) {
151 let _ = press_tx.send(host);
152 }
153 });
154
155 info!(
156 route = %keyboard,
157 controls = armed.len(),
158 "host switch link active"
159 );
160 let outcome = tokio::select! {
161 reason = shutdown => {
162 let reason = reason.unwrap_or(HostSwitchStopReason::DeviceLost);
163 (None, reason == HostSwitchStopReason::Graceful)
164 },
165 Some(host) = press_rx.recv() => (Some(host), true),
166 };
167
168 drop(listener);
169 if outcome.1 && device_io.wait_until_allowed().await {
170 restore_host_controls(&controls, armed).await;
171 }
172 Ok(outcome.0)
173}
174
175pub async fn switch_linked_hosts(
179 keyboard: &DeviceRoute,
180 targets: &[DeviceRoute],
181 host: u8,
182 channel_pool: &ChannelPool,
183) -> Result<bool, HostSwitchError> {
184 let channel = open_channel(channel_pool, keyboard, "opening keyboard channel")
185 .await?
186 .ok_or(HostSwitchError::KeyboardNotFound)?;
187 let keyboard_change = prepare_host_change_on(&channel, keyboard.device_index(), host).await?;
194 for target in targets {
195 match prepare_host_change(target, host, keyboard, &channel, channel_pool).await {
196 Ok(change) => {
197 if let Err(error) = apply_host_change(change).await {
198 debug!(%error, route = %target, host, "linked device host switch failed");
199 }
200 }
201 Err(error) => {
202 debug!(%error, route = %target, host, "linked device host switch preparation failed");
203 }
204 }
205 }
206 let changed = apply_host_change(keyboard_change).await?;
207 if changed {
208 debug!(host, route = %keyboard, "keyboard host switched");
209 }
210 Ok(changed)
211}
212
213async fn arm_host_controls(
214 controls: &ReprogControlsV4,
215) -> Result<Vec<ArmedControl>, HostSwitchError> {
216 let mut armed = Vec::new();
217 if let Err(error) = arm_host_controls_inner(controls, &mut armed).await {
218 restore_host_controls(controls, armed).await;
219 return Err(error);
220 }
221 Ok(armed)
222}
223
224async fn arm_host_controls_inner(
225 controls: &ReprogControlsV4,
226 armed: &mut Vec<ArmedControl>,
227) -> Result<(), HostSwitchError> {
228 let count = timed_hidpp("reading host control count", controls.get_count()).await?;
229 for index in 0..count {
230 let info = timed_hidpp(
231 "reading host control information",
232 controls.get_ctrl_id_info(index),
233 )
234 .await?;
235 let Some(host) = host_channel(info) else {
236 continue;
237 };
238 debug!(
239 cid = format_args!("{:#06x}", info.cid),
240 task_id = format_args!("{:#06x}", info.task_id),
241 host,
242 divertable = info.is_divertable(),
243 analytics = info.supports_analytics_events(),
244 "host switch control discovered"
245 );
246 let mode = if info.is_divertable() {
247 Some(ReportingMode::Diverted)
248 } else if info.supports_analytics_events() {
249 Some(ReportingMode::Analytics)
250 } else {
251 None
252 };
253 if let Some(mode) = mode {
254 let original = timed_hidpp(
255 "reading host control reporting",
256 controls.get_cid_reporting(info.cid),
257 )
258 .await?;
259 armed.push(ArmedControl {
263 cid: info.cid,
264 host,
265 mode,
266 original,
267 });
268 match mode {
269 ReportingMode::Diverted => {
270 timed_hidpp("diverting host control", controls.divert_cid(info.cid)).await?;
271 }
272 ReportingMode::Analytics => {
273 timed_hidpp(
274 "enabling host control analytics",
275 controls.set_cid_reporting_full(
276 info.cid,
277 reprog_controls::CidReportingChange {
278 analytics_key_events: Some(true),
279 ..reprog_controls::CidReportingChange::default()
280 },
281 ),
282 )
283 .await?;
284 }
285 }
286 }
287 }
288 Ok(())
289}
290
291async fn restore_host_controls(controls: &ReprogControlsV4, armed: Vec<ArmedControl>) {
292 for control in armed {
293 let mut restored = restore_host_control(controls, control).await;
294 if restored.is_err() {
295 restored = restore_host_control(controls, control).await;
296 }
297 if let Err(error) = restored {
298 debug!(
299 ?error,
300 cid = control.cid,
301 "could not restore host switch control"
302 );
303 }
304 }
305}
306
307async fn restore_host_control(
308 controls: &ReprogControlsV4,
309 control: ArmedControl,
310) -> Result<(), HostSwitchError> {
311 timed_hidpp(
312 "restoring host control reporting",
313 controls.set_cid_reporting_full(control.cid, restoration_change(control)),
314 )
315 .await
316 .map(|_echo| ())
317}
318
319fn restoration_change(control: ArmedControl) -> reprog_controls::CidReportingChange {
320 match control.mode {
321 ReportingMode::Diverted => reprog_controls::CidReportingChange {
322 diverted: Some(control.original.diverted),
323 raw_xy: Some(control.original.raw_xy),
324 ..reprog_controls::CidReportingChange::default()
325 },
326 ReportingMode::Analytics => reprog_controls::CidReportingChange {
327 analytics_key_events: Some(control.original.analytics_key_events),
328 ..reprog_controls::CidReportingChange::default()
329 },
330 }
331}
332
333struct PreparedHostChange {
334 feature: Arc<ChangeHostFeature>,
335 device_index: u8,
336 host: u8,
337 required: bool,
338}
339
340async fn prepare_host_change(
341 target: &DeviceRoute,
342 host: u8,
343 keyboard: &DeviceRoute,
344 keyboard_channel: &Arc<HidppChannel>,
345 channel_pool: &ChannelPool,
346) -> Result<PreparedHostChange, HostSwitchError> {
347 if shares_channel(target, keyboard) {
348 prepare_host_change_on(keyboard_channel, target.device_index(), host).await
349 } else {
350 let channel = open_channel(channel_pool, target, "opening linked device channel")
351 .await?
352 .ok_or(HostSwitchError::TargetNotFound)?;
353 prepare_host_change_on(&channel, target.device_index(), host).await
354 }
355}
356
357async fn prepare_host_change_on(
358 channel: &Arc<HidppChannel>,
359 device_index: u8,
360 host: u8,
361) -> Result<PreparedHostChange, HostSwitchError> {
362 let mut device = timed_hidpp(
363 "opening host-change device",
364 Device::new(Arc::clone(channel), device_index),
365 )
366 .await?;
367 let info = timed_hidpp(
368 "locating host-change feature",
369 device.root().get_feature(ChangeHostFeature::ID),
370 )
371 .await?
372 .ok_or_else(|| HostSwitchError::Hidpp("ChangeHost is unsupported".into()))?;
373 let change_host = device.add_feature::<ChangeHostFeature>(info.index);
374 let state = timed_hidpp("reading current host", change_host.get_host_info()).await?;
375 let required = host_change_required(state.current_host, state.host_count, host)?;
376 if required && host_slot_is_empty(&mut device, host).await {
377 return Err(HostSwitchError::HostSlotEmpty { host });
378 }
379 Ok(PreparedHostChange {
380 feature: change_host,
381 device_index,
382 host,
383 required,
384 })
385}
386
387async fn apply_host_change(change: PreparedHostChange) -> Result<bool, HostSwitchError> {
388 if !change.required {
389 let PreparedHostChange {
390 device_index, host, ..
391 } = change;
392 debug!(device_index, host, "device already uses requested host");
393 return Ok(false);
394 }
395 timed_hidpp(
396 "writing current host",
397 change.feature.set_current_host(change.host),
398 )
399 .await?;
400 Ok(true)
401}
402
403async fn open_channel(
404 channel_pool: &ChannelPool,
405 route: &DeviceRoute,
406 operation: &'static str,
407) -> Result<Option<Arc<HidppChannel>>, HostSwitchError> {
408 timeout(HIDPP_OPERATION_TIMEOUT, channel_pool.open(route))
409 .await
410 .map_err(|_| HostSwitchError::TimedOut { operation })?
411 .map_err(HostSwitchError::Hid)
412}
413
414async fn timed_hidpp<T, E>(
415 operation: &'static str,
416 future: impl Future<Output = Result<T, E>>,
417) -> Result<T, HostSwitchError>
418where
419 E: std::fmt::Debug,
420{
421 timeout(HIDPP_OPERATION_TIMEOUT, future)
422 .await
423 .map_err(|_| HostSwitchError::TimedOut { operation })?
424 .map_err(|error| hidpp_error(operation, error))
425}
426
427async fn host_slot_is_empty(device: &mut Device, host: u8) -> bool {
443 let feature = timed_hidpp(
444 "locating hosts-info feature",
445 device.root().get_feature(HostsInfoFeature::ID),
446 )
447 .await;
448 let index = match feature {
449 Ok(Some(info)) => info.index,
450 Ok(None) => return false,
451 Err(error) => {
452 debug!(host, %error, "hosts-info lookup failed; treating the slot as usable");
453 return false;
454 }
455 };
456 let hosts_info = device.add_feature::<HostsInfoFeature>(index);
457 match timed_hidpp(
458 "reading host slot status",
459 hosts_info.get_host_info(HostIndex::Slot(host)),
460 )
461 .await
462 {
463 Ok(slot) => slot.status == HostSlotStatus::Empty,
464 Err(error) => {
465 debug!(host, %error, "host slot status is unreadable; treating the slot as usable");
466 false
467 }
468 }
469}
470
471fn host_change_required(
472 current_host: u8,
473 host_count: u8,
474 requested_host: u8,
475) -> Result<bool, HostSwitchError> {
476 if requested_host >= host_count {
477 return Err(HostSwitchError::Hidpp(format!(
478 "host {requested_host} is outside device host count {host_count}"
479 )));
480 }
481 Ok(current_host != requested_host)
482}
483
484fn shares_channel(left: &DeviceRoute, right: &DeviceRoute) -> bool {
485 left.shares_transport(right)
486}
487
488fn hidpp_error(operation: &'static str, error: impl std::fmt::Debug) -> HostSwitchError {
489 HostSwitchError::Hidpp(format!("{operation}: {error:?}"))
490}
491
492fn host_channel(info: reprog_controls::CtrlIdInfo) -> Option<u8> {
493 HOST_CONTROL_IDS
494 .iter()
495 .find_map(|(cid, host)| (info.cid == cid.0).then_some(*host))
496 .or_else(|| {
497 HOST_TASK_IDS
498 .iter()
499 .find_map(|(task, host)| (info.task_id == task.0).then_some(*host))
500 })
501}
502
503fn event_host(
504 controls: &[ArmedControl],
505 event: reprog_controls::ReprogControlsEvent,
506) -> Option<u8> {
507 match event {
508 reprog_controls::ReprogControlsEvent::DivertedButtons(cids) => controls
509 .iter()
510 .find_map(|control| cids.contains(&control.cid.into()).then_some(control.host)),
511 reprog_controls::ReprogControlsEvent::AnalyticsKeyEvents(events) => {
512 controls.iter().find_map(|control| {
513 events
514 .iter()
515 .any(|event| event.cid.0 == control.cid)
516 .then_some(control.host)
517 })
518 }
519 reprog_controls::ReprogControlsEvent::DivertedRawMouseXy { .. }
520 | reprog_controls::ReprogControlsEvent::DivertedRawWheel { .. } => None,
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use std::sync::Arc;
527
528 use hidpp::channel::HidppChannel;
529
530 use super::{
531 ArmedControl, HostSwitchError, ReportingMode, event_host, host_change_required,
532 host_channel, prepare_host_change_on, restoration_change, shares_channel,
533 };
534 use crate::DeviceRoute;
535 use crate::channel::scripted::{ScriptedRawHidChannel, feature_error};
536 use crate::reprog_controls::{
537 AnalyticsKeyEvent, CidReporting, ControlId, CtrlIdInfo, ReprogControlsEvent,
538 };
539
540 const CHANGE_HOST_INDEX: u8 = 0x04;
542 const HOSTS_INFO_INDEX: u8 = 0x05;
544
545 const BUSY: u8 = 0x08;
547
548 #[derive(Clone, Copy, PartialEq, Eq)]
550 enum SlotStatus {
551 Reported,
553 Unimplemented,
555 LookupErrors,
558 ReadErrors,
560 }
561
562 fn keyboard_with_an_empty_third_slot(request: &[u8]) -> Option<Vec<u8>> {
566 scripted_keyboard(request, SlotStatus::Reported)
567 }
568
569 fn keyboard_without_hosts_info(request: &[u8]) -> Option<Vec<u8>> {
571 scripted_keyboard(request, SlotStatus::Unimplemented)
572 }
573
574 fn keyboard_erroring_on_hosts_info_lookup(request: &[u8]) -> Option<Vec<u8>> {
576 scripted_keyboard(request, SlotStatus::LookupErrors)
577 }
578
579 fn keyboard_erroring_on_slot_status(request: &[u8]) -> Option<Vec<u8>> {
581 scripted_keyboard(request, SlotStatus::ReadErrors)
582 }
583
584 fn scripted_keyboard(request: &[u8], slot_status: SlotStatus) -> Option<Vec<u8>> {
585 if request.len() < 7 || !matches!(request[0], 0x10 | 0x11) {
586 return None;
587 }
588 let mut payload = [0u8; 16];
589 match (request[2], request[3] >> 4) {
590 (0x00, 0x01) => payload[0] = 4,
592 (0x00, 0x00) => {
594 payload[0] = match u16::from_be_bytes([request[4], request[5]]) {
595 0x1814 => CHANGE_HOST_INDEX,
596 0x1815 => match slot_status {
597 SlotStatus::Unimplemented => 0x00,
598 SlotStatus::LookupErrors => return Some(feature_error(request, BUSY)),
599 SlotStatus::Reported | SlotStatus::ReadErrors => HOSTS_INFO_INDEX,
600 },
601 _ => 0x00,
602 };
603 }
604 (CHANGE_HOST_INDEX, 0x00) => payload[..2].copy_from_slice(&[3, 0]),
606 (HOSTS_INFO_INDEX, 0x01) => {
608 if slot_status == SlotStatus::ReadErrors {
609 return Some(feature_error(request, BUSY));
610 }
611 payload[0] = request[4];
612 payload[1] = u8::from(request[4] < 2);
613 }
614 _ => return None,
615 }
616
617 let mut response = vec![0u8; 7];
618 response[0] = 0x10;
619 response[1..4].copy_from_slice(&request[1..4]);
620 response[4..].copy_from_slice(&payload[..3]);
621 Some(response)
622 }
623
624 async fn scripted_channel(responder: crate::channel::scripted::Responder) -> Arc<HidppChannel> {
625 let (raw, _handle) = ScriptedRawHidChannel::with_responder(responder);
626 crate::channel::scripted::scripted_channel(raw).await
627 }
628
629 #[tokio::test]
630 async fn switching_to_an_unpaired_slot_is_refused() {
631 let channel = scripted_channel(keyboard_with_an_empty_third_slot).await;
635
636 let Err(error) = prepare_host_change_on(&channel, 1, 2).await else {
637 panic!("an unpaired slot must not be switched to");
638 };
639
640 assert!(
641 matches!(error, HostSwitchError::HostSlotEmpty { host: 2 }),
642 "got {error:?}"
643 );
644 }
645
646 #[tokio::test]
647 async fn switching_to_a_paired_slot_proceeds() {
648 let channel = scripted_channel(keyboard_with_an_empty_third_slot).await;
649
650 let change = prepare_host_change_on(&channel, 1, 1)
651 .await
652 .expect("a paired slot must be switchable");
653
654 assert!(change.required, "host 1 differs from the current host 0");
655 }
656
657 #[tokio::test]
658 async fn a_device_without_hosts_info_is_still_switched() {
659 let channel = scripted_channel(keyboard_without_hosts_info).await;
663
664 let change = prepare_host_change_on(&channel, 1, 2)
665 .await
666 .expect("a device that cannot report slot status must still switch");
667
668 assert!(change.required);
669 }
670
671 #[tokio::test]
672 async fn a_failed_hosts_info_lookup_does_not_block_the_switch() {
673 let channel = scripted_channel(keyboard_erroring_on_hosts_info_lookup).await;
677
678 let change = prepare_host_change_on(&channel, 1, 2)
679 .await
680 .expect("an errored feature lookup must not abort the switch");
681
682 assert!(change.required);
683 }
684
685 #[tokio::test]
686 async fn an_unreadable_slot_status_does_not_block_the_switch() {
687 let channel = scripted_channel(keyboard_erroring_on_slot_status).await;
691
692 let change = prepare_host_change_on(&channel, 1, 2)
693 .await
694 .expect("an errored status read must not abort the switch");
695
696 assert!(change.required);
697 }
698
699 #[tokio::test]
700 async fn a_switch_to_the_current_host_never_consults_slot_status() {
701 let channel = scripted_channel(keyboard_with_an_empty_third_slot).await;
704
705 let change = prepare_host_change_on(&channel, 1, 0)
706 .await
707 .expect("staying on the current host is always fine");
708
709 assert!(!change.required);
710 }
711
712 fn noisy_reporting() -> CidReporting {
715 CidReporting {
716 cid: ControlId(0x00d3),
717 diverted: false,
718 persistently_diverted: true,
719 force_raw_xy: true,
720 raw_xy: false,
721 remap: Some(ControlId(0x1234)),
722 analytics_key_events: false,
723 raw_wheel: true,
724 }
725 }
726
727 #[test]
728 fn receiver_slots_share_one_channel() {
729 let keyboard = DeviceRoute::Bolt {
730 receiver_uid: "AABB".into(),
731 slot: 1,
732 };
733 let mouse = DeviceRoute::Bolt {
734 receiver_uid: "aabb".into(),
735 slot: 2,
736 };
737 assert!(shares_channel(&keyboard, &mouse));
738 }
739
740 #[test]
741 fn direct_devices_do_not_share_channels() {
742 let route = DeviceRoute::Direct {
743 vendor_id: 0x046d,
744 product_id: 0xb025,
745 };
746 assert!(!shares_channel(&route, &route));
747 }
748
749 #[test]
750 fn host_controls_are_recognized_by_task_when_cid_varies() {
751 let info = CtrlIdInfo {
752 cid: 0x1234,
753 task_id: 0x00af,
754 flags: 0,
755 };
756 assert_eq!(host_channel(info), Some(1));
757 }
758
759 #[test]
760 fn analytics_event_selects_the_matching_host() {
761 let controls = [ArmedControl {
762 cid: 0x00d3,
763 host: 2,
764 mode: ReportingMode::Analytics,
765 original: noisy_reporting(),
766 }];
767 let mut events = [AnalyticsKeyEvent::default(); 5];
768 events[0] = AnalyticsKeyEvent {
769 cid: ControlId(0x00d3),
770 event: 1,
771 };
772 assert_eq!(
773 event_host(&controls, ReprogControlsEvent::AnalyticsKeyEvents(events)),
774 Some(2)
775 );
776 }
777
778 #[test]
779 fn current_host_does_not_require_a_change() {
780 assert!(matches!(host_change_required(1, 3, 1), Ok(false)));
781 }
782
783 #[test]
784 fn different_valid_host_requires_a_change() {
785 assert!(matches!(host_change_required(0, 3, 2), Ok(true)));
786 }
787
788 #[test]
789 fn host_outside_device_range_is_rejected() {
790 assert!(
791 host_change_required(0, 2, 2).is_err(),
792 "host 2 is outside a device that reports 2 hosts and must be rejected"
793 );
794 }
795
796 #[test]
797 fn diverted_cleanup_restores_only_the_original_temporary_bits() {
798 let change = restoration_change(ArmedControl {
799 cid: 0x00d3,
800 host: 2,
801 mode: ReportingMode::Diverted,
802 original: CidReporting {
803 diverted: true,
804 raw_xy: true,
805 ..noisy_reporting()
806 },
807 });
808
809 assert_eq!(change.diverted, Some(true));
810 assert_eq!(change.raw_xy, Some(true));
811 assert_eq!(change.analytics_key_events, None);
812 assert_eq!(change.persistently_diverted, None);
813 assert_eq!(change.remap, None);
814 }
815
816 #[test]
817 fn analytics_cleanup_restores_the_original_analytics_bit() {
818 let change = restoration_change(ArmedControl {
819 cid: 0x00d3,
820 host: 2,
821 mode: ReportingMode::Analytics,
822 original: CidReporting {
823 analytics_key_events: true,
824 ..noisy_reporting()
825 },
826 });
827
828 assert_eq!(change.analytics_key_events, Some(true));
829 assert_eq!(change.diverted, None);
830 assert_eq!(change.raw_xy, None);
831 }
832}