Add init --sync and --prune for incremental monitor setup
`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>
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -398,3 +406,248 @@ def test_init_output_parses_back_through_parser():
|
||||
"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()) == []
|
||||
|
||||
Reference in New Issue
Block a user