use std::collections::HashSet;
use glam::Vec2;
use repame_input::{
ActionMap, ActionState, Binding, HapticBus, HapticEffect, KeymapDevice, KeymapEntry,
MotionSource, MotionTracker, RemapSession, SensorKind, decode_keymap_entry,
encode_keymap_entry,
};
use repame_shell::{
MotionPoller, PadBridge, RUMBLE_REFRESH, RUMBLE_UPLOAD_MS, RumbleBridge, Staging,
};
use repose_core::input::{
GamepadButton, GamepadEvent, GamepadId, Key, Modifiers, PhysicalKey, PointerButton,
SensorSample,
};
use repose_core::runtime::Scheduler;
use repose_core::shortcuts::KeyChord;
use repose_platform::sensor::SensorReading;
use web_time::{Duration, Instant};
fn chord(key: Key) -> KeymapEntry {
KeymapEntry::Key(KeyChord::new(key, Modifiers::default()))
}
fn focused_scheduler() -> Scheduler {
let mut sched = Scheduler::new();
sched.window_focused = true;
sched
}
fn approx(left: f32, right: f32, tolerance: f32) -> bool {
(left - right).abs() <= tolerance
}
fn reading(device: &str, sample: SensorSample) -> SensorReading {
SensorReading {
device: device.to_string(),
sample,
}
}
#[test]
fn codec_keeps_logical_and_physical_distinct() {
let unambiguous = [
chord(Key::Character('w')),
chord(Key::Character('1')),
KeymapEntry::Physical(PhysicalKey::KeyW),
KeymapEntry::Physical(PhysicalKey::Digit1),
KeymapEntry::Physical(PhysicalKey::Minus),
KeymapEntry::Mouse(PointerButton::Primary),
KeymapEntry::Mouse(PointerButton::Secondary),
KeymapEntry::Pad(repose_core::input::GamepadButton::South),
KeymapEntry::Axis {
axis: repose_core::input::GamepadAxis::LeftTrigger,
threshold: 0.5,
},
KeymapEntry::None,
];
for entry in unambiguous {
let text = encode_keymap_entry(&entry);
assert_eq!(decode_keymap_entry(&text), entry, "round trip {text:?}");
}
let named_keys = [
Key::Space,
Key::Tab,
Key::Enter,
Key::Escape,
Key::ArrowUp,
Key::ArrowDown,
Key::ArrowLeft,
Key::ArrowRight,
Key::ShiftLeft,
Key::ShiftRight,
];
let named_positions = [
PhysicalKey::Space,
PhysicalKey::Tab,
PhysicalKey::Enter,
PhysicalKey::Escape,
PhysicalKey::ArrowUp,
PhysicalKey::ArrowDown,
PhysicalKey::ArrowLeft,
PhysicalKey::ArrowRight,
PhysicalKey::ShiftLeft,
PhysicalKey::ShiftRight,
PhysicalKey::KeyW,
];
for (key, position) in named_keys.into_iter().zip(named_positions) {
let logical = chord(key.clone());
let physical = KeymapEntry::Physical(position);
let ltext = encode_keymap_entry(&logical);
let ptext = encode_keymap_entry(&physical);
assert_ne!(ltext, ptext, "wire forms must differ for {key:?}");
assert_eq!(decode_keymap_entry(<ext), logical, "logical {ltext:?}");
assert_eq!(decode_keymap_entry(&ptext), physical, "physical {ptext:?}");
}
let shifted = KeymapEntry::Key(KeyChord::new(
Key::Space,
Modifiers {
shift: true,
..Modifiers::default()
},
));
let text = encode_keymap_entry(&shifted);
assert_eq!(decode_keymap_entry(&text), shifted, "modifiers survive");
assert_eq!(
decode_keymap_entry("Space"),
chord(Key::Space),
"legacy bare Space is the logical default (NT Swap)"
);
assert_eq!(
decode_keymap_entry("phys:Space"),
KeymapEntry::Physical(PhysicalKey::Space)
);
assert_eq!(
decode_keymap_entry("KeyW"),
KeymapEntry::Physical(PhysicalKey::KeyW),
"legacy bare KeyW stays physical"
);
}
#[test]
fn missed_key_release_drops_level_without_synthesizing_edge() {
let mut staging = Staging::default();
staging.window_focused = true;
staging.stage_physical(PhysicalKey::KeyW, true, false);
assert!(staging.held.contains(&PhysicalKey::KeyW));
assert_eq!(staging.take_edges().len(), 1);
staging.feed_polled(&focused_scheduler());
assert!(
!staging.held.contains(&PhysicalKey::KeyW),
"missed W release must drop the level"
);
assert!(
staging.take_edges().is_empty(),
"repair must never synthesize press edges"
);
}
#[test]
fn missed_mouse_release_drops_level() {
let mut staging = Staging::default();
staging.window_focused = true;
staging.pick_down(PointerButton::Primary);
assert!(staging.lmb_held);
staging.feed_polled(&focused_scheduler());
assert!(!staging.lmb_held, "missed button-up must drop LMB");
}
#[test]
fn missed_touch_release_drops_stale_contact() {
let mut staging = Staging::default();
staging.window_focused = true;
staging.touch_down(7, Vec2::new(100.0, 100.0));
assert!(!staging.touch_active.is_empty());
staging.feed_polled(&focused_scheduler());
assert!(
staging.touch_active.is_empty(),
"missed touch Ended must drop the stale contact"
);
}
#[test]
fn polled_snapshot_repairs_contact_positions_without_stealing_edges() {
let mut staging = Staging::default();
staging.window_focused = true;
staging.touch_down(7, Vec2::new(100.0, 100.0));
let _ = staging.touch_contacts();
let mut sched = focused_scheduler();
sched.touch_points = vec![(7, 150.0, 160.0)];
staging.feed_polled(&sched);
let contacts = staging.touch_contacts();
assert_eq!(contacts.len(), 1, "live finger must survive repair");
assert_eq!(contacts[0].id, 7);
assert!(
!contacts[0].just_pressed,
"repair must never re-latch an edge on a finger the event path staged"
);
assert!(
(contacts[0].pos.x - 150.0).abs() < 1e-3 && (contacts[0].pos.y - 160.0).abs() < 1e-3,
"repair must track the snapshot position, got {:?}",
contacts[0].pos
);
assert!(
(contacts[0].start.x - 100.0).abs() < 1e-3,
"repair must never rewrite the press anchor, got {:?}",
contacts[0].start
);
}
#[test]
fn polled_snapshot_backfills_swallowed_second_finger_with_press_edge() {
let mut staging = Staging::default();
staging.window_focused = true;
staging.touch_down(1, Vec2::new(60.0, 170.0));
let _ = staging.touch_contacts();
let mut sched = focused_scheduler();
sched.touch_points = vec![(1, 60.0, 170.0), (2, 260.0, 160.0)];
staging.feed_polled(&sched);
let contacts = staging.touch_contacts();
assert_eq!(contacts.len(), 2, "swallowed finger must appear");
let second = contacts
.iter()
.find(|c| c.id == 2)
.expect("finger 2 backfilled");
assert!(
second.just_pressed,
"backfilled finger must latch just_pressed or stick claims never see it"
);
let first = contacts.iter().find(|c| c.id == 1).expect("finger 1 kept");
assert!(
!first.just_pressed,
"repair must not re-latch the already-claimed finger"
);
}
#[test]
fn focus_loss_cancels_all_pending_input() {
let mut staging = Staging::default();
staging.window_focused = true;
staging.stage_physical(PhysicalKey::KeyW, true, false);
staging.take_edges();
staging.pick_down(PointerButton::Primary);
staging.pick_down(PointerButton::Secondary);
staging.stage_click(Vec2::new(10.0, 10.0), [10.0, 10.0], 1.0);
staging.touch_down(7, Vec2::new(100.0, 100.0));
staging.set_window_focused(false);
assert!(staging.held.is_empty());
assert!(staging.take_edges().is_empty());
assert!(!staging.lmb_held && !staging.rmb_held);
assert!(
staging.clicks.is_empty(),
"staged clicks must not survive alt-tab"
);
assert!(
staging.mouse_edges.is_empty(),
"mouse edges must not survive alt-tab"
);
assert!(
!staging.take_rmb_down(),
"RMB latch must not survive alt-tab"
);
assert!(
staging.touch_active.is_empty(),
"touch contacts must not survive alt-tab"
);
assert!(
staging.capture_pending_physical.is_none()
&& staging.capture_pending_key.is_none()
&& staging.capture_pending_mouse.is_none()
);
}
#[test]
fn staged_hover_is_baked_aim_and_clears_on_focus_loss() {
let mut staging = Staging::default();
staging.stage_hover(Vec2::new(12.0, 34.0), [56.0, 78.0]);
assert_eq!(staging.aim.baked(), Some(Vec2::new(12.0, 34.0)));
assert_eq!(staging.aim.live_px(), Some(Vec2::new(56.0, 78.0)));
staging.set_window_focused(false);
assert_eq!(staging.aim.baked(), None);
assert_eq!(staging.aim.live_px(), None);
}
#[test]
fn consume_expires_at_tick_end() {
let mut map = ActionMap::new();
let space = KeyChord::new(Key::Space, Modifiers::default());
map.bind("jump", Binding::Key(space.clone()));
let mut state = ActionState::new(map);
state.key(&space, true);
assert!(state.consume(&"jump"));
assert!(!state.pressed(&"jump"));
state.end_tick();
assert!(
state.pressed(&"jump"),
"consume must not leak past tick end"
);
}
#[test]
fn physical_capture_binds_physical_and_drives_action() {
let mut session: RemapSession<&str> = RemapSession::default();
session.begin("jump", KeymapDevice::KeyboardMouse);
assert!(session.resolve_physical(PhysicalKey::KeyZ));
assert_eq!(
session.map.keyboard(&"jump"),
KeymapEntry::Physical(PhysicalKey::KeyZ)
);
let map = session.map.to_action_map();
assert!(
map.bindings_for(&"jump")
.contains(&Binding::Physical(PhysicalKey::KeyZ))
);
let mut state: ActionState<&str> = ActionState::new(map);
state.physical(PhysicalKey::KeyZ, true);
assert!(state.pressed(&"jump"));
assert!(state.just_pressed(&"jump"));
state.end_tick();
state.physical(PhysicalKey::KeyZ, false);
assert!(!state.pressed(&"jump"));
assert!(state.just_released(&"jump"));
}
#[test]
fn replace_map_swaps_live_evaluation() {
let space = KeyChord::new(Key::Space, Modifiers::default());
let mut first = ActionMap::new();
first.bind("jump", Binding::Key(space.clone()));
let mut state: ActionState<&str> = ActionState::new(first);
state.key(&space, true);
assert!(state.pressed(&"jump"));
state.key(&space, false);
state.end_tick();
let chord_e = KeyChord::new(Key::Character('e'), Modifiers::default());
let mut second = ActionMap::new();
second.bind("jump", Binding::Key(chord_e.clone()));
state.replace_map(second);
state.key(&space, true);
assert!(!state.pressed(&"jump"), "old binding must go dead");
assert!(!state.just_pressed(&"jump"));
state.key(&space, false);
state.key(&chord_e, true);
assert!(state.pressed(&"jump"));
assert!(state.just_pressed(&"jump"));
}
#[test]
fn reconcile_never_synthesizes_edges_for_levels_only_repair() {
let mut staged: HashSet<u8> = HashSet::from([1]);
let polled: HashSet<PhysicalKey> = HashSet::from([PhysicalKey::Space]);
repame_input::reconcile_held(
&mut staged,
&polled,
|_| Some(2u8),
|code| {
if *code == 1 {
Some(&[][..])
} else {
Some(&[PhysicalKey::Space][..])
}
},
);
assert!(!staged.contains(&1));
assert!(staged.contains(&2));
}
#[test]
fn pad_snapshot_reports_start_select_and_stick_clicks_with_held_levels() {
let mut bridge = PadBridge::default();
for button in [
GamepadButton::Start,
GamepadButton::Select,
GamepadButton::LeftStick,
GamepadButton::RightStick,
GamepadButton::South,
] {
bridge.button(button, true);
}
let pressed = bridge.snapshot();
assert!(
pressed.start_held && pressed.start_pressed,
"start must reach the pad snapshot"
);
assert!(
pressed.select_held && pressed.select_pressed,
"select must reach the pad snapshot"
);
assert!(
pressed.left_stick_click_held && pressed.left_stick_click_pressed,
"left stick click must reach the pad snapshot"
);
assert!(
pressed.right_stick_click_held && pressed.right_stick_click_pressed,
"right stick click must reach the pad snapshot"
);
assert!(
pressed.south_held && pressed.south_pressed,
"a held face button reports both levels"
);
bridge.clear_edges();
let held = bridge.snapshot();
assert!(
held.start_held && !held.start_pressed && held.south_held && !held.south_pressed,
"clearing edges must keep the held level, got {held:?}"
);
}
#[test]
fn sensor_devices_route_to_pads_by_name_and_convert_units() {
let mut poller = MotionPoller::new();
poller.feed_gamepad(&[GamepadEvent::Connected {
id: GamepadId(2),
name: "Wireless Controller (Vendor: 054c)".to_string(),
vendor_id: 0x054c,
product_id: 0,
}]);
assert!(poller.motion_ids().is_empty(), "no motion before a sample");
poller.feed_readings(vec![
reading(
"wireless controller vendor 054c",
SensorSample::gyroscope(180.0, 0.0, -90.0),
),
reading(
"wireless controller vendor 054c",
SensorSample::accelerometer(0.0, 1.0, 0.0),
),
]);
assert_eq!(poller.motion_ids(), vec![GamepadId(2)]);
let motion = poller.source(GamepadId(2));
assert_eq!(motion.kind(), SensorKind::GyroscopeAccelerometer);
let sample = motion.sample().expect("pad reported motion");
assert!(
approx(sample.angular_velocity.x, std::f32::consts::PI, 1e-5)
&& approx(
sample.angular_velocity.z,
-std::f32::consts::FRAC_PI_2,
1e-5
)
&& approx(sample.angular_velocity.y, 0.0, 1e-5),
"gyro must reach the sim in radians per second, got {:?}",
sample.angular_velocity
);
assert!(
approx(sample.acceleration.y, repame_input::GRAVITY, 1e-4),
"accelerometer must reach the sim in m/s^2, got {:?}",
sample.acceleration
);
}
#[test]
fn unmatched_sensor_devices_never_invent_a_pad() {
let mut poller = MotionPoller::new();
poller.feed_gamepad(&[GamepadEvent::Connected {
id: GamepadId(0),
name: "Pad".to_string(),
vendor_id: 0,
product_id: 0,
}]);
poller.feed_readings(vec![
reading("Some Other Pad", SensorSample::gyroscope(90.0, 90.0, 90.0)),
reading("Pad", SensorSample::gyroscope(90.0, 0.0, 0.0)),
]);
assert_eq!(
poller.motion_ids(),
vec![GamepadId(0)],
"a device with no matching pad must be dropped"
);
let mut tracker = MotionTracker::new(SensorKind::Gyroscope);
tracker.integrate(poller.source(GamepadId(0)).sample().unwrap(), 1.0);
assert!(
approx(
tracker.angular_velocity().x,
std::f32::consts::FRAC_PI_2,
1e-5
),
"integration consumes radians per second, got {:?}",
tracker.angular_velocity()
);
poller.feed_gamepad(&[GamepadEvent::Disconnected { id: GamepadId(0) }]);
assert!(poller.motion_ids().is_empty());
assert_eq!(poller.source(GamepadId(0)).kind(), SensorKind::None);
assert!(poller.source(GamepadId(0)).sample().is_none());
}
#[test]
fn rumble_uploads_on_change_refreshes_on_a_clock_and_stops_when_dropped() {
let mut bus = HapticBus::new();
let mut bridge = RumbleBridge::new();
let t0 = Instant::now();
bus.play(2, HapticEffect::for_seconds(0.8, 0.2, 1.0));
bridge.sync_at(&bus, t0);
assert_eq!(
bridge.drain(),
vec![(GamepadId(2), 0.8, 0.2, RUMBLE_UPLOAD_MS)]
);
bridge.sync_at(&bus, t0 + Duration::from_millis(16));
assert!(
bridge.drain().is_empty(),
"steady strengths must not upload every frame"
);
bridge.sync_at(&bus, t0 + RUMBLE_REFRESH);
assert_eq!(
bridge.drain(),
vec![(GamepadId(2), 0.8, 0.2, RUMBLE_UPLOAD_MS)],
"a held effect must be renewed before the upload expires"
);
bus.play(2, HapticEffect::for_seconds(0.5, 0.5, 1.0));
bridge.sync_at(&bus, t0 + RUMBLE_REFRESH);
assert_eq!(
bridge.drain(),
vec![(GamepadId(2), 0.5, 0.5, RUMBLE_UPLOAD_MS)]
);
bus.advance(1.5);
assert!(!bus.is_playing(2));
bridge.sync_at(&bus, t0 + RUMBLE_REFRESH);
assert_eq!(
bridge.drain(),
vec![(GamepadId(2), 0.0, 0.0, 0)],
"an expired effect must stop the pad"
);
bridge.sync_at(&bus, t0 + RUMBLE_REFRESH);
assert!(bridge.drain().is_empty(), "a stopped pad must not repeat");
}
#[test]
fn rumble_reaches_the_runtime_queue_the_runner_drains() {
let mut bus = HapticBus::new();
let mut bridge = RumbleBridge::new();
bus.play(1, HapticEffect::constant(0.25, 0.75));
let mut runtime = repose_app::ReposeRuntime::new();
bridge.sync(&bus);
bridge.forward_to(&mut runtime);
assert_eq!(
runtime.take_rumble_requests(),
vec![(1, 0.25, 0.75, RUMBLE_UPLOAD_MS)]
);
assert!(runtime.take_rumble_requests().is_empty());
}