import os import json import subprocess import sys import pytest from lib_py.layout import ( compute_2xk_layout, LayoutDecision, ) def test_empty_or_malformed_json_fallback(): d = compute_2xk_layout({}, default_anchor_id="pane-123") assert d.target_pane_id == "pane-123" assert d.direction == "right" assert not d.is_overflow def test_1_pane_split_down(): payload = { "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 120, "height": 80}} ] } } d = compute_2xk_layout(payload, min_cols=60, min_rows=20) assert d.target_pane_id == "p1" assert d.direction == "down" assert not d.is_overflow def test_1_pane_height_constrained_splits_right(): payload = { "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 160, "height": 30}} ] } } d = compute_2xk_layout(payload, min_cols=60, min_rows=20) assert d.target_pane_id == "p1" assert d.direction == "right" assert not d.is_overflow def test_1_pane_overflow(): payload = { "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 30}} ] } } d = compute_2xk_layout(payload, min_cols=60, min_rows=20) assert d.target_pane_id == "p1" assert d.direction == "overflow" assert d.is_overflow def test_2_panes_to_3_panes_new_column_right(): payload = { "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 160, "height": 40}}, {"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 160, "height": 40}} ] } } d = compute_2xk_layout(payload, min_cols=60, min_rows=20) assert d.target_pane_id == "p1" assert d.direction == "right" assert not d.is_overflow def test_3_panes_to_4_panes_fill_singleton(): payload = { "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 40}}, {"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 80, "height": 40}}, {"pane_id": "p3", "rect": {"x": 80, "y": 0, "width": 80, "height": 80}} ] } } d = compute_2xk_layout(payload, min_cols=60, min_rows=20) assert d.target_pane_id == "p3" assert d.direction == "down" assert not d.is_overflow def test_4_panes_to_5_panes_new_column(): payload = { "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 120, "height": 40}}, {"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 120, "height": 40}}, {"pane_id": "p3", "rect": {"x": 120, "y": 0, "width": 120, "height": 40}}, {"pane_id": "p4", "rect": {"x": 120, "y": 40, "width": 120, "height": 40}} ] } } d = compute_2xk_layout(payload, min_cols=60, min_rows=20) assert d.target_pane_id == "p3" assert d.direction == "right" assert not d.is_overflow def test_4_panes_overflow_when_width_constrained(): payload = { "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 60, "height": 40}}, {"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 60, "height": 40}}, {"pane_id": "p3", "rect": {"x": 60, "y": 0, "width": 60, "height": 40}}, {"pane_id": "p4", "rect": {"x": 60, "y": 40, "width": 60, "height": 40}} ] } } d = compute_2xk_layout(payload, min_cols=60, min_rows=20) assert d.direction == "overflow" assert d.is_overflow def test_max_columns_limit(): payload = { "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 100, "height": 40}}, {"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 100, "height": 40}}, {"pane_id": "p3", "rect": {"x": 100, "y": 0, "width": 100, "height": 40}}, {"pane_id": "p4", "rect": {"x": 100, "y": 40, "width": 100, "height": 40}} ] } } d = compute_2xk_layout(payload, min_cols=30, min_rows=20, max_columns=2) assert d.direction == "overflow" assert d.is_overflow def test_headless_0x0_transitions(): # N=1 -> down p1 = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}]}} assert compute_2xk_layout(p1).direction == "down" # N=2 -> right p2 = {"result": {"panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}, {"pane_id": "p2", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}} ]}} assert compute_2xk_layout(p2).direction == "right" # N=3 -> down p3 = {"result": {"panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}, {"pane_id": "p2", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}, {"pane_id": "p3", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}} ]}} assert compute_2xk_layout(p3).direction == "down" # N=4 -> right p4 = {"result": {"panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}, {"pane_id": "p2", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}, {"pane_id": "p3", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}, {"pane_id": "p4", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}} ]}} assert compute_2xk_layout(p4).direction == "right" def test_real_herdr_080_nested_layout_format(): payload = { "result": { "layout": { "area": {"height": 78, "width": 120, "x": 26, "y": 1}, "focused_pane_id": "wK:p1", "panes": [ {"pane_id": "wK:p1", "rect": {"height": 39, "width": 60, "x": 26, "y": 1, "focused": True}}, {"pane_id": "wK:p2", "rect": {"height": 39, "width": 60, "x": 26, "y": 40, "focused": False}}, {"pane_id": "wK:p3", "rect": {"height": 78, "width": 60, "x": 86, "y": 1, "focused": False}} ], "splits": [{"direction": "right", "id": "split_0_root", "ratio": 0.5}], "workspace_id": "wK", "tab_id": "wK:t1", "zoomed": False }, "type": "pane_layout" } } d = compute_2xk_layout(payload, min_cols=30, min_rows=20) assert d.target_pane_id == "wK:p3" assert d.direction == "down" def test_cli_invocation_pipe(): payload = json.dumps({ "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 120, "height": 80}} ] } }) skills_dir = os.path.abspath(".agents/skills") env = {**os.environ, "PYTHONPATH": skills_dir} res = subprocess.run( [sys.executable, "-m", "lib_py.layout", "--min-cols", "60", "--min-rows", "20"], input=payload, capture_output=True, text=True, env=env ) assert res.returncode == 0 assert res.stdout.strip() == "down p1" res_json = subprocess.run( [sys.executable, "-m", "lib_py.layout", "--json"], input=payload, capture_output=True, text=True, env=env ) assert res_json.returncode == 0 data = json.loads(res_json.stdout) assert data["target_pane_id"] == "p1" assert data["direction"] == "down" assert not data["is_overflow"] def test_lib_sh_no_local_in_shim_heredoc(): """Verify F-1: No 'local' declarations inside the top-level shim heredoc dispatcher.""" import os lib_path = os.path.abspath(".agents/skills/lib.sh") with open(lib_path, "r", encoding="utf-8") as f: content = f.read() start_idx = content.find("cat <<'EOF' > \"$tmp_file\"") end_idx = content.find("\nEOF\n", start_idx) assert start_idx != -1 and end_idx != -1 heredoc = content[start_idx:end_idx] # Check specifically in the layout block layout_idx = heredoc.find('split_dir=""') assert layout_idx != -1 layout_block = heredoc[layout_idx:layout_idx + 800] assert "local " not in layout_block, f"Forbidden 'local' found in top-level shim heredoc:\n{layout_block}" def test_5_panes_to_6_panes_fill_singleton_in_3rd_column(): """Verify 5 panes (2x2 full + 1 singleton in 3rd col) -> splits 3rd col singleton down to make 2x3 grid.""" payload = { "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 40}}, {"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 80, "height": 40}}, {"pane_id": "p3", "rect": {"x": 80, "y": 0, "width": 80, "height": 40}}, {"pane_id": "p4", "rect": {"x": 80, "y": 40, "width": 80, "height": 40}}, {"pane_id": "p5", "rect": {"x": 160, "y": 0, "width": 80, "height": 80}} ] } } d = compute_2xk_layout(payload, min_cols=60, min_rows=20) assert d.target_pane_id == "p5" assert d.direction == "down" assert not d.is_overflow def test_lib_sh_layout_split_in_set_e_subshell(tmp_path): """Verify F-1 & F-2: lib.sh layout split block executes cleanly in set -euo pipefail top-level script.""" import os skills_dir = os.path.abspath(".agents/skills") script = f"""#!/usr/bin/env bash set -euo pipefail export PYTHONPATH="{skills_dir}" _real_herdr() {{ if [ "${{1:-}}" = "pane" ] && [ "${{2:-}}" = "layout" ]; then echo '{{"result": {{"panes": [{{"pane_id": "p1", "rect": {{"x": 0, "y": 0, "width": 120, "height": 80}}}}]}}}}' return 0 fi return 1 }} sample_pane="p1" split_dir="" # Exact snippet from lib.sh:429-435 if [ -n "$sample_pane" ]; then layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "") read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${{MAM_MIN_PANE_COLS:-15}}" --min-rows "${{MAM_MIN_PANE_ROWS:-0}}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane") split_dir="${{split_dir:-right}}" sample_pane="${{split_target:-$sample_pane}}" fi echo "SPLIT_DIR=$split_dir" echo "SAMPLE_PANE=$sample_pane" """ res = subprocess.run(["bash", "-c", script], capture_output=True, text=True) assert res.returncode == 0, f"Script failed with code {res.returncode}. Stderr: {res.stderr}" assert "SPLIT_DIR=down" in res.stdout assert "SAMPLE_PANE=p1" in res.stdout def test_real_generated_shim_layout_split(tmp_path): """Verify generated shim executes layout.py without command not found or local aborts.""" import os skills_dir = os.path.abspath(".agents/skills") ws_dir = str(tmp_path / "ws") os.makedirs(ws_dir, exist_ok=True) test_script = f"""#!/usr/bin/env bash set -euo pipefail export WORKSPACE_ROOT="{ws_dir}" export SKILL_DIR="{skills_dir}" source "{skills_dir}/lib.sh" _init_herdr_isolation shim_path="$WORKSPACE_ROOT/.mam/shim/herdr" if [ ! -x "$shim_path" ]; then echo "ERROR: shim not generated or not executable" >&2 exit 1 fi # Verify no 'local ' inside the shim heredoc body if grep -E '^[[:space:]]*local layout_' "$shim_path"; then echo "ERROR: 'local layout_' found in generated shim" >&2 exit 1 fi echo "SHIM_OK" """ res = subprocess.run(["bash", "-c", test_script], capture_output=True, text=True) assert res.returncode == 0, f"Shim test failed: {res.stderr}" assert "SHIM_OK" in res.stdout def _four_panes_two_columns(): """GUI payload: 2 full columns x 2 rows (4 panes). Shared by the max-cols tests.""" return { "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 100, "height": 40}}, {"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 100, "height": 40}}, {"pane_id": "p3", "rect": {"x": 100, "y": 0, "width": 100, "height": 40}}, {"pane_id": "p4", "rect": {"x": 100, "y": 40, "width": 100, "height": 40}}, ] } } def test_cli_max_cols_flag_triggers_overflow(): """CLI --max-cols reaches compute_2xk_layout (the lib.sh-facing path).""" payload = json.dumps(_four_panes_two_columns()) skills_dir = os.path.abspath(".agents/skills") env = {**os.environ, "PYTHONPATH": skills_dir} res = subprocess.run( [sys.executable, "-m", "lib_py.layout", "--min-cols", "30", "--min-rows", "20", "--max-cols", "2", "--json"], input=payload, capture_output=True, text=True, env=env) assert res.returncode == 0, res.stderr d = json.loads(res.stdout) assert d["direction"] == "overflow" and d["is_overflow"] assert d["reason"] == "max_columns_reached" def test_env_max_cols_applies_without_flag(): """MAM_MAX_PANE_COLS is honoured with no --max-cols flag, which is exactly how lib.sh invokes the module (lib.sh passes no --max-cols).""" payload = json.dumps(_four_panes_two_columns()) skills_dir = os.path.abspath(".agents/skills") env = {**os.environ, "PYTHONPATH": skills_dir, "MAM_MAX_PANE_COLS": "2"} res = subprocess.run( [sys.executable, "-m", "lib_py.layout", "--min-cols", "30", "--min-rows", "20", "--json"], input=payload, capture_output=True, text=True, env=env) assert res.returncode == 0, res.stderr assert json.loads(res.stdout)["reason"] == "max_columns_reached" def test_headless_max_columns_growth_guard(): """C-1: headless mode must honour max_columns too. A headless 2xK grid completes n // 2 columns, so at n=4 with max_columns=2 a further `right` split would open a third column and must overflow instead. Note the cap blocks *opening* a new column; it does not force an existing over-cap layout to shrink -- the odd-n `down` branch (and the GUI's fill_singleton_column) deliberately ignore it. """ def headless(n): return {"result": {"panes": [ {"pane_id": f"p{i}", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}} for i in range(1, n + 1)]}} d4 = compute_2xk_layout(headless(4), max_columns=2) assert d4.is_overflow and d4.direction == "overflow" assert d4.reason == "max_columns_reached" # Continues growing below the cap d2 = compute_2xk_layout(headless(2), max_columns=2) assert d2.direction == "right" and not d2.is_overflow # Filling an existing column is not blocked (mirrors GUI fill_singleton_column) d3 = compute_2xk_layout(headless(3), max_columns=2) assert d3.direction == "down" and not d3.is_overflow # n=5 is the first odd n that can discriminate: n//2 == 2 == max_columns, so an # over-correction that also checked the cap on the odd branch would return # overflow here. n=3 has n//2 == 1 and cannot reach the check at all. d5 = compute_2xk_layout(headless(5), max_columns=2) assert d5.direction == "down" and not d5.is_overflow assert d5.reason == "headless_odd_down" # When max_columns is not set, existing alternation is preserved (behavior neutrality) assert compute_2xk_layout(headless(4)).direction == "right" _LAYOUT_ENV_VARS = ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS", "MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS", "MAM_MAX_COLS", "MAM_MAX_PANE_COLS") def _run_layout(payload, args=(), env_extra=None): env = {**os.environ, "PYTHONPATH": os.path.abspath(".agents/skills")} for k in _LAYOUT_ENV_VARS: env.pop(k, None) # 호출자 셸의 오염 차단 env.update(env_extra or {}) res = subprocess.run([sys.executable, "-m", "lib_py.layout", "--json", *args], input=json.dumps(payload), capture_output=True, text=True, env=env) assert res.returncode == 0, res.stderr return json.loads(res.stdout) # height//2 = 15 < min_rows(20) 로 제약 분기 진입, width//2 = 25 가 min_cols 와 비교됨. _ZERO_TRAP = {"result": {"panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 30}}]}} def test_j1_env_zero_min_cols_matches_flag_zero(): """J-1: MAM_MIN_PANE_COLS=0 must mean 0, not fall through to the 15 default.""" flag = _run_layout(_ZERO_TRAP, ("--min-cols", "0", "--min-rows", "20")) assert flag["direction"] == "right" and flag["reason"] == "single_pane_height_constrained" for var in ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS"): assert _run_layout(_ZERO_TRAP, ("--min-rows", "20"), {var: "0"}) == flag, var def test_j1_env_zero_min_rows_matches_flag_zero(): flag = _run_layout(_ZERO_TRAP, ("--min-rows", "0")) assert flag["direction"] == "down" and flag["reason"] == "single_pane_split_down" for var in ("MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS"): assert _run_layout(_ZERO_TRAP, (), {var: "0"}) == flag, var def test_j1_nonzero_and_malformed_env_behaviour_unchanged(): """Behaviour neutrality: non-zero env still applies, and a lone typo still lands on the documented default instead of crashing on a None comparison.""" assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "25", "MAM_MIN_PANE_ROWS": "20"}) == \ _run_layout(_ZERO_TRAP, ("--min-cols", "25", "--min-rows", "20")) assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "abc"}) == _run_layout(_ZERO_TRAP) def test_j1b_invalid_alias_does_not_shadow_the_documented_var(): """C-2: MAM_MIN_COLS is a legacy alias checked first; MAM_MIN_PANE_COLS is the name .mam.env.example documents. An unparsable value in the alias must be skipped, not abort the search and discard the documented setting. Empty values already fell through (`if raw:`); this makes invalid values behave the same way. When every candidate is unusable, `default` still wins. """ good = _run_layout(_ZERO_TRAP, ("--min-rows", "20"), {"MAM_MIN_PANE_COLS": "25"}) assert good["direction"] == "right" # 별칭이 깨져 있어도 문서화된 변수가 적용된다 assert _run_layout(_ZERO_TRAP, ("--min-rows", "20"), {"MAM_MIN_COLS": "foo", "MAM_MIN_PANE_COLS": "25"}) == good # 0 도 마찬가지 (J-1 과의 상호작용) assert _run_layout(_ZERO_TRAP, ("--min-rows", "20"), {"MAM_MIN_COLS": "foo", "MAM_MIN_PANE_COLS": "0"}) == \ _run_layout(_ZERO_TRAP, ("--min-cols", "0", "--min-rows", "20")) # 모든 후보가 무효면 문서화된 기본값으로 흡수 (Rev.1 불변식 보존) assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo", "MAM_MIN_PANE_COLS": "bar"}) == _run_layout(_ZERO_TRAP) def test_default_min_cols_is_15_and_min_rows_is_0(): """Verify compute_2xk_layout default min_cols is 15 and min_rows is 0. With 1 pane of 54x23: - min_rows=0 -> splits down without height overflow With 2 panes of 30x23 (width//2 = 15): - min_cols=15 -> 15 >= 15 -> split right (new column). - min_cols=40 -> 15 < 40 -> overflow. Default invocation (no min_cols/min_rows passed) must split right for 30 cols. """ # 1. 1 pane of 54x23 splits down (no height constraint) p1_54x23 = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 54, "height": 23}}]}} d1 = compute_2xk_layout(p1_54x23) assert d1.direction == "down" assert not d1.is_overflow # 2. 2 panes with width 30 (30 // 2 = 15 == min_cols 15) -> splits right cleanly payload = { "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 30, "height": 20}}, {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 30, "height": 20}}, ] } } decision = compute_2xk_layout(payload) assert decision.direction == "right" assert not decision.is_overflow assert decision.reason == "new_column_right" def test_30_col_2_column_splitting_boundary(): """Verify width >= 30 cols allows 2-column splitting with default min_cols=15, while width < 30 (e.g. 29) triggers column_width_overflow. """ # 30 cols: 30 // 2 = 15 == min_cols(15) -> splits right payload_30 = { "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 30, "height": 20}}, {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 30, "height": 20}}, ] } } d30 = compute_2xk_layout(payload_30) assert d30.direction == "right" assert not d30.is_overflow # 29 cols: 29 // 2 = 14 < min_cols(15) -> overflow payload_29 = { "result": { "panes": [ {"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 29, "height": 20}}, {"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 29, "height": 20}}, ] } } d29 = compute_2xk_layout(payload_29) assert d29.direction == "overflow" assert d29.is_overflow assert d29.reason == "column_width_overflow" def test_54x23_compact_viewport_single_workspace_multi_pane_tiling(): """Verify complete 1 -> 2 -> 3 -> 4 pane tiling in standard 80x24 (54x23 content area) workspace. - 1 pane (54x23): splits down to p1(54x11), p2(54x11) - 2 panes: splits right (54//2 = 27 >= 15) to start col 2 -> p3(27x23) - 3 panes: fills singleton col 2 down -> p4(27x11) - 4 panes (2x2 grid): 5th agent overflows because 27 // 2 = 13 < 15 """ # 1 -> 2 (splits down) p1_layout = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 54, "height": 23}}]}} d1 = compute_2xk_layout(p1_layout) assert d1.direction == "down" assert d1.target_pane_id == "p1" assert not d1.is_overflow # 2 -> 3 (splits right) p2_layout = {"result": {"panes": [ {"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 54, "height": 11}}, {"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 54, "height": 11}}, ]}} d2 = compute_2xk_layout(p2_layout) assert d2.direction == "right" assert d2.target_pane_id == "p1" assert not d2.is_overflow # 3 -> 4 (fills singleton col 2 down) p3_layout = {"result": {"panes": [ {"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 27, "height": 11}}, {"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 27, "height": 11}}, {"pane_id": "p3", "rect": {"x": 53, "y": 1, "width": 27, "height": 23}}, ]}} d3 = compute_2xk_layout(p3_layout) assert d3.direction == "down" assert d3.target_pane_id == "p3" assert not d3.is_overflow # 4 -> 5 (overflow to new workspace because 27 // 2 = 13 < 15) p4_layout = {"result": {"panes": [ {"pane_id": "p1", "rect": {"x": 26, "y": 1, "width": 27, "height": 11}}, {"pane_id": "p2", "rect": {"x": 26, "y": 12, "width": 27, "height": 11}}, {"pane_id": "p3", "rect": {"x": 53, "y": 1, "width": 27, "height": 11}}, {"pane_id": "p4", "rect": {"x": 53, "y": 12, "width": 27, "height": 11}}, ]}} d4 = compute_2xk_layout(p4_layout) assert d4.direction == "overflow" assert d4.is_overflow assert d4.reason == "column_width_overflow"