@@ -307,3 +307,221 @@ def test_hooks_mixin(
307307 0 : "set-option -g status-left-style bg=red" ,
308308 },
309309 )
310+
311+
312+ # =============================================================================
313+ # Comprehensive Hook Test Grid
314+ # =============================================================================
315+
316+
317+ class HookTestCase (t .NamedTuple ):
318+ """Test case for hook validation."""
319+
320+ test_id : str
321+ hook : str # tmux hook name (hyphenated)
322+ min_version : str = "3.0" # Minimum tmux version required
323+ xfail_reason : str | None = None # Mark as expected failure with reason
324+
325+
326+ # --- Alert Hooks ---
327+ ALERT_HOOKS : list [HookTestCase ] = [
328+ HookTestCase ("alert_activity" , "alert-activity" ),
329+ HookTestCase ("alert_bell" , "alert-bell" ),
330+ HookTestCase ("alert_silence" , "alert-silence" ),
331+ ]
332+
333+ # --- Client Hooks ---
334+ CLIENT_HOOKS : list [HookTestCase ] = [
335+ HookTestCase ("client_active" , "client-active" ),
336+ HookTestCase ("client_attached" , "client-attached" ),
337+ HookTestCase ("client_detached" , "client-detached" ),
338+ HookTestCase ("client_focus_in" , "client-focus-in" ),
339+ HookTestCase ("client_focus_out" , "client-focus-out" ),
340+ HookTestCase ("client_resized" , "client-resized" ),
341+ HookTestCase ("client_session_changed" , "client-session-changed" ),
342+ ]
343+
344+ # --- Session Hooks ---
345+ SESSION_HOOKS : list [HookTestCase ] = [
346+ HookTestCase ("session_created" , "session-created" ),
347+ HookTestCase ("session_closed" , "session-closed" ),
348+ HookTestCase ("session_renamed" , "session-renamed" ),
349+ ]
350+
351+ # --- Window Hooks ---
352+ WINDOW_HOOKS : list [HookTestCase ] = [
353+ HookTestCase ("window_linked" , "window-linked" ),
354+ HookTestCase ("window_renamed" , "window-renamed" ),
355+ HookTestCase ("window_resized" , "window-resized" ),
356+ HookTestCase ("window_unlinked" , "window-unlinked" ),
357+ HookTestCase ("session_window_changed" , "session-window-changed" ),
358+ ]
359+
360+ # --- Pane Hooks ---
361+ PANE_HOOKS : list [HookTestCase ] = [
362+ HookTestCase ("pane_died" , "pane-died" ),
363+ HookTestCase ("pane_exited" , "pane-exited" ),
364+ HookTestCase ("pane_focus_in" , "pane-focus-in" ),
365+ HookTestCase ("pane_focus_out" , "pane-focus-out" ),
366+ HookTestCase ("pane_mode_changed" , "pane-mode-changed" ),
367+ HookTestCase ("pane_set_clipboard" , "pane-set-clipboard" ),
368+ ]
369+
370+ # --- After-* Hooks ---
371+ AFTER_HOOKS : list [HookTestCase ] = [
372+ HookTestCase ("after_bind_key" , "after-bind-key" ),
373+ HookTestCase ("after_capture_pane" , "after-capture-pane" ),
374+ HookTestCase ("after_copy_mode" , "after-copy-mode" ),
375+ HookTestCase ("after_display_message" , "after-display-message" ),
376+ HookTestCase ("after_display_panes" , "after-display-panes" ),
377+ HookTestCase ("after_kill_pane" , "after-kill-pane" ),
378+ HookTestCase ("after_list_buffers" , "after-list-buffers" ),
379+ HookTestCase ("after_list_clients" , "after-list-clients" ),
380+ HookTestCase ("after_list_keys" , "after-list-keys" ),
381+ HookTestCase ("after_list_panes" , "after-list-panes" ),
382+ HookTestCase ("after_list_sessions" , "after-list-sessions" ),
383+ HookTestCase ("after_list_windows" , "after-list-windows" ),
384+ HookTestCase ("after_load_buffer" , "after-load-buffer" ),
385+ HookTestCase ("after_lock_server" , "after-lock-server" ),
386+ HookTestCase ("after_new_session" , "after-new-session" ),
387+ HookTestCase ("after_new_window" , "after-new-window" ),
388+ HookTestCase ("after_paste_buffer" , "after-paste-buffer" ),
389+ HookTestCase ("after_pipe_pane" , "after-pipe-pane" ),
390+ HookTestCase ("after_queue" , "after-queue" ),
391+ HookTestCase ("after_refresh_client" , "after-refresh-client" ),
392+ HookTestCase ("after_rename_session" , "after-rename-session" ),
393+ HookTestCase ("after_rename_window" , "after-rename-window" ),
394+ HookTestCase ("after_resize_pane" , "after-resize-pane" ),
395+ HookTestCase ("after_resize_window" , "after-resize-window" ),
396+ HookTestCase ("after_save_buffer" , "after-save-buffer" ),
397+ HookTestCase ("after_select_layout" , "after-select-layout" ),
398+ HookTestCase ("after_select_pane" , "after-select-pane" ),
399+ HookTestCase ("after_select_window" , "after-select-window" ),
400+ HookTestCase ("after_send_keys" , "after-send-keys" ),
401+ HookTestCase ("after_set_buffer" , "after-set-buffer" ),
402+ HookTestCase ("after_set_environment" , "after-set-environment" ),
403+ HookTestCase ("after_set_hook" , "after-set-hook" ),
404+ HookTestCase ("after_set_option" , "after-set-option" ),
405+ HookTestCase ("after_show_environment" , "after-show-environment" ),
406+ HookTestCase ("after_show_messages" , "after-show-messages" ),
407+ HookTestCase ("after_show_options" , "after-show-options" ),
408+ HookTestCase ("after_split_window" , "after-split-window" ),
409+ HookTestCase ("after_unbind_key" , "after-unbind-key" ),
410+ ]
411+
412+ # --- New Hooks (tmux 3.5+) ---
413+ NEW_HOOKS : list [HookTestCase ] = [
414+ HookTestCase (
415+ "pane_title_changed" ,
416+ "pane-title-changed" ,
417+ "3.5" ,
418+ xfail_reason = "pane-title-changed requires tmux 3.5+" ,
419+ ),
420+ HookTestCase (
421+ "client_light_theme" ,
422+ "client-light-theme" ,
423+ "3.5" ,
424+ xfail_reason = "client-light-theme requires tmux 3.5+" ,
425+ ),
426+ HookTestCase (
427+ "client_dark_theme" ,
428+ "client-dark-theme" ,
429+ "3.5" ,
430+ xfail_reason = "client-dark-theme requires tmux 3.5+" ,
431+ ),
432+ ]
433+
434+ # Combine all hook test cases
435+ ALL_HOOK_TEST_CASES : list [HookTestCase ] = (
436+ ALERT_HOOKS + CLIENT_HOOKS + SESSION_HOOKS + WINDOW_HOOKS + PANE_HOOKS + AFTER_HOOKS
437+ )
438+
439+
440+ def _build_hook_params () -> list [t .Any ]:
441+ """Build pytest params with appropriate marks."""
442+ params = []
443+ for tc in ALL_HOOK_TEST_CASES :
444+ marks : list [t .Any ] = []
445+ if tc .xfail_reason :
446+ marks .append (pytest .mark .xfail (reason = tc .xfail_reason ))
447+ params .append (pytest .param (tc , id = tc .test_id , marks = marks ))
448+ return params
449+
450+
451+ @pytest .mark .parametrize ("test_case" , _build_hook_params ())
452+ def test_hook_set_show_unset_cycle (server : Server , test_case : HookTestCase ) -> None :
453+ """Test set/show/unset cycle for each hook.
454+
455+ This parametrized test ensures all hooks in the libtmux constants can be:
456+ 1. Set with a command
457+ 2. Shown and verified
458+ 3. Unset cleanly
459+ """
460+ if not has_gte_version (test_case .min_version ):
461+ pytest .skip (f"Requires tmux { test_case .min_version } +" )
462+
463+ session = server .new_session (session_name = "test_hook_cycle" )
464+ window = session .active_window
465+ assert window is not None
466+ pane = window .active_pane
467+ assert pane is not None
468+
469+ hook_cmd = "display-message 'test hook fired'"
470+
471+ # Test set_hook (using session-level hook which works on all tmux versions)
472+ session .set_hook (f"{ test_case .hook } [0]" , hook_cmd )
473+
474+ # Test show_hook
475+ result = session ._show_hook (f"{ test_case .hook } [0]" )
476+ assert result is not None , f"Expected hook { test_case .hook } to be set"
477+
478+ # Parse and verify
479+ hooks = Hooks .from_stdout (result )
480+ hook_attr = test_case .hook .replace ("-" , "_" )
481+ hook_value = getattr (hooks , hook_attr , None )
482+ assert hook_value is not None , f"Hook attribute { hook_attr } not found in Hooks"
483+ assert len (hook_value ) > 0 , f"Expected hook { test_case .hook } to have values"
484+ assert "display-message" in hook_value [0 ], (
485+ f"Expected 'display-message' in hook value, got: { hook_value [0 ]} "
486+ )
487+
488+ # Test unset_hook
489+ session .unset_hook (f"{ test_case .hook } [0]" )
490+
491+ # Verify unset
492+ result_after_unset = session ._show_hook (f"{ test_case .hook } [0]" )
493+ if result_after_unset :
494+ hooks_after = Hooks .from_stdout (result_after_unset )
495+ hook_value_after = getattr (hooks_after , hook_attr , None )
496+ # After unset, the hook should be empty or have empty value
497+ if hook_value_after :
498+ assert len (hook_value_after .as_list ()) == 0 or hook_value_after [0 ] == "" , (
499+ f"Expected hook { test_case .hook } to be unset"
500+ )
501+
502+
503+ @pytest .mark .parametrize (
504+ "test_case" ,
505+ [pytest .param (tc , id = tc .test_id ) for tc in NEW_HOOKS ],
506+ )
507+ def test_new_hooks_version_gated (server : Server , test_case : HookTestCase ) -> None :
508+ """Test new hooks that require tmux 3.5+.
509+
510+ These hooks are version-gated and will skip on older tmux versions.
511+ """
512+ if not has_gte_version (test_case .min_version ):
513+ pytest .skip (f"Requires tmux { test_case .min_version } +" )
514+
515+ session = server .new_session (session_name = "test_new_hooks" )
516+
517+ hook_cmd = "display-message 'new hook fired'"
518+
519+ # Test set_hook
520+ session .set_hook (f"{ test_case .hook } [0]" , hook_cmd )
521+
522+ # Test show_hook
523+ result = session ._show_hook (f"{ test_case .hook } [0]" )
524+ assert result is not None , f"Expected hook { test_case .hook } to be set"
525+
526+ # Cleanup
527+ session .unset_hook (f"{ test_case .hook } [0]" )
0 commit comments