623 lines
24 KiB
Python
623 lines
24 KiB
Python
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:-40}}" --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 40 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)
|
|
|
|
|
|
def test_default_min_cols_is_40():
|
|
"""Verify compute_2xk_layout default min_cols is 40.
|
|
With width 80 (width//2 = 40):
|
|
- min_cols=40 -> 40 >= 40 -> split right (new column).
|
|
- min_cols=60 -> 40 < 60 -> overflow.
|
|
Default invocation (no min_cols passed) must split right.
|
|
"""
|
|
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}},
|
|
]
|
|
}
|
|
}
|
|
decision = compute_2xk_layout(payload)
|
|
assert decision.direction == "right"
|
|
assert not decision.is_overflow
|
|
assert decision.reason == "new_column_right"
|
|
|
|
|
|
def test_80_col_2_column_splitting_boundary():
|
|
"""Verify width >= 80 cols allows 2-column splitting with default min_cols=40,
|
|
while width < 80 (e.g. 79) triggers column_width_overflow.
|
|
"""
|
|
# 80 cols: 80 // 2 = 40 == min_cols(40) -> splits right
|
|
payload_80 = {
|
|
"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}},
|
|
]
|
|
}
|
|
}
|
|
d80 = compute_2xk_layout(payload_80)
|
|
assert d80.direction == "right"
|
|
assert not d80.is_overflow
|
|
|
|
# 79 cols: 79 // 2 = 39 < min_cols(40) -> overflow
|
|
payload_79 = {
|
|
"result": {
|
|
"panes": [
|
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 79, "height": 40}},
|
|
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 79, "height": 40}},
|
|
]
|
|
}
|
|
}
|
|
d79 = compute_2xk_layout(payload_79)
|
|
assert d79.direction == "overflow"
|
|
assert d79.is_overflow
|
|
assert d79.reason == "column_width_overflow"
|
|
|
|
|
|
def test_90_col_single_workspace_multi_pane_tiling():
|
|
"""Verify complete 1 -> 2 -> 3 -> 4 pane tiling in a 90-col single workspace.
|
|
- 1 pane (90x40): splits down to p1(90x20), p2(90x20)
|
|
- 2 panes: splits right to start col 2 -> p3(45x40)
|
|
- 3 panes: fills singleton col 2 down -> p4(45x20)
|
|
- 4 panes (2x2 grid): 5th agent overflows because 45 // 2 = 22 < 40
|
|
"""
|
|
# 1 -> 2
|
|
p1_layout = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 90, "height": 40}}]}}
|
|
d1 = compute_2xk_layout(p1_layout)
|
|
assert d1.direction == "down"
|
|
assert d1.target_pane_id == "p1"
|
|
|
|
# 2 -> 3
|
|
p2_layout = {"result": {"panes": [
|
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 90, "height": 20}},
|
|
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 90, "height": 20}},
|
|
]}}
|
|
d2 = compute_2xk_layout(p2_layout)
|
|
assert d2.direction == "right"
|
|
assert d2.target_pane_id == "p1"
|
|
assert not d2.is_overflow
|
|
|
|
# 3 -> 4
|
|
p3_layout = {"result": {"panes": [
|
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 45, "height": 20}},
|
|
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 45, "height": 20}},
|
|
{"pane_id": "p3", "rect": {"x": 45, "y": 0, "width": 45, "height": 40}},
|
|
]}}
|
|
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)
|
|
p4_layout = {"result": {"panes": [
|
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 45, "height": 20}},
|
|
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 45, "height": 20}},
|
|
{"pane_id": "p3", "rect": {"x": 45, "y": 0, "width": 45, "height": 20}},
|
|
{"pane_id": "p4", "rect": {"x": 45, "y": 20, "width": 45, "height": 20}},
|
|
]}}
|
|
d4 = compute_2xk_layout(p4_layout)
|
|
assert d4.direction == "overflow"
|
|
assert d4.is_overflow
|
|
assert d4.reason == "column_width_overflow"
|
|
|
|
|
|
def test_100_col_single_workspace_multi_pane_tiling():
|
|
"""Verify complete 1 -> 2 -> 3 -> 4 pane tiling in a 100-col single workspace."""
|
|
# 1 -> 2
|
|
p1_layout = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 100, "height": 40}}]}}
|
|
d1 = compute_2xk_layout(p1_layout)
|
|
assert d1.direction == "down"
|
|
|
|
# 2 -> 3
|
|
p2_layout = {"result": {"panes": [
|
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 100, "height": 20}},
|
|
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 100, "height": 20}},
|
|
]}}
|
|
d2 = compute_2xk_layout(p2_layout)
|
|
assert d2.direction == "right"
|
|
assert not d2.is_overflow
|
|
|
|
# 3 -> 4
|
|
p3_layout = {"result": {"panes": [
|
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 20}},
|
|
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 50, "height": 20}},
|
|
{"pane_id": "p3", "rect": {"x": 50, "y": 0, "width": 50, "height": 40}},
|
|
]}}
|
|
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)
|
|
p4_layout = {"result": {"panes": [
|
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 20}},
|
|
{"pane_id": "p2", "rect": {"x": 0, "y": 20, "width": 50, "height": 20}},
|
|
{"pane_id": "p3", "rect": {"x": 50, "y": 0, "width": 50, "height": 20}},
|
|
{"pane_id": "p4", "rect": {"x": 50, "y": 20, "width": 50, "height": 20}},
|
|
]}}
|
|
d4 = compute_2xk_layout(p4_layout)
|
|
assert d4.direction == "overflow"
|
|
assert d4.is_overflow
|
|
assert d4.reason == "column_width_overflow"
|
|
|
|
|
|
|