From a459632cbb93b46c013556465a9e69b0baad745a Mon Sep 17 00:00:00 2001 From: eunhhu Date: Fri, 25 Sep 2026 13:34:43 +0100 Subject: Add records, lists and indices A record declares named number and text fields with defaults. A list of a record is runtime-owned state, and an index of a list always names one of its items: it follows its item across inserts and removes and stops at the ends. Items are read and changed by field (tasks[at].title), appended or inserted as record values whose missing fields take defaults, and removed by index. Using an index of another list is a compile error. examples/tasks.aup matches a Python reference model over 4,800 random steps, including events that fail on an empty list. Replacing it with a module whose record gained and reordered fields keeps every item. --- tests/check.py | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) (limited to 'tests') diff --git a/tests/check.py b/tests/check.py index 5ed90ff..f4f3b92 100644 --- a/tests/check.py +++ b/tests/check.py @@ -14,6 +14,10 @@ mark exactly as a reference Python model under random typing (ASCII, Korean, emoji, combining marks, NUL, newlines), moves and deletions; string escapes and number text follow their stated rules. +7. List model: examples/tasks.aup keeps its list, index, views and failed + events exactly as a reference Python model under random appends, moves, + toggles, copies, renames and removals; replacing it with a module whose + record changed keeps each item's fields by name. 5. Editor parity: the document editor written in aup, run by pure-edit-module, answers random show/edit/apply operations exactly as pure-edit running the original editor.pure (stdout, stderr, exit code), on the same tree store @@ -30,6 +34,9 @@ from pathlib import Path HERE = Path(__file__).resolve().parent ROOT = HERE.parent +RECORD = 'record T {\n a = 0\n}\n' +ON_K = 'input k = key KeyK\non k.down {\n' + DIAGNOSTICS = [ ('state x = 1\nstate x = 2\n', '2:7', "already declared as a state"), ('state a = b\n', '1:11', 'expected a numeric state initializer'), @@ -86,6 +93,38 @@ DIAGNOSTICS = [ ('input typed = text\non typed.down {\n}\n', '2:4', "'typed' is a text input; write on typed { }"), ('view v = text(3)\n', '1:10', 'text(...) converts bytes, not a number'), ('view v = "é"\nview w = 1 é\n', '2:12', 'unexpected byte 0xc3 outside a string'), + # records, lists and indices + ('record T {\n a = 0\n a = 1\n}\n', '3:3', "duplicate field 'a'"), + ('record T {\n}\n', '1:8', 'a record needs at least one field'), + ('record T {\n a: number\n}\n', '2:6', 'a field is a number (field = 0) or text (field: text)'), + ('record T {\n a = 0\n}\nstate T = 0\n', '4:7', "'T' is already declared as a record"), + ('state xs: list of Missing\n', '1:7', "a list holds a record; 'Missing' is not one"), + ('state x = 0\nstate at: index of x\n', '2:7', "an index points into a list state; 'x' is not one"), + (RECORD + 'state xs: list T\n', '4:16', "expected 'of'"), + (RECORD + 'state xs: list of T\nstate ys: list of T\nstate at: index of xs\nview v = ys[at].a\n', '7:13', + "an index of 'xs' cannot point into 'ys'"), + (RECORD + 'state xs: list of T\nstate at: index of xs\nview v = xs[at].b\n', '6:10', "record 'T' has no field 'b'"), + (RECORD + 'state xs: list of T\nstate at: index of xs\nview v = xs[at]\n', '6:10', 'read a field of the item'), + (RECORD + 'state xs: list of T\nview v = xs\n', '5:10', "'xs' is a list; read xs.count"), + (RECORD + 'state xs: list of T\nview v = xs.size\n', '5:10', "a list has count, first and last, not 'size'"), + (RECORD + 'state xs: list of T\nstate at: index of xs\nview v = at\n', '6:10', 'a view is a number or text, not an index'), + (RECORD + 'state xs: list of T\nstate at: index of xs\n' + ON_K + ' at = 3\n}\n', '8:8', 'expected an index, not a number'), + (RECORD + 'state xs: list of T\n' + ON_K + ' xs = 1\n}\n', '7:3', 'a list changes by append, insert, remove'), + (RECORD + 'record U {\n a = 0\n}\nstate xs: list of T\n' + ON_K + ' xs.append(U { a: 1 })\n}\n', '10:13', + "'xs' holds T, not U"), + (RECORD + 'state xs: list of T\n' + ON_K + ' xs.append(1)\n}\n', '7:13', "'xs' holds T records"), + (RECORD + 'state xs: list of T\n' + ON_K + ' xs.append(T { a: "x" })\n}\n', '7:20', 'expected a number, not text'), + (RECORD + 'state xs: list of T\n' + ON_K + ' xs.append(T { b: 1 })\n}\n', '7:17', "record 'T' has no field 'b'"), + (RECORD + 'state xs: list of T\n' + ON_K + ' xs.append(T { a: 1, a: 2 })\n}\n', '7:23', "field 'a' is given twice"), + (RECORD + 'state xs: list of T\n' + ON_K + ' xs.append(T { a: 1 a: 2 })\n}\n', '7:22', + "expected ',' or a new line between fields"), + (RECORD + 'state xs: list of T\nstate at: index of xs\n' + ON_K + ' xs.remove(at .. at)\n}\n', '8:19', + 'a list removes one item'), + (RECORD + 'state xs: list of T\nstate at: index of xs\n' + ON_K + ' xs[at].a = "x"\n}\n', '8:14', + 'expected a number, not text'), + (RECORD + 'state n: text\nstate at: cursor in n\n' + ON_K + ' n.insert(at, T { a: 1 })\n}\n', '8:16', + 'a record value goes into a list'), + ('state n: text\nstate at: cursor in n\n' + ON_K + ' n.remove(at)\n}\n', '5:3', 'a text removes a range'), ] ACCEPTED = [ @@ -102,6 +141,15 @@ ACCEPTED = [ 'on open {\n note = text(documents[key])\n at = note.end\n}\n' 'on typed { note.insert(at, typed) }\n' 'on save {\n documents[key].replace(0 .. documents[key].bytes, note)\n reply "saved {note.chars} characters"\n}\n', + # a record literal over several lines, text defaults, inserting at the first item + 'record Item {\n name: text = "untitled"\n price = 0; count = -1\n}\n' + 'state items: list of Item\nstate at: index of items\ninput add = key KeyA\n' + 'on add.down {\n items.insert(items.first, Item {\n price: 2.5,\n count: items.count + 1,\n })\n' + ' at = items.first\n items[at].name = "{items[at].name} #{items.count}"\n}\nview total = items.count\n', + # index arithmetic and positions in conditions and views + 'record Row {\n label: text\n}\nstate rows: list of Row\nstate focus: index of rows\ninput next = key Tab\n' + 'on next.down {\n if focus.position + 1 < rows.count {\n focus = focus + 1\n } else {\n focus = rows.first\n }\n}\n' + 'view summary = "{rows.count} rows, focus {focus.position}"\n', ] @@ -376,6 +424,89 @@ class NotesModel: 'size': len(self.note.encode()), 'lines': self.note.count('\n') + 1}) +class TasksModel: + """Reference semantics of examples/tasks.aup: the index names an item (0 in + an empty list), stays on its item across inserts and removes before it, + names the next item when its item is removed, and stops at the ends. An + event that reads a missing item fails and changes nothing.""" + + def __init__(self): + self.tasks, self.at, self.made = [], 0, 0 + + def last(self): + return max(len(self.tasks) - 1, 0) + + def typed(self, word): + self.tasks.append({'title': word, 'done': 0}) + self.at, self.made = self.last(), self.made + 1 + return 'none' + + def key(self, name): + if name in ('toggle', 'drop', 'again', 'shout') and not self.tasks: + return 'not_found' + if name == 'up': self.at = max(self.at - 1, 0) + elif name == 'down': self.at = min(self.at + 1, self.last()) + elif name == 'toggle': self.tasks[self.at]['done'] = int(self.tasks[self.at]['done'] == 0) + elif name == 'drop': + del self.tasks[self.at] + self.at = min(self.at, self.last()) + elif name == 'top': self.at = 0 + elif name == 'bottom': self.at = self.last() + elif name == 'again': + self.tasks.insert(self.at, dict(self.tasks[self.at])) + self.at += 1 + elif name == 'shout': self.tasks[self.at]['title'] += '!' + return 'none' + + def expected(self): + item = self.tasks[self.at] if self.tasks else None + return ({'tasks': [dict(task) for task in self.tasks], 'at': self.at, 'made': self.made}, + {'count': len(self.tasks), 'position': self.at, 'current': item and item['title'], + 'status': f'{self.at + 1}/{len(self.tasks)}', 'finished': item and item['done']}) + + +def tasks_parity(aup, pure_run, work, seeds, steps=400): + module = work / 'tasks.pmod' + compile_module(aup, ROOT / 'examples/tasks.aup', module) + words = ['milk', '빵', '', 'a"b\\', '😀\n', '\u0000', '👩\u200d💻'] + keys = ['up', 'down', 'toggle', 'drop', 'drop', 'top', 'bottom', 'again', 'shout'] + compared = failed = 0 + for seed in seeds: + rng = random.Random(seed + 9000) + model, commands, expected = TasksModel(), [], [] + for _ in range(steps): + # Appends and copies balance drops, so the list keeps coming back to empty. + if rng.random() < 0.1: + word = rng.choice(words) + commands.append('type typed ' + (word.encode().hex() or '-')) + fault = model.typed(word) + else: + name = rng.choice(keys) + commands.append(f'down {name}') + fault = model.key(name) + expected.append((fault, *model.expected())) + lines = run(pure_run, module, stdin='\n'.join(commands) + '\n', check=False).stdout.splitlines() + assert len(lines) == len(commands), (seed, len(lines)) + for line, command, (fault, states, views) in zip(lines, commands, expected): + snapshot = json.loads(line) + where = (seed, command) + assert snapshot['fault'] == fault and (snapshot['error'] is None) == (fault == 'none'), (where, snapshot) + assert snapshot['states'] == states, (where, snapshot['states'], states) + assert snapshot['views'] == views, (where, snapshot['views'], views) + compared += 1 + failed += fault != 'none' + # A replacement whose record gained and reordered fields keeps every item. + (work / 'tasks2.aup').write_text('record Task {\n urgent = 1\n done = 0\n title: text = "-"\n}\n' + 'state tasks: list of Task\nstate at: index of tasks\n') + compile_module(aup, work / 'tasks2.aup', work / 'tasks2.pmod') + lines = run(pure_run, module, stdin='type typed 6d696c6b\ntype typed 62\ndown toggle\ndown up\n' + f'replace {work / "tasks2.pmod"}\n').stdout.splitlines() + final = json.loads(lines[-1]) + assert final['states'] == {'tasks': [{'urgent': 1, 'done': 0, 'title': 'milk'}, + {'urgent': 1, 'done': 1, 'title': 'b'}], 'at': 0}, final + return compared, failed + + def text_parity(aup, pure_run, work, seeds, steps=400): module = work / 'notes.pmod' compile_module(aup, ROOT / 'examples/notes.aup', module) @@ -484,6 +615,8 @@ def main(): report['parity_failed_events_matched'] = sum(failures for _, failures in results) report['parity_programs'] = len(pairs) report['text_model_snapshots'] = text_parity(aup, pure_run, work, range(args.text_seeds)) + report['list_model_snapshots'], report['list_model_failed_events'] = \ + tasks_parity(aup, pure_run, work, range(args.text_seeds)) report['editor_parity_operations'], report['editor_parity_outcomes'] = \ editor_parity(aup, args.pure.resolve(), work, range(args.editor_seeds)) print(json.dumps(report)) -- cgit v1.2.3