1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
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()
|