diff options
Diffstat (limited to 'tests/layout-navigate')
| -rwxr-xr-x | tests/layout-navigate/fake-hyprctl | 48 | ||||
| -rw-r--r-- | tests/layout-navigate/test_layout_navigate.py | 219 |
2 files changed, 267 insertions, 0 deletions
diff --git a/tests/layout-navigate/fake-hyprctl b/tests/layout-navigate/fake-hyprctl new file mode 100755 index 0000000..701f397 --- /dev/null +++ b/tests/layout-navigate/fake-hyprctl @@ -0,0 +1,48 @@ +#!/bin/sh +# Fake hyprctl for testing layout-navigate. +# +# State files live in $FAKE_HYPR_DIR: +# activewindow.json - first activewindow call returns this +# activewindow.1.json - second call (after togglespecialworkspace) returns this, if present +# layout.json - getoption general:layout returns this +# dispatch.log - every "dispatch" invocation appended here (one line) +# call-count - internal counter for activewindow calls + +: "${FAKE_HYPR_DIR:?FAKE_HYPR_DIR must be set}" + +cmd="$1" +shift + +case "$cmd" in + activewindow) + # Count calls so tests can provide a post-toggle state + count_file="$FAKE_HYPR_DIR/call-count" + count=$(cat "$count_file" 2>/dev/null || echo 0) + next=$((count + 1)) + echo "$next" > "$count_file" + + if [ "$count" -eq 0 ]; then + cat "$FAKE_HYPR_DIR/activewindow.json" + else + # Try numbered file; fall back to original + numbered="$FAKE_HYPR_DIR/activewindow.$count.json" + if [ -f "$numbered" ]; then + cat "$numbered" + else + cat "$FAKE_HYPR_DIR/activewindow.json" + fi + fi + ;; + getoption) + cat "$FAKE_HYPR_DIR/layout.json" + ;; + dispatch) + # Log the entire dispatch invocation as one line + echo "dispatch $*" >> "$FAKE_HYPR_DIR/dispatch.log" + echo "ok" + ;; + *) + echo "fake-hyprctl: unknown command '$cmd'" >&2 + exit 1 + ;; +esac diff --git a/tests/layout-navigate/test_layout_navigate.py b/tests/layout-navigate/test_layout_navigate.py new file mode 100644 index 0000000..41294b7 --- /dev/null +++ b/tests/layout-navigate/test_layout_navigate.py @@ -0,0 +1,219 @@ +"""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() |
