Files
fn_registry/cpp/tests/e2e/test_terminal_panel_e2e.py
egutierrez 6a318bf0c9 fix(0132): terminal_panel black bg + prompt input + cross-platform demos + e2e
terminal_panel.cpp:
  - BeginChild con PushStyleColor(ChildBg, negro) + PushStyleColor(Text, gris claro)
  - PushStyleVar(WindowPadding, 8/6px) para padding terminal real
  - Input prompt siempre visible cuando readonly=false
  - Prefijo "$ " antes del InputText (TextUnformatted + SameLine)
  - BeginDisabled() cuando el shell esta cerrado (en vez de ocultar el widget)
  - Calculo de child_h reserva exactamente GetFrameHeightWithSpacing+6 para el prompt

cpp/tests/e2e/test_terminal_panel_e2e.py (nuevo):
  - 4 asserts: PNG existe, no todo-blanco, region oscura >= 30%, pixels no-negros >= 0.3%
  - Lanza primitives_gallery --capture, busca el binario Linux o Windows.exe automaticamente
  - Skip graceful si no hay GL ni binario (WSL/CI headless)
  - 4/4 pasan en Linux con LIBGL_ALWAYS_SOFTWARE=1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:48:42 +02:00

226 lines
7.9 KiB
Python

"""E2E tests for terminal_panel demos in primitives_gallery.
Lanza primitives_gallery en modo --capture, captura el demo "terminal_panel"
como PNG y verifica que la region del terminal tiene fondo oscuro (fix del
issue 0132: fondo negro + prompt input).
Uso desde la raiz del registry:
python/.venv/bin/python3 -m pytest cpp/tests/e2e/test_terminal_panel_e2e.py -v
Requisitos:
- primitives_gallery compilado (Linux o Windows .exe).
- WSL2 con interop habilitado para el path Windows.
- Pillow instalado en el venv del registry (python/.venv).
En entornos sin GL (CI headless), el binario sale != 0 y el test se skipea
automaticamente (SKIP, no FAIL).
"""
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# Helpers de localizacion del binario
# ---------------------------------------------------------------------------
REGISTRY_ROOT = Path(__file__).resolve().parents[3] # fn_registry/
def _find_binary() -> Path | None:
"""Devuelve el primer primitives_gallery encontrado (Linux o Windows)."""
# Paths fijos conocidos primero.
candidates = [
REGISTRY_ROOT / "cpp" / "build" / "apps" / "primitives_gallery" / "primitives_gallery",
REGISTRY_ROOT / "cpp" / "build" / "linux" / "apps" / "primitives_gallery" / "primitives_gallery",
REGISTRY_ROOT / "cpp" / "build" / "windows" / "apps" / "primitives_gallery" / "primitives_gallery.exe",
# Desktop de Windows (deploy anterior)
Path("/mnt/c/Users/lucas/Desktop/apps/primitives_gallery/primitives_gallery.exe"),
]
for p in candidates:
if p.exists():
return p
# Busqueda amplia como fallback.
for pattern in ("primitives_gallery", "primitives_gallery.exe"):
for found in (REGISTRY_ROOT / "cpp" / "build").rglob(pattern):
if found.is_file():
return found
return None
# ---------------------------------------------------------------------------
# Fixture: captura PNG del demo terminal_panel
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def terminal_png(tmp_path_factory) -> Path:
"""Lanza primitives_gallery --capture y devuelve el PNG generado."""
binary = _find_binary()
if binary is None:
pytest.skip("primitives_gallery binary not found — build it first")
out_dir = tmp_path_factory.mktemp("terminal_capture")
# En WSL, un .exe Windows necesita invocarse como proceso Windows.
# En Linux, se invoca directamente con LIBGL_ALWAYS_SOFTWARE=1.
env = os.environ.copy()
is_windows_exe = binary.suffix == ".exe"
if is_windows_exe:
# Convertir el out_dir a path Windows via wslpath.
wslpath_result = subprocess.run(
["wslpath", "-w", str(out_dir)],
capture_output=True, text=True
)
if wslpath_result.returncode != 0:
pytest.skip("wslpath not available — can't convert path for Windows exe")
win_out_dir = wslpath_result.stdout.strip()
cmd = [str(binary), "--capture", win_out_dir]
else:
env["LIBGL_ALWAYS_SOFTWARE"] = "1"
cmd = [str(binary), "--capture", str(out_dir)]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env=env,
cwd=str(REGISTRY_ROOT),
timeout=60,
)
if result.returncode != 0:
# Sin GL o sin display — skip en lugar de FAIL.
pytest.skip(
f"primitives_gallery --capture exited {result.returncode} "
f"(no GL context?). stdout: {result.stdout[-200:]} "
f"stderr: {result.stderr[-200:]}"
)
png_path = out_dir / "terminal_panel.png"
if not png_path.exists():
pytest.skip(f"terminal_panel.png not generated in {out_dir}. "
f"stdout: {result.stdout[-300:]}")
return png_path
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_terminal_panel_png_exists(terminal_png: Path):
"""El PNG del demo terminal_panel debe existir despues del capture."""
assert terminal_png.exists(), f"PNG not found: {terminal_png}"
assert terminal_png.stat().st_size > 1000, "PNG sospechosamente pequeño"
def test_terminal_panel_not_all_white(terminal_png: Path):
"""La imagen no debe ser completamente blanca (render vacio)."""
try:
from PIL import Image
except ImportError:
pytest.skip("Pillow not installed — run: pip install Pillow")
img = Image.open(terminal_png).convert("RGB")
px = img.load()
w, h = img.size
total = w * h
white_count = sum(
1
for y in range(h)
for x in range(w)
if px[x, y][0] > 240 and px[x, y][1] > 240 and px[x, y][2] > 240 # type: ignore[index]
)
white_ratio = white_count / total
assert white_ratio < 0.95, (
f"Image is {white_ratio:.1%} white — terminal render likely failed. "
f"({terminal_png})"
)
def test_terminal_panel_dark_background(terminal_png: Path):
"""La region central del terminal debe ser mayormente oscura (fondo negro fix 0132)."""
try:
from PIL import Image
except ImportError:
pytest.skip("Pillow not installed — run: pip install Pillow")
img = Image.open(terminal_png).convert("RGB")
w, h = img.size
# Recortar la region central-inferior (donde vive el scrollback del terminal).
# El demo header ocupa ~15% superior; el resto deberia ser el area del terminal.
# Ajustar: top=20%, bottom=85%, left=10%, right=90%.
left = int(w * 0.10)
right = int(w * 0.90)
top = int(h * 0.20)
bottom = int(h * 0.85)
region = img.crop((left, top, right, bottom))
rw, rh = region.size
total = rw * rh
if total == 0:
pytest.skip("Crop region empty — image too small?")
rpx = region.load()
# Pixel oscuro: todos los canales RGB < 60.
dark_count = sum(
1
for y in range(rh)
for x in range(rw)
if rpx[x, y][0] < 60 and rpx[x, y][1] < 60 and rpx[x, y][2] < 60 # type: ignore[index]
)
dark_ratio = dark_count / total
assert dark_ratio >= 0.30, (
f"Terminal region has only {dark_ratio:.1%} dark pixels (expected >= 30%). "
f"The black background fix (issue 0132) may not be active. "
f"Region: ({left},{top})-({right},{bottom}) in {w}x{h} image. "
f"({terminal_png})"
)
def test_terminal_panel_has_light_text_on_dark(terminal_png: Path):
"""Debe haber pixels claros (texto/toolbar) sobre fondo oscuro — render activo.
En modo --capture el PTY reader es async y puede no entregar output en los
primeros frames. Verificamos que al menos la toolbar (Clear/Copy/Reset) y el
borde del child tienen pixels no-negros (> 0.3% de la imagen total), lo que
confirma que el panel se renderizo.
"""
try:
from PIL import Image
except ImportError:
pytest.skip("Pillow not installed — run: pip install Pillow")
img = Image.open(terminal_png).convert("RGB")
pixels = img.load()
w, h = img.size
total = w * h
# Contar pixels con al menos un canal > 60 en toda la imagen.
# Incluye la toolbar (botones), bordes, prompt "$ " y cualquier output.
light_count = sum(
1
for y in range(h)
for x in range(w)
if max(pixels[x, y]) > 60 # type: ignore[index]
)
light_ratio = light_count / total
# Umbral conservador: > 0.3% — basta con que la toolbar sea visible.
# En modo interactivo con PTY output el ratio sera mucho mayor (> 5%).
assert light_ratio >= 0.003, (
f"Image has only {light_ratio:.2%} non-dark pixels — "
f"terminal panel may not be rendering at all. "
f"Check that fn_term::render is called and ImGui window is visible. "
f"({terminal_png})"
)