"""Tests for dotfiles/hyprland/.local/bin/layout-navigate. The script is a sh wrapper around `hyprctl` that chooses the right dispatch command for the active layout (master/dwindle/scrolling) and for the active window's state (floating vs tiled, regular workspace vs special overlay). Tests invoke the real script with a faked `hyprctl` on PATH. The fake reads canned JSON for activewindow/getoption queries and records each dispatch call to a log file. Assertions compare the dispatch log to the expected sequence for the given scenario — we test behavior (what hyprctl calls the script emits), not implementation. """ import json import os import stat import subprocess import tempfile import unittest REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) SCRIPT = os.path.join(REPO_ROOT, "dotfiles/hyprland/.local/bin/layout-navigate") FAKE_HYPRCTL = os.path.join(os.path.dirname(__file__), "fake-hyprctl") def make_activewindow(floating=False, workspace_name="1", workspace_id=1): return { "address": "0xabc", "floating": bool(floating), "workspace": {"id": workspace_id, "name": workspace_name}, "class": "test", "title": "test", } def make_layout(name): return {"str": name} class LayoutNavigateHarness(unittest.TestCase): """Shared harness: run layout-navigate with fake hyprctl, read dispatch log.""" def setUp(self): self.tmp = tempfile.mkdtemp(prefix="layout-navigate-test-") # Ensure the fake hyprctl is executable os.chmod(FAKE_HYPRCTL, os.stat(FAKE_HYPRCTL).st_mode | stat.S_IEXEC) # Create a bin dir with a symlink to fake-hyprctl named "hyprctl" self.bin_dir = os.path.join(self.tmp, "bin") os.makedirs(self.bin_dir) os.symlink(FAKE_HYPRCTL, os.path.join(self.bin_dir, "hyprctl")) # Initialize dispatch log self.dispatch_log = os.path.join(self.tmp, "dispatch.log") open(self.dispatch_log, "w").close() def tearDown(self): import shutil shutil.rmtree(self.tmp, ignore_errors=True) def set_state(self, activewindow, layout, next_activewindow=None): with open(os.path.join(self.tmp, "activewindow.json"), "w") as f: json.dump(activewindow, f) with open(os.path.join(self.tmp, "layout.json"), "w") as f: json.dump(layout, f) if next_activewindow is not None: with open(os.path.join(self.tmp, "activewindow.1.json"), "w") as f: json.dump(next_activewindow, f) def run_script(self, *args): # Preserve current PATH so jq, sh, etc. are reachable env = os.environ.copy() env["PATH"] = self.bin_dir + os.pathsep + env.get("PATH", "") env["FAKE_HYPR_DIR"] = self.tmp result = subprocess.run( [SCRIPT] + list(args), env=env, capture_output=True, text=True, timeout=10, ) return result def dispatches(self): with open(self.dispatch_log) as f: return [line.rstrip("\n") for line in f if line.strip()] class TestTiledMasterLayout(LayoutNavigateHarness): """Characterization: existing behavior for master/dwindle layout on a regular workspace.""" def test_layout_navigate_master_tiled_next_focus_emits_cyclenext(self): self.set_state(make_activewindow(), make_layout("master")) self.run_script("next") self.assertEqual(self.dispatches(), ["dispatch layoutmsg cyclenext"]) def test_layout_navigate_master_tiled_prev_focus_emits_cycleprev(self): self.set_state(make_activewindow(), make_layout("master")) self.run_script("prev") self.assertEqual(self.dispatches(), ["dispatch layoutmsg cycleprev"]) def test_layout_navigate_master_tiled_next_move_emits_swapnext(self): self.set_state(make_activewindow(), make_layout("master")) self.run_script("next", "move") self.assertEqual(self.dispatches(), ["dispatch layoutmsg swapnext"]) def test_layout_navigate_master_tiled_prev_move_emits_swapprev(self): self.set_state(make_activewindow(), make_layout("master")) self.run_script("prev", "move") self.assertEqual(self.dispatches(), ["dispatch layoutmsg swapprev"]) class TestScrollingLayout(LayoutNavigateHarness): """Characterization: existing behavior for scrolling layout on a regular workspace.""" def test_layout_navigate_scrolling_next_focus_emits_focus_l(self): self.set_state(make_activewindow(), make_layout("scrolling")) self.run_script("next") self.assertEqual(self.dispatches(), ["dispatch layoutmsg focus l"]) def test_layout_navigate_scrolling_next_move_emits_swapwindow_l(self): self.set_state(make_activewindow(), make_layout("scrolling")) self.run_script("next", "move") self.assertEqual(self.dispatches(), ["dispatch swapwindow l"]) class TestFloatingOnRegularWorkspace(LayoutNavigateHarness): """Characterization: floating window on a regular workspace short-circuits to cyclenext tiled.""" def test_layout_navigate_floating_regular_next_focus_emits_cyclenext_tiled(self): self.set_state(make_activewindow(floating=True), make_layout("master")) self.run_script("next") self.assertEqual(self.dispatches(), ["dispatch cyclenext tiled"]) def test_layout_navigate_floating_regular_prev_focus_emits_cyclenext_prev_tiled(self): self.set_state(make_activewindow(floating=True), make_layout("master")) self.run_script("prev") self.assertEqual(self.dispatches(), ["dispatch cyclenext prev tiled"]) class TestTiledInSpecialWorkspace(LayoutNavigateHarness): """New behavior: tiled window in a special workspace toggles overlay off, then cycles. The special:stash overlay (or any special workspace) hides the underlying regular workspace. Cycling with layoutmsg only operates within the current workspace, so without toggling first, $mod+J gets trapped inside the overlay. Fix: hide the overlay, then dispatch the normal cycle — one keypress does both. """ def test_layout_navigate_tiled_special_next_focus_toggles_then_cyclenext(self): active = make_activewindow(workspace_name="special:stash", workspace_id=-92) # Post-toggle, focus lands on a tiled window on regular ws 1 post = make_activewindow(workspace_name="1", workspace_id=1) self.set_state(active, make_layout("master"), next_activewindow=post) self.run_script("next") self.assertEqual( self.dispatches(), [ "dispatch togglespecialworkspace stash", "dispatch layoutmsg cyclenext", ], ) def test_layout_navigate_tiled_special_prev_focus_toggles_then_cycleprev(self): active = make_activewindow(workspace_name="special:stash", workspace_id=-92) post = make_activewindow(workspace_name="1", workspace_id=1) self.set_state(active, make_layout("master"), next_activewindow=post) self.run_script("prev") self.assertEqual( self.dispatches(), [ "dispatch togglespecialworkspace stash", "dispatch layoutmsg cycleprev", ], ) def test_layout_navigate_tiled_special_scrolling_toggles_then_focus_l(self): """Toggle-then-cycle must honor the active layout, not hard-code master.""" active = make_activewindow(workspace_name="special:stash", workspace_id=-92) post = make_activewindow(workspace_name="1", workspace_id=1) self.set_state(active, make_layout("scrolling"), next_activewindow=post) self.run_script("next") self.assertEqual( self.dispatches(), [ "dispatch togglespecialworkspace stash", "dispatch layoutmsg focus l", ], ) def test_layout_navigate_tiled_special_next_move_does_not_toggle(self): """MOVE variant should NOT auto-toggle — moving a window out of a scratchpad is a separate UX we don't want triggered by the common navigate key.""" active = make_activewindow(workspace_name="special:stash", workspace_id=-92) self.set_state(active, make_layout("master")) self.run_script("next", "move") self.assertEqual(self.dispatches(), ["dispatch layoutmsg swapnext"]) def test_layout_navigate_floating_special_next_focus_toggles_first(self): """Floating scratchpad (e.g. special:S-term foot) should also toggle off. After the toggle, the re-read state determines whether to take the floating branch or fall through to the layout branch.""" active = make_activewindow(floating=True, workspace_name="special:S-term", workspace_id=-98) # After toggling S-term off, focus lands on a tiled window on ws 1 post = make_activewindow(floating=False, workspace_name="1", workspace_id=1) self.set_state(active, make_layout("master"), next_activewindow=post) self.run_script("next") self.assertEqual( self.dispatches(), [ "dispatch togglespecialworkspace S-term", "dispatch layoutmsg cyclenext", ], ) if __name__ == "__main__": unittest.main()