`init` planned every file on every run and refused if any of them existed, so attaching a new monitor could not be configured without --force — which reallocated global workspace numbers by i3's current output order and renumbered the monitors already in use. - allocate_global_starts() keeps the block each known output already holds and gives new outputs the lowest free block; blocks of inactive outputs stay reserved. - read_allocations()/parse_allocation() read those blocks back out of the generated files (inverse of render_workspaces_for_output). - --sync writes only the missing files, --prune drops configs for outputs that are gone and frees their numbers. - Files without the auto-generated header are never written or deleted, --force included. Co-Authored-By: Claude <noreply@anthropic.com>
654 lines
20 KiB
Python
654 lines
20 KiB
Python
from textwrap import dedent
|
|
|
|
import pytest
|
|
|
|
from pi3.smart_workspace import (
|
|
Allocation,
|
|
InitArgs,
|
|
Rect,
|
|
_planned_files,
|
|
allocate_global_starts,
|
|
expand_i3_includes,
|
|
find_output_at_cursor,
|
|
parse_allocation,
|
|
parse_workspaces_per_output,
|
|
read_allocations,
|
|
render_bindings,
|
|
render_workspaces_for_output,
|
|
run_init,
|
|
sanitize_var_suffix,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_workspaces_per_output
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parses_readme_example():
|
|
"""The full README.md example config round-trips through the parser."""
|
|
config = dedent("""
|
|
# Displays
|
|
set $primary eDP
|
|
set $top HDMI-A-0
|
|
set $bottom HDMI2
|
|
|
|
# Workspaces
|
|
set $ws1 1:1
|
|
set $ws2 2:2
|
|
set $TopWs1 3:1
|
|
set $TopWs2 4:2
|
|
set $BottomWs1 5:1
|
|
|
|
workspace $ws1 output $primary
|
|
workspace $ws2 output $primary
|
|
workspace $TopWs1 output $top
|
|
workspace $TopWs2 output $top
|
|
workspace $BottomWs1 output $bottom
|
|
""")
|
|
|
|
result = parse_workspaces_per_output(config, ["eDP", "HDMI-A-0", "HDMI2"])
|
|
|
|
assert result == {
|
|
"eDP": ["1:1", "2:2"],
|
|
"HDMI-A-0": ["3:1", "4:2"],
|
|
"HDMI2": ["5:1"],
|
|
}
|
|
|
|
|
|
def test_inactive_output_is_skipped():
|
|
config = dedent("""
|
|
set $primary eDP
|
|
set $external HDMI2
|
|
set $ws1 1:1
|
|
set $extWs1 9:ext
|
|
workspace $ws1 output $primary
|
|
workspace $extWs1 output $external
|
|
""")
|
|
|
|
# HDMI2 is unplugged
|
|
result = parse_workspaces_per_output(config, ["eDP"])
|
|
|
|
assert result == {"eDP": ["1:1"]}
|
|
|
|
|
|
def test_declaration_order_is_preserved():
|
|
config = dedent("""
|
|
set $out eDP
|
|
set $a 3:c
|
|
set $b 1:a
|
|
set $c 2:b
|
|
workspace $b output $out
|
|
workspace $c output $out
|
|
workspace $a output $out
|
|
""")
|
|
|
|
result = parse_workspaces_per_output(config, ["eDP"])
|
|
|
|
assert result == {"eDP": ["1:a", "2:b", "3:c"]}
|
|
|
|
|
|
def test_unrelated_set_lines_are_ignored():
|
|
config = dedent("""
|
|
set $primary eDP
|
|
set $ws1 1:1
|
|
set $term i3-sensible-terminal
|
|
set $launcher dmenu_run
|
|
set $mod Mod4
|
|
workspace $ws1 output $primary
|
|
""")
|
|
|
|
result = parse_workspaces_per_output(config, ["eDP"])
|
|
|
|
assert result == {"eDP": ["1:1"]}
|
|
|
|
|
|
def test_empty_active_outputs_returns_empty():
|
|
config = "set $primary eDP\nset $ws1 1:1\nworkspace $ws1 output $primary\n"
|
|
|
|
assert parse_workspaces_per_output(config, []) == {}
|
|
|
|
|
|
def test_binding_with_unknown_var_is_skipped():
|
|
config = dedent("""
|
|
set $primary eDP
|
|
set $ws1 1:1
|
|
workspace $ws1 output $unknownOutput
|
|
workspace $unknownWs output $primary
|
|
""")
|
|
|
|
assert parse_workspaces_per_output(config, ["eDP"]) == {}
|
|
|
|
|
|
def test_output_name_with_special_regex_chars_is_escaped():
|
|
# DisplayPort outputs often look like DP-1, HDMI-A-0 — regex-sensitive
|
|
config = dedent("""
|
|
set $main DP-1
|
|
set $ws1 1:1
|
|
workspace $ws1 output $main
|
|
""")
|
|
|
|
assert parse_workspaces_per_output(config, ["DP-1"]) == {"DP-1": ["1:1"]}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# find_output_at_cursor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _outputs(*specs):
|
|
return [(name, Rect(*rect)) for name, rect in specs]
|
|
|
|
|
|
def test_single_output_contains_origin():
|
|
outputs = _outputs(("eDP", (0, 0, 1920, 1080)))
|
|
|
|
assert find_output_at_cursor(outputs, (0, 0)) == "eDP"
|
|
assert find_output_at_cursor(outputs, (960, 540)) == "eDP"
|
|
assert find_output_at_cursor(outputs, (1920, 1080)) == "eDP"
|
|
|
|
|
|
def test_cursor_outside_all_outputs_returns_none():
|
|
outputs = _outputs(("eDP", (0, 0, 1920, 1080)))
|
|
|
|
assert find_output_at_cursor(outputs, (3000, 2000)) is None
|
|
|
|
|
|
def test_side_by_side_outputs_shared_vertical_edge_belongs_to_left():
|
|
# Primary at origin, secondary directly to the right of it.
|
|
outputs = _outputs(
|
|
("eDP", (0, 0, 1920, 1080)),
|
|
("HDMI", (1920, 0, 1920, 1080)),
|
|
)
|
|
|
|
# On the shared edge x=1920 → belongs to the LEFT output.
|
|
assert find_output_at_cursor(outputs, (1920, 500)) == "eDP"
|
|
# Just past it → right output.
|
|
assert find_output_at_cursor(outputs, (1921, 500)) == "HDMI"
|
|
# Deep inside right output.
|
|
assert find_output_at_cursor(outputs, (3000, 500)) == "HDMI"
|
|
|
|
|
|
def test_stacked_outputs_shared_horizontal_edge_belongs_to_top():
|
|
outputs = _outputs(
|
|
("eDP", (0, 0, 1920, 1080)),
|
|
("HDMI", (0, 1080, 1920, 1080)),
|
|
)
|
|
|
|
# On shared edge y=1080 → top output.
|
|
assert find_output_at_cursor(outputs, (500, 1080)) == "eDP"
|
|
# Just past it → bottom output.
|
|
assert find_output_at_cursor(outputs, (500, 1081)) == "HDMI"
|
|
|
|
|
|
def test_iteration_order_does_not_change_edge_attribution():
|
|
# Same as side-by-side, but with outputs declared in opposite order.
|
|
outputs = _outputs(
|
|
("HDMI", (1920, 0, 1920, 1080)),
|
|
("eDP", (0, 0, 1920, 1080)),
|
|
)
|
|
|
|
assert find_output_at_cursor(outputs, (1920, 500)) == "eDP"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Quoted workspace values (e.g. set $ws1 "1")
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parses_quoted_workspace_values():
|
|
"""i3 configs commonly use 'set $ws1 "1"' — the parser must strip quotes."""
|
|
config = dedent('''
|
|
set $primary eDP
|
|
set $ws1 "1"
|
|
set $ws2 "2:web"
|
|
workspace $ws1 output $primary
|
|
workspace $ws2 output $primary
|
|
''')
|
|
|
|
assert parse_workspaces_per_output(config, ["eDP"]) == {"eDP": ["1", "2:web"]}
|
|
|
|
|
|
def test_quoted_and_unquoted_can_mix():
|
|
config = dedent('''
|
|
set $primary eDP
|
|
set $ws1 "1"
|
|
set $ws2 2:b
|
|
workspace $ws1 output $primary
|
|
workspace $ws2 output $primary
|
|
''')
|
|
|
|
assert parse_workspaces_per_output(config, ["eDP"]) == {"eDP": ["1", "2:b"]}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# init rendering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sanitize_var_suffix_replaces_non_alnum():
|
|
assert sanitize_var_suffix("eDP-1") == "eDP_1"
|
|
assert sanitize_var_suffix("HDMI-A-0") == "HDMI_A_0"
|
|
assert sanitize_var_suffix("DVI-I-2-2") == "DVI_I_2_2"
|
|
assert sanitize_var_suffix("plain") == "plain"
|
|
|
|
|
|
def test_render_workspaces_for_output_uses_global_offset():
|
|
out = render_workspaces_for_output("HDMI-A-0", global_start=5, count=3)
|
|
|
|
# Global numbers continue from 5; local labels reset to 1.
|
|
assert "set $ws_HDMI_A_0_1 5:1" in out
|
|
assert "set $ws_HDMI_A_0_2 6:2" in out
|
|
assert "set $ws_HDMI_A_0_3 7:3" in out
|
|
# Output alias is present and uses the sanitized var name.
|
|
assert "set $out_HDMI_A_0 HDMI-A-0" in out
|
|
# All three workspaces are bound to the output.
|
|
assert out.count("output $out_HDMI_A_0") == 3
|
|
|
|
|
|
def test_render_workspaces_rejects_bad_args():
|
|
import pytest
|
|
|
|
with pytest.raises(ValueError):
|
|
render_workspaces_for_output("eDP-1", global_start=0, count=8)
|
|
with pytest.raises(ValueError):
|
|
render_workspaces_for_output("eDP-1", global_start=1, count=0)
|
|
|
|
|
|
def test_render_bindings_emits_three_blocks():
|
|
out = render_bindings(count=2)
|
|
|
|
assert "bindsym $mod+1 exec --no-startup-id pi3-smart-workspace -i 1" in out
|
|
assert "bindsym $mod+2 exec --no-startup-id pi3-smart-workspace -i 2" in out
|
|
assert "bindsym $mod+Shift+1 exec --no-startup-id pi3-smart-workspace -i 1 -s" in out
|
|
assert "bindsym $mod+Ctrl+2 exec --no-startup-id pi3-smart-workspace -i 2 -s -k" in out
|
|
|
|
|
|
def test_render_bindings_honors_custom_mod():
|
|
out = render_bindings(count=1, mod="$myMod")
|
|
|
|
assert "$myMod+1" in out
|
|
assert "$mod+1" not in out
|
|
|
|
|
|
def test_planned_files_assigns_contiguous_global_ranges():
|
|
from pathlib import Path
|
|
|
|
outputs = [
|
|
("eDP-1", Rect(0, 0, 1920, 1080)),
|
|
("HDMI-A-0", Rect(1920, 0, 1920, 1080)),
|
|
("DP-1", Rect(3840, 0, 1920, 1080)),
|
|
]
|
|
|
|
files = _planned_files(Path("/tmp/x"), outputs, count=3)
|
|
|
|
# First file is the shared bindings.conf, then one workspaces.conf per output.
|
|
paths = [p for p, _ in files]
|
|
assert paths == [
|
|
Path("/tmp/x/bindings.conf"),
|
|
Path("/tmp/x/eDP-1/workspaces.conf"),
|
|
Path("/tmp/x/HDMI-A-0/workspaces.conf"),
|
|
Path("/tmp/x/DP-1/workspaces.conf"),
|
|
]
|
|
|
|
edp = files[1][1]
|
|
hdmi = files[2][1]
|
|
dp = files[3][1]
|
|
|
|
assert "set $ws_eDP_1_1 1:1" in edp
|
|
assert "set $ws_HDMI_A_0_1 4:1" in hdmi # 3 (count) + 1
|
|
assert "set $ws_DP_1_1 7:1" in dp # 6 + 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# expand_i3_includes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_expand_includes_inlines_absolute_path(tmp_path):
|
|
inc = tmp_path / "inc.conf"
|
|
inc.write_text("set $foo bar\n")
|
|
main = f"# top\ninclude {inc}\n# tail\n"
|
|
|
|
result = expand_i3_includes(main, tmp_path)
|
|
|
|
assert "set $foo bar" in result
|
|
assert "include " not in result # directive consumed
|
|
assert "# top" in result and "# tail" in result
|
|
|
|
|
|
def test_expand_includes_handles_glob_in_alphabetical_order(tmp_path):
|
|
(tmp_path / "20-b.conf").write_text("# B\n")
|
|
(tmp_path / "10-a.conf").write_text("# A\n")
|
|
(tmp_path / "30-c.conf").write_text("# C\n")
|
|
main = f"include {tmp_path}/*.conf\n"
|
|
|
|
result = expand_i3_includes(main, tmp_path)
|
|
|
|
# Glob results are sorted, so A appears before B which appears before C.
|
|
assert result.index("# A") < result.index("# B") < result.index("# C")
|
|
|
|
|
|
def test_expand_includes_resolves_relative_paths_against_base_dir(tmp_path):
|
|
(tmp_path / "sub.conf").write_text("set $rel ok\n")
|
|
main = "include sub.conf\n"
|
|
|
|
result = expand_i3_includes(main, tmp_path)
|
|
|
|
assert "set $rel ok" in result
|
|
|
|
|
|
def test_expand_includes_recurses_through_chained_includes(tmp_path):
|
|
(tmp_path / "a.conf").write_text(f"include {tmp_path}/b.conf\n")
|
|
(tmp_path / "b.conf").write_text("set $deep yes\n")
|
|
main = f"include {tmp_path}/a.conf\n"
|
|
|
|
result = expand_i3_includes(main, tmp_path)
|
|
|
|
assert "set $deep yes" in result
|
|
|
|
|
|
def test_expand_includes_breaks_self_referential_cycle(tmp_path):
|
|
a = tmp_path / "a.conf"
|
|
a.write_text(f"set $marker present\ninclude {a}\n")
|
|
main = f"include {a}\n"
|
|
|
|
# Without cycle protection this would recurse forever.
|
|
result = expand_i3_includes(main, tmp_path)
|
|
|
|
assert result.count("set $marker present") == 1
|
|
|
|
|
|
def test_expand_includes_tolerates_missing_files(tmp_path):
|
|
main = f"set $before yes\ninclude {tmp_path}/does-not-exist.conf\nset $after yes\n"
|
|
|
|
# Glob simply yields nothing — must not raise.
|
|
result = expand_i3_includes(main, tmp_path)
|
|
|
|
assert "set $before yes" in result and "set $after yes" in result
|
|
|
|
|
|
def test_expand_then_parse_finds_workspaces_from_included_files(tmp_path):
|
|
"""The end-to-end fix: workspaces declared in an included file must
|
|
end up in parse_workspaces_per_output's output."""
|
|
(tmp_path / "out.conf").write_text(
|
|
"set $primary eDP\n"
|
|
"set $ws1 1:1\n"
|
|
"set $ws2 2:2\n"
|
|
"workspace $ws1 output $primary\n"
|
|
"workspace $ws2 output $primary\n"
|
|
)
|
|
main = f"set $mod Mod4\ninclude {tmp_path}/out.conf\n"
|
|
|
|
expanded = expand_i3_includes(main, tmp_path)
|
|
result = parse_workspaces_per_output(expanded, ["eDP"])
|
|
|
|
assert result == {"eDP": ["1:1", "2:2"]}
|
|
|
|
|
|
def test_init_output_parses_back_through_parser():
|
|
"""Round-trip: what `init` generates is parseable by `parse_workspaces_per_output`."""
|
|
from pathlib import Path
|
|
|
|
outputs = [
|
|
("eDP-1", Rect(0, 0, 1920, 1080)),
|
|
("HDMI-A-0", Rect(1920, 0, 1920, 1080)),
|
|
]
|
|
files = _planned_files(Path("/anywhere"), outputs, count=3)
|
|
|
|
# Concatenate everything except bindings.conf (parser only cares about
|
|
# the workspace/output declarations).
|
|
combined = "\n".join(content for _, content in files[1:])
|
|
|
|
result = parse_workspaces_per_output(combined, ["eDP-1", "HDMI-A-0"])
|
|
|
|
assert result == {
|
|
"eDP-1": ["1:1", "2:2", "3:3"],
|
|
"HDMI-A-0": ["4:1", "5:2", "6:3"],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# incremental allocation (init --sync)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_allocation_round_trips_render():
|
|
text = render_workspaces_for_output("HDMI-A-0", global_start=9, count=8)
|
|
|
|
assert parse_allocation(text) == Allocation("HDMI-A-0", 9, 8)
|
|
|
|
|
|
def test_parse_allocation_ignores_unrelated_config():
|
|
assert parse_allocation("set $mod Mod4\nset $ws1 1:1\n") is None
|
|
assert parse_allocation("") is None
|
|
|
|
|
|
def test_allocate_keeps_existing_outputs_and_appends_new():
|
|
"""The bug this fixes: a new monitor must not renumber the old ones."""
|
|
existing = {"eDP-1": Allocation("eDP-1", 1, 8)}
|
|
|
|
# i3 reports the newcomer *first*, which used to push eDP-1 to 9..16.
|
|
starts = allocate_global_starts(["HDMI-A-0", "eDP-1"], existing, count=8)
|
|
|
|
assert starts == {"eDP-1": 1, "HDMI-A-0": 9}
|
|
|
|
|
|
def test_allocate_reserves_blocks_of_currently_inactive_outputs():
|
|
"""A switched-off monitor keeps its numbers unless it's pruned."""
|
|
existing = {
|
|
"eDP-1": Allocation("eDP-1", 1, 8),
|
|
"HDMI-A-0": Allocation("HDMI-A-0", 9, 8), # unplugged right now
|
|
}
|
|
|
|
starts = allocate_global_starts(["eDP-1", "DP-1"], existing, count=8)
|
|
|
|
assert starts == {"eDP-1": 1, "DP-1": 17}
|
|
|
|
|
|
def test_allocate_fills_the_hole_left_by_a_pruned_output():
|
|
existing = {"eDP-1": Allocation("eDP-1", 9, 8)} # HDMI at 1..8 was pruned
|
|
|
|
starts = allocate_global_starts(["eDP-1", "DP-1"], existing, count=8)
|
|
|
|
assert starts == {"eDP-1": 9, "DP-1": 1}
|
|
|
|
|
|
def test_allocate_reallocates_when_the_count_changes():
|
|
"""A different -n means the old block no longer fits; it's released."""
|
|
existing = {
|
|
"eDP-1": Allocation("eDP-1", 1, 8),
|
|
"HDMI-A-0": Allocation("HDMI-A-0", 9, 8),
|
|
}
|
|
|
|
starts = allocate_global_starts(["eDP-1", "HDMI-A-0"], existing, count=4)
|
|
|
|
# Both 8-wide blocks are released and repacked tightly at the new width.
|
|
assert starts == {"eDP-1": 1, "HDMI-A-0": 5}
|
|
|
|
|
|
def test_allocate_assigns_from_scratch_when_nothing_exists():
|
|
starts = allocate_global_starts(["eDP-1", "HDMI-A-0"], {}, count=3)
|
|
|
|
assert starts == {"eDP-1": 1, "HDMI-A-0": 4}
|
|
|
|
|
|
def test_planned_files_honors_explicit_starts():
|
|
from pathlib import Path
|
|
|
|
outputs = [("HDMI-A-0", Rect(0, 0, 1920, 1080)), ("eDP-1", Rect(1920, 0, 1920, 1080))]
|
|
|
|
files = _planned_files(Path("/x"), outputs, count=8, starts={"eDP-1": 1, "HDMI-A-0": 9})
|
|
|
|
assert "set $ws_HDMI_A_0_1 9:1" in files[1][1]
|
|
assert "set $ws_eDP_1_1 1:1" in files[2][1]
|
|
|
|
|
|
def test_read_allocations_skips_hand_written_files(tmp_path):
|
|
generated = tmp_path / "eDP-1" / "workspaces.conf"
|
|
generated.parent.mkdir()
|
|
generated.write_text(render_workspaces_for_output("eDP-1", 1, 8))
|
|
|
|
handwritten = tmp_path / "DP-1" / "workspaces.conf"
|
|
handwritten.parent.mkdir()
|
|
handwritten.write_text("set $out_DP_1 DP-1\nset $ws_DP_1_1 1:1\n")
|
|
|
|
assert read_allocations(tmp_path) == {"eDP-1": Allocation("eDP-1", 1, 8)}
|
|
|
|
|
|
def test_read_allocations_on_missing_directory(tmp_path):
|
|
assert read_allocations(tmp_path / "nope") == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# run_init (with a stubbed i3 connection)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeOutput:
|
|
def __init__(self, name, rect):
|
|
self.name = name
|
|
self.rect = rect
|
|
self.active = True
|
|
|
|
|
|
class _FakeConnection:
|
|
def __init__(self, outputs):
|
|
self._outputs = outputs
|
|
|
|
def get_outputs(self):
|
|
return self._outputs
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_i3(monkeypatch):
|
|
"""Install a stub i3 connection reporting the given active outputs."""
|
|
import pi3.smart_workspace as sw
|
|
|
|
def install(*names):
|
|
outputs = [
|
|
_FakeOutput(name, Rect(i * 1920, 0, 1920, 1080))
|
|
for i, name in enumerate(names)
|
|
]
|
|
monkeypatch.setattr(
|
|
sw.i3ipc, "Connection", lambda *a, **kw: _FakeConnection(outputs)
|
|
)
|
|
|
|
return install
|
|
|
|
|
|
def _init_args(config_dir, **overrides):
|
|
defaults = dict(
|
|
count=8, config_dir=config_dir, dry_run=False, force=False,
|
|
sync=False, prune=False, debug=True,
|
|
)
|
|
return InitArgs(**{**defaults, **overrides})
|
|
|
|
|
|
def test_init_sync_adds_new_monitor_without_renumbering(tmp_path, fake_i3):
|
|
fake_i3("eDP-1")
|
|
run_init(_init_args(tmp_path))
|
|
before = (tmp_path / "eDP-1" / "workspaces.conf").read_text()
|
|
|
|
# New monitor shows up, and i3 lists it before the existing one.
|
|
fake_i3("HDMI-A-0", "eDP-1")
|
|
run_init(_init_args(tmp_path, sync=True))
|
|
|
|
assert (tmp_path / "eDP-1" / "workspaces.conf").read_text() == before
|
|
new = (tmp_path / "HDMI-A-0" / "workspaces.conf").read_text()
|
|
assert "set $ws_HDMI_A_0_1 9:1" in new
|
|
assert "set $ws_HDMI_A_0_8 16:8" in new
|
|
|
|
|
|
def test_init_sync_result_parses_back_without_number_collisions(tmp_path, fake_i3):
|
|
fake_i3("eDP-1")
|
|
run_init(_init_args(tmp_path, count=3))
|
|
fake_i3("HDMI-A-0", "eDP-1")
|
|
run_init(_init_args(tmp_path, count=3, sync=True))
|
|
|
|
combined = "\n".join(
|
|
p.read_text() for p in sorted(tmp_path.glob("*/workspaces.conf"))
|
|
)
|
|
result = parse_workspaces_per_output(combined, ["eDP-1", "HDMI-A-0"])
|
|
|
|
assert result == {"eDP-1": ["1:1", "2:2", "3:3"], "HDMI-A-0": ["4:1", "5:2", "6:3"]}
|
|
# No global workspace number is claimed by two outputs.
|
|
numbers = [ws.split(":")[0] for names in result.values() for ws in names]
|
|
assert len(numbers) == len(set(numbers))
|
|
|
|
|
|
def test_init_sync_is_a_noop_when_everything_exists(tmp_path, fake_i3):
|
|
fake_i3("eDP-1")
|
|
run_init(_init_args(tmp_path))
|
|
before = {p: p.read_text() for p in tmp_path.rglob("*.conf")}
|
|
|
|
run_init(_init_args(tmp_path, sync=True))
|
|
|
|
assert {p: p.read_text() for p in tmp_path.rglob("*.conf")} == before
|
|
|
|
|
|
def test_init_without_sync_still_refuses_to_overwrite(tmp_path, fake_i3):
|
|
fake_i3("eDP-1")
|
|
run_init(_init_args(tmp_path))
|
|
|
|
fake_i3("HDMI-A-0", "eDP-1")
|
|
with pytest.raises(RuntimeError, match="--sync"):
|
|
run_init(_init_args(tmp_path))
|
|
|
|
|
|
def test_init_force_renumbers_by_output_order(tmp_path, fake_i3):
|
|
fake_i3("eDP-1")
|
|
run_init(_init_args(tmp_path))
|
|
|
|
fake_i3("HDMI-A-0", "eDP-1")
|
|
run_init(_init_args(tmp_path, force=True))
|
|
|
|
assert "set $ws_HDMI_A_0_1 1:1" in (tmp_path / "HDMI-A-0" / "workspaces.conf").read_text()
|
|
assert "set $ws_eDP_1_1 9:1" in (tmp_path / "eDP-1" / "workspaces.conf").read_text()
|
|
|
|
|
|
def test_init_force_refuses_to_clobber_hand_written_files(tmp_path, fake_i3):
|
|
fake_i3("eDP-1")
|
|
target = tmp_path / "eDP-1" / "workspaces.conf"
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text("# mine, hand-tuned\nset $out_eDP_1 eDP-1\n")
|
|
|
|
with pytest.raises(RuntimeError, match="not generated"):
|
|
run_init(_init_args(tmp_path, force=True))
|
|
|
|
assert target.read_text() == "# mine, hand-tuned\nset $out_eDP_1 eDP-1\n"
|
|
|
|
|
|
def test_init_prune_removes_inactive_output_and_frees_its_numbers(tmp_path, fake_i3):
|
|
fake_i3("eDP-1", "HDMI-A-0")
|
|
run_init(_init_args(tmp_path))
|
|
assert (tmp_path / "HDMI-A-0").exists()
|
|
|
|
# HDMI unplugged, DP-1 plugged in instead.
|
|
fake_i3("eDP-1", "DP-1")
|
|
run_init(_init_args(tmp_path, sync=True, prune=True))
|
|
|
|
assert not (tmp_path / "HDMI-A-0").exists()
|
|
# DP-1 reuses the block HDMI-A-0 gave up rather than starting at 17.
|
|
assert "set $ws_DP_1_1 9:1" in (tmp_path / "DP-1" / "workspaces.conf").read_text()
|
|
|
|
|
|
def test_init_prune_leaves_hand_written_files_alone(tmp_path, fake_i3):
|
|
fake_i3("eDP-1")
|
|
run_init(_init_args(tmp_path))
|
|
handwritten = tmp_path / "HDMI-A-0" / "workspaces.conf"
|
|
handwritten.parent.mkdir()
|
|
handwritten.write_text("# mine\n")
|
|
|
|
run_init(_init_args(tmp_path, sync=True, prune=True))
|
|
|
|
assert handwritten.exists()
|
|
|
|
|
|
def test_init_dry_run_touches_nothing(tmp_path, fake_i3):
|
|
fake_i3("eDP-1")
|
|
|
|
run_init(_init_args(tmp_path, dry_run=True))
|
|
|
|
assert list(tmp_path.iterdir()) == []
|