@@ -260,4 +260,170 @@ mod tests {
260260 OverlayInputResult :: CloseOverlay
261261 ) ;
262262 }
263+
264+ // ─── UX-win G: dismiss-key consistency ───────────────────────────────────
265+
266+ /// Esc closes every overlay that supports it (all except PauseBadge/PauseLogo).
267+ #[ test]
268+ fn esc_closes_all_dismissable_overlays ( ) {
269+ let esc = make_key ( KeyCode :: Esc , KeyModifiers :: NONE ) ;
270+ for overlay in [
271+ OverlayType :: Controls ,
272+ OverlayType :: Dashboard ,
273+ OverlayType :: KeyboardHints ,
274+ OverlayType :: ConfigBrowser ,
275+ OverlayType :: ConfigSave ,
276+ OverlayType :: DirtyGuard ,
277+ OverlayType :: PresetComparison ,
278+ ] {
279+ let mut state = OverlayState :: default ( ) ;
280+ state. open ( overlay) ;
281+ let result = OverlayInputManager :: handle_input ( & state, & esc) ;
282+ // PaletteEditor delegates internally; all others return CloseOverlay.
283+ assert_eq ! (
284+ result,
285+ OverlayInputResult :: CloseOverlay ,
286+ "{overlay:?} + Esc should return CloseOverlay"
287+ ) ;
288+ }
289+ }
290+
291+ /// Pause overlays do NOT close on Esc (they are not user-dismissable modals).
292+ #[ test]
293+ fn esc_does_not_close_pause_overlays ( ) {
294+ let esc = make_key ( KeyCode :: Esc , KeyModifiers :: NONE ) ;
295+ for overlay in [ OverlayType :: PauseBadge , OverlayType :: PauseLogo ] {
296+ let mut state = OverlayState :: default ( ) ;
297+ state. open ( overlay) ;
298+ let result = OverlayInputManager :: handle_input ( & state, & esc) ;
299+ // Pause overlays don't close on Esc — the key is not consumed either.
300+ assert_ne ! (
301+ result,
302+ OverlayInputResult :: CloseOverlay ,
303+ "{overlay:?} + Esc must NOT return CloseOverlay"
304+ ) ;
305+ }
306+ }
307+
308+ /// PaletteEditor Esc is delegated to the internal handler (returns NotHandled
309+ /// so the runner's PaletteEditor block can do its mode-aware close logic).
310+ #[ test]
311+ fn esc_on_palette_editor_is_delegated ( ) {
312+ let mut state = OverlayState :: default ( ) ;
313+ state. open ( OverlayType :: PaletteEditor ) ;
314+ let esc = make_key ( KeyCode :: Esc , KeyModifiers :: NONE ) ;
315+ assert_eq ! (
316+ OverlayInputManager :: handle_input( & state, & esc) ,
317+ OverlayInputResult :: NotHandled ,
318+ "PaletteEditor Esc must be delegated (NotHandled), not CloseOverlay"
319+ ) ;
320+ }
321+
322+ /// Each overlay's opening toggle key re-closes it.
323+ #[ test]
324+ fn toggle_key_re_closes_controls ( ) {
325+ let mut state = OverlayState :: default ( ) ;
326+ state. open ( OverlayType :: Controls ) ;
327+ for c in [ 'h' , 'H' ] {
328+ let k = make_key ( KeyCode :: Char ( c) , KeyModifiers :: NONE ) ;
329+ assert_eq ! (
330+ OverlayInputManager :: handle_input( & state, & k) ,
331+ OverlayInputResult :: CloseOverlay ,
332+ "'{c}' should re-close Controls"
333+ ) ;
334+ }
335+ }
336+
337+ /// Dashboard dual-key: both `\` and `|` re-close when Dashboard is open.
338+ #[ test]
339+ fn dashboard_dual_toggle_both_reclose ( ) {
340+ let mut state = OverlayState :: default ( ) ;
341+ state. open ( OverlayType :: Dashboard ) ;
342+ for c in [ '\\' , '|' ] {
343+ let k = make_key ( KeyCode :: Char ( c) , KeyModifiers :: NONE ) ;
344+ assert_eq ! (
345+ OverlayInputManager :: handle_input( & state, & k) ,
346+ OverlayInputResult :: CloseOverlay ,
347+ "'{c}' should re-close Dashboard"
348+ ) ;
349+ }
350+ }
351+
352+ /// KeyboardHints `?` re-closes when open.
353+ #[ test]
354+ fn keyboard_hints_toggle_recloses ( ) {
355+ let mut state = OverlayState :: default ( ) ;
356+ state. open ( OverlayType :: KeyboardHints ) ;
357+ let k = make_key ( KeyCode :: Char ( '?' ) , KeyModifiers :: NONE ) ;
358+ assert_eq ! (
359+ OverlayInputManager :: handle_input( & state, & k) ,
360+ OverlayInputResult :: CloseOverlay
361+ ) ;
362+ }
363+
364+ /// ConfigBrowser Ctrl+L/B re-closes when open; plain l/b pass through.
365+ #[ test]
366+ fn config_browser_toggle_recloses_ctrl_only ( ) {
367+ let mut state = OverlayState :: default ( ) ;
368+ state. open ( OverlayType :: ConfigBrowser ) ;
369+ for c in [ 'l' , 'L' , 'b' , 'B' ] {
370+ let ctrl = make_key ( KeyCode :: Char ( c) , KeyModifiers :: CONTROL ) ;
371+ assert_eq ! (
372+ OverlayInputManager :: handle_input( & state, & ctrl) ,
373+ OverlayInputResult :: CloseOverlay ,
374+ "Ctrl+{c} should re-close ConfigBrowser"
375+ ) ;
376+ // Plain key must NOT close (it's not the toggle key)
377+ let plain = make_key ( KeyCode :: Char ( c) , KeyModifiers :: NONE ) ;
378+ assert_ne ! (
379+ OverlayInputManager :: handle_input( & state, & plain) ,
380+ OverlayInputResult :: CloseOverlay ,
381+ "plain '{c}' must not re-close ConfigBrowser"
382+ ) ;
383+ }
384+ }
385+
386+ /// ConfigSave Ctrl+S re-closes when open.
387+ #[ test]
388+ fn config_save_toggle_recloses ( ) {
389+ let mut state = OverlayState :: default ( ) ;
390+ state. open ( OverlayType :: ConfigSave ) ;
391+ let ctrl_s = make_key ( KeyCode :: Char ( 's' ) , KeyModifiers :: CONTROL ) ;
392+ assert_eq ! (
393+ OverlayInputManager :: handle_input( & state, & ctrl_s) ,
394+ OverlayInputResult :: CloseOverlay
395+ ) ;
396+ }
397+
398+ /// Esc when no overlay is open returns NotHandled (passes to error-MSG dismiss logic).
399+ #[ test]
400+ fn esc_with_no_overlay_is_not_handled ( ) {
401+ let state = OverlayState :: default ( ) ;
402+ let esc = make_key ( KeyCode :: Esc , KeyModifiers :: NONE ) ;
403+ assert_eq ! (
404+ OverlayInputManager :: handle_input( & state, & esc) ,
405+ OverlayInputResult :: NotHandled ,
406+ "Esc with no overlay open must be NotHandled (reaches sticky-error dismiss)"
407+ ) ;
408+ }
409+
410+ /// Space (pause) is never consumed by the overlay input manager — it always passes through.
411+ #[ test]
412+ fn space_never_consumed_by_overlay_manager ( ) {
413+ let space = make_key ( KeyCode :: Char ( ' ' ) , KeyModifiers :: NONE ) ;
414+ for overlay in [
415+ OverlayType :: Controls ,
416+ OverlayType :: Dashboard ,
417+ OverlayType :: KeyboardHints ,
418+ ] {
419+ let mut state = OverlayState :: default ( ) ;
420+ state. open ( overlay) ;
421+ let result = OverlayInputManager :: handle_input ( & state, & space) ;
422+ assert_ne ! (
423+ result,
424+ OverlayInputResult :: Consumed ,
425+ "Space must never be Consumed ({overlay:?} open)"
426+ ) ;
427+ }
428+ }
263429}
0 commit comments