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()) == []