from textwrap import dedent from pi3.smart_workspace import ( Rect, _planned_files, expand_i3_includes, find_output_at_cursor, parse_workspaces_per_output, render_bindings, render_workspaces_for_output, 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"], }