Skip to main content

cuqueclicker_lib/
input.rs

1//! Platform-agnostic input router.
2//!
3//! Defines a small [`InputEvent`] vocabulary that's a strict superset of
4//! what we need to translate from any input source (crossterm on native,
5//! ratzilla on web). The router consumes one [`InputEvent`] and produces
6//! zero or more [`Action`]s into a caller-owned `Vec<Action>` buffer; it
7//! also mutates the [`UiState`] that doesn't belong on the sim side
8//! (`mode`, `zoom_idx`, `running`, `last_mouse_pos`).
9//!
10//! Adapters live next to their event source — `app.rs::translate_crossterm`
11//! produces `InputEvent` from `crossterm::event::Event`, and (when the
12//! wasm port lands) a sibling adapter does the same for `ratzilla::event`.
13//! Both feed the same router so behavior parity is enforced by sharing
14//! code, not by duplicating it.
15//!
16//! Geometry (`fingerer_rows`, `upgrade_rows`, `help_hits`, etc.) is passed
17//! in via [`InputContext`] — the renderer recomputes these every frame
18//! and the click handler hit-tests against the latest set.
19
20use ratatui::layout::Rect;
21
22use crate::game::state::GameState;
23use crate::sim::{Action, BuyQty};
24use crate::ui::{HelpAction, Mode};
25
26/// Platform-neutral input vocabulary. Crossterm's `Event::{Key,Mouse,Resize,…}`
27/// and ratzilla's `KeyEvent`/`MouseEvent`/`WheelEvent` both narrow into this
28/// — anything we don't need (focus, paste, resize) is dropped at the adapter.
29#[derive(Clone, Debug)]
30pub enum InputEvent {
31    /// A key was pressed. `code` is the resolved key (with shifted symbols
32    /// already mapped to their character form, e.g. Shift+1 → `!`).
33    KeyPress { code: KeyCode, mods: Modifiers },
34    /// A mouse button went down at terminal cell `(col, row)`.
35    MouseDown {
36        col: u16,
37        row: u16,
38        button: MouseButton,
39        mods: Modifiers,
40    },
41    /// The mouse moved over `(col, row)` — used for hover highlighting.
42    /// Drag events normalize to this too; the router doesn't care which.
43    MouseMoved { col: u16, row: u16 },
44    /// Scroll wheel scrolled. `(col, row)` is the cursor cell at the time
45    /// of the wheel tick — used to gate zoom to the play-area.
46    Wheel {
47        col: u16,
48        row: u16,
49        delta: WheelDelta,
50    },
51}
52
53/// Subset of key codes the game actually consumes. Anything else from the
54/// underlying terminal event is dropped at the adapter.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum KeyCode {
57    Char(char),
58    Esc,
59    F(u8),
60}
61
62/// Subset of mouse buttons the game cares about. Middle-click is dropped
63/// at every adapter (it had no game effect on native and we don't intend
64/// one on web either); add a variant here if a future input source wants
65/// it routed.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum MouseButton {
68    Left,
69    Right,
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
73pub struct Modifiers {
74    pub shift: bool,
75    pub alt: bool,
76    pub ctrl: bool,
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum WheelDelta {
81    Up,
82    Down,
83}
84
85/// State that lives on the input/render side of the boundary, not on the
86/// sim. Persistence-wise: not serialized, recreated fresh on each launch.
87pub struct UiState {
88    pub mode: Mode,
89    pub zoom_idx: usize,
90    pub running: bool,
91    pub last_mouse_pos: Option<(u16, u16)>,
92}
93
94impl UiState {
95    pub fn new() -> Self {
96        Self {
97            mode: Mode::Game,
98            zoom_idx: 0,
99            running: true,
100            last_mouse_pos: None,
101        }
102    }
103}
104
105impl Default for UiState {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111/// Per-frame geometry the click router hit-tests against. All `Rect`s come
112/// from the latest `ui::draw` output; `current` is the latest published
113/// snapshot. Borrowed for the duration of one event dispatch.
114pub struct InputContext<'a> {
115    pub fingerer_rows: &'a [(usize, Rect)],
116    pub upgrade_rows: &'a [(usize, Rect)],
117    pub help_hits: &'a [(HelpAction, Rect)],
118    pub biscuit_rect: Rect,
119    /// Screen position of the biscuit's focal cell. See
120    /// [`crate::ui::DrawOutput::biscuit_focal`]. Used by `hands::occupied_at`
121    /// to keep its hit-test math in sync with the visual orbit.
122    pub biscuit_focal: (u16, u16),
123    /// `(spawn_id, rect)` for every on-screen powerup. Click hit-test
124    /// walks this list and routes the first match to
125    /// `Action::CatchPowerup(spawn_id)`. The `g` hotkey min_by_key's it
126    /// for the most-urgent. Both reference instances by id, never by
127    /// Vec index — `swap_remove` on catch is safe.
128    pub powerup_rects: &'a [(u64, Rect)],
129    pub play_area: Rect,
130    pub prestige_reset_rect: Rect,
131    pub debug: bool,
132    pub current: &'a GameState,
133}
134
135impl<'a> InputContext<'a> {
136    /// Build an input context by borrowing from a render
137    /// [`DrawOutput`](crate::ui::DrawOutput).
138    ///
139    /// The platform shells (`app.rs`, `wasm_app.rs`) call this so adding
140    /// a new clickable region only touches `DrawOutput` + `InputContext` +
141    /// this projection — never the platform code. Without this single
142    /// projection point, native and wasm each kept their own field-by-field
143    /// copy of the layout snapshot, and a new field meant updating both;
144    /// the wasm build broke once when only the native copy was updated.
145    pub fn from_layout(
146        layout: &'a crate::ui::DrawOutput,
147        current: &'a GameState,
148        debug: bool,
149    ) -> Self {
150        InputContext {
151            fingerer_rows: &layout.fingerer_rows,
152            upgrade_rows: &layout.upgrade_rows,
153            help_hits: &layout.help_hits,
154            biscuit_rect: layout.biscuit_rect,
155            biscuit_focal: layout.biscuit_focal,
156            powerup_rects: &layout.powerup_rects,
157            play_area: layout.play_area,
158            prestige_reset_rect: layout.prestige_reset_rect,
159            debug,
160            current,
161        }
162    }
163}
164
165/// Process one [`InputEvent`]. Mutates [`UiState`]; appends produced actions
166/// to `out`. Pure data — does no I/O. The router *reads* `GameState` (via
167/// `ctx.current` for `prestige_available()` / `powerups.iter()` and via
168/// `ui::hands::occupied_at` for misclick gating) but never mutates it; all
169/// mutation flows through the produced [`Action`]s and `apply_action`.
170pub fn process_input_event(
171    ev: InputEvent,
172    ui: &mut UiState,
173    ctx: &InputContext,
174    out: &mut Vec<Action>,
175) {
176    match ev {
177        InputEvent::KeyPress { code, mods } => handle_key(code, mods, ui, ctx, out),
178        InputEvent::MouseDown {
179            col,
180            row,
181            button,
182            mods,
183        } => {
184            ui.last_mouse_pos = Some((col, row));
185            // M1+M2: try help-bar / prestige-reset hits first. These give
186            // the mouse-only player parity with `[u]/[p]/[s]/[a]/[g]/[q]/[r]`
187            // shortcuts. Consumed hits short-circuit the rest of the click
188            // pipeline so we don't also fire a misclick particle.
189            if try_help_click(col, row, ui, ctx, out) {
190                return;
191            }
192            handle_click(col, row, button, mods, ui, ctx, out);
193        }
194        InputEvent::MouseMoved { col, row } => {
195            // K5: hover highlighting; renderer reads `last_mouse_pos`.
196            // Drag events from the underlying terminal collapse to this.
197            ui.last_mouse_pos = Some((col, row));
198        }
199        InputEvent::Wheel { col, row, delta } => {
200            // Scroll only zooms inside the play area (the whole left column
201            // where the biscuit lives, including the void around a small
202            // biscuit at low zoom). Cold frames (no rect yet) conservatively
203            // allow zoom so the very first scroll after launch isn't dropped.
204            if !in_play_area(col, row, ctx.play_area) {
205                return;
206            }
207            match delta {
208                WheelDelta::Up => ui.zoom_idx = ui.zoom_idx.saturating_sub(1),
209                WheelDelta::Down => {
210                    ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
211                }
212            }
213        }
214    }
215}
216
217/// True when the scroll happened anywhere inside the play-area rect — the
218/// whole left column the biscuit lives in (HUD-and-help-excluded). Cold
219/// frames (no rect yet) conservatively allow zoom so the very first scroll
220/// after launch isn't dropped.
221fn in_play_area(col: u16, row: u16, play_area: Rect) -> bool {
222    if play_area.width == 0 || play_area.height == 0 {
223        return true;
224    }
225    col >= play_area.x
226        && col < play_area.x + play_area.width
227        && row >= play_area.y
228        && row < play_area.y + play_area.height
229}
230
231fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
232    rect.width > 0
233        && rect.height > 0
234        && col >= rect.x
235        && col < rect.x + rect.width
236        && row >= rect.y
237        && row < rect.y + rect.height
238}
239
240/// Push the catch action for whichever on-screen powerup has the fewest
241/// life ticks remaining (most-urgent across every kind). No-op if nothing
242/// is on screen. Shared by the keyboard `g` handler and the help-bar
243/// `[g]` click.
244fn push_grab_most_urgent(ctx: &InputContext, out: &mut Vec<Action>) {
245    if let Some(p) = ctx.current.powerups.iter().min_by_key(|p| p.life_ticks) {
246        out.push(Action::CatchPowerup(p.spawn_id));
247    }
248}
249
250fn click_buy_qty(mods: Modifiers) -> BuyQty {
251    if mods.alt || mods.ctrl {
252        BuyQty::Max
253    } else if mods.shift {
254        BuyQty::Ten
255    } else {
256        BuyQty::One
257    }
258}
259
260/// Try to consume a click on a help-bar hint or the prestige-reset confirm
261/// line. Returns true when the click was handled — caller short-circuits
262/// the rest of the pipeline (no biscuit/row/misclick path).
263fn try_help_click(
264    col: u16,
265    row: u16,
266    ui: &mut UiState,
267    ctx: &InputContext,
268    out: &mut Vec<Action>,
269) -> bool {
270    // Prestige-reset confirm: in-panel button. Match BEFORE help-bar so
271    // the confirm "wins" if the help bar happens to overlap it (it
272    // shouldn't, but defensive).
273    if rect_contains(ctx.prestige_reset_rect, col, row) && ctx.current.prestige_available() > 0 {
274        out.push(Action::PrestigeReset);
275        ui.mode = Mode::Game;
276        return true;
277    }
278    for &(action, rect) in ctx.help_hits {
279        if !rect_contains(rect, col, row) {
280            continue;
281        }
282        match action {
283            HelpAction::OpenMode(target) => {
284                // Same toggle semantics the keyboard uses: tapping the
285                // hint for the active mode returns to Game.
286                ui.mode = if ui.mode == target {
287                    Mode::Game
288                } else {
289                    target
290                };
291            }
292            HelpAction::GrabGolden => {
293                // Help-bar `[g]` click — grab the most-urgent powerup
294                // currently on screen, identical to the keyboard 'g'.
295                push_grab_most_urgent(ctx, out);
296            }
297            HelpAction::PrestigeReset => {
298                if ctx.current.prestige_available() > 0 {
299                    out.push(Action::PrestigeReset);
300                    ui.mode = Mode::Game;
301                }
302            }
303            HelpAction::Quit => {
304                ui.running = false;
305            }
306        }
307        return true;
308    }
309    false
310}
311
312fn handle_click(
313    col: u16,
314    row: u16,
315    button: MouseButton,
316    mods: Modifiers,
317    ui: &UiState,
318    ctx: &InputContext,
319    out: &mut Vec<Action>,
320) {
321    // Powerups are catchable from ANY panel — match the keyboard 'g'
322    // behavior, which has no mode guard. The marker still renders on the
323    // biscuit while a non-Game panel is open. Right-click on a powerup
324    // also catches.
325    //
326    // Each powerup is its own click target — clicking one does NOT vacuum
327    // up an adjacent or overlapping powerup. Multiple of any kind coexist
328    // freely; each one catches only itself, by spawn_id.
329    for &(id, rect) in ctx.powerup_rects {
330        if rect_contains(rect, col, row) {
331            out.push(Action::CatchPowerup(id));
332            return;
333        }
334    }
335    // Clicking the biscuit itself is also mode-agnostic. Right-click on
336    // the biscuit is a no-op so a player can't accidentally finger the
337    // cuque with the wrong button.
338    if rect_contains(ctx.biscuit_rect, col, row) {
339        if button == MouseButton::Left {
340            out.push(Action::Click { col, row });
341        }
342        return;
343    }
344    // Mouse-buy fingerers from the sidebar in Game mode. Modifiers control
345    // quantity (plain = 1, Shift = 10, Alt/Ctrl = max), matching the
346    // digit-key shortcuts. Right-click is the always-Max affordance
347    // regardless of modifiers.
348    if ui.mode == Mode::Game {
349        for &(idx, r) in ctx.fingerer_rows {
350            if rect_contains(r, col, row) {
351                let qty = if button == MouseButton::Right {
352                    BuyQty::Max
353                } else {
354                    click_buy_qty(mods)
355                };
356                out.push(Action::BuyFingerer { idx, qty });
357                return;
358            }
359        }
360    }
361    // Mouse-buy upgrades from the Upgrades panel. Modifiers ignored — each
362    // upgrade is a one-shot purchase. Right-click also buys.
363    if ui.mode == Mode::Upgrades {
364        for &(idx, r) in ctx.upgrade_rows {
365            if rect_contains(r, col, row) {
366                out.push(Action::BuyUpgrade(idx));
367                return;
368            }
369        }
370    }
371    // J10: nothing actionable under the click. Acknowledge it visually with
372    // a brief "·" so the dead-zone (e.g. the air around a 25%-zoom biscuit)
373    // doesn't feel inert. Skip when:
374    //   - the click was right-button (right-click without a target is a
375    //     true no-op);
376    //   - the click landed on an orbital hand glyph — those are decoration,
377    //     not click targets, but they're visually present, so a misclick
378    //     "·" replacing part of `[]` / `:*` / `>>` reads as flicker.
379    //   - M3: the click landed OUTSIDE the play area (HUD title, sidebar,
380    //     debug pane, help bar). Inert UI chrome shouldn't get a "·"
381    //     overpainted into it.
382    if button != MouseButton::Left {
383        return;
384    }
385    if !rect_contains(ctx.play_area, col, row) {
386        return;
387    }
388    if crate::ui::hands::occupied_at(col, row, ctx.biscuit_rect, ctx.biscuit_focal, ctx.current) {
389        return;
390    }
391    out.push(Action::Misclick { col, row });
392}
393
394fn handle_key(
395    code: KeyCode,
396    mods: Modifiers,
397    ui: &mut UiState,
398    ctx: &InputContext,
399    out: &mut Vec<Action>,
400) {
401    match code {
402        // Gated on the platform's `can_quit` capability so a stray `q`
403        // press in the browser doesn't silently flip `ui.running` (which
404        // a future feature might key off of even though the rAF loop
405        // doesn't read it today).
406        KeyCode::Char('q') if crate::platform::CAPABILITIES.can_quit => ui.running = false,
407        // J12: Esc dismisses panels back to Game mode but is a NO-OP from
408        // Game itself. Quit is `q` only — Esc-to-quit was an aggressive
409        // default that surprised playtesters who reflex-pressed it to
410        // "deselect" with no panel open.
411        KeyCode::Esc => match ui.mode {
412            Mode::Game => {}
413            _ => ui.mode = Mode::Game,
414        },
415        KeyCode::Char('s') | KeyCode::Char('S') => {
416            ui.mode = if matches!(ui.mode, Mode::Stats) {
417                Mode::Game
418            } else {
419                Mode::Stats
420            };
421        }
422        KeyCode::Char('a') | KeyCode::Char('A') => {
423            ui.mode = if matches!(ui.mode, Mode::Achievements) {
424                Mode::Game
425            } else {
426                Mode::Achievements
427            };
428        }
429        KeyCode::Char('u') | KeyCode::Char('U') => {
430            ui.mode = if matches!(ui.mode, Mode::Upgrades) {
431                Mode::Game
432            } else {
433                Mode::Upgrades
434            };
435        }
436        // [g] catches the most-urgent powerup (lowest remaining life
437        // ticks) across all four slots — Lucky, Frenzy, Buff, and
438        // Green Coin. A second [g] press grabs the next-most-urgent.
439        // Lets the player race against expiry without the keyboard
440        // accidentally vacuuming up siblings.
441        KeyCode::Char('g') | KeyCode::Char('G') => {
442            push_grab_most_urgent(ctx, out);
443        }
444        // Debug/testing: gated by `debug`. See src/ui/debug_pane.rs for the
445        // advertised key list. F8 (not F1) is Lucky because Chrome / Edge /
446        // Safari hijack F1 for browser Help and never forward the keydown
447        // to the wasm page; F8 has no default browser binding outside of
448        // an open DevTools instance.
449        KeyCode::F(8) if ctx.debug => {
450            out.push(Action::DevForcePowerup(
451                crate::game::powerup::PowerupKind::Lucky,
452            ));
453        }
454        KeyCode::F(2) if ctx.debug => {
455            out.push(Action::DevForcePowerup(
456                crate::game::powerup::PowerupKind::Frenzy,
457            ));
458        }
459        KeyCode::F(3) if ctx.debug => {
460            out.push(Action::DevForcePowerup(
461                crate::game::powerup::PowerupKind::Buff,
462            ));
463        }
464        KeyCode::F(4) if ctx.debug => {
465            out.push(Action::DevAddCuques(1_000_000.0));
466        }
467        KeyCode::F(5) if ctx.debug => {
468            out.push(Action::DevForcePowerup(
469                crate::game::powerup::PowerupKind::GreenCoin,
470            ));
471        }
472        KeyCode::Char('p') | KeyCode::Char('P') => {
473            ui.mode = if matches!(ui.mode, Mode::Prestige) {
474                Mode::Game
475            } else {
476                Mode::Prestige
477            };
478        }
479        // Prestige confirm: check the snapshot for available prestige before
480        // firing. Optimistically close the panel — if the sim rejects the
481        // reset (raced against a simultaneous lifetime-cuque drop) nothing
482        // bad happens.
483        KeyCode::Char('r') | KeyCode::Char('R')
484            if ui.mode == Mode::Prestige && ctx.current.prestige_available() > 0 =>
485        {
486            out.push(Action::PrestigeReset);
487            ui.mode = Mode::Game;
488        }
489        KeyCode::Char('+') | KeyCode::Char('=') => {
490            ui.zoom_idx = ui.zoom_idx.saturating_sub(1);
491        }
492        KeyCode::Char('-') | KeyCode::Char('_') => {
493            ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
494        }
495        // Space ALWAYS fingers the cuque, regardless of which panel is open
496        // — same contract as left-click on the biscuit.
497        KeyCode::Char(' ') => {
498            out.push(Action::ClickCenter);
499        }
500        KeyCode::Char(c) => {
501            if let Some((slot, shifted_sym)) = digit_slot(c) {
502                let buy_10 = shifted_sym || mods.shift;
503                let buy_max = mods.alt || mods.ctrl;
504                match ui.mode {
505                    Mode::Game => {
506                        if let Some(&(fid, _)) = ctx.fingerer_rows.get(slot) {
507                            let qty = if buy_max {
508                                BuyQty::Max
509                            } else if buy_10 {
510                                BuyQty::Ten
511                            } else {
512                                BuyQty::One
513                            };
514                            out.push(Action::BuyFingerer { idx: fid, qty });
515                        }
516                    }
517                    Mode::Upgrades => {
518                        if let Some(&(u_idx, _)) = ctx.upgrade_rows.get(slot) {
519                            out.push(Action::BuyUpgrade(u_idx));
520                        }
521                    }
522                    _ => {}
523                }
524            }
525        }
526        _ => {}
527    }
528}
529
530fn digit_slot(c: char) -> Option<(usize, bool)> {
531    match c {
532        '1' => Some((0, false)),
533        '2' => Some((1, false)),
534        '3' => Some((2, false)),
535        '4' => Some((3, false)),
536        '5' => Some((4, false)),
537        '6' => Some((5, false)),
538        '7' => Some((6, false)),
539        '8' => Some((7, false)),
540        '9' => Some((8, false)),
541        '0' => Some((9, false)),
542        '!' => Some((0, true)),
543        '@' => Some((1, true)),
544        '#' => Some((2, true)),
545        '$' => Some((3, true)),
546        '%' => Some((4, true)),
547        '^' => Some((5, true)),
548        '&' => Some((6, true)),
549        '*' => Some((7, true)),
550        '(' => Some((8, true)),
551        ')' => Some((9, true)),
552        _ => None,
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    //! Router tests. The whole point of pulling `process_input_event` into a
559    //! platform-neutral module is that we can exercise it with no terminal,
560    //! no event loop, and no threading. Each test feeds a synthetic event
561    //! and asserts on the produced `Vec<Action>` + `UiState` deltas.
562    //!
563    //! Constructing an `InputContext` requires stubs for the per-frame rects
564    //! and a borrowed `GameState`; helpers below collapse the boilerplate.
565    //! `state_with_lucky()` and `state_with_prestige()` mutate just enough
566    //! of the default to exercise the gates the router cares about.
567    use super::*;
568    use crate::game::powerup::{Powerup, PowerupKind};
569    use crate::sim::{Action, BuyQty};
570    use ratatui::layout::Rect;
571    use std::mem::discriminant;
572
573    fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
574        Rect::new(x, y, w, h)
575    }
576
577    /// Builds an `InputContext` over caller-provided rect/row/state slices.
578    /// The lifetimes line up because everything is borrowed from locals
579    /// the test itself owns.
580    #[allow(clippy::too_many_arguments)]
581    fn ctx<'a>(
582        biscuit: Rect,
583        powerup_rects: &'a [(u64, Rect)],
584        play_area: Rect,
585        prestige_reset_rect: Rect,
586        fingerer_rows: &'a [(usize, Rect)],
587        upgrade_rows: &'a [(usize, Rect)],
588        help_hits: &'a [(HelpAction, Rect)],
589        debug: bool,
590        current: &'a GameState,
591    ) -> InputContext<'a> {
592        InputContext {
593            fingerer_rows,
594            upgrade_rows,
595            help_hits,
596            biscuit_rect: biscuit,
597            biscuit_focal: (0, 0),
598            powerup_rects,
599            play_area,
600            prestige_reset_rect,
601            debug,
602            current,
603        }
604    }
605
606    fn empty_ctx<'a>(state: &'a GameState) -> InputContext<'a> {
607        ctx(
608            Rect::default(),
609            &[],
610            Rect::default(),
611            Rect::default(),
612            &[],
613            &[],
614            &[],
615            false,
616            state,
617        )
618    }
619
620    /// State with a Lucky powerup queued so `[g]` / hit-test clicks have
621    /// something to catch. Returns the `(state, spawn_id)` pair so tests
622    /// that need to override `life_ticks` or hit-test can do so by id.
623    fn state_with_lucky() -> (GameState, u64) {
624        let mut s = GameState::default();
625        let id = s.mint_spawn_id();
626        s.powerups.push(Powerup {
627            kind: PowerupKind::Lucky,
628            spawn_id: id,
629            frac_x: 0.5,
630            frac_y: 0.5,
631            life_ticks: PowerupKind::Lucky.lifetime_ticks(),
632        });
633        (s, id)
634    }
635
636    /// State with enough lifetime cuques to make `prestige_available()` > 0,
637    /// so the prestige-reset confirm rect is "live".
638    fn state_with_prestige() -> GameState {
639        // prestige_available() square-roots `lifetime_cuques / 1e9` and
640        // floors. 4e9 → 2 prestige tokens.
641        GameState {
642            lifetime_cuques: 4_000_000_000.0,
643            ..GameState::default()
644        }
645    }
646
647    fn key(code: KeyCode) -> InputEvent {
648        InputEvent::KeyPress {
649            code,
650            mods: Modifiers::default(),
651        }
652    }
653
654    fn key_with(code: KeyCode, shift: bool, alt: bool, ctrl: bool) -> InputEvent {
655        InputEvent::KeyPress {
656            code,
657            mods: Modifiers { shift, alt, ctrl },
658        }
659    }
660
661    fn mouse_down(col: u16, row: u16, button: MouseButton, mods: Modifiers) -> InputEvent {
662        InputEvent::MouseDown {
663            col,
664            row,
665            button,
666            mods,
667        }
668    }
669
670    // -- Key handling ------------------------------------------------------
671
672    #[test]
673    fn q_key_flips_running_off() {
674        let s = GameState::default();
675        let mut ui = UiState::new();
676        let mut out = Vec::new();
677        process_input_event(key(KeyCode::Char('q')), &mut ui, &empty_ctx(&s), &mut out);
678        assert!(!ui.running);
679        assert!(out.is_empty());
680    }
681
682    #[test]
683    fn esc_from_game_is_noop() {
684        // J12: Esc from Game must not quit and must not change mode.
685        let s = GameState::default();
686        let mut ui = UiState::new();
687        let mut out = Vec::new();
688        process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
689        assert!(ui.running);
690        assert_eq!(ui.mode, Mode::Game);
691        assert!(out.is_empty());
692    }
693
694    #[test]
695    fn esc_from_stats_returns_to_game() {
696        let s = GameState::default();
697        let mut ui = UiState::new();
698        ui.mode = Mode::Stats;
699        let mut out = Vec::new();
700        process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
701        assert_eq!(ui.mode, Mode::Game);
702        assert!(out.is_empty());
703    }
704
705    #[test]
706    fn s_key_toggles_stats() {
707        let s = GameState::default();
708        let mut ui = UiState::new();
709        let mut out = Vec::new();
710        process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
711        assert_eq!(ui.mode, Mode::Stats);
712        process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
713        assert_eq!(ui.mode, Mode::Game);
714    }
715
716    #[test]
717    fn space_emits_click_center() {
718        let s = GameState::default();
719        let mut ui = UiState::new();
720        let mut out = Vec::new();
721        process_input_event(key(KeyCode::Char(' ')), &mut ui, &empty_ctx(&s), &mut out);
722        assert_eq!(out.len(), 1);
723        assert!(matches!(out[0], Action::ClickCenter));
724    }
725
726    #[test]
727    fn g_with_no_powerup_is_silent() {
728        let s = GameState::default();
729        let mut ui = UiState::new();
730        let mut out = Vec::new();
731        process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
732        assert!(out.is_empty());
733    }
734
735    #[test]
736    fn g_with_powerup_emits_catch() {
737        let (s, id) = state_with_lucky();
738        let mut ui = UiState::new();
739        let mut out = Vec::new();
740        process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
741        assert!(
742            matches!(out.as_slice(), [Action::CatchPowerup(spawn_id)] if *spawn_id == id),
743            "expected CatchPowerup({id}), got {out:?}"
744        );
745    }
746
747    #[test]
748    fn fkeys_gated_by_debug() {
749        let s = GameState::default();
750        let mut ui = UiState::new();
751        // debug=false → all F-keys silent.
752        let mut out = Vec::new();
753        let c = empty_ctx(&s);
754        process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
755        process_input_event(key(KeyCode::F(2)), &mut ui, &c, &mut out);
756        process_input_event(key(KeyCode::F(3)), &mut ui, &c, &mut out);
757        process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
758        process_input_event(key(KeyCode::F(5)), &mut ui, &c, &mut out);
759        assert!(out.is_empty(), "F-keys must be silent when debug=false");
760    }
761
762    #[test]
763    fn fkeys_active_when_debug() {
764        let s = GameState::default();
765        let mut ui = UiState::new();
766        let c = ctx(
767            Rect::default(),
768            &[],
769            Rect::default(),
770            Rect::default(),
771            &[],
772            &[],
773            &[],
774            true, // debug ON
775            &s,
776        );
777        let mut out = Vec::new();
778        process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
779        process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
780        process_input_event(key(KeyCode::F(5)), &mut ui, &c, &mut out);
781        assert!(matches!(
782            out[0],
783            Action::DevForcePowerup(PowerupKind::Lucky)
784        ));
785        assert!(matches!(out[1], Action::DevAddCuques(_)));
786        assert!(matches!(
787            out[2],
788            Action::DevForcePowerup(PowerupKind::GreenCoin)
789        ));
790    }
791
792    // -- Digit shortcuts: modifier→BuyQty ----------------------------------
793
794    fn fingerer_row_ctx<'a>(state: &'a GameState, rows: &'a [(usize, Rect)]) -> InputContext<'a> {
795        ctx(
796            Rect::default(),
797            &[],
798            Rect::default(),
799            Rect::default(),
800            rows,
801            &[],
802            &[],
803            false,
804            state,
805        )
806    }
807
808    #[test]
809    fn digit_1_buys_one() {
810        let s = GameState::default();
811        let mut ui = UiState::new();
812        let rows = [(0_usize, rect(0, 0, 1, 1))];
813        let mut out = Vec::new();
814        process_input_event(
815            key(KeyCode::Char('1')),
816            &mut ui,
817            &fingerer_row_ctx(&s, &rows),
818            &mut out,
819        );
820        assert!(matches!(
821            out.as_slice(),
822            [Action::BuyFingerer {
823                idx: 0,
824                qty: BuyQty::One,
825            }]
826        ));
827    }
828
829    #[test]
830    fn shifted_digit_symbol_buys_ten() {
831        // Terminals emit '!' for Shift+1 without a SHIFT modifier on macOS.
832        // The router must recognize '!' as the Shift-1 alias.
833        let s = GameState::default();
834        let mut ui = UiState::new();
835        let rows = [(0_usize, rect(0, 0, 1, 1))];
836        let mut out = Vec::new();
837        process_input_event(
838            key(KeyCode::Char('!')),
839            &mut ui,
840            &fingerer_row_ctx(&s, &rows),
841            &mut out,
842        );
843        assert!(matches!(
844            out.as_slice(),
845            [Action::BuyFingerer {
846                idx: 0,
847                qty: BuyQty::Ten,
848            }]
849        ));
850    }
851
852    #[test]
853    fn shift_modifier_on_digit_buys_ten() {
854        // Some keymaps emit '1' WITH the SHIFT modifier — also valid for ×10.
855        let s = GameState::default();
856        let mut ui = UiState::new();
857        let rows = [(0_usize, rect(0, 0, 1, 1))];
858        let mut out = Vec::new();
859        process_input_event(
860            key_with(KeyCode::Char('1'), true, false, false),
861            &mut ui,
862            &fingerer_row_ctx(&s, &rows),
863            &mut out,
864        );
865        assert!(matches!(
866            out.as_slice(),
867            [Action::BuyFingerer {
868                qty: BuyQty::Ten,
869                ..
870            }]
871        ));
872    }
873
874    #[test]
875    fn alt_or_ctrl_modifier_on_digit_buys_max() {
876        let s = GameState::default();
877        let rows = [(0_usize, rect(0, 0, 1, 1))];
878        for (alt, ctrl) in [(true, false), (false, true)] {
879            let mut ui = UiState::new();
880            let mut out = Vec::new();
881            process_input_event(
882                key_with(KeyCode::Char('1'), false, alt, ctrl),
883                &mut ui,
884                &fingerer_row_ctx(&s, &rows),
885                &mut out,
886            );
887            assert!(
888                matches!(
889                    out.as_slice(),
890                    [Action::BuyFingerer {
891                        qty: BuyQty::Max,
892                        ..
893                    }]
894                ),
895                "alt={alt} ctrl={ctrl} should buy max",
896            );
897        }
898    }
899
900    #[test]
901    fn digit_with_no_visible_row_is_silent() {
902        // Game mode but no fingerer_rows yet (cold frame): pressing 1 must
903        // not panic or emit an action.
904        let s = GameState::default();
905        let mut ui = UiState::new();
906        let mut out = Vec::new();
907        process_input_event(
908            key(KeyCode::Char('1')),
909            &mut ui,
910            &fingerer_row_ctx(&s, &[]),
911            &mut out,
912        );
913        assert!(out.is_empty());
914    }
915
916    // -- Mouse button semantics --------------------------------------------
917
918    #[test]
919    fn left_click_on_biscuit_emits_click() {
920        let s = GameState::default();
921        let mut ui = UiState::new();
922        let c = ctx(
923            rect(10, 5, 30, 20),
924            &[],
925            rect(0, 0, 100, 30),
926            Rect::default(),
927            &[],
928            &[],
929            &[],
930            false,
931            &s,
932        );
933        let mut out = Vec::new();
934        process_input_event(
935            mouse_down(20, 10, MouseButton::Left, Modifiers::default()),
936            &mut ui,
937            &c,
938            &mut out,
939        );
940        assert!(
941            matches!(out.as_slice(), [Action::Click { col: 20, row: 10 }]),
942            "got {:?}",
943            out
944        );
945    }
946
947    #[test]
948    fn right_click_on_biscuit_is_noop() {
949        // Right-click on the biscuit must not finger the cuque (avoids
950        // accidental clicks). Specifically: no Click action, no misclick.
951        let s = GameState::default();
952        let mut ui = UiState::new();
953        let c = ctx(
954            rect(10, 5, 30, 20),
955            &[],
956            rect(0, 0, 100, 30),
957            Rect::default(),
958            &[],
959            &[],
960            &[],
961            false,
962            &s,
963        );
964        let mut out = Vec::new();
965        process_input_event(
966            mouse_down(20, 10, MouseButton::Right, Modifiers::default()),
967            &mut ui,
968            &c,
969            &mut out,
970        );
971        assert!(out.is_empty(), "got {:?}", out);
972    }
973
974    #[test]
975    fn left_click_on_powerup_emits_catch() {
976        let (s, id) = state_with_lucky();
977        let mut ui = UiState::new();
978        let powerup_rects = [(id, rect(50, 12, 4, 2))];
979        let c = ctx(
980            Rect::default(),
981            &powerup_rects,
982            rect(0, 0, 100, 30),
983            Rect::default(),
984            &[],
985            &[],
986            &[],
987            false,
988            &s,
989        );
990        let mut out = Vec::new();
991        process_input_event(
992            mouse_down(51, 13, MouseButton::Left, Modifiers::default()),
993            &mut ui,
994            &c,
995            &mut out,
996        );
997        assert!(
998            matches!(out.as_slice(), [Action::CatchPowerup(spawn_id)] if *spawn_id == id),
999            "got {out:?}"
1000        );
1001    }
1002
1003    #[test]
1004    fn left_click_on_powerup_only_catches_one_under_cursor() {
1005        // Two powerups on screen; clicking the second's rect catches only
1006        // it. Multiple powerups coexist — clicking one doesn't vacuum up
1007        // any others, regardless of kind.
1008        let mut s = GameState::default();
1009        let lucky_id = s.mint_spawn_id();
1010        s.powerups.push(Powerup {
1011            kind: PowerupKind::Lucky,
1012            spawn_id: lucky_id,
1013            frac_x: 0.5,
1014            frac_y: 0.5,
1015            life_ticks: 100,
1016        });
1017        let green_id = s.mint_spawn_id();
1018        s.powerups.push(Powerup {
1019            kind: PowerupKind::GreenCoin,
1020            spawn_id: green_id,
1021            frac_x: 0.5,
1022            frac_y: 0.5,
1023            life_ticks: 100,
1024        });
1025        let mut ui = UiState::new();
1026        let powerup_rects = [
1027            (lucky_id, rect(50, 12, 5, 3)),
1028            (green_id, rect(70, 12, 5, 3)),
1029        ];
1030        let c = ctx(
1031            Rect::default(),
1032            &powerup_rects,
1033            rect(0, 0, 100, 30),
1034            Rect::default(),
1035            &[],
1036            &[],
1037            &[],
1038            false,
1039            &s,
1040        );
1041        let mut out = Vec::new();
1042        process_input_event(
1043            mouse_down(72, 13, MouseButton::Left, Modifiers::default()),
1044            &mut ui,
1045            &c,
1046            &mut out,
1047        );
1048        assert!(
1049            matches!(out.as_slice(), [Action::CatchPowerup(id)] if *id == green_id),
1050            "got {out:?}"
1051        );
1052    }
1053
1054    #[test]
1055    fn g_with_two_kinds_picks_lower_life_ticks() {
1056        // `g` should grab the most-urgent powerup first when multiples are
1057        // on screen, regardless of kind. Min by `life_ticks`.
1058        let mut s = GameState::default();
1059        let lucky_id = s.mint_spawn_id();
1060        s.powerups.push(Powerup {
1061            kind: PowerupKind::Lucky,
1062            spawn_id: lucky_id,
1063            frac_x: 0.5,
1064            frac_y: 0.5,
1065            life_ticks: 50,
1066        });
1067        let green_id = s.mint_spawn_id();
1068        s.powerups.push(Powerup {
1069            kind: PowerupKind::GreenCoin,
1070            spawn_id: green_id,
1071            frac_x: 0.5,
1072            frac_y: 0.5,
1073            life_ticks: 200,
1074        });
1075        let c = empty_ctx(&s);
1076        let mut ui = UiState::new();
1077        let mut out = Vec::new();
1078        process_input_event(key(KeyCode::Char('g')), &mut ui, &c, &mut out);
1079        assert!(
1080            matches!(out.as_slice(), [Action::CatchPowerup(id)] if *id == lucky_id),
1081            "lower-life Lucky should win, got {out:?}"
1082        );
1083
1084        // Flip the lifetimes — green is now the urgent one.
1085        let mut s = GameState::default();
1086        let lucky_id = s.mint_spawn_id();
1087        s.powerups.push(Powerup {
1088            kind: PowerupKind::Lucky,
1089            spawn_id: lucky_id,
1090            frac_x: 0.5,
1091            frac_y: 0.5,
1092            life_ticks: 200,
1093        });
1094        let green_id = s.mint_spawn_id();
1095        s.powerups.push(Powerup {
1096            kind: PowerupKind::GreenCoin,
1097            spawn_id: green_id,
1098            frac_x: 0.5,
1099            frac_y: 0.5,
1100            life_ticks: 50,
1101        });
1102        let c = empty_ctx(&s);
1103        let mut out = Vec::new();
1104        process_input_event(key(KeyCode::Char('g')), &mut ui, &c, &mut out);
1105        assert!(
1106            matches!(out.as_slice(), [Action::CatchPowerup(id)] if *id == green_id),
1107            "lower-life GreenCoin should win, got {out:?}"
1108        );
1109    }
1110
1111    #[test]
1112    fn right_click_on_powerup_also_catches() {
1113        // The marker is small and reflex right-clicks shouldn't waste it.
1114        let (s, id) = state_with_lucky();
1115        let mut ui = UiState::new();
1116        let powerup_rects = [(id, rect(50, 12, 4, 2))];
1117        let c = ctx(
1118            Rect::default(),
1119            &powerup_rects,
1120            rect(0, 0, 100, 30),
1121            Rect::default(),
1122            &[],
1123            &[],
1124            &[],
1125            false,
1126            &s,
1127        );
1128        let mut out = Vec::new();
1129        process_input_event(
1130            mouse_down(51, 13, MouseButton::Right, Modifiers::default()),
1131            &mut ui,
1132            &c,
1133            &mut out,
1134        );
1135        assert!(
1136            matches!(out.as_slice(), [Action::CatchPowerup(spawn_id)] if *spawn_id == id),
1137            "got {out:?}"
1138        );
1139    }
1140
1141    #[test]
1142    fn left_click_on_fingerer_row_buys_one() {
1143        let s = GameState::default();
1144        let mut ui = UiState::new();
1145        let rows = [(2_usize, rect(100, 5, 38, 3))];
1146        let c = ctx(
1147            Rect::default(),
1148            &[],
1149            Rect::default(),
1150            Rect::default(),
1151            &rows,
1152            &[],
1153            &[],
1154            false,
1155            &s,
1156        );
1157        let mut out = Vec::new();
1158        process_input_event(
1159            mouse_down(110, 6, MouseButton::Left, Modifiers::default()),
1160            &mut ui,
1161            &c,
1162            &mut out,
1163        );
1164        assert!(matches!(
1165            out.as_slice(),
1166            [Action::BuyFingerer {
1167                idx: 2,
1168                qty: BuyQty::One,
1169            }]
1170        ));
1171    }
1172
1173    #[test]
1174    fn right_click_on_fingerer_row_buys_max() {
1175        // J15: right-click is the always-Max affordance (modifiers ignored).
1176        let s = GameState::default();
1177        let mut ui = UiState::new();
1178        let rows = [(2_usize, rect(100, 5, 38, 3))];
1179        let c = ctx(
1180            Rect::default(),
1181            &[],
1182            Rect::default(),
1183            Rect::default(),
1184            &rows,
1185            &[],
1186            &[],
1187            false,
1188            &s,
1189        );
1190        let mut out = Vec::new();
1191        process_input_event(
1192            mouse_down(110, 6, MouseButton::Right, Modifiers::default()),
1193            &mut ui,
1194            &c,
1195            &mut out,
1196        );
1197        assert!(matches!(
1198            out.as_slice(),
1199            [Action::BuyFingerer {
1200                qty: BuyQty::Max,
1201                ..
1202            }]
1203        ));
1204    }
1205
1206    #[test]
1207    fn shift_left_click_on_fingerer_row_buys_ten() {
1208        let s = GameState::default();
1209        let mut ui = UiState::new();
1210        let rows = [(2_usize, rect(100, 5, 38, 3))];
1211        let c = ctx(
1212            Rect::default(),
1213            &[],
1214            Rect::default(),
1215            Rect::default(),
1216            &rows,
1217            &[],
1218            &[],
1219            false,
1220            &s,
1221        );
1222        let mut out = Vec::new();
1223        let mods = Modifiers {
1224            shift: true,
1225            ..Modifiers::default()
1226        };
1227        process_input_event(
1228            mouse_down(110, 6, MouseButton::Left, mods),
1229            &mut ui,
1230            &c,
1231            &mut out,
1232        );
1233        assert!(matches!(
1234            out.as_slice(),
1235            [Action::BuyFingerer {
1236                qty: BuyQty::Ten,
1237                ..
1238            }]
1239        ));
1240    }
1241
1242    // -- Misclick gating ---------------------------------------------------
1243
1244    #[test]
1245    fn dead_zone_left_click_inside_play_area_emits_misclick() {
1246        // Click in the empty space of the play area (not biscuit, not row).
1247        let s = GameState::default();
1248        let mut ui = UiState::new();
1249        let c = ctx(
1250            rect(40, 10, 20, 10), // biscuit far from click
1251            &[],
1252            rect(0, 0, 100, 30), // play_area covers the click
1253            Rect::default(),
1254            &[],
1255            &[],
1256            &[],
1257            false,
1258            &s,
1259        );
1260        let mut out = Vec::new();
1261        process_input_event(
1262            mouse_down(5, 5, MouseButton::Left, Modifiers::default()),
1263            &mut ui,
1264            &c,
1265            &mut out,
1266        );
1267        assert!(
1268            matches!(out.as_slice(), [Action::Misclick { col: 5, row: 5 }]),
1269            "got {:?}",
1270            out
1271        );
1272    }
1273
1274    #[test]
1275    fn click_outside_play_area_does_not_misclick() {
1276        // M3: clicks on inert UI chrome (HUD title, sidebar, debug pane)
1277        // must NOT spawn a misclick particle.
1278        let s = GameState::default();
1279        let mut ui = UiState::new();
1280        let c = ctx(
1281            rect(40, 10, 20, 10),
1282            &[],
1283            rect(0, 0, 100, 30), // play area capped at col 100
1284            Rect::default(),
1285            &[],
1286            &[],
1287            &[],
1288            false,
1289            &s,
1290        );
1291        let mut out = Vec::new();
1292        process_input_event(
1293            mouse_down(120, 5, MouseButton::Left, Modifiers::default()),
1294            &mut ui,
1295            &c,
1296            &mut out,
1297        );
1298        assert!(out.is_empty(), "got {:?}", out);
1299    }
1300
1301    #[test]
1302    fn right_click_in_dead_zone_is_silent() {
1303        // Right-click on nothing actionable is a true no-op (no misclick ack).
1304        let s = GameState::default();
1305        let mut ui = UiState::new();
1306        let c = ctx(
1307            rect(40, 10, 20, 10),
1308            &[],
1309            rect(0, 0, 100, 30),
1310            Rect::default(),
1311            &[],
1312            &[],
1313            &[],
1314            false,
1315            &s,
1316        );
1317        let mut out = Vec::new();
1318        process_input_event(
1319            mouse_down(5, 5, MouseButton::Right, Modifiers::default()),
1320            &mut ui,
1321            &c,
1322            &mut out,
1323        );
1324        assert!(out.is_empty());
1325    }
1326
1327    // -- Help-bar clickable hints ------------------------------------------
1328
1329    #[test]
1330    fn click_quit_hint_flips_running() {
1331        let s = GameState::default();
1332        let mut ui = UiState::new();
1333        let hits = [(HelpAction::Quit, rect(50, 29, 8, 1))];
1334        let c = ctx(
1335            Rect::default(),
1336            &[],
1337            rect(0, 0, 100, 30),
1338            Rect::default(),
1339            &[],
1340            &[],
1341            &hits,
1342            false,
1343            &s,
1344        );
1345        let mut out = Vec::new();
1346        process_input_event(
1347            mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1348            &mut ui,
1349            &c,
1350            &mut out,
1351        );
1352        assert!(!ui.running);
1353        assert!(out.is_empty(), "Quit is UI-only, no Action emitted");
1354    }
1355
1356    #[test]
1357    fn click_open_mode_hint_toggles_mode() {
1358        let s = GameState::default();
1359        let mut ui = UiState::new();
1360        let hits = [(HelpAction::OpenMode(Mode::Stats), rect(50, 29, 8, 1))];
1361        let c = ctx(
1362            Rect::default(),
1363            &[],
1364            rect(0, 0, 100, 30),
1365            Rect::default(),
1366            &[],
1367            &[],
1368            &hits,
1369            false,
1370            &s,
1371        );
1372        let mut out = Vec::new();
1373        process_input_event(
1374            mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1375            &mut ui,
1376            &c,
1377            &mut out,
1378        );
1379        assert_eq!(ui.mode, Mode::Stats);
1380    }
1381
1382    // -- Prestige reset rect -----------------------------------------------
1383
1384    #[test]
1385    fn prestige_reset_rect_unavailable_does_not_reset() {
1386        // With prestige_available() == 0, clicking the confirm rect must
1387        // NOT produce a PrestigeReset action. The click can still fall
1388        // through to a Misclick if it's in the play area (that's by
1389        // design — it's a dead-zone click) but never to a reset.
1390        let s = GameState::default(); // prestige_available() = 0
1391        let mut ui = UiState::new();
1392        let c = ctx(
1393            Rect::default(),
1394            &[],
1395            rect(0, 0, 100, 30),
1396            rect(40, 15, 30, 1), // prestige_reset_rect at this position
1397            &[],
1398            &[],
1399            &[],
1400            false,
1401            &s,
1402        );
1403        let mut out = Vec::new();
1404        process_input_event(
1405            mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1406            &mut ui,
1407            &c,
1408            &mut out,
1409        );
1410        assert!(
1411            !out.iter().any(|a| matches!(a, Action::PrestigeReset)),
1412            "no PrestigeReset when unavailable; got {:?}",
1413            out
1414        );
1415        assert_eq!(ui.mode, Mode::Game, "mode unchanged from default Game");
1416    }
1417
1418    #[test]
1419    fn prestige_reset_rect_available_emits_action() {
1420        let s = state_with_prestige();
1421        let mut ui = UiState::new();
1422        ui.mode = Mode::Prestige;
1423        let c = ctx(
1424            Rect::default(),
1425            &[],
1426            rect(0, 0, 100, 30),
1427            rect(40, 15, 30, 1),
1428            &[],
1429            &[],
1430            &[],
1431            false,
1432            &s,
1433        );
1434        let mut out = Vec::new();
1435        process_input_event(
1436            mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1437            &mut ui,
1438            &c,
1439            &mut out,
1440        );
1441        assert_eq!(out.len(), 1);
1442        assert_eq!(discriminant(&out[0]), discriminant(&Action::PrestigeReset),);
1443        assert_eq!(
1444            ui.mode,
1445            Mode::Game,
1446            "panel auto-closes after prestige confirm",
1447        );
1448    }
1449
1450    // -- Wheel zoom --------------------------------------------------------
1451
1452    #[test]
1453    fn wheel_down_inside_play_area_increases_zoom_idx() {
1454        let s = GameState::default();
1455        let mut ui = UiState::new();
1456        let c = ctx(
1457            Rect::default(),
1458            &[],
1459            rect(0, 0, 100, 30),
1460            Rect::default(),
1461            &[],
1462            &[],
1463            &[],
1464            false,
1465            &s,
1466        );
1467        let mut out = Vec::new();
1468        process_input_event(
1469            InputEvent::Wheel {
1470                col: 50,
1471                row: 15,
1472                delta: WheelDelta::Down,
1473            },
1474            &mut ui,
1475            &c,
1476            &mut out,
1477        );
1478        assert_eq!(ui.zoom_idx, 1);
1479    }
1480
1481    #[test]
1482    fn wheel_outside_play_area_does_not_zoom() {
1483        // Wheel events on the right-hand sidebar must not zoom the biscuit.
1484        let s = GameState::default();
1485        let mut ui = UiState::new();
1486        let c = ctx(
1487            Rect::default(),
1488            &[],
1489            rect(0, 0, 100, 30),
1490            Rect::default(),
1491            &[],
1492            &[],
1493            &[],
1494            false,
1495            &s,
1496        );
1497        let mut out = Vec::new();
1498        process_input_event(
1499            InputEvent::Wheel {
1500                col: 120,
1501                row: 10,
1502                delta: WheelDelta::Down,
1503            },
1504            &mut ui,
1505            &c,
1506            &mut out,
1507        );
1508        assert_eq!(ui.zoom_idx, 0);
1509    }
1510
1511    #[test]
1512    fn wheel_up_saturates_at_zero() {
1513        let s = GameState::default();
1514        let mut ui = UiState::new();
1515        ui.zoom_idx = 0;
1516        let c = ctx(
1517            Rect::default(),
1518            &[],
1519            rect(0, 0, 100, 30),
1520            Rect::default(),
1521            &[],
1522            &[],
1523            &[],
1524            false,
1525            &s,
1526        );
1527        let mut out = Vec::new();
1528        process_input_event(
1529            InputEvent::Wheel {
1530                col: 50,
1531                row: 15,
1532                delta: WheelDelta::Up,
1533            },
1534            &mut ui,
1535            &c,
1536            &mut out,
1537        );
1538        assert_eq!(ui.zoom_idx, 0, "saturating_sub at 0 stays 0");
1539    }
1540
1541    #[test]
1542    fn wheel_down_caps_at_last_level() {
1543        let s = GameState::default();
1544        let mut ui = UiState::new();
1545        let last = crate::ui::biscuit::level_count() - 1;
1546        ui.zoom_idx = last;
1547        let c = ctx(
1548            Rect::default(),
1549            &[],
1550            rect(0, 0, 100, 30),
1551            Rect::default(),
1552            &[],
1553            &[],
1554            &[],
1555            false,
1556            &s,
1557        );
1558        let mut out = Vec::new();
1559        process_input_event(
1560            InputEvent::Wheel {
1561                col: 50,
1562                row: 15,
1563                delta: WheelDelta::Down,
1564            },
1565            &mut ui,
1566            &c,
1567            &mut out,
1568        );
1569        assert_eq!(ui.zoom_idx, last, "min() cap at last level");
1570    }
1571
1572    // -- Mouse position tracking -------------------------------------------
1573
1574    #[test]
1575    fn mouse_moved_updates_last_position() {
1576        let s = GameState::default();
1577        let mut ui = UiState::new();
1578        let mut out = Vec::new();
1579        process_input_event(
1580            InputEvent::MouseMoved { col: 42, row: 7 },
1581            &mut ui,
1582            &empty_ctx(&s),
1583            &mut out,
1584        );
1585        assert_eq!(ui.last_mouse_pos, Some((42, 7)));
1586        assert!(out.is_empty());
1587    }
1588
1589    #[test]
1590    fn mouse_down_updates_last_position_before_dispatch() {
1591        // The hover renderer reads `last_mouse_pos` next frame; a click
1592        // should leave the cursor "where it landed" so the row beneath the
1593        // click is highlighted.
1594        let s = GameState::default();
1595        let mut ui = UiState::new();
1596        let c = ctx(
1597            rect(40, 10, 20, 10),
1598            &[],
1599            rect(0, 0, 100, 30),
1600            Rect::default(),
1601            &[],
1602            &[],
1603            &[],
1604            false,
1605            &s,
1606        );
1607        let mut out = Vec::new();
1608        process_input_event(
1609            mouse_down(7, 7, MouseButton::Left, Modifiers::default()),
1610            &mut ui,
1611            &c,
1612            &mut out,
1613        );
1614        assert_eq!(ui.last_mouse_pos, Some((7, 7)));
1615    }
1616}