Skip to main content

jugar_web/
input.rs

1//! Browser input event translation
2//!
3//! Translates browser events (keyboard, mouse, touch) to Jugar's `InputState`.
4//! All computation happens in Rust - JavaScript only forwards raw events.
5
6use glam::Vec2;
7use jugar_input::{
8    ButtonState, GamepadAxis, GamepadButton, InputState, KeyCode, MouseButton, TouchEvent,
9    TouchPhase,
10};
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14/// Input translation errors
15#[derive(Error, Debug, Clone, PartialEq, Eq)]
16pub enum InputTranslationError {
17    /// Failed to parse input JSON
18    #[error("Failed to parse input JSON: {0}")]
19    InvalidJson(String),
20    /// Unknown event type
21    #[error("Unknown event type: {0}")]
22    UnknownEventType(String),
23    /// Invalid event data
24    #[error("Invalid event data: {0}")]
25    InvalidData(String),
26}
27
28/// Browser input event from JavaScript
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct BrowserInputEvent {
31    /// Type of the event
32    pub event_type: String,
33    /// Timestamp in milliseconds (DOMHighResTimeStamp)
34    pub timestamp: f64,
35    /// Event-specific data
36    pub data: BrowserEventData,
37}
38
39/// Event-specific data
40///
41/// Note: Variants are ordered from most specific (more fields) to least specific
42/// because serde's `untagged` tries them in order.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(untagged)]
45pub enum BrowserEventData {
46    /// Keyboard event data
47    Key {
48        /// JavaScript key code (e.g., "KeyW", "Space", "ArrowUp")
49        key: String,
50    },
51    /// Gamepad axis event data (has 3 unique fields)
52    GamepadAxis {
53        /// Gamepad index
54        gamepad: u8,
55        /// Axis index
56        axis: u8,
57        /// Axis value (-1.0 to 1.0)
58        value: f32,
59    },
60    /// Touch event data (has id, x, y - 3 fields)
61    Touch {
62        /// Touch identifier
63        id: u32,
64        /// X position in pixels
65        x: f32,
66        /// Y position in pixels
67        y: f32,
68    },
69    /// Mouse button event data (has button, x, y - 3 fields)
70    MouseButton {
71        /// Button index (0=left, 1=middle, 2=right)
72        button: u8,
73        /// X position in pixels
74        x: f32,
75        /// Y position in pixels
76        y: f32,
77    },
78    /// Gamepad button event data (has 2 fields)
79    GamepadButton {
80        /// Gamepad index
81        gamepad: u8,
82        /// Button index
83        button: u8,
84    },
85    /// Mouse move event data (has x, y - 2 fields, least specific)
86    MouseMove {
87        /// X position in pixels
88        x: f32,
89        /// Y position in pixels
90        y: f32,
91    },
92}
93
94/// Translates a JavaScript key code to Jugar KeyCode
95#[must_use]
96pub fn translate_key(js_key: &str) -> Option<KeyCode> {
97    match js_key {
98        // Arrow keys
99        "ArrowUp" => Some(KeyCode::Up),
100        "ArrowDown" => Some(KeyCode::Down),
101        "ArrowLeft" => Some(KeyCode::Left),
102        "ArrowRight" => Some(KeyCode::Right),
103
104        // Special keys
105        "Space" => Some(KeyCode::Space),
106        "Enter" => Some(KeyCode::Enter),
107        "Escape" => Some(KeyCode::Escape),
108
109        // Letter keys (KeyA through KeyZ)
110        key if key.starts_with("Key") && key.len() == 4 => {
111            let c = key.chars().nth(3)?;
112            if c.is_ascii_uppercase() {
113                Some(KeyCode::Letter(c))
114            } else {
115                None
116            }
117        }
118
119        // Number keys (Digit0 through Digit9)
120        key if key.starts_with("Digit") && key.len() == 6 => {
121            let c = key.chars().nth(5)?;
122            let n = c.to_digit(10)? as u8;
123            Some(KeyCode::Number(n))
124        }
125
126        // Function keys (F1 through F12)
127        key if key.starts_with('F') && key.len() <= 3 => {
128            let n: u8 = key[1..].parse().ok()?;
129            if (1..=12).contains(&n) {
130                Some(KeyCode::Function(n))
131            } else {
132                None
133            }
134        }
135
136        _ => None,
137    }
138}
139
140/// Translates a JavaScript mouse button index to Jugar MouseButton
141#[must_use]
142pub const fn translate_mouse_button(button: u8) -> MouseButton {
143    match button {
144        0 => MouseButton::Left,
145        1 => MouseButton::Middle,
146        2 => MouseButton::Right,
147        n => MouseButton::Extra(n.saturating_sub(3)),
148    }
149}
150
151/// Translates a JavaScript gamepad button index to Jugar GamepadButton
152#[must_use]
153pub const fn translate_gamepad_button(button: u8) -> Option<GamepadButton> {
154    match button {
155        0 => Some(GamepadButton::South),
156        1 => Some(GamepadButton::East),
157        2 => Some(GamepadButton::West),
158        3 => Some(GamepadButton::North),
159        4 => Some(GamepadButton::LeftBumper),
160        5 => Some(GamepadButton::RightBumper),
161        8 => Some(GamepadButton::Select),
162        9 => Some(GamepadButton::Start),
163        10 => Some(GamepadButton::LeftStick),
164        11 => Some(GamepadButton::RightStick),
165        12 => Some(GamepadButton::DPadUp),
166        13 => Some(GamepadButton::DPadDown),
167        14 => Some(GamepadButton::DPadLeft),
168        15 => Some(GamepadButton::DPadRight),
169        _ => None,
170    }
171}
172
173/// Translates a JavaScript gamepad axis index to Jugar GamepadAxis
174#[must_use]
175pub const fn translate_gamepad_axis(axis: u8) -> Option<GamepadAxis> {
176    match axis {
177        0 => Some(GamepadAxis::LeftStickX),
178        1 => Some(GamepadAxis::LeftStickY),
179        2 => Some(GamepadAxis::RightStickX),
180        3 => Some(GamepadAxis::RightStickY),
181        _ => None,
182    }
183}
184
185/// Processes a batch of browser input events into InputState
186///
187/// # Arguments
188///
189/// * `events_json` - JSON array of browser input events
190/// * `state` - InputState to update
191/// * `canvas_offset` - Offset to subtract from coordinates (converts viewport to canvas coords)
192///
193/// # Errors
194///
195/// Returns an error if the JSON is malformed or contains invalid data.
196pub fn process_input_events(
197    events_json: &str,
198    state: &mut InputState,
199    canvas_offset: Vec2,
200) -> Result<(), InputTranslationError> {
201    if events_json.is_empty() || events_json == "[]" {
202        return Ok(());
203    }
204
205    let events: Vec<BrowserInputEvent> = serde_json::from_str(events_json)
206        .map_err(|e| InputTranslationError::InvalidJson(e.to_string()))?;
207
208    for event in events {
209        process_single_event(&event, state, canvas_offset)?;
210    }
211
212    Ok(())
213}
214
215/// Processes a single browser input event
216///
217/// # Arguments
218///
219/// * `event` - The browser input event
220/// * `state` - InputState to update
221/// * `offset` - Offset to subtract from coordinates (viewport to canvas conversion)
222#[allow(clippy::too_many_lines)]
223fn process_single_event(
224    event: &BrowserInputEvent,
225    state: &mut InputState,
226    offset: Vec2,
227) -> Result<(), InputTranslationError> {
228    match event.event_type.as_str() {
229        "KeyDown" => {
230            if let BrowserEventData::Key { key } = &event.data {
231                if let Some(key_code) = translate_key(key) {
232                    let current = state.key(key_code);
233                    if !current.is_down() {
234                        state.set_key(key_code, ButtonState::JustPressed);
235                    }
236                }
237            }
238        }
239        "KeyUp" => {
240            if let BrowserEventData::Key { key } = &event.data {
241                if let Some(key_code) = translate_key(key) {
242                    state.set_key(key_code, ButtonState::JustReleased);
243                }
244            }
245        }
246        "MouseMove" => {
247            if let BrowserEventData::MouseMove { x, y } = &event.data {
248                let old_pos = state.mouse_position;
249                // Apply offset to convert viewport coords to canvas coords
250                state.mouse_position = Vec2::new(*x - offset.x, *y - offset.y);
251                state.mouse_delta = state.mouse_position - old_pos;
252            }
253        }
254        "MouseDown" => {
255            if let BrowserEventData::MouseButton { button, x, y } = &event.data {
256                // Apply offset to convert viewport coords to canvas coords
257                state.mouse_position = Vec2::new(*x - offset.x, *y - offset.y);
258                let idx = mouse_button_index(*button);
259                if idx < state.mouse_buttons.len() {
260                    state.mouse_buttons[idx] = ButtonState::JustPressed;
261                }
262            }
263        }
264        "MouseUp" => {
265            if let BrowserEventData::MouseButton { button, x, y } = &event.data {
266                // Apply offset to convert viewport coords to canvas coords
267                state.mouse_position = Vec2::new(*x - offset.x, *y - offset.y);
268                let idx = mouse_button_index(*button);
269                if idx < state.mouse_buttons.len() {
270                    state.mouse_buttons[idx] = ButtonState::JustReleased;
271                }
272            }
273        }
274        "TouchStart" => {
275            if let BrowserEventData::Touch { id, x, y } = &event.data {
276                // Apply offset to convert viewport coords to canvas coords
277                let canvas_pos = Vec2::new(*x - offset.x, *y - offset.y);
278                state.touches.push(
279                    TouchEvent::new(canvas_pos)
280                        .with_id(*id)
281                        .with_phase(TouchPhase::Started),
282                );
283            }
284        }
285        "TouchMove" => {
286            if let BrowserEventData::Touch { id, x, y } = &event.data {
287                // Apply offset to convert viewport coords to canvas coords
288                let canvas_pos = Vec2::new(*x - offset.x, *y - offset.y);
289                // Update existing touch or add new one
290                if let Some(touch) = state.touches.iter_mut().find(|t| t.id == *id) {
291                    touch.delta = canvas_pos - touch.position;
292                    touch.position = canvas_pos;
293                    touch.phase = TouchPhase::Moved;
294                }
295            }
296        }
297        "TouchEnd" => {
298            if let BrowserEventData::Touch { id, .. } = &event.data {
299                if let Some(touch) = state.touches.iter_mut().find(|t| t.id == *id) {
300                    touch.phase = TouchPhase::Ended;
301                }
302            }
303        }
304        "TouchCancel" => {
305            if let BrowserEventData::Touch { id, .. } = &event.data {
306                if let Some(touch) = state.touches.iter_mut().find(|t| t.id == *id) {
307                    touch.phase = TouchPhase::Cancelled;
308                }
309            }
310        }
311        "GamepadButtonDown" => {
312            if let BrowserEventData::GamepadButton { gamepad, button } = &event.data {
313                let gp_idx = *gamepad as usize;
314                if gp_idx < state.gamepads.len() {
315                    if let Some(btn) = translate_gamepad_button(*button) {
316                        let btn_idx = btn as usize;
317                        if btn_idx < state.gamepads[gp_idx].buttons.len() {
318                            state.gamepads[gp_idx].buttons[btn_idx] = ButtonState::JustPressed;
319                            state.gamepads[gp_idx].connected = true;
320                        }
321                    }
322                }
323            }
324        }
325        "GamepadButtonUp" => {
326            if let BrowserEventData::GamepadButton { gamepad, button } = &event.data {
327                let gp_idx = *gamepad as usize;
328                if gp_idx < state.gamepads.len() {
329                    if let Some(btn) = translate_gamepad_button(*button) {
330                        let btn_idx = btn as usize;
331                        if btn_idx < state.gamepads[gp_idx].buttons.len() {
332                            state.gamepads[gp_idx].buttons[btn_idx] = ButtonState::JustReleased;
333                        }
334                    }
335                }
336            }
337        }
338        "GamepadAxisMove" => {
339            if let BrowserEventData::GamepadAxis {
340                gamepad,
341                axis,
342                value,
343            } = &event.data
344            {
345                let gp_idx = *gamepad as usize;
346                if gp_idx < state.gamepads.len() {
347                    if let Some(ax) = translate_gamepad_axis(*axis) {
348                        let ax_idx = ax as usize;
349                        if ax_idx < state.gamepads[gp_idx].axes.len() {
350                            state.gamepads[gp_idx].axes[ax_idx] = *value;
351                            state.gamepads[gp_idx].connected = true;
352                        }
353                    }
354                }
355            }
356        }
357        "GamepadConnected" => {
358            if let BrowserEventData::GamepadButton { gamepad, .. } = &event.data {
359                let gp_idx = *gamepad as usize;
360                if gp_idx < state.gamepads.len() {
361                    state.gamepads[gp_idx].connected = true;
362                }
363            }
364        }
365        "GamepadDisconnected" => {
366            if let BrowserEventData::GamepadButton { gamepad, .. } = &event.data {
367                let gp_idx = *gamepad as usize;
368                if gp_idx < state.gamepads.len() {
369                    state.gamepads[gp_idx].connected = false;
370                }
371            }
372        }
373        unknown => {
374            return Err(InputTranslationError::UnknownEventType(unknown.to_string()));
375        }
376    }
377
378    Ok(())
379}
380
381/// Maps JS mouse button index to internal array index
382const fn mouse_button_index(button: u8) -> usize {
383    match button {
384        0 => 0, // Left
385        1 => 2, // Middle (JS uses 1, we use 2)
386        2 => 1, // Right (JS uses 2, we use 1)
387        n => (n as usize).saturating_sub(3).saturating_add(3),
388    }
389}
390
391#[cfg(test)]
392#[allow(clippy::unwrap_used, clippy::expect_used)]
393mod tests {
394    use super::*;
395
396    // ==================== KEY TRANSLATION TESTS ====================
397
398    #[test]
399    fn test_translate_arrow_keys() {
400        assert_eq!(translate_key("ArrowUp"), Some(KeyCode::Up));
401        assert_eq!(translate_key("ArrowDown"), Some(KeyCode::Down));
402        assert_eq!(translate_key("ArrowLeft"), Some(KeyCode::Left));
403        assert_eq!(translate_key("ArrowRight"), Some(KeyCode::Right));
404    }
405
406    #[test]
407    fn test_translate_special_keys() {
408        assert_eq!(translate_key("Space"), Some(KeyCode::Space));
409        assert_eq!(translate_key("Enter"), Some(KeyCode::Enter));
410        assert_eq!(translate_key("Escape"), Some(KeyCode::Escape));
411    }
412
413    #[test]
414    fn test_translate_letter_keys() {
415        assert_eq!(translate_key("KeyA"), Some(KeyCode::Letter('A')));
416        assert_eq!(translate_key("KeyW"), Some(KeyCode::Letter('W')));
417        assert_eq!(translate_key("KeyS"), Some(KeyCode::Letter('S')));
418        assert_eq!(translate_key("KeyD"), Some(KeyCode::Letter('D')));
419        assert_eq!(translate_key("KeyZ"), Some(KeyCode::Letter('Z')));
420    }
421
422    #[test]
423    fn test_translate_number_keys() {
424        assert_eq!(translate_key("Digit0"), Some(KeyCode::Number(0)));
425        assert_eq!(translate_key("Digit1"), Some(KeyCode::Number(1)));
426        assert_eq!(translate_key("Digit9"), Some(KeyCode::Number(9)));
427    }
428
429    #[test]
430    fn test_translate_function_keys() {
431        assert_eq!(translate_key("F1"), Some(KeyCode::Function(1)));
432        assert_eq!(translate_key("F5"), Some(KeyCode::Function(5)));
433        assert_eq!(translate_key("F12"), Some(KeyCode::Function(12)));
434    }
435
436    #[test]
437    fn test_translate_unknown_key() {
438        assert_eq!(translate_key("Unknown"), None);
439        assert_eq!(translate_key(""), None);
440        assert_eq!(translate_key("Key"), None); // Too short
441        assert_eq!(translate_key("KeyAB"), None); // Too long
442        assert_eq!(translate_key("F13"), None); // Out of range
443        assert_eq!(translate_key("F0"), None); // Out of range
444    }
445
446    // ==================== MOUSE BUTTON TESTS ====================
447
448    #[test]
449    fn test_translate_mouse_buttons() {
450        assert_eq!(translate_mouse_button(0), MouseButton::Left);
451        assert_eq!(translate_mouse_button(1), MouseButton::Middle);
452        assert_eq!(translate_mouse_button(2), MouseButton::Right);
453        assert_eq!(translate_mouse_button(3), MouseButton::Extra(0));
454        assert_eq!(translate_mouse_button(4), MouseButton::Extra(1));
455    }
456
457    // ==================== GAMEPAD BUTTON TESTS ====================
458
459    #[test]
460    fn test_translate_gamepad_buttons() {
461        assert_eq!(translate_gamepad_button(0), Some(GamepadButton::South));
462        assert_eq!(translate_gamepad_button(1), Some(GamepadButton::East));
463        assert_eq!(translate_gamepad_button(2), Some(GamepadButton::West));
464        assert_eq!(translate_gamepad_button(3), Some(GamepadButton::North));
465        assert_eq!(translate_gamepad_button(4), Some(GamepadButton::LeftBumper));
466        assert_eq!(
467            translate_gamepad_button(5),
468            Some(GamepadButton::RightBumper)
469        );
470        assert_eq!(translate_gamepad_button(8), Some(GamepadButton::Select));
471        assert_eq!(translate_gamepad_button(9), Some(GamepadButton::Start));
472        assert_eq!(translate_gamepad_button(12), Some(GamepadButton::DPadUp));
473        assert_eq!(translate_gamepad_button(13), Some(GamepadButton::DPadDown));
474        assert_eq!(translate_gamepad_button(14), Some(GamepadButton::DPadLeft));
475        assert_eq!(translate_gamepad_button(15), Some(GamepadButton::DPadRight));
476    }
477
478    #[test]
479    fn test_translate_gamepad_button_invalid() {
480        assert_eq!(translate_gamepad_button(6), None);
481        assert_eq!(translate_gamepad_button(7), None);
482        assert_eq!(translate_gamepad_button(16), None);
483        assert_eq!(translate_gamepad_button(255), None);
484    }
485
486    // ==================== GAMEPAD AXIS TESTS ====================
487
488    #[test]
489    fn test_translate_gamepad_axes() {
490        assert_eq!(translate_gamepad_axis(0), Some(GamepadAxis::LeftStickX));
491        assert_eq!(translate_gamepad_axis(1), Some(GamepadAxis::LeftStickY));
492        assert_eq!(translate_gamepad_axis(2), Some(GamepadAxis::RightStickX));
493        assert_eq!(translate_gamepad_axis(3), Some(GamepadAxis::RightStickY));
494    }
495
496    #[test]
497    fn test_translate_gamepad_axis_invalid() {
498        assert_eq!(translate_gamepad_axis(4), None);
499        assert_eq!(translate_gamepad_axis(255), None);
500    }
501
502    // ==================== EVENT PROCESSING TESTS ====================
503
504    #[test]
505    fn test_process_empty_events() {
506        let mut state = InputState::new();
507        assert!(process_input_events("", &mut state, Vec2::ZERO).is_ok());
508        assert!(process_input_events("[]", &mut state, Vec2::ZERO).is_ok());
509    }
510
511    #[test]
512    fn test_process_key_down() {
513        let mut state = InputState::new();
514        let events = r#"[{"event_type":"KeyDown","timestamp":0,"data":{"key":"Space"}}]"#;
515
516        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
517        assert!(state.key(KeyCode::Space).just_pressed());
518    }
519
520    #[test]
521    fn test_process_key_up() {
522        let mut state = InputState::new();
523        state.set_key(KeyCode::Space, ButtonState::Pressed);
524
525        let events = r#"[{"event_type":"KeyUp","timestamp":0,"data":{"key":"Space"}}]"#;
526
527        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
528        assert!(state.key(KeyCode::Space).just_released());
529    }
530
531    #[test]
532    fn test_process_mouse_move() {
533        let mut state = InputState::new();
534        let events = r#"[{"event_type":"MouseMove","timestamp":0,"data":{"x":100.0,"y":200.0}}]"#;
535
536        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
537        assert!((state.mouse_position.x - 100.0).abs() < f32::EPSILON);
538        assert!((state.mouse_position.y - 200.0).abs() < f32::EPSILON);
539    }
540
541    #[test]
542    fn test_process_mouse_down() {
543        let mut state = InputState::new();
544        let events =
545            r#"[{"event_type":"MouseDown","timestamp":0,"data":{"button":0,"x":50.0,"y":60.0}}]"#;
546
547        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
548        assert!(state.mouse_button(MouseButton::Left).just_pressed());
549        assert!((state.mouse_position.x - 50.0).abs() < f32::EPSILON);
550    }
551
552    #[test]
553    fn test_process_mouse_up() {
554        let mut state = InputState::new();
555        state.mouse_buttons[0] = ButtonState::Pressed;
556
557        let events =
558            r#"[{"event_type":"MouseUp","timestamp":0,"data":{"button":0,"x":50.0,"y":60.0}}]"#;
559
560        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
561        assert!(state.mouse_button(MouseButton::Left).just_released());
562    }
563
564    #[test]
565    fn test_process_touch_start() {
566        let mut state = InputState::new();
567        let events =
568            r#"[{"event_type":"TouchStart","timestamp":0,"data":{"id":1,"x":100.0,"y":200.0}}]"#;
569
570        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
571        assert_eq!(state.touches.len(), 1);
572        // Note: ID is set via with_id() builder
573        assert_eq!(state.touches[0].phase, TouchPhase::Started);
574    }
575
576    #[test]
577    fn test_process_touch_move() {
578        let mut state = InputState::new();
579        // Create touch with same ID that will be moved
580        state
581            .touches
582            .push(TouchEvent::new(Vec2::new(100.0, 200.0)).with_id(1));
583
584        let events =
585            r#"[{"event_type":"TouchMove","timestamp":0,"data":{"id":1,"x":150.0,"y":250.0}}]"#;
586
587        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
588        // TouchMove should update existing touch phase
589        assert_eq!(state.touches[0].phase, TouchPhase::Moved);
590        assert!((state.touches[0].position.x - 150.0).abs() < f32::EPSILON);
591    }
592
593    #[test]
594    fn test_process_touch_end() {
595        let mut state = InputState::new();
596        state
597            .touches
598            .push(TouchEvent::new(Vec2::new(100.0, 200.0)).with_id(1));
599
600        let events = r#"[{"event_type":"TouchEnd","timestamp":0,"data":{"id":1,"x":0.0,"y":0.0}}]"#;
601
602        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
603        assert_eq!(state.touches[0].phase, TouchPhase::Ended);
604    }
605
606    #[test]
607    fn test_process_gamepad_button() {
608        let mut state = InputState::new();
609        let events =
610            r#"[{"event_type":"GamepadButtonDown","timestamp":0,"data":{"gamepad":0,"button":0}}]"#;
611
612        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
613        assert!(state.gamepads[0].connected);
614        assert!(state.gamepads[0]
615            .button(GamepadButton::South)
616            .just_pressed());
617    }
618
619    #[test]
620    fn test_process_gamepad_axis() {
621        let mut state = InputState::new();
622        let events = r#"[{"event_type":"GamepadAxisMove","timestamp":0,"data":{"gamepad":0,"axis":0,"value":0.75}}]"#;
623
624        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
625        assert!(state.gamepads[0].connected);
626        assert!((state.gamepads[0].axis(GamepadAxis::LeftStickX) - 0.75).abs() < f32::EPSILON);
627    }
628
629    #[test]
630    fn test_process_multiple_events() {
631        let mut state = InputState::new();
632        let events = r#"[
633            {"event_type":"KeyDown","timestamp":0,"data":{"key":"KeyW"}},
634            {"event_type":"KeyDown","timestamp":1,"data":{"key":"Space"}},
635            {"event_type":"MouseMove","timestamp":2,"data":{"x":400.0,"y":300.0}}
636        ]"#;
637
638        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
639        assert!(state.key(KeyCode::Letter('W')).just_pressed());
640        assert!(state.key(KeyCode::Space).just_pressed());
641        assert!((state.mouse_position.x - 400.0).abs() < f32::EPSILON);
642    }
643
644    #[test]
645    fn test_process_invalid_json() {
646        let mut state = InputState::new();
647        let result = process_input_events("not json", &mut state, Vec2::ZERO);
648        assert!(matches!(result, Err(InputTranslationError::InvalidJson(_))));
649    }
650
651    #[test]
652    fn test_process_unknown_event_type() {
653        let mut state = InputState::new();
654        let events = r#"[{"event_type":"Unknown","timestamp":0,"data":{"key":"Space"}}]"#;
655        let result = process_input_events(events, &mut state, Vec2::ZERO);
656        assert!(matches!(
657            result,
658            Err(InputTranslationError::UnknownEventType(_))
659        ));
660    }
661
662    // ==================== CANVAS OFFSET TESTS ====================
663
664    #[test]
665    fn test_canvas_offset_mouse_move() {
666        let mut state = InputState::new();
667        // Simulating canvas at (100, 50) in viewport
668        let canvas_offset = Vec2::new(100.0, 50.0);
669        // Raw viewport coordinates: 200, 150
670        let events = r#"[{"event_type":"MouseMove","timestamp":0,"data":{"x":200.0,"y":150.0}}]"#;
671
672        assert!(process_input_events(events, &mut state, canvas_offset).is_ok());
673        // Canvas coordinates should be: (200-100, 150-50) = (100, 100)
674        assert!((state.mouse_position.x - 100.0).abs() < f32::EPSILON);
675        assert!((state.mouse_position.y - 100.0).abs() < f32::EPSILON);
676    }
677
678    #[test]
679    fn test_canvas_offset_mouse_down() {
680        let mut state = InputState::new();
681        let canvas_offset = Vec2::new(50.0, 25.0);
682        // Raw viewport coordinates: 150, 125
683        let events =
684            r#"[{"event_type":"MouseDown","timestamp":0,"data":{"button":0,"x":150.0,"y":125.0}}]"#;
685
686        assert!(process_input_events(events, &mut state, canvas_offset).is_ok());
687        // Canvas coordinates should be: (150-50, 125-25) = (100, 100)
688        assert!((state.mouse_position.x - 100.0).abs() < f32::EPSILON);
689        assert!((state.mouse_position.y - 100.0).abs() < f32::EPSILON);
690    }
691
692    #[test]
693    fn test_canvas_offset_touch_start() {
694        let mut state = InputState::new();
695        let canvas_offset = Vec2::new(30.0, 40.0);
696        // Raw viewport coordinates: 130, 140
697        let events =
698            r#"[{"event_type":"TouchStart","timestamp":0,"data":{"id":1,"x":130.0,"y":140.0}}]"#;
699
700        assert!(process_input_events(events, &mut state, canvas_offset).is_ok());
701        assert_eq!(state.touches.len(), 1);
702        // Canvas coordinates should be: (130-30, 140-40) = (100, 100)
703        assert!((state.touches[0].position.x - 100.0).abs() < f32::EPSILON);
704        assert!((state.touches[0].position.y - 100.0).abs() < f32::EPSILON);
705    }
706
707    #[test]
708    fn test_canvas_offset_touch_move() {
709        let mut state = InputState::new();
710        state
711            .touches
712            .push(TouchEvent::new(Vec2::new(100.0, 100.0)).with_id(1));
713
714        let canvas_offset = Vec2::new(20.0, 10.0);
715        // Raw viewport coordinates: 170, 160 -> canvas: (150, 150)
716        let events =
717            r#"[{"event_type":"TouchMove","timestamp":0,"data":{"id":1,"x":170.0,"y":160.0}}]"#;
718
719        assert!(process_input_events(events, &mut state, canvas_offset).is_ok());
720        assert!((state.touches[0].position.x - 150.0).abs() < f32::EPSILON);
721        assert!((state.touches[0].position.y - 150.0).abs() < f32::EPSILON);
722    }
723
724    // ==================== ERROR DISPLAY TESTS ====================
725
726    #[test]
727    fn test_error_display() {
728        let err = InputTranslationError::InvalidJson("test".to_string());
729        assert!(format!("{err}").contains("test"));
730
731        let err = InputTranslationError::UnknownEventType("foo".to_string());
732        assert!(format!("{err}").contains("foo"));
733
734        let err = InputTranslationError::InvalidData("bar".to_string());
735        assert!(format!("{err}").contains("bar"));
736    }
737
738    // ==================== MOUSE BUTTON INDEX TESTS ====================
739
740    #[test]
741    fn test_mouse_button_index() {
742        assert_eq!(mouse_button_index(0), 0); // Left
743        assert_eq!(mouse_button_index(1), 2); // Middle (swapped)
744        assert_eq!(mouse_button_index(2), 1); // Right (swapped)
745        assert_eq!(mouse_button_index(3), 3); // Extra
746    }
747
748    // ==================== EDGE CASE TESTS FOR COVERAGE ====================
749
750    #[test]
751    fn test_translate_key_lowercase_letter_returns_none() {
752        // Line 115: lowercase letter keys should return None
753        assert!(translate_key("Keya").is_none());
754        assert!(translate_key("Keyz").is_none());
755    }
756
757    #[test]
758    fn test_translate_gamepad_button_stick_buttons() {
759        // Lines 163-164: LeftStick and RightStick buttons
760        assert_eq!(translate_gamepad_button(10), Some(GamepadButton::LeftStick));
761        assert_eq!(
762            translate_gamepad_button(11),
763            Some(GamepadButton::RightStick)
764        );
765    }
766
767    #[test]
768    fn test_key_down_unknown_key_does_not_set_state() {
769        // Lines 236-237: when translate_key returns None, nothing is set
770        let mut state = InputState::new();
771        let events = r#"[{"event_type":"KeyDown","timestamp":0,"data":{"key":"UnknownKey"}}]"#;
772
773        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
774        // No keys should be pressed since "UnknownKey" is not recognized
775        assert!(!state.key(KeyCode::Space).is_down());
776    }
777
778    #[test]
779    fn test_key_down_already_pressed_no_double_just_pressed() {
780        // When key is already down, don't set JustPressed again
781        let mut state = InputState::new();
782        state.set_key(KeyCode::Space, ButtonState::Pressed);
783
784        let events = r#"[{"event_type":"KeyDown","timestamp":0,"data":{"key":"Space"}}]"#;
785        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
786
787        // Should still be Pressed (not JustPressed), since it was already down
788        assert!(state.key(KeyCode::Space).is_down());
789    }
790
791    // ==================== TOUCH EVENT COVERAGE ====================
792
793    #[test]
794    fn test_touch_end_sets_ended_phase() {
795        let mut state = InputState::new();
796        state
797            .touches
798            .push(TouchEvent::new(Vec2::new(100.0, 100.0)).with_id(5));
799
800        let events =
801            r#"[{"event_type":"TouchEnd","timestamp":0,"data":{"id":5,"x":100.0,"y":100.0}}]"#;
802        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
803
804        assert_eq!(state.touches[0].phase, TouchPhase::Ended);
805    }
806
807    #[test]
808    fn test_touch_cancel_sets_cancelled_phase() {
809        let mut state = InputState::new();
810        state
811            .touches
812            .push(TouchEvent::new(Vec2::new(100.0, 100.0)).with_id(7));
813
814        let events =
815            r#"[{"event_type":"TouchCancel","timestamp":0,"data":{"id":7,"x":100.0,"y":100.0}}]"#;
816        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
817
818        assert_eq!(state.touches[0].phase, TouchPhase::Cancelled);
819    }
820
821    // ==================== MOUSE UP COVERAGE ====================
822
823    #[test]
824    fn test_mouse_up_event() {
825        let mut state = InputState::new();
826        state.mouse_buttons[0] = ButtonState::Pressed;
827
828        let events =
829            r#"[{"event_type":"MouseUp","timestamp":0,"data":{"button":0,"x":150.0,"y":200.0}}]"#;
830        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
831
832        assert_eq!(state.mouse_buttons[0], ButtonState::JustReleased);
833        assert!((state.mouse_position.x - 150.0).abs() < f32::EPSILON);
834        assert!((state.mouse_position.y - 200.0).abs() < f32::EPSILON);
835    }
836
837    // ==================== GAMEPAD CONNECTION COVERAGE ====================
838
839    #[test]
840    fn test_gamepad_connected_event() {
841        let mut state = InputState::new();
842        assert!(!state.gamepads[0].connected);
843
844        let events =
845            r#"[{"event_type":"GamepadConnected","timestamp":0,"data":{"gamepad":0,"button":0}}]"#;
846        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
847
848        assert!(state.gamepads[0].connected);
849    }
850
851    #[test]
852    fn test_gamepad_disconnected_event() {
853        let mut state = InputState::new();
854        state.gamepads[0].connected = true;
855
856        let events = r#"[{"event_type":"GamepadDisconnected","timestamp":0,"data":{"gamepad":0,"button":0}}]"#;
857        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
858
859        assert!(!state.gamepads[0].connected);
860    }
861
862    #[test]
863    fn test_gamepad_button_up_event() {
864        let mut state = InputState::new();
865        state.gamepads[0].buttons[0] = ButtonState::Pressed;
866
867        let events =
868            r#"[{"event_type":"GamepadButtonUp","timestamp":0,"data":{"gamepad":0,"button":0}}]"#;
869        assert!(process_input_events(events, &mut state, Vec2::ZERO).is_ok());
870
871        assert_eq!(state.gamepads[0].buttons[0], ButtonState::JustReleased);
872    }
873}
874
875// ==================== PROPERTY-BASED TESTS ====================
876
877#[cfg(test)]
878#[allow(clippy::uninlined_format_args)]
879mod property_tests {
880    use super::*;
881    use proptest::prelude::*;
882
883    proptest! {
884        /// Property: Canvas offset subtraction is linear and reversible
885        #[test]
886        fn property_canvas_offset_linear(
887            viewport_x in 0.0f32..2000.0,
888            viewport_y in 0.0f32..2000.0,
889            offset_x in 0.0f32..500.0,
890            offset_y in 0.0f32..500.0,
891        ) {
892            let mut state = InputState::new();
893            let canvas_offset = Vec2::new(offset_x, offset_y);
894            let events = format!(
895                r#"[{{"event_type":"MouseMove","timestamp":0,"data":{{"x":{},"y":{}}}}}]"#,
896                viewport_x, viewport_y
897            );
898
899            let result = process_input_events(&events, &mut state, canvas_offset);
900            prop_assert!(result.is_ok());
901
902            // Canvas coords = viewport coords - offset
903            let expected_x = viewport_x - offset_x;
904            let expected_y = viewport_y - offset_y;
905            prop_assert!((state.mouse_position.x - expected_x).abs() < 0.001);
906            prop_assert!((state.mouse_position.y - expected_y).abs() < 0.001);
907        }
908
909        /// Property: Zero offset preserves coordinates exactly
910        #[test]
911        fn property_zero_offset_identity(
912            x in 0.0f32..2000.0,
913            y in 0.0f32..2000.0,
914        ) {
915            let mut state = InputState::new();
916            let events = format!(
917                r#"[{{"event_type":"MouseDown","timestamp":0,"data":{{"button":0,"x":{},"y":{}}}}}]"#,
918                x, y
919            );
920
921            let result = process_input_events(&events, &mut state, Vec2::ZERO);
922            prop_assert!(result.is_ok());
923            prop_assert!((state.mouse_position.x - x).abs() < 0.001);
924            prop_assert!((state.mouse_position.y - y).abs() < 0.001);
925        }
926
927        /// Property: Touch coordinates also get offset applied
928        #[test]
929        fn property_touch_offset_applied(
930            viewport_x in 0.0f32..2000.0,
931            viewport_y in 0.0f32..2000.0,
932            offset_x in 0.0f32..500.0,
933            offset_y in 0.0f32..500.0,
934            touch_id in 0u32..100,
935        ) {
936            let mut state = InputState::new();
937            let canvas_offset = Vec2::new(offset_x, offset_y);
938            let events = format!(
939                r#"[{{"event_type":"TouchStart","timestamp":0,"data":{{"id":{},"x":{},"y":{}}}}}]"#,
940                touch_id, viewport_x, viewport_y
941            );
942
943            let result = process_input_events(&events, &mut state, canvas_offset);
944            prop_assert!(result.is_ok());
945            prop_assert_eq!(state.touches.len(), 1);
946
947            let expected_x = viewport_x - offset_x;
948            let expected_y = viewport_y - offset_y;
949            prop_assert!((state.touches[0].position.x - expected_x).abs() < 0.001);
950            prop_assert!((state.touches[0].position.y - expected_y).abs() < 0.001);
951        }
952
953        /// Property: Negative canvas coordinates are valid (canvas not at 0,0)
954        #[test]
955        fn property_negative_coords_valid(
956            viewport_x in 0.0f32..100.0,
957            viewport_y in 0.0f32..100.0,
958            offset_x in 100.0f32..500.0,
959            offset_y in 100.0f32..500.0,
960        ) {
961            let mut state = InputState::new();
962            let canvas_offset = Vec2::new(offset_x, offset_y);
963            let events = format!(
964                r#"[{{"event_type":"MouseMove","timestamp":0,"data":{{"x":{},"y":{}}}}}]"#,
965                viewport_x, viewport_y
966            );
967
968            let result = process_input_events(&events, &mut state, canvas_offset);
969            prop_assert!(result.is_ok());
970
971            // Result should be negative (clicked outside canvas area)
972            prop_assert!(state.mouse_position.x < 0.0);
973            prop_assert!(state.mouse_position.y < 0.0);
974        }
975
976        /// Property: Multiple mouse events maintain offset consistency
977        #[test]
978        fn property_multiple_events_consistent_offset(
979            x1 in 0.0f32..1000.0,
980            y1 in 0.0f32..1000.0,
981            x2 in 0.0f32..1000.0,
982            y2 in 0.0f32..1000.0,
983            offset_x in 0.0f32..200.0,
984            offset_y in 0.0f32..200.0,
985        ) {
986            let mut state = InputState::new();
987            let canvas_offset = Vec2::new(offset_x, offset_y);
988            let events = format!(
989                r#"[
990                    {{"event_type":"MouseDown","timestamp":0,"data":{{"button":0,"x":{},"y":{}}}}},
991                    {{"event_type":"MouseMove","timestamp":1,"data":{{"x":{},"y":{}}}}}
992                ]"#,
993                x1, y1, x2, y2
994            );
995
996            let result = process_input_events(&events, &mut state, canvas_offset);
997            prop_assert!(result.is_ok());
998
999            // Final position should be last event with offset applied
1000            let expected_x = x2 - offset_x;
1001            let expected_y = y2 - offset_y;
1002            prop_assert!((state.mouse_position.x - expected_x).abs() < 0.001);
1003            prop_assert!((state.mouse_position.y - expected_y).abs() < 0.001);
1004        }
1005    }
1006}