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:-60}}" --min-rows "${{MAM_MIN_PANE_ROWS:-20}}" --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 60 default.""" flag = _run_layout(_ZERO_TRAP, ("--min-cols", "0")) 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, (), {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"}) == \ _run_layout(_ZERO_TRAP, ("--min-cols", "25")) 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, (), {"MAM_MIN_PANE_COLS": "25"}) assert good["direction"] == "right" # 별칭이 깨져 있어도 문서화된 변수가 적용된다 assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo", "MAM_MIN_PANE_COLS": "25"}) == good # 0 도 마찬가지 (J-1 과의 상호작용) assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo", "MAM_MIN_PANE_COLS": "0"}) == \ _run_layout(_ZERO_TRAP, ("--min-cols", "0")) # 모든 후보가 무효면 문서화된 기본값으로 흡수 (Rev.1 불변식 보존) assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo", "MAM_MIN_PANE_COLS": "bar"}) == _run_layout(_ZERO_TRAP)