199 lines
7.1 KiB
Python
199 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Layout/flash regression tests for cmux splits.
|
|
|
|
Goals:
|
|
1) Ensure programmatic splits don't transiently render EmptyPanelView (visible flash).
|
|
2) Validate selected panel bounds are non-zero and aligned with bonsplit pane bounds.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from cmux import cmux, cmuxError
|
|
|
|
|
|
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
|
|
|
|
|
def _rect_area(r: dict) -> float:
|
|
return max(0.0, float(r.get("width", 0.0))) * max(0.0, float(r.get("height", 0.0)))
|
|
|
|
|
|
def _rect_intersection_area(a: dict, b: dict) -> float:
|
|
ax1 = float(a["x"])
|
|
ay1 = float(a["y"])
|
|
ax2 = ax1 + float(a["width"])
|
|
ay2 = ay1 + float(a["height"])
|
|
|
|
bx1 = float(b["x"])
|
|
by1 = float(b["y"])
|
|
bx2 = bx1 + float(b["width"])
|
|
by2 = by1 + float(b["height"])
|
|
|
|
ix1 = max(ax1, bx1)
|
|
iy1 = max(ay1, by1)
|
|
ix2 = min(ax2, bx2)
|
|
iy2 = min(ay2, by2)
|
|
|
|
if ix2 <= ix1 or iy2 <= iy1:
|
|
return 0.0
|
|
return (ix2 - ix1) * (iy2 - iy1)
|
|
|
|
|
|
def _assert_selected_panels_healthy(payload: dict, *, min_wh: float = 80.0) -> None:
|
|
selected = payload.get("selectedPanels") or []
|
|
if not selected:
|
|
raise cmuxError("layout_debug returned no selectedPanels")
|
|
|
|
for i, row in enumerate(selected):
|
|
pane_id = row.get("paneId")
|
|
pane_frame = row.get("paneFrame")
|
|
view_frame = row.get("viewFrame")
|
|
|
|
panel_id = row.get("panelId")
|
|
if not panel_id:
|
|
raise cmuxError(f"selectedPanels[{i}] missing panelId (pane={pane_id})")
|
|
|
|
if row.get("inWindow") is not True:
|
|
raise cmuxError(f"selectedPanels[{i}] panel not in window (pane={pane_id}, panel={panel_id})")
|
|
|
|
if row.get("hidden") is True:
|
|
raise cmuxError(f"selectedPanels[{i}] panel is hidden (pane={pane_id}, panel={panel_id})")
|
|
|
|
if not view_frame:
|
|
raise cmuxError(f"selectedPanels[{i}] missing viewFrame (pane={pane_id}, panel={panel_id})")
|
|
|
|
if float(view_frame.get("width", 0.0)) < min_wh or float(view_frame.get("height", 0.0)) < min_wh:
|
|
raise cmuxError(
|
|
f"selectedPanels[{i}] viewFrame too small: {view_frame} (pane={pane_id}, panel={panel_id})"
|
|
)
|
|
|
|
# Coordinate sanity: selected panel should substantially overlap its pane.
|
|
# This implicitly verifies we're measuring in a consistent coordinate space.
|
|
if pane_frame:
|
|
inter = _rect_intersection_area(pane_frame, view_frame)
|
|
denom = min(_rect_area(pane_frame), _rect_area(view_frame))
|
|
ratio = inter / denom if denom > 0 else 0.0
|
|
if ratio < 0.50:
|
|
raise cmuxError(
|
|
f"selectedPanels[{i}] bounds mismatch (overlap={ratio:.2f}). "
|
|
f"pane={pane_frame} view={view_frame} pane_id={pane_id} panel={panel_id}"
|
|
)
|
|
|
|
|
|
def _assert_no_transient_detach_or_hide(
|
|
c: cmux,
|
|
*,
|
|
duration_s: float = 1.0,
|
|
cadence_s: float = 0.005,
|
|
max_false_samples: int = 2,
|
|
) -> None:
|
|
false_in_window: dict[str, int] = {}
|
|
hidden_true: dict[str, int] = {}
|
|
deadline = time.time() + duration_s
|
|
|
|
while time.time() < deadline:
|
|
rows = c.surface_health()
|
|
for row in rows:
|
|
if row.get("type") != "terminal":
|
|
continue
|
|
panel_id = (row.get("id") or "").lower()
|
|
if not panel_id:
|
|
continue
|
|
if row.get("in_window") is False:
|
|
false_in_window[panel_id] = false_in_window.get(panel_id, 0) + 1
|
|
if row.get("hidden") is True:
|
|
hidden_true[panel_id] = hidden_true.get(panel_id, 0) + 1
|
|
time.sleep(cadence_s)
|
|
|
|
detached = {k: v for k, v in false_in_window.items() if v > max_false_samples}
|
|
hidden = {k: v for k, v in hidden_true.items() if v > max_false_samples}
|
|
if detached or hidden:
|
|
raise cmuxError(
|
|
f"Transient detach/hide during split exceeds tolerance "
|
|
f"(detached={detached}, hidden={hidden})"
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
with cmux(SOCKET_PATH) as c:
|
|
# Run on a fresh workspace to avoid state carry-over from restored sessions.
|
|
test_workspace = c.new_workspace()
|
|
c.select_workspace(test_workspace)
|
|
time.sleep(0.2)
|
|
|
|
# Baseline: a fresh counter, no flashes just from connecting.
|
|
c.reset_empty_panel_count()
|
|
|
|
base = c.layout_debug()
|
|
_assert_selected_panels_healthy(base)
|
|
|
|
# Programmatic split should not show EmptyPanelView even briefly.
|
|
c.reset_empty_panel_count()
|
|
c.new_split("right")
|
|
time.sleep(0.3)
|
|
flashes = c.empty_panel_count()
|
|
if flashes != 0:
|
|
raise cmuxError(f"EmptyPanelView appeared during split (count={flashes})")
|
|
|
|
after = c.layout_debug()
|
|
# Expect at least 2 panes after split (exact count can vary if user already has splits).
|
|
panes = after.get("layout", {}).get("panes") or []
|
|
if len(panes) < 2:
|
|
raise cmuxError(f"Expected >= 2 panes after split, got {len(panes)}")
|
|
_assert_selected_panels_healthy(after)
|
|
|
|
# Drag-to-split from a single-surface pane should also avoid EmptyPanelView flashes.
|
|
drag_workspace = c.new_workspace()
|
|
c.select_workspace(drag_workspace)
|
|
time.sleep(0.2)
|
|
drag_before = c.layout_debug()
|
|
_assert_selected_panels_healthy(drag_before)
|
|
drag_selected = drag_before.get("selectedPanels") or []
|
|
if not drag_selected:
|
|
raise cmuxError("layout_debug returned no selectedPanels for drag split setup")
|
|
drag_panel_id = drag_selected[0].get("panelId")
|
|
if not drag_panel_id:
|
|
raise cmuxError("drag split setup selected panel has no panelId")
|
|
drag_panes_before = len(drag_before.get("layout", {}).get("panes") or [])
|
|
|
|
c.reset_empty_panel_count()
|
|
c.drag_surface_to_split(drag_panel_id, "right")
|
|
_assert_no_transient_detach_or_hide(c)
|
|
time.sleep(0.4)
|
|
flashes = c.empty_panel_count()
|
|
if flashes != 0:
|
|
raise cmuxError(f"EmptyPanelView appeared during drag split (count={flashes})")
|
|
|
|
drag_after = c.layout_debug()
|
|
drag_panes_after = len(drag_after.get("layout", {}).get("panes") or [])
|
|
if drag_panes_after < drag_panes_before + 1:
|
|
raise cmuxError(
|
|
f"Expected drag split to add a pane: before={drag_panes_before} after={drag_panes_after}"
|
|
)
|
|
_assert_selected_panels_healthy(drag_after)
|
|
|
|
# Browser split should also avoid EmptyPanelView flashes.
|
|
c.reset_empty_panel_count()
|
|
_browser_id = c.open_browser("https://example.com")
|
|
time.sleep(0.4)
|
|
flashes = c.empty_panel_count()
|
|
if flashes != 0:
|
|
raise cmuxError(f"EmptyPanelView appeared during browser split (count={flashes})")
|
|
|
|
after_browser = c.layout_debug()
|
|
_assert_selected_panels_healthy(after_browser)
|
|
|
|
c.close_workspace(test_workspace)
|
|
time.sleep(0.1)
|
|
|
|
print("PASS: split flash + layout bounds checks")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|