feat(layout): implement right-growth 2xK grid layout engine in lib_py.layout and refactor lib.sh (B-20)
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
from lib_py.layout import (
|
||||
compute_2xk_layout,
|
||||
extract_panes_and_focus,
|
||||
LayoutDecision,
|
||||
PaneInfo,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user