#!/usr/bin/env python3 """Vega checks: one aup spec, three surfaces. 1. CLI and NUI: examples/todo.aup under random adds, moves, toggles, urgent marks and removals prints exactly what a reference Python model of the app and of Vega's conventions says (lines for pipes, sentences for speech), including failed events on an empty list. 2. TUI: the same runs drawn at several sizes keep the focused row on screen with the least scrolling, show the rows around it in order, right-align badges by display width (Hangul, CJK and emoji take two cells, combining marks none), and evaluate only the cells on screen: a frame costs the title, the field, the focus and three cells per visible row, however long the list is. 3. Tables: examples/scores.aup aligns its columns by display width and marks row tones. 4. Every aup example runs on every surface under random keys without a crash. 5. Stored: surfaces take turns on one store and each continues the list; an interactive session's entries are there in the next one. """ import argparse import json import os import random import subprocess import sys import tempfile import unicodedata from pathlib import Path HERE = Path(__file__).resolve().parent ROOT = HERE.parent def run(*args, stdin='', env=None): return subprocess.run([str(a) for a in args], input=stdin, capture_output=True, text=True, timeout=120, env=env) def width(text): total = 0 for ch in text: if unicodedata.combining(ch) or ch in '‍️': continue total += 2 if unicodedata.east_asian_width(ch) in 'WF' else 1 return total def pictures(text): """Control characters as Vega shows them.""" return ''.join(chr(0x2400 + ord(c)) if ord(c) < 0x20 else '␡' if c == '\x7f' else c for c in text) class Todo: """examples/todo.aup and Vega's reading of it.""" def __init__(self): self.tasks, self.at = [], 0 def event(self, name, text=None): """Returns False when the event fails (a missing item).""" if name == 'add': self.tasks.append({'title': text, 'done': 0, 'urgent': 0}) self.at = len(self.tasks) - 1 return True last = max(len(self.tasks) - 1, 0) if name in ('toggle', 'urgent', 'drop') and not self.tasks: return False if name == 'up': self.at = max(self.at - 1, 0) elif name == 'down': self.at = min(self.at + 1, last) elif name == 'toggle': self.tasks[self.at]['done'] = int(not self.tasks[self.at]['done']) elif name == 'urgent': self.tasks[self.at]['urgent'] = int(not self.tasks[self.at]['urgent']) elif name == 'drop': del self.tasks[self.at] self.at = min(self.at, max(len(self.tasks) - 1, 0)) return True @staticmethod def badge(task): return 'done' if task['done'] else 'urgent' if task['urgent'] else '' @staticmethod def tone(task): return 'muted' if task['done'] else 'danger' if task['urgent'] else 'normal' def cli(self): lines = ['To do', f'left: {len(self.tasks)} tasks', f'tasks_list ({len(self.tasks)})'] for i, task in enumerate(self.tasks): line = ('> ' if i == self.at else ' ') + pictures(task['title']) if self.badge(task): line += f' [{self.badge(task)}]' if self.tone(task) != 'normal': line += f' ({self.tone(task)})' lines.append(line) return lines @staticmethod def spoken(text): text = text.rstrip(''.join(chr(c) for c in range(0x21))) return ''.join(' ' if ord(c) < 0x20 else c for c in text) def focus(self): if not self.tasks: return 'tasks_list is empty.' task = self.tasks[self.at] words = [self.spoken(task['title'])] + ([self.badge(task)] if self.badge(task) else []) tone = 'danger, ' if self.tone(task) == 'danger' else '' return f'Item {self.at + 1} of {len(self.tasks)}: {tone}' + ', '.join(words) + '.' def say(self): n = len(self.tasks) lines = ['To do.', f'left: {n} tasks.', f'tasks_list, {n} item{"" if n == 1 else "s"}.'] return lines + ([self.focus()] if n else []) KEYS = {'up': 'ArrowUp', 'down': 'ArrowDown', 'toggle': 'Space', 'urgent': 'KeyU', 'drop': 'Delete'} WORDS = ['milk', '우유 사기', 'call 엄마', '日本語', '😀 party', 'café', 'tab\there', 'x' * 60, '', 'a\nb', 'ok'] def cli_and_say(vega, module, seeds, steps=300): compared = failures = 0 for seed in seeds: rng = random.Random(seed) model, script, expected_cli, expected_say = Todo(), [], [], [] for _ in range(steps): if rng.random() < 0.25: word = rng.choice(WORDS) script.append('text ' + (word.encode().hex() or '-')) ok, name = model.event('add', word), 'add' else: name = rng.choice(list(KEYS) + ['drop']) script.append(f'key {KEYS[name]}') ok = model.event(name) expected_say.append(model.focus() if ok else f'Could not {name}.') failures += not ok if rng.random() < 0.2: script.append('show') expected_cli += model.cli() expected_say += model.say() text = '\n'.join(script) + '\n' cli = run(vega, 'cli', module, stdin=text) assert cli.returncode == 0 and cli.stdout.splitlines() == expected_cli, \ (seed, [(a, b) for a, b in zip(cli.stdout.splitlines(), expected_cli) if a != b][:3]) say = run(vega, 'say', module, stdin=text) assert say.returncode == 0 and say.stdout.splitlines() == expected_say, \ (seed, [(a, b) for a, b in zip(say.stdout.splitlines(), expected_say) if a != b][:3]) compared += len(expected_cli) + len(expected_say) return compared, failures def stored_surfaces(vega, module, work, seeds, steps=240): """One store, several surfaces in turn: each run continues the list where the last one left it, and prints or says what the model says.""" compared = 0 for seed in seeds: rng = random.Random(seed + 500) model, store = Todo(), work / f'store-{seed}' cuts = sorted(rng.sample(range(1, steps), 6)) for number, (start, end) in enumerate(zip([0] + cuts, cuts + [steps])): surface = ['cli', 'say'][number % 2] script, expected = [], [] for _ in range(start, end): if rng.random() < 0.3: word = rng.choice(WORDS) script.append('text ' + (word.encode().hex() or '-')) ok, name = model.event('add', word), 'add' else: name = rng.choice(list(KEYS) + ['drop']) script.append(f'key {KEYS[name]}') ok = model.event(name) if surface == 'say': expected.append(model.focus() if ok else f'Could not {name}.') script.append('show') expected += model.cli() if surface == 'cli' else model.say() out = run(vega, surface, module, '--store', store, stdin='\n'.join(script) + '\n') assert out.returncode == 0 and out.stdout.splitlines() == expected, \ (seed, number, [(a, b) for a, b in zip(out.stdout.splitlines(), expected) if a != b][:3]) compared += len(expected) frame = run(vega, 'tui', module, '--store', store, '--dump', '40x12', stdin='show\n').stdout.splitlines() assert frame[2].startswith(f'|tasks_list ({len(model.tasks)})'), frame return compared def frames(output, height): lines = output.splitlines() for start in range(0, len(lines), height + 1): frame, cells = lines[start:start + height], lines[start + height] assert len(frame) == height and cells.startswith('cells '), lines[start:start + height + 1] yield frame, int(cells.split()[1]) def tui(vega, module, seeds, sizes, steps=300): """Each frame against the model: which rows show, which one is focused, where badges end, and how many cells were evaluated.""" checked = 0 for columns, rows in sizes: area = rows - 5 # title, field, list header, key bar, input line for seed in seeds: rng = random.Random(seed * 7 + columns) model, script, states = Todo(), [], [] for _ in range(steps): if rng.random() < 0.3: word = rng.choice(WORDS) script.append('text ' + (word.encode().hex() or '-')) model.event('add', word) else: name = rng.choice(list(KEYS)) script.append(f'key {KEYS[name]}') model.event(name) script.append('show') states.append(([dict(t) for t in model.tasks], model.at)) result = run(vega, 'tui', module, '--dump', f'{columns}x{rows}', stdin='\n'.join(script) + '\n') assert result.returncode == 0, result.stderr first = 0 for (frame, cells), (tasks, at) in zip(frames(result.stdout, rows), states): n = len(tasks) assert frame[0] == '|To do' and frame[2].startswith(f'|tasks_list ({n})'), frame[:3] if at < first: first = at if at >= first + area: first = at - area + 1 if first + area > n: first = max(n - area, 0) shown = frame[3:3 + area] visible = tasks[first:first + area] for i, line in enumerate(shown): if i >= len(visible): assert line == '|', (seed, line) continue task, focused = visible[i], first + i == at body = (line[1:].split('\t')[0] + ' ')[:max(2, len(line[1:].split('\t')[0]))] # trailing spaces are trimmed assert line[0] == ('>' if focused else '|') and body.startswith('> ' if focused else ' '), (seed, i, line) label = pictures(task['title']) badge = Todo.badge(task) if badge: # the badge ends at the last column assert body.endswith(badge) and width(body) == columns, (seed, body, width(body)) if width(label) + 2 + (len(badge) + 2 if badge else 0) <= columns: assert body[2:].startswith(label), (seed, body, label) else: assert '…' in body, (seed, body) tone = Todo.tone(task) assert (line.split('\t')[1] if '\t' in line else 'normal') == tone, (seed, line, tone) assert cells == 2 + (2 + 3 * len(visible) if n else 0), (seed, cells, len(visible)) checked += 1 # A long list costs what its screen shows: 2,000 tasks, 7 visible rows. script = ['text ' + f'task {i}'.encode().hex() for i in range(2000)] + ['key ArrowUp'] * 3 + ['show'] frame, cells = next(frames(run(vega, 'tui', module, '--dump', '40x12', stdin='\n'.join(script) + '\n').stdout, 12)) assert cells == 2 + 2 + 3 * 7 and frame[9] == '>> task 1996' and frame[3] == '| task 1990', (cells, frame) return checked def scores(vega, module): joins = ['ann', '범수', 'Zoë'] script = [f'text {name.encode().hex()}' for name in joins] script += ['key ArrowUp', 'key KeyW', 'key KeyW', 'key KeyW', 'key ArrowUp', 'key KeyL', 'show'] out = run(vega, 'cli', module, stdin='\n'.join(script) + '\n').stdout.splitlines() assert out == ['Scores', 'board (3)', ' name score rank', '> ann -1 - (danger)', ' 범수 3 gold (good)', ' Zoë 0 -'], out tui = run(vega, 'tui', module, '--dump', '30x8', stdin='\n'.join(script) + '\n').stdout.splitlines() assert tui[1:5] == ['|board (3)', '| name score rank\tmuted', '>> ann -1 -\tdanger', '| 범수 3 gold\tgood'], tui say = run(vega, 'say', module, stdin='show\n' + '\n'.join(script) + '\n').stdout.splitlines() assert 'Item 1 of 3: danger, name ann, score -1, rank -.' in say, say return len(out) def interactive(vega, module, store=None): """The terminal surface on a real pseudo-terminal: typed text commits with Enter, arrow keys and letters reach the app after Tab, Ctrl-C leaves and restores the terminal.""" import fcntl, pty, select, struct, termios, time pid, fd = pty.fork() if pid == 0: os.execv(str(vega), [str(vega), 'tui', str(module)] + (['--store', str(store)] if store else [])) fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', 10, 40, 0, 0)) os.kill(pid, 28) # SIGWINCH: take the size def read_for(seconds): data, end = b'', time.time() + seconds while time.time() < end: ready, _, _ = select.select([fd], [], [], 0.05) if ready: try: data += os.read(fd, 65536) except OSError: break return data screen = read_for(0.3) for keys in (b'milk\r', 'b\u00e9ton\r'.encode(), b'\t', b'\x1b[A', b' ', b'u'): os.write(fd, keys) screen += read_for(0.2) os.write(fd, b'\x03') screen += read_for(0.3) _, status = os.waitpid(pid, 0) os.close(fd) text = screen.decode(errors='replace') assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, status assert '\x1b[?1049h' in text and text.rstrip().endswith('\x1b[?1049l'), text[-80:] assert 'milk' in text and 'béton' in text and '(2)' in text and 'done' in text, text[-400:] if store: # what was typed is still there in the next session again = run(vega, 'cli', module, '--store', store, stdin='show\n').stdout.splitlines() assert again == ['To do', 'left: 2 tasks', 'tasks_list (2)', '> milk [done] (muted)', ' béton'], again return 1 def robustness(vega, aup, work, rounds): """Every aup example, every surface, random keys and text: never a crash.""" examples = sorted((ROOT.parent / 'aup' / 'examples').glob('*.aup')) + sorted((ROOT / 'examples').glob('*.aup')) keys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Enter', 'Delete', 'Backspace', 'Home', 'End', 'KeyD', 'KeyS', 'KeyU', 'KeyW', 'KeyL', 'KeyM'] env = dict(os.environ, AUP_PATH=str(ROOT / 'lib')) runs = 0 for path in examples: module = work / (path.stem + '.pmod') built = run(aup, 'build', path, '-o', module, env=env) assert built.returncode == 0, (path, built.stderr) rng = random.Random(path.stem) for _ in range(rounds): script = [] for _ in range(60): roll = rng.random() if roll < 0.2: script.append('text ' + rng.choice(WORDS).encode().hex()) elif roll < 0.3: script.append('show') elif roll < 0.35: script.append('frame 3') elif roll < 0.4: script.append(rng.choice(['type 61', 'enter', 'tab'])) else: script.append('key ' + rng.choice(keys)) text = '\n'.join(script) + '\nshow\n' for surface in (['cli'], ['say'], ['tui', '--dump', rng.choice(['80x24', '12x5', '1x1', '200x3'])]): result = run(vega, surface[0], module, *surface[1:], stdin=text) assert result.returncode == 0, (path, surface, result.stderr[-400:]) runs += 1 return len(examples), runs def main(): parser = argparse.ArgumentParser() parser.add_argument('--vega', type=Path, required=True) parser.add_argument('--aup', type=Path, required=True) parser.add_argument('--seeds', type=int, default=8) parser.add_argument('--rounds', type=int, default=3) args = parser.parse_args() vega, aup = args.vega.resolve(), args.aup.resolve() report = {} with tempfile.TemporaryDirectory(prefix='vega-check-') as directory: work = Path(directory) env = dict(os.environ, AUP_PATH=str(ROOT / 'lib')) modules = {} for name in ('todo', 'scores'): modules[name] = work / f'{name}.pmod' built = run(aup, 'build', ROOT / 'examples' / f'{name}.aup', '-o', modules[name], env=env) assert built.returncode == 0, built.stderr report['cli_and_say_lines'], report['failed_events_spoken'] = \ cli_and_say(vega, modules['todo'], range(args.seeds)) report['tui_frames'] = tui(vega, modules['todo'], range(args.seeds), [(40, 12), (24, 9), (80, 30), (16, 6)]) report['table_lines'] = scores(vega, modules['scores']) report['interactive_sessions'] = interactive(vega, modules['todo']) + \ interactive(vega, modules['todo'], work / 'interactive-store') report['stored_surface_lines'] = stored_surfaces(vega, modules['todo'], work, range(args.seeds)) report['examples'], report['robustness_runs'] = robustness(vega, aup, work, args.rounds) print(json.dumps(report)) if __name__ == '__main__': sys.exit(main())