- Move backend/ and layout/ directories into src/ - Update all path references in ASD, scripts, docs - Convert README.org from Markdown syntax to proper Org-mode - Fix demo.lisp use-package conflict (both backend and input export #:read-event) - Fix modern-backend TIOCGWINSZ ioctl alien type (alien-sap wrapper) - Add v0.15.0 section to ROADMAP, update line count to 5760 - Add known gaps (suspend/resume-backend, slot modes) to v1.0.0 checklist - Remove docs/plans/, debug-layout.lisp, system-index.txt, ci-watchdog.sh - Move tangle.py to Hermes skill (org-babel-tangle) - Add .gitignore for fasl files
183 lines
5.7 KiB
Python
Executable File
183 lines
5.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""PTY-based interactive test for cl-tty demo.
|
|
|
|
Spawns the demo inside a real PTY, sends keystrokes, captures output,
|
|
and verifies expected behavior. Exits with status 0 if all checks pass,
|
|
non-zero otherwise.
|
|
"""
|
|
|
|
import pty
|
|
import os
|
|
import sys
|
|
import time
|
|
import select
|
|
import re
|
|
import subprocess
|
|
|
|
PASS = 0
|
|
FAIL = 0
|
|
|
|
def check(name, condition, detail=""):
|
|
global PASS, FAIL
|
|
if condition:
|
|
PASS += 1
|
|
print(f" OK {name}")
|
|
else:
|
|
FAIL += 1
|
|
print(f" FAIL {name}" + (f" ({detail})" if detail else ""))
|
|
|
|
def spawn_demo():
|
|
"""Fork PTY, exec demo.sh, return (pid, fd).
|
|
Blocks 1s for demo to start and enter its event loop."""
|
|
pid, fd = pty.fork()
|
|
if pid == 0:
|
|
os.chdir("/mnt/hermes/projects/cl-tty")
|
|
os.execve("./demo.sh", ["./demo.sh"], {"TERM": "xterm-256color"})
|
|
os._exit(1)
|
|
time.sleep(1.0)
|
|
return pid, fd
|
|
|
|
def read_all(fd, timeout=0.5):
|
|
"""Drain all available output from fd within timeout."""
|
|
data = b""
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
r, _, _ = select.select([fd], [], [], max(0, deadline - time.time()))
|
|
if r:
|
|
try:
|
|
chunk = os.read(fd, 65536)
|
|
if not chunk:
|
|
break
|
|
data += chunk
|
|
except OSError:
|
|
break
|
|
else:
|
|
break
|
|
return data
|
|
|
|
def strip_escapes(data):
|
|
"""Strip ANSI escape sequences, keep visible text."""
|
|
text = data.decode("latin-1")
|
|
text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
|
|
text = re.sub(r'\x1b\][0-9;]*[a-zA-Z].*?\x07', '', text)
|
|
text = re.sub(r'\x1b[()][0-9A-Z]', '', text)
|
|
text = re.sub(r'\x1b', '', text)
|
|
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text)
|
|
return text
|
|
|
|
def has_text(data, *patterns):
|
|
text = strip_escapes(data)
|
|
return all(p in text for p in patterns)
|
|
|
|
def last_event_count(data):
|
|
"""Extract the last event count from output like 'Tab N/3 | M events'."""
|
|
text = strip_escapes(data)
|
|
matches = re.findall(r'Tab \d+/\d+ \| (\d+) events?', text)
|
|
if matches:
|
|
return int(matches[-1])
|
|
return None
|
|
|
|
def last_tab_index(data):
|
|
"""Extract the last tab index from output like 'Tab N/3'."""
|
|
text = strip_escapes(data)
|
|
matches = re.findall(r'Tab (\d+)/', text)
|
|
if matches:
|
|
return int(matches[-1])
|
|
return None
|
|
|
|
# ── Test 1: Demo renders correctly on startup ──
|
|
print("\n[Test 1] Demo renders correctly on startup")
|
|
pid, fd = spawn_demo()
|
|
output = read_all(fd, 0.5)
|
|
os.close(fd)
|
|
os.waitpid(pid, 0)
|
|
|
|
size = len(output)
|
|
check("Output is non-empty", size > 100, f"got {size} bytes")
|
|
check("Shows title 'cl-tty'", has_text(output, "cl-tty"))
|
|
check("Shows component list", has_text(output, "TextInput"))
|
|
check("Shows test count", has_text(output, "483"))
|
|
check("Shows controls help", has_text(output, "Ctrl+C"))
|
|
check("Shows tab bar items", has_text(output, "Home"))
|
|
check("Shows Console tab", has_text(output, "Console"))
|
|
check("Starts with 1 event (init log)", last_event_count(output) == 1,
|
|
f"got {last_event_count(output)}")
|
|
|
|
# ── Test 2: Escape key quits the demo ──
|
|
print("\n[Test 2] Escape key quits the demo")
|
|
pid, fd = spawn_demo()
|
|
os.write(fd, b"\x1b")
|
|
output = read_all(fd, 1.0)
|
|
os.close(fd)
|
|
os.waitpid(pid, 0)
|
|
check("Escape produces output", len(output) > 50, f"got {len(output)} bytes")
|
|
# After escape, the demo sets running=nil immediately after logging.
|
|
# The last rendered frame may still show count 1.
|
|
# Key check: no busy-spin.
|
|
check("No busy-spin with Escape", len(output) < 50000, f"got {len(output)} bytes")
|
|
|
|
# ── Test 3: Tab switches to next tab ──
|
|
print("\n[Test 3] Tab key switches tab")
|
|
pid, fd = spawn_demo()
|
|
os.write(fd, b"\x09") # Tab key
|
|
time.sleep(1.0)
|
|
os.write(fd, b"\x09") # Tab again to trigger another render
|
|
time.sleep(1.0)
|
|
output = read_all(fd, 0.5)
|
|
os.close(fd)
|
|
os.waitpid(pid, 0)
|
|
count = last_event_count(output)
|
|
tab = last_tab_index(output)
|
|
check("Events were logged", count is not None and count >= 2,
|
|
f"last count: {count}")
|
|
check("Tab switched from 1", tab is not None and tab > 1,
|
|
f"last tab: {tab}")
|
|
|
|
# ── Test 4: 'q' types into text input, does not quit ──
|
|
print("\n[Test 4] 'q' does NOT quit, types into text input instead")
|
|
pid, fd = spawn_demo()
|
|
os.write(fd, b"q")
|
|
time.sleep(0.5)
|
|
os.write(fd, b"a")
|
|
time.sleep(1.0)
|
|
output = read_all(fd, 0.5)
|
|
os.close(fd)
|
|
os.waitpid(pid, 0)
|
|
count = last_event_count(output)
|
|
check("Events were logged ('q' + 'a')", count is not None and count >= 3,
|
|
f"last count: {count}")
|
|
check("Demo still running after 'q' (no busy-spin)", len(output) < 50000,
|
|
f"got {len(output)} bytes")
|
|
|
|
# ── Test 5: Ctrl+C quits the demo ──
|
|
print("\n[Test 5] Ctrl+C quits the demo")
|
|
pid, fd = spawn_demo()
|
|
os.write(fd, b"\x03") # Ctrl+C
|
|
output = read_all(fd, 1.0)
|
|
os.close(fd)
|
|
os.waitpid(pid, 0)
|
|
check("Ctrl+C produces output", len(output) > 50, f"got {len(output)} bytes")
|
|
|
|
# ── Test 6: EOF on stdin quits cleanly ──
|
|
print("\n[Test 6] EOF on stdin quits cleanly (no busy-spin)")
|
|
result = subprocess.run(
|
|
["timeout", "5", "bash", "-c",
|
|
"cd /mnt/hermes/projects/cl-tty && exec sbcl --noinform --script demo.lisp < /dev/null"],
|
|
capture_output=True, timeout=10
|
|
)
|
|
eof_output = result.stdout + result.stderr
|
|
check("EOF exits quickly (not killed by timeout)",
|
|
result.returncode == 0,
|
|
f"exit code: {result.returncode}")
|
|
check("No busy-spin on EOF", len(eof_output) < 50000,
|
|
f"got {len(eof_output)} bytes")
|
|
|
|
# ── Summary ──
|
|
print(f"\n{'='*50}")
|
|
print(f"Results: {PASS} passed, {FAIL} failed, {PASS+FAIL} total")
|
|
if FAIL == 0:
|
|
print("ALL CHECKS PASSED")
|
|
else:
|
|
print("SOME CHECKS FAILED")
|
|
sys.exit(FAIL > 0)
|