use super::*;
use crate::backend::NodeId;
use crate::channel::scripted::{ScriptedRawHidChannel, scripted_channel};
const GESTURE: &[u16] = &[reprog_controls::GESTURE_BUTTON_CID];
const PANEL: &[u16] = &[reprog_controls::HAPTIC_PANEL_CID];
const BOTH: &[u16] = &[
reprog_controls::GESTURE_BUTTON_CID,
reprog_controls::HAPTIC_PANEL_CID,
];
#[tokio::test]
async fn dpi_gesture_arming_never_overwrites_raw_xy_with_plain_diversion() {
for cid in [0x00c4u16, 0x00ed, 0x00fd] {
for (gestures, raw_xy, expected_flags) in [
(true, true, vec![0x33]),
(true, false, vec![]),
(false, true, vec![0x23]),
(false, false, vec![0x23]),
] {
let (raw, handle) = ScriptedRawHidChannel::with_dynamic_responder(move |request| {
let mut response = vec![0; 20];
response[..4].copy_from_slice(&request[..4]);
response[0] = 0x11;
match (request[2], request[3] >> 4) {
(0, 1) => response[4] = 4,
(0, 0) => response[4] = 2,
(2, 0) => response[4] = 1,
(2, 1) => {
response[4..6].copy_from_slice(&cid.to_be_bytes());
response[8] = 0x20;
response[12] = u8::from(raw_xy);
}
(2, 2) => response[4..6].copy_from_slice(&cid.to_be_bytes()),
(2, 3) => return Some(request.to_vec()),
_ => panic!("unexpected capture request: {request:02x?}"),
}
Some(response)
});
let channel = scripted_channel(raw).await;
let device = Device::new(channel.clone(), 0xff).await.unwrap();
let spec = CaptureSpec {
divert_gesture_buttons: if gestures {
vec![(cid, ButtonId::DpiToggle)]
} else {
vec![]
},
..CaptureSpec::default()
};
let mut armed = ArmedControls::default();
arm_controls_into(&device, &channel, 0xff, &spec, &mut armed)
.await
.unwrap();
let writes: Vec<_> = handle
.written_reports()
.into_iter()
.filter(|report| report[2] == 2 && report[3] >> 4 == 3)
.map(|report| report[6])
.collect();
assert_eq!(
writes, expected_flags,
"CID {cid:04x}, gestures {gestures}, raw XY {raw_xy}"
);
assert_eq!(
armed.gesture_button_cids,
if gestures && raw_xy {
vec![(cid, ButtonId::DpiToggle)]
} else {
vec![]
}
);
assert_eq!(armed.dpi_cids, if gestures { vec![] } else { vec![cid] });
}
}
}
fn reporting(
diverted: bool,
remap: Option<reprog_controls::ControlId>,
) -> reprog_controls::CidReporting {
reprog_controls::CidReporting {
cid: reprog_controls::ControlId(reprog_controls::GESTURE_BUTTON_CID),
diverted,
persistently_diverted: true,
force_raw_xy: true,
raw_xy: true,
remap,
analytics_key_events: true,
raw_wheel: true,
}
}
#[tokio::test]
async fn pending_restore_waits_for_a_replacement_then_undiverts_through_it() {
let route = DeviceRoute::Direct {
vendor_id: 0x046d,
product_id: 0xb35b,
};
let (retired_raw, retired_handle) = ScriptedRawHidChannel::with_responder(|_| None);
let retired_channel = scripted_channel(retired_raw).await;
let retired = SharedChannel::new(retired_channel.clone(), route.clone());
let registry = ChannelRegistry::default();
let node = NodeId::from("mouse-node".to_owned());
registry.replace_node(node.clone(), [route.clone()], retired_channel);
let pending = PendingCaptureRestore::new(
&retired,
ReprogRestore::new(
0x22,
vec![ArmedReporting {
cid: reprog_controls::GESTURE_BUTTON_CID,
original: reporting(false, None),
}],
),
None,
)
.expect("one diverted control should require restoration");
let pending = match pending.retry(®istry).await {
CaptureSessionOutcome::RestorePending(pending) => pending,
CaptureSessionOutcome::Restored => {
panic!("the retired transport must never restore underneath a replacement")
}
};
assert!(retired_handle.written_reports().is_empty());
let (replacement_raw, replacement_handle) =
ScriptedRawHidChannel::with_responder(|request| Some(request.to_vec()));
let replacement = scripted_channel(replacement_raw).await;
registry.replace_node(node, [route.clone()], replacement);
assert!(matches!(
pending.retry(®istry).await,
CaptureSessionOutcome::Restored
));
let reports = replacement_handle.written_reports();
assert_eq!(reports.len(), 1);
let restore = &reports[0];
assert_eq!(restore[2], 0x22, "restore must address ReprogControlsV4");
assert_eq!(restore[3] >> 4, 3, "restore must call setCidReporting");
assert_eq!(
&restore[4..7],
&[0x00, 0xc3, 0x22],
"restore must clear diversion and raw-XY using their valid bits"
);
}
#[tokio::test]
async fn restore_retries_when_inventory_changes_during_an_awaited_write() {
let route = DeviceRoute::Direct {
vendor_id: 0x046d,
product_id: 0xb35b,
};
let registry = ChannelRegistry::default();
let node = NodeId::from("mouse-node".to_owned());
let (retired_raw, _) = ScriptedRawHidChannel::with_responder(|_| None);
let retired_channel = scripted_channel(retired_raw).await;
let retired = SharedChannel::new(retired_channel, route.clone());
let pending = PendingCaptureRestore::new(
&retired,
ReprogRestore::new(
0x22,
vec![ArmedReporting {
cid: reprog_controls::GESTURE_BUTTON_CID,
original: reporting(false, None),
}],
),
None,
)
.expect("one diverted control should require restoration");
let (winner_raw, winner_handle) =
ScriptedRawHidChannel::with_responder(|request| Some(request.to_vec()));
let winner = scripted_channel(winner_raw).await;
let replacement_registry = registry.clone();
let replacement_node = node.clone();
let replacement_route = route.clone();
let replacement_winner = winner.clone();
let (superseded_raw, superseded_handle) =
ScriptedRawHidChannel::with_dynamic_responder(move |request| {
replacement_registry.replace_node(
replacement_node.clone(),
[replacement_route.clone()],
replacement_winner.clone(),
);
Some(request.to_vec())
});
let superseded = scripted_channel(superseded_raw).await;
registry.replace_node(node, [route], superseded);
let pending = match pending.retry(®istry).await {
CaptureSessionOutcome::RestorePending(pending) => pending,
CaptureSessionOutcome::Restored => {
panic!("a write to a publication replaced during await is not final")
}
};
assert_eq!(superseded_handle.written_reports().len(), 1);
assert!(matches!(
pending.retry(®istry).await,
CaptureSessionOutcome::Restored
));
assert_eq!(winner_handle.written_reports().len(), 1);
}
#[tokio::test]
async fn failed_setup_rollback_returns_its_restore_capability() {
let route = DeviceRoute::Direct {
vendor_id: 0x046d,
product_id: 0xb35b,
};
let (raw, _) =
ScriptedRawHidChannel::with_failing_writes(|request| Some(request.to_vec()), |_| true);
let channel = scripted_channel(raw).await;
let shared = SharedChannel::new(channel, route.clone());
let pending = PendingCaptureRestore::new(
&shared,
ReprogRestore::new(
0x22,
vec![ArmedReporting {
cid: reprog_controls::GESTURE_BUTTON_CID,
original: reporting(false, None),
}],
),
None,
);
let failure = rollback_capture_start(
GestureError::Hidpp("diversion failed".into()),
pending,
&shared,
None,
)
.await;
let (_, pending) = failure.into_parts();
assert!(
pending.is_some(),
"a failed compensating write must not discard firmware ownership"
);
}
#[tokio::test]
async fn capture_listener_outlives_native_reporting_restore() {
struct DropProbe(Arc<std::sync::atomic::AtomicBool>);
impl Drop for DropProbe {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false));
let (restored_tx, restored_rx) = oneshot::channel();
let task = tokio::spawn(drop_listener_after(
DropProbe(Arc::clone(&dropped)),
async move {
let _ = restored_rx.await;
},
));
tokio::task::yield_now().await;
assert!(
!dropped.load(std::sync::atomic::Ordering::Relaxed),
"the listener must remain installed while native reporting is still diverted"
);
restored_tx.send(()).expect("restore signal should be open");
task.await.expect("listener-retirement task should finish");
assert!(
dropped.load(std::sync::atomic::Ordering::Relaxed),
"the listener may be removed after native reporting is restored"
);
}
#[tokio::test(start_paused = true)]
async fn channel_change_takes_teardown_precedence_over_ready_shutdown() {
let route = DeviceRoute::Direct {
vendor_id: 0x046d,
product_id: 0xb35b,
};
let registry = ChannelRegistry::default();
let node = NodeId::from("mouse-node".to_owned());
let (retired_raw, _) = ScriptedRawHidChannel::with_responder(|_| None);
let retired_channel = scripted_channel(retired_raw).await;
registry.replace_node(node.clone(), [route.clone()], retired_channel);
let retired = registry
.lookup(&route)
.expect("the capture publication should be current");
let (replacement_raw, _) = ScriptedRawHidChannel::with_responder(|_| None);
let replacement = scripted_channel(replacement_raw).await;
registry.replace_node(node, [route], replacement);
assert!(matches!(
stop_for_current_publication(Some(®istry), &retired),
CaptureStop::ChannelChanged
));
assert!(matches!(
wait_for_channel_change(Some(®istry), &retired).await,
CaptureStop::ChannelChanged
));
}
#[test]
fn restore_clears_a_diversion_it_found_already_set() {
let change = undivert_change(reporting(true, None));
assert_eq!(change.diverted, Some(false));
assert_eq!(change.raw_xy, Some(false));
}
#[test]
fn restore_returns_the_remap_target_and_touches_nothing_else() {
let remap = reprog_controls::ControlId(0x0053);
let change = undivert_change(reporting(false, Some(remap)));
assert_eq!(change.remap, Some(remap));
assert_eq!(change.persistently_diverted, None);
assert_eq!(change.force_raw_xy, None);
assert_eq!(change.analytics_key_events, None);
assert_eq!(change.raw_wheel, None);
}
#[test]
fn wake_rearm_restores_diversion_mode_and_remap_target() {
let remap = reprog_controls::ControlId(0x0053);
let change = divert_change(reporting(false, Some(remap)), true);
assert_eq!(change.diverted, Some(true));
assert_eq!(change.raw_xy, Some(true));
assert_eq!(change.remap, Some(remap));
}
fn press() -> RawControlEvent {
RawControlEvent::DivertedButtons([reprog_controls::GESTURE_BUTTON_CID, 0, 0, 0])
}
fn panel_press() -> RawControlEvent {
RawControlEvent::DivertedButtons([reprog_controls::HAPTIC_PANEL_CID, 0, 0, 0])
}
fn both_press() -> RawControlEvent {
RawControlEvent::DivertedButtons([
reprog_controls::GESTURE_BUTTON_CID,
reprog_controls::HAPTIC_PANEL_CID,
0,
0,
])
}
fn release() -> RawControlEvent {
RawControlEvent::DivertedButtons([0, 0, 0, 0])
}
fn next_gesture(
rx: &mut mpsc::UnboundedReceiver<CapturedInput>,
) -> Result<CapturedInput, mpsc::error::TryRecvError> {
loop {
let input = rx.try_recv()?;
if matches!(input, CapturedInput::Gesture(..)) {
return Ok(input);
}
}
}
#[test]
fn a_still_held_second_source_takes_over_when_the_holder_releases() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
handle_reprog(&mut acc, press(), BOTH, &[], &[], &tx);
handle_reprog(&mut acc, both_press(), BOTH, &[], &[], &tx);
handle_reprog(&mut acc, panel_press(), BOTH, &[], &[], &tx);
assert_eq!(
next_gesture(&mut rx),
Ok(CapturedInput::Gesture(
ButtonId::GestureButton,
GestureDirection::Click
)),
"the released holder still clicks"
);
acc.backdate_hold_for_test();
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: 120, dy: 5 },
BOTH,
&[],
&[],
&tx,
);
assert_eq!(
next_gesture(&mut rx),
Ok(CapturedInput::Gesture(
ButtonId::HapticPanel,
GestureDirection::Right
)),
"the taken-over hold dispatches through the panel's own map"
);
handle_reprog(&mut acc, release(), BOTH, &[], &[], &tx);
assert!(
next_gesture(&mut rx).is_err(),
"a committed takeover swipe must not also click on release"
);
}
#[test]
fn raw_xy_during_a_two_source_overlap_is_dropped_not_misattributed() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
handle_reprog(&mut acc, press(), BOTH, &[], &[], &tx);
acc.backdate_hold_for_test();
handle_reprog(&mut acc, both_press(), BOTH, &[], &[], &tx);
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: 120, dy: 5 },
BOTH,
&[],
&[],
&tx,
);
assert!(
next_gesture(&mut rx).is_err(),
"ambiguous overlap motion must not commit a swipe"
);
handle_reprog(&mut acc, press(), BOTH, &[], &[], &tx);
acc.backdate_hold_for_test();
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: 120, dy: 5 },
BOTH,
&[],
&[],
&tx,
);
assert_eq!(
next_gesture(&mut rx),
Ok(CapturedInput::Gesture(
ButtonId::GestureButton,
GestureDirection::Right
)),
"the original hold resumes once the overlap ends"
);
}
#[test]
fn a_same_report_swap_to_the_panel_still_discards_its_contact_jump() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
handle_reprog(&mut acc, press(), BOTH, &[], &[], &tx);
handle_reprog(&mut acc, panel_press(), BOTH, &[], &[], &tx);
assert_eq!(
next_gesture(&mut rx),
Ok(CapturedInput::Gesture(
ButtonId::GestureButton,
GestureDirection::Click
)),
"the swapped-out holder still clicks"
);
acc.backdate_hold_for_test();
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: -3000, dy: 40 },
BOTH,
&[],
&[],
&tx,
);
assert!(
next_gesture(&mut rx).is_err(),
"the panel's contact jump must not commit a swipe"
);
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: 120, dy: 5 },
BOTH,
&[],
&[],
&tx,
);
assert_eq!(
next_gesture(&mut rx),
Ok(CapturedInput::Gesture(
ButtonId::HapticPanel,
GestureDirection::Right
))
);
}
#[test]
fn quick_tap_is_a_click_even_while_the_cursor_moves() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
handle_reprog(&mut acc, press(), GESTURE, &[], &[], &tx);
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: 120, dy: 5 },
GESTURE,
&[],
&[],
&tx,
);
handle_reprog(&mut acc, release(), GESTURE, &[], &[], &tx);
assert_eq!(
next_gesture(&mut rx),
Ok(CapturedInput::Gesture(
ButtonId::GestureButton,
GestureDirection::Click
))
);
assert!(
next_gesture(&mut rx).is_err(),
"a quick tap emits exactly one click"
);
}
#[test]
fn a_held_gesture_commits_a_swipe_and_does_not_also_click() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
handle_reprog(&mut acc, press(), GESTURE, &[], &[], &tx);
acc.backdate_hold_for_test();
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: 120, dy: 5 },
GESTURE,
&[],
&[],
&tx,
);
assert_eq!(
next_gesture(&mut rx),
Ok(CapturedInput::Gesture(
ButtonId::GestureButton,
GestureDirection::Right
))
);
handle_reprog(&mut acc, release(), GESTURE, &[], &[], &tx);
assert!(
next_gesture(&mut rx).is_err(),
"a committed swipe must not also click on release"
);
}
#[test]
fn the_haptic_panel_gestures_when_diverted_for_gestures() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
handle_reprog(&mut acc, panel_press(), PANEL, &[], &[], &tx);
acc.backdate_hold_for_test();
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: -3000, dy: 40 },
PANEL,
&[],
&[],
&tx,
);
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: 5, dy: -120 },
PANEL,
&[],
&[],
&tx,
);
assert_eq!(
next_gesture(&mut rx),
Ok(CapturedInput::Gesture(
ButtonId::HapticPanel,
GestureDirection::Up
))
);
handle_reprog(&mut acc, release(), PANEL, &[], &[], &tx);
assert!(
next_gesture(&mut rx).is_err(),
"a committed panel swipe must not also click on release"
);
}
#[test]
fn a_quick_panel_tap_is_a_click() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
handle_reprog(&mut acc, panel_press(), PANEL, &[], &[], &tx);
handle_reprog(&mut acc, release(), PANEL, &[], &[], &tx);
assert_eq!(
next_gesture(&mut rx),
Ok(CapturedInput::Gesture(
ButtonId::HapticPanel,
GestureDirection::Click
))
);
assert!(
next_gesture(&mut rx).is_err(),
"a panel tap emits exactly one click"
);
}
#[test]
fn the_panels_first_raw_xy_sample_after_contact_is_discarded() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
handle_reprog(&mut acc, panel_press(), PANEL, &[], &[], &tx);
acc.backdate_hold_for_test();
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: -3000, dy: 40 },
PANEL,
&[],
&[],
&tx,
);
assert!(
next_gesture(&mut rx).is_err(),
"the contact jump must not commit a swipe"
);
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: 120, dy: 5 },
PANEL,
&[],
&[],
&tx,
);
assert_eq!(
next_gesture(&mut rx),
Ok(CapturedInput::Gesture(
ButtonId::HapticPanel,
GestureDirection::Right
))
);
}
#[test]
fn the_dedicated_buttons_first_sample_is_not_discarded() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
handle_reprog(&mut acc, press(), GESTURE, &[], &[], &tx);
acc.backdate_hold_for_test();
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: 120, dy: 5 },
GESTURE,
&[],
&[],
&tx,
);
assert_eq!(
next_gesture(&mut rx),
Ok(CapturedInput::Gesture(
ButtonId::GestureButton,
GestureDirection::Right
)),
"the dedicated button's very first sample still counts"
);
}
#[test]
fn an_undiverted_gesture_source_does_not_gesture() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
handle_reprog(&mut acc, press(), PANEL, &[], &[], &tx);
acc.backdate_hold_for_test();
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: 120, dy: 5 },
PANEL,
&[],
&[],
&tx,
);
handle_reprog(&mut acc, release(), PANEL, &[], &[], &tx);
assert!(
next_gesture(&mut rx).is_err(),
"a non-owner source must neither gesture nor click"
);
}
#[test]
fn gesture_sources_emit_independent_edges_without_snapshot_duplicates() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
handle_reprog(&mut acc, press(), BOTH, &[], &[], &tx);
handle_reprog(&mut acc, press(), BOTH, &[], &[], &tx);
handle_reprog(&mut acc, both_press(), BOTH, &[], &[], &tx);
handle_reprog(&mut acc, panel_press(), BOTH, &[], &[], &tx);
handle_reprog(&mut acc, release(), BOTH, &[], &[], &tx);
let edges: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
.filter(|input| !matches!(input, CapturedInput::Gesture(..)))
.collect();
assert_eq!(
edges,
vec![
CapturedInput::ButtonDown(ButtonId::GestureButton),
CapturedInput::ButtonDown(ButtonId::HapticPanel),
CapturedInput::ButtonUp(ButtonId::GestureButton),
CapturedInput::ButtonUp(ButtonId::HapticPanel),
]
);
}
#[test]
fn a_plain_diverted_gesture_button_presses_without_gesturing() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
let buttons = [(reprog_controls::GESTURE_BUTTON_CID, ButtonId::GestureButton)];
handle_reprog(&mut acc, press(), &[], &[], &buttons, &tx);
handle_reprog(&mut acc, release(), &[], &[], &buttons, &tx);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonDown(ButtonId::GestureButton))
);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonUp(ButtonId::GestureButton))
);
assert!(
rx.try_recv().is_err(),
"a plain-diverted gesture button must not also emit a gesture click"
);
}
#[test]
fn a_plain_diverted_haptic_panel_presses_as_its_own_button() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
let buttons = [(reprog_controls::HAPTIC_PANEL_CID, ButtonId::HapticPanel)];
handle_reprog(&mut acc, panel_press(), &[], &[], &buttons, &tx);
handle_reprog(&mut acc, release(), &[], &[], &buttons, &tx);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonDown(ButtonId::HapticPanel))
);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonUp(ButtonId::HapticPanel))
);
assert!(
rx.try_recv().is_err(),
"a plain-diverted panel must not also emit a gesture click"
);
}
#[test]
fn plain_button_snapshots_emit_each_edge_once_and_keep_buttons_independent() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
let buttons = [(0x0053, ButtonId::Back), (0x0056, ButtonId::Forward)];
let back = RawControlEvent::DivertedButtons([0x0053, 0, 0, 0]);
let both = RawControlEvent::DivertedButtons([0x0053, 0x0056, 0, 0]);
let forward = RawControlEvent::DivertedButtons([0x0056, 0, 0, 0]);
handle_reprog(&mut acc, back, &[], &[], &buttons, &tx);
handle_reprog(&mut acc, back, &[], &[], &buttons, &tx);
handle_reprog(&mut acc, both, &[], &[], &buttons, &tx);
handle_reprog(&mut acc, forward, &[], &[], &buttons, &tx);
handle_reprog(&mut acc, release(), &[], &[], &buttons, &tx);
assert_eq!(rx.try_recv(), Ok(CapturedInput::ButtonDown(ButtonId::Back)));
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonDown(ButtonId::Forward))
);
assert_eq!(rx.try_recv(), Ok(CapturedInput::ButtonUp(ButtonId::Back)));
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonUp(ButtonId::Forward))
);
assert!(rx.try_recv().is_err(), "unchanged snapshots emit no edges");
}
#[test]
fn a_side_gesture_button_uses_its_hidpp_raw_xy() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
let cid = 0x0056;
let buttons = [(cid, ButtonId::Forward)];
let down = RawControlEvent::DivertedButtons([cid, 0, 0, 0]);
handle_reprog_with_gesture_buttons(&mut acc, down, &[], &[], &buttons, &[], &tx);
acc.backdate_hold_for_test();
handle_reprog_with_gesture_buttons(
&mut acc,
RawControlEvent::RawXy { dx: -120, dy: 5 },
&[],
&[],
&buttons,
&[],
&tx,
);
handle_reprog_with_gesture_buttons(&mut acc, release(), &[], &[], &buttons, &[], &tx);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonDown(ButtonId::Forward))
);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::Gesture(
ButtonId::Forward,
GestureDirection::Left
))
);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonUp(ButtonId::Forward))
);
assert!(
rx.try_recv().is_err(),
"a committed side-button swipe must not also click on release"
);
}
#[test]
fn a_side_gesture_button_tap_is_a_click() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
let cid = 0x0056;
let buttons = [(cid, ButtonId::Forward)];
let down = RawControlEvent::DivertedButtons([cid, 0, 0, 0]);
handle_reprog_with_gesture_buttons(&mut acc, down, &[], &[], &buttons, &[], &tx);
handle_reprog_with_gesture_buttons(&mut acc, release(), &[], &[], &buttons, &[], &tx);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonDown(ButtonId::Forward))
);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::Gesture(
ButtonId::Forward,
GestureDirection::Click
))
);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonUp(ButtonId::Forward))
);
assert_eq!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty));
}
#[test]
fn a_dpi_gesture_button_uses_the_shared_raw_xy_path() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
let cid = reprog_controls::DPI_MODE_SHIFT_CIDS[0];
let buttons = [(cid, ButtonId::DpiToggle)];
let down = RawControlEvent::DivertedButtons([cid, 0, 0, 0]);
handle_reprog_with_gesture_buttons(&mut acc, down, &[], &[], &buttons, &[], &tx);
acc.backdate_hold_for_test();
handle_reprog_with_gesture_buttons(
&mut acc,
RawControlEvent::RawXy { dx: 5, dy: -120 },
&[],
&[],
&buttons,
&[],
&tx,
);
handle_reprog_with_gesture_buttons(&mut acc, release(), &[], &[], &buttons, &[], &tx);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonDown(ButtonId::DpiToggle))
);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::Gesture(
ButtonId::DpiToggle,
GestureDirection::Up
))
);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonUp(ButtonId::DpiToggle))
);
assert_eq!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty));
}
#[test]
fn a_held_dpi_button_presses_once_on_the_rising_edge() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
let dpi = reprog_controls::DPI_MODE_SHIFT_CIDS[0];
let down = RawControlEvent::DivertedButtons([dpi, 0, 0, 0]);
handle_reprog(&mut acc, down, GESTURE, &[dpi], &[], &tx);
handle_reprog(&mut acc, down, GESTURE, &[dpi], &[], &tx);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonDown(ButtonId::DpiToggle))
);
assert!(rx.try_recv().is_err(), "a held DPI button presses once");
}
#[test]
fn a_dpi_button_re_presses_after_a_release() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
let dpi = reprog_controls::DPI_MODE_SHIFT_CIDS[0];
let down = RawControlEvent::DivertedButtons([dpi, 0, 0, 0]);
let up = RawControlEvent::DivertedButtons([0, 0, 0, 0]);
handle_reprog(&mut acc, down, GESTURE, &[dpi], &[], &tx);
handle_reprog(&mut acc, up, GESTURE, &[dpi], &[], &tx);
handle_reprog(&mut acc, down, GESTURE, &[dpi], &[], &tx);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonDown(ButtonId::DpiToggle))
);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonUp(ButtonId::DpiToggle)),
"the falling edge must be preserved"
);
assert_eq!(
rx.try_recv(),
Ok(CapturedInput::ButtonDown(ButtonId::DpiToggle)),
"a release re-arms the rising edge"
);
assert!(
rx.try_recv().is_err(),
"press → release → press emits exactly three lifecycle edges"
);
}
const TRACED_RES: thumbwheel::WheelResolution = thumbwheel::WheelResolution {
native_res: 20,
diverted_res: 120,
};
fn thumb_event(
rotation: i16,
rotation_status: thumbwheel::RotationStatus,
single_tap: bool,
) -> thumbwheel::ThumbwheelEvent {
thumbwheel::ThumbwheelEvent {
rotation,
rotation_status,
single_tap,
touch: true,
proxy: true,
}
}
#[test]
fn a_rolling_report_is_a_roll_even_when_it_flags_a_tap() {
assert_eq!(
thumbwheel_input(
thumb_event(-3, thumbwheel::RotationStatus::Active, true),
TRACED_RES
),
Some(CapturedInput::Scroll {
increments: -3,
resolution: TRACED_RES
})
);
}
#[test]
fn the_release_that_ends_a_roll_is_not_a_tap() {
assert_eq!(
thumbwheel_input(
thumb_event(0, thumbwheel::RotationStatus::Stop, true),
TRACED_RES
),
None
);
}
#[test]
fn a_tap_on_a_settled_wheel_is_a_tap() {
assert_eq!(
thumbwheel_input(
thumb_event(0, thumbwheel::RotationStatus::Inactive, true),
TRACED_RES
),
Some(CapturedInput::ButtonPulse(ButtonId::Thumbwheel))
);
}
#[test]
fn rotation_alone_still_marks_a_roll() {
assert_eq!(
thumbwheel_input(
thumb_event(4, thumbwheel::RotationStatus::Inactive, true),
TRACED_RES
),
Some(CapturedInput::Scroll {
increments: 4,
resolution: TRACED_RES
})
);
}
#[test]
fn contact_without_rotation_or_a_tap_carries_no_input() {
assert_eq!(
thumbwheel_input(
thumb_event(0, thumbwheel::RotationStatus::Inactive, false),
TRACED_RES
),
None
);
}