278 lines
13 KiB
Python
278 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise APP_SHELL_V1 through a real Notcurses process in an isolated tmux PTY.
|
|
|
|
This is deliberately a black-box companion to the C geometry tests. Capture-pane
|
|
is text, so it proves routes, overlays, footer reservation and interaction; it does
|
|
not infer terminal cell widths from Python UTF-8 string lengths.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SIZES = ((120, 35), (120, 31), (100, 30), (100, 25), (80, 24), (72, 20))
|
|
|
|
|
|
class Failure(RuntimeError):
|
|
pass
|
|
|
|
|
|
class PtyValidation:
|
|
def __init__(self, binary, output):
|
|
self.binary = binary.resolve()
|
|
self.output_root = output.resolve()
|
|
self.output = self.output_root / ("run-" + uuid.uuid4().hex)
|
|
self.data_home = self.output / "xdg-data"
|
|
self.socket = f"trainlog-app-shell-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
|
self.session = "app-shell"
|
|
self.checks = []
|
|
self.captures = {}
|
|
self.height = 35
|
|
|
|
def tmux(self, *args, timeout=5, check=True):
|
|
command = ["tmux", "-L", self.socket, *args]
|
|
result = subprocess.run(command, text=True, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, timeout=timeout)
|
|
if check and result.returncode:
|
|
raise Failure("tmux failed: %s\nstderr: %s" % (command, result.stderr.strip()))
|
|
return result
|
|
|
|
def send(self, *keys):
|
|
self.tmux("send-keys", "-t", self.session, *keys)
|
|
time.sleep(0.35)
|
|
|
|
def literal(self, value):
|
|
self.tmux("send-keys", "-t", self.session, "-l", value)
|
|
time.sleep(0.35)
|
|
|
|
def capture(self, name):
|
|
text = self.tmux("capture-pane", "-p", "-t", self.session).stdout
|
|
path = self.output / f"pty-{name}.txt"
|
|
path.write_text(text, encoding="utf-8")
|
|
self.captures[name] = path.name
|
|
return text
|
|
|
|
def require(self, condition, name, detail=""):
|
|
if not condition:
|
|
raise Failure("assertion failed: %s%s" % (name, ": " + detail if detail else ""))
|
|
self.checks.append(name)
|
|
|
|
def footer(self, text):
|
|
rows = text.splitlines()
|
|
return "\n".join(rows[-2:])
|
|
|
|
def check_shell(self, name, text, marker):
|
|
rows = text.splitlines()
|
|
self.require(len(rows) == self.height, name + ": terminal height")
|
|
self.require("TRAINLOG" in rows[0], name + ": header")
|
|
# CONTRACT: section labels in the persistent sidebar are not evidence
|
|
# that a destination opened. Only the reserved route header identifies it.
|
|
route = "Accueil" if marker in ("Votre entraînement", "Accueil") else marker
|
|
self.require(rows[1].strip().startswith(route), name + ": active route", route)
|
|
self.require(marker in text, name + ": route marker", marker)
|
|
self.require("F7 Actions" in self.footer(text), name + ": reserved two-line footer")
|
|
|
|
def resize(self, width, height):
|
|
self.tmux("resize-window", "-t", self.session, "-x", str(width), "-y", str(height))
|
|
self.height = height
|
|
time.sleep(0.45)
|
|
|
|
def pane_dead(self):
|
|
return self.tmux("display-message", "-p", "-t", self.session,
|
|
"#{pane_dead}").stdout.strip()
|
|
|
|
def data_snapshot(self):
|
|
"""Hash initialized data after startup; navigation must not mutate it."""
|
|
snapshot = {}
|
|
for path in self.data_home.rglob("*"):
|
|
if path.is_file():
|
|
snapshot[str(path.relative_to(self.data_home))] = hashlib.sha256(
|
|
path.read_bytes()).hexdigest()
|
|
return snapshot
|
|
|
|
@staticmethod
|
|
def selected_list_line(text):
|
|
"""Return the content selection marker, excluding the sidebar marker."""
|
|
for line in text.splitlines():
|
|
# Content may share a physical row with a sidebar label. Its marker
|
|
# follows the content padding; the sidebar marker starts in column 1.
|
|
selected = re.search(r" {2,}(>\s+\S.*)", line)
|
|
if selected:
|
|
return selected.group(1).strip()
|
|
return ""
|
|
|
|
def run(self):
|
|
self.output.mkdir(parents=True, exist_ok=False)
|
|
self.data_home.mkdir(parents=True, exist_ok=True)
|
|
if not self.binary.is_file() or not os.access(self.binary, os.X_OK):
|
|
raise Failure("binary is not executable: %s" % self.binary)
|
|
# A distinct server/socket and XDG directory make this run unable to touch
|
|
# a user's tmux server or normal Trainlog database. Do not set HOME.
|
|
environment = ["env", "XDG_DATA_HOME=" + str(self.data_home),
|
|
"TERM=xterm-256color", str(self.binary)]
|
|
self.tmux("new-session", "-d", "-s", self.session, "-x", "120", "-y", "35", *environment,
|
|
timeout=8)
|
|
self.tmux("set-option", "-t", self.session, "remain-on-exit", "on")
|
|
# Notcurses may wait for DA. Send it as terminal input, then normalize any
|
|
# resulting '?' help route with a physical Escape and explicit Home route.
|
|
self.literal("\x1b[?1;2c")
|
|
self.send("Escape")
|
|
self.send("0")
|
|
time.sleep(0.6)
|
|
home = self.capture("home")
|
|
self.check_shell("home", home, "Votre entraînement")
|
|
database_before_navigation = self.data_snapshot()
|
|
|
|
# All six contract dimensions must retain a usable shell and footer.
|
|
for width, height in SIZES:
|
|
self.resize(width, height)
|
|
view = self.capture(f"size-{width}x{height}")
|
|
self.check_shell(f"geometry-{width}x{height}", view,
|
|
"Votre entraînement" if width >= 80 else "Accueil")
|
|
self.resize(120, 35)
|
|
|
|
self.send("?")
|
|
help_view = self.capture("help")
|
|
self.require("Aide contextuelle" in help_view, "question opens help")
|
|
self.send("Escape")
|
|
self.check_shell("escape closes help", self.capture("after-help-escape"), "Votre entraînement")
|
|
|
|
# F6 is a sidebar focus at wide sizes, and an overlay at compact sizes.
|
|
self.send("F6", "Down", "Down", "Down", "Enter")
|
|
equipment = self.capture("equipment-from-f6")
|
|
self.check_shell("F6 navigation opens equipment", equipment, "Équipements")
|
|
self.send("F6", "Escape")
|
|
restored = self.capture("equipment-after-f6-escape")
|
|
self.check_shell("F6 Escape restores equipment", restored, "Équipements")
|
|
self.send("Tab", "BTab")
|
|
self.check_shell("Tab and Shift-Tab preserve route", self.capture("equipment-after-tabs"), "Équipements")
|
|
|
|
# A sidebar focus survives compact resize as the visible Navigation
|
|
# control. Its overlay restores both focus and section selection.
|
|
self.send("F6", "Down", "Down", "Down")
|
|
self.resize(100, 25)
|
|
compact_navigation = self.capture("compact-navigation-focus")
|
|
self.require("> F6 Navigation" in compact_navigation,
|
|
"sidebar focus becomes visible compact Navigation control")
|
|
self.send("Enter")
|
|
self.require("Navigation" in self.capture("compact-navigation-overlay"),
|
|
"compact Navigation Enter opens overlay")
|
|
self.send("Escape")
|
|
restored_navigation = self.capture("compact-navigation-restored")
|
|
self.require("> F6 Navigation" in restored_navigation,
|
|
"Navigation Escape restores compact control focus")
|
|
self.send("BTab")
|
|
self.require("> F7 Actions" in self.capture("compact-reverse-actions"),
|
|
"reverse Tab reaches rendered Actions control")
|
|
self.send("BTab", "BTab", "BTab")
|
|
self.require("> F6 Navigation" in self.capture("compact-reverse-navigation"),
|
|
"reverse Tab follows actions, content, search, navigation")
|
|
self.send("Tab", "Tab")
|
|
self.resize(120, 35)
|
|
|
|
self.send("Down")
|
|
before_detail = self.capture("equipment-selected")
|
|
selected_before_detail = self.selected_list_line(before_detail)
|
|
self.require(bool(selected_before_detail), "equipment selection visible before detail")
|
|
self.send("Enter")
|
|
detail = self.capture("equipment-detail")
|
|
self.require("Équipements / Fiche" in detail, "equipment detail opens")
|
|
self.send("Escape")
|
|
back_to_list = self.capture("equipment-detail-back")
|
|
self.check_shell("detail Escape restores equipment list", back_to_list, "Équipements")
|
|
self.require("Équipements / Fiche" not in back_to_list,
|
|
"detail Escape restores list route")
|
|
self.require(self.selected_list_line(back_to_list) == selected_before_detail,
|
|
"detail Escape restores equipment selection")
|
|
|
|
self.send("F7")
|
|
actions = self.capture("actions")
|
|
self.require("Actions" in actions, "F7 opens actions")
|
|
self.send("Escape")
|
|
self.check_shell("F7 Escape restores equipment", self.capture("after-actions-escape"), "Équipements")
|
|
|
|
# The minimum terminal still supports list search and overlay isolation.
|
|
self.resize(72, 20)
|
|
self.send("4")
|
|
minimum_equipment = self.capture("minimum-equipment")
|
|
self.check_shell("72x20 equipment", minimum_equipment, "Équipements")
|
|
self.send("/", "l", "e", "g")
|
|
filtered = self.capture("equipment-filtered")
|
|
self.require("leg" in filtered.lower(), "live equipment filter")
|
|
self.send("Escape")
|
|
cleared = self.capture("equipment-search-cleared")
|
|
self.require("Recherche : — [saisie]" in cleared,
|
|
"first Escape clears query and retains search focus")
|
|
self.send("Escape")
|
|
search_closed = self.capture("equipment-search-closed")
|
|
self.check_shell("second Escape returns equipment list", search_closed, "Équipements")
|
|
self.require("[saisie]" not in search_closed, "second Escape clears search focus")
|
|
self.send("?")
|
|
self.require("Aide contextuelle" in self.capture("minimum-help"), "help at minimum geometry")
|
|
self.send("3")
|
|
self.require("Aide contextuelle" in self.capture("help-overlay-isolates-global-route"),
|
|
"global route ignored while help overlay open")
|
|
self.resize(100, 25)
|
|
self.require("Aide contextuelle" in self.capture("help-after-resize"), "resize preserves help overlay")
|
|
self.send("Escape")
|
|
self.check_shell("Escape restores equipment after overlay resize",
|
|
self.capture("after-overlay-resize-escape"), "Équipements")
|
|
|
|
self.send("0", "q")
|
|
time.sleep(0.5)
|
|
self.require(self.pane_dead() == "1", "q exits pane")
|
|
self.require(self.tmux("display-message", "-p", "-t", self.session,
|
|
"#{pane_dead_status}").stdout.strip() == "0", "q exits zero")
|
|
self.require(self.data_snapshot() == database_before_navigation,
|
|
"navigation leaves initialized temporary database unchanged")
|
|
|
|
def close(self):
|
|
# This is our unique socket, never the caller's tmux server.
|
|
self.tmux("kill-server", check=False)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--binary", type=Path, default=ROOT / "build/tui/trainlog")
|
|
parser.add_argument("--output", type=Path, default=Path("/tmp/trainlog-app-shell-v1"))
|
|
args = parser.parse_args()
|
|
runner = PtyValidation(args.binary, args.output)
|
|
result = {"binary": str(args.binary), "output": str(runner.output), "checks": [], "captures": {}}
|
|
try:
|
|
runner.run()
|
|
result.update(status="PASS", checks=runner.checks, captures=runner.captures)
|
|
print("APP_SHELL_PTY=PASS")
|
|
print("ARTIFACT_DIR=" + str(runner.output))
|
|
for check in runner.checks:
|
|
print("CHECK=" + check)
|
|
except (Failure, subprocess.TimeoutExpired, OSError) as error:
|
|
result.update(status="FAIL", error=str(error), checks=runner.checks, captures=runner.captures)
|
|
print("APP_SHELL_PTY=FAIL: " + str(error), file=sys.stderr)
|
|
return 1
|
|
finally:
|
|
runner.output.mkdir(parents=True, exist_ok=True)
|
|
(runner.output / "results.json").write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n",
|
|
encoding="utf-8")
|
|
(runner.output / "pty-script.md").write_text(
|
|
"# APP_SHELL_V1 PTY evidence\n\n"
|
|
"`validate_app_shell_pty.py` starts the supplied binary in a unique tmux "
|
|
"server with a temporary `XDG_DATA_HOME`. Captures are text snapshots of "
|
|
"the actual pane. They verify route and overlay behavior and the reserved "
|
|
"footer content at each requested geometry; they do not attempt to prove "
|
|
"terminal-cell width from UTF-8 byte or Python-string length. The C geometry "
|
|
"tests provide that complementary cell-bound coverage.\n",
|
|
encoding="utf-8")
|
|
runner.close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|