diff --git a/CLAUDE.md b/CLAUDE.md index 0908e9c..be0b37f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,7 @@ There is no unit test framework. The automated checks are `test:protocol` (plain - Values are shown through `_glpython_safe_repr`, which writes types as learners do (`int`, not ``); the program's own output keeps Python's repr. - It abbreviates long values (`[1, 2, 3, 4, 5, …, 100]`; levels in `_GLPYTHON_ABBREVIATION_LEVELS`) and notes each full text. The next recorded step gets `fullValues` (`{shown text: index}`), the trace result `values` (`{text, cut, length, kind}`). The UI (`fullValuesUpTo`, `ValueText`, `LocalsValue`, `FullValueDialog`) opens one on click. - A name indexed in a subscript (`dauer` in `dauer[1]`, `_glpython_indexed`) gets no step of its own, so `dauer[1]` is one step to the element. + - A display (`[…]`, `(…)`, `{…}`) gets no step of its own (`_glpython_record_display`), only where its value reads differently (unpacking, duplicates); nor does a negative number literal (`_glpython_is_negative_number`). Every step must change something. Worker messages: - `run` → `result`/`error`; while it runs, `output` messages (`{id, text}`, `id` undefined for a program, the input's id for a REPL input) stream what was printed, at most 50 per 50 ms. The final message brings the whole output again. diff --git a/README.md b/README.md index c1676d3..548e4e3 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,16 @@ the variables panel. The same holds for a tuple, a string or a dict, and for a slice (`w[1:3]`). A variable passed to a call is still replaced by its value (`notenschnitt(noten) → notenschnitt([5, 4.5, 6])`), and so is a parameter in a substituted function's expression. A list written out and indexed directly -(`[1, 2, 30][1]`) keeps its own step. +is one step too: `[1, 2, 30][1] → 2`. + +A list, tuple, set or dict written out has no step of its own: once its parts +are values, it reads as its value, as on paper. So a display of literals has +no steps at all, nested at any depth and with negative numbers (`-3`, `-2.5`): +`baum = ["+", ["*", 2, ["-", 7, -3]], 4]` is one step, the assignment. Otherwise +only the parts that change have steps: `[1 + 2, 3, 4] → [3, 3, 4]`, +`["+", 1, ["*", n, 3]] → ["+", 1, ["*", 2, 3]]`. A display keeps its step only +where its value reads differently: unpacking (`[*a, 2] → [1, 2]`) and a set or +dict whose duplicates go (`{1, 1} → {1}`). A long value is abbreviated, in the step and in the variables panel alike, keeping its first elements and its last one: `[1, 2, 3, 4, 5, …, 100]`. diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs index b76bb62..8be01b3 100644 --- a/scripts/check-console-browser.mjs +++ b/scripts/check-console-browser.mjs @@ -12,6 +12,8 @@ // visualizer), and without isolation stops with an error and its hint. // The visualizer (P39): a named list indexed in one step, a long list // abbreviated in the step and the variables and shown in full on request. +// A display of literals has no steps, in Ersetzung and Kasten (P40); every +// step changes the code or the variables. // The run file is the module __main__ (P37), also in the REPL after it. // // Drives the built embed (dist-embed/, from npm run build:embed) in Chrome @@ -276,6 +278,35 @@ try { await dialog.waitFor({ state: 'detached', timeout: 5000 }) } console.log('visualizer: a long list abbreviated, in full on request: ok') + + // P40: only the parts that change have steps, in both modes. + const displays = 'baum = ["+", ["*", 2, ["-", 7, -3]], ["/", ["+", 1, 2], 3]]\nr = [-1, 2]\nx = [1 + 2, 3, 4]\na = 5\ny = [a, 2]\nn = 2\nw = ["+", 1, ["*", n, 3]]\n' + for (const mode of ['Kasten', 'Ersetzung']) { + await frame.locator('.visualizer-mode').getByRole('button', { name: mode }).click() + await page.evaluate((source) => window.send('visualize', source), displays) + await frame.locator('.frame-box--root > .frame-code .frame-line').filter({ hasText: 'baum = ' }).waitFor({ timeout: 30000 }) + if (!(await stepButton(0).isDisabled())) await stepButton(0).click() + await frame.locator('.visualizer-step-count').filter({ hasText: 'Vor der Ausführung' }).waitFor({ timeout: 5000 }) + const snapshot = async () => JSON.stringify([await lines(), await frame.locator('.locals-row').allInnerTexts()]) + const seen = { 1: [], 3: [], 5: [], 7: [] } + let previous = await snapshot() + let steps = 0 + while (!(await stepButton(2).isDisabled())) { + await stepButton(2).click() + steps += 1 + const current = await snapshot() + assert.notEqual(current, previous, `${mode}, step ${steps}: something changes`) + previous = current + const texts = await lines() + for (const line of [1, 3, 5, 7]) if (seen[line].at(-1) !== texts[line - 1]) seen[line].push(texts[line - 1]) + } + assert.equal(steps, 10, `${mode}: seven assignments and three substitutions`) + assert.deepEqual(seen[1], ['baum = ["+", ["*", 2, ["-", 7, -3]], ["/", ["+", 1, 2], 3]]'], `${mode}: the literal tree`) + assert.ok(seen[3].includes('x = [3, 3, 4]'), `${mode}: [1 + 2, 3, 4]: ${JSON.stringify(seen[3])}`) + assert.ok(seen[5].includes('y = [5, 2]'), `${mode}: [a, 2]: ${JSON.stringify(seen[5])}`) + assert.ok(seen[7].includes('w = ["+", 1, ["*", 2, 3]]'), `${mode}: the tree with n: ${JSON.stringify(seen[7])}`) + } + console.log('visualizer: a display of literals has no steps: ok') } catch (error) { failed += 1 console.error(error.message) diff --git a/scripts/check-runtime-lists.py b/scripts/check-runtime-lists.py index 5061668..aca88e3 100644 --- a/scripts/check-runtime-lists.py +++ b/scripts/check-runtime-lists.py @@ -1,7 +1,10 @@ -"""Regression test: lists in the visualizer (P39). Indexing a named list is +"""Regression test: lists in the visualizer (P39, P40). Indexing a named list is one step to the element ("dauer[1]" -> "2", not first "[1, 2, 30][1]"), as the course writes chains on paper; a named list passed to a call is still -replaced by its value, and a list literal indexed directly keeps its step. +replaced by its value, and a list literal indexed directly is one step. +A display ("[1, 2]", "(a, b)", "{x}", "{k: v}") has no step of its own: of +literals (negative numbers too) it has none at all, otherwise only its parts +that change have one. A long value is abbreviated ("[1, 2, 3, 4, 5, …, 100]"), the same in the step and in the variables panel, with its full value in the trace's "values"; short values and the course's expression trees stay complete. @@ -43,8 +46,8 @@ def evals_on_line(result, line): expect("dauer[1]", evals_on_line(result, 2), [("dauer[1]", "2")]) expect("dauer[0] + dauer[1]", evals_on_line(result, 3), [("dauer[0]", "1"), ("dauer[1]", "2"), ("1 + 2", "3")]) expect("the list in the panel", result["trace"][-1]["locals"]["dauer"], "[1, 2, 30]") -# A literal list indexed directly: its own step, then the element. -expect("[1, 2, 30][1]", evals_on_line(result, 4), [("[1, 2, 30]", "[1, 2, 30]"), ("[1, 2, 30][1]", "2")]) +# A literal list indexed directly: one step to the element. +expect("[1, 2, 30][1]", evals_on_line(result, 4), [("[1, 2, 30][1]", "2")]) # The same for a tuple, a string, a dict, a slice, an index that is a # variable (its value is shown) and a list in a list. @@ -95,6 +98,52 @@ def evals_on_line(result, line): result = trace('baum = ["+", ["*", 2, ["-", 7, 3]], ["/", ["+", 1, 2], 3]]\n') expect("expression tree: no full value", (result["values"], result["trace"][-1]["locals"]["baum"]), ([], "['+', ['*', 2, ['-', 7, 3]], ['/', ['+', 1, 2], 3]]")) +# A display of literals is a value: no steps but the assignment, nested at +# any depth, negative numbers included (P40). +def steps_on_line(result, line): + return [(step["kind"], step["label"], step.get("valueText")) for step in result["trace"] if step["line"] == line] + +LITERALS = [ + 'baum = ["+", ["*", 2, ["-", 7, 3]], ["/", ["+", 1, 2], 3]]', + 'baum = ["-", 7, -3]', + 'baum = [-1, 2]', + 'baum = (-2.5, ("a", -1j), {"k": [-1, {2, -3}]}, {-4})', + 'baum = -3', +] +for line in LITERALS: + result = trace(line + "\n") + expect(f"no steps: {line}", [kind for kind, _, _ in steps_on_line(result, 1)], ["exec"]) + expect(f"its value: {line}", result["trace"][-1]["locals"]["baum"], repr(eval(line.split(" = ", 1)[1]))) + +# Names, calls and operators keep their steps; the display builds none. +for setup, line, expected, value in [ + ("", "w = [1 + 2, 3, 4]", [("1 + 2", "3")], "[3, 3, 4]"), + ("a = 5", "w = [a, 2]", [("a", "5")], "[5, 2]"), + ("x = 7", "w = [[1, 2], x]", [("x", "7")], "[[1, 2], 7]"), + ("n = 2", 'w = ["+", 1, ["*", n, 3]]', [("n", "2")], "['+', 1, ['*', 2, 3]]"), + ("n = 2", "w = (n, -n, {n: [n]})", [("n", "2"), ("n", "2"), ("-2", "-2"), ("n", "2"), ("n", "2")], "(2, -2, {2: [2]})"), + ("", "w = [len([1, 2]), 3]", [("len(...)", "2")], "[2, 3]"), +]: + result = trace(f"{setup}\n{line}\n") + expect(f"one step each: {line}", evals_on_line(result, 2), expected) + expect(f"the assignment: {line}", [label for kind, label, _ in steps_on_line(result, 2) if kind == "exec"], [f"w = {value}"]) + +# Where the value reads differently from the display, the display has its +# step: unpacking, and duplicates in a set or dict. Unpacking as in Python. +for setup, line, expected in [ + ("b = [1]", "w = [*b, 2]", [("b", "[1]"), ("[*b, 2]", "[1, 2]")]), + ("b = [1]", "w = (*b, 2)", [("b", "[1]"), ("(*b, 2)", "(1, 2)")]), + ("b = [1]", "w = {*b, 2}", [("b", "[1]"), ("{*b, 2}", "{1, 2}")]), + ('d = {"k": 1}', 'w = {**d, "j": 2}', [("d", "{'k': 1}"), ('{**d, "j": 2}', "{'k': 1, 'j': 2}")]), + ("", "w = {1, 1}", [("{1, 1}", "{1}")]), + ("", 'w = {"a": 1, "a": 2}', [('{"a": 1, "a": 2}', "{'a': 2}")]), +]: + result = trace(f"{setup}\n{line}\n") + expect(f"display step: {line}", evals_on_line(result, 2), expected) + namespace_python = {} + exec(f"{setup}\n{line}\n", namespace_python) + expect(f"value: {line}", result["trace"][-1]["locals"]["w"], repr(namespace_python["w"])) + # How other long values are abbreviated: dicts, sets and tuples as lists, # a list in a list, long strings in their middle, recursion as Python shows it. for value, expected in [ diff --git a/src/runtime/visualizer.py b/src/runtime/visualizer.py index a7b8081..a53fdc6 100644 --- a/src/runtime/visualizer.py +++ b/src/runtime/visualizer.py @@ -600,10 +600,16 @@ def _eval_binop(node, scope): _glpython_record(node, "eval", f"{_glpython_safe_repr(left, 40)} {symbol} {_glpython_safe_repr(right, 40)}", _glpython_safe_repr(value), _glpython_current_func()) return value +# A negative number ("-3", "-2.5") is a literal, as on paper: no step. +def _glpython_is_negative_number(node): + return isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub) and isinstance(node.operand, ast.Constant) and type(node.operand.value) in (int, float, complex) + def _eval_unaryop(node, scope): operand = _glpython_eval(node.operand, scope) op_func = _GLPYTHON_UNARY_OPS.get(type(node.op)) value = op_func(operand) if op_func else _glpython_fallback_eval(node, scope) + if _glpython_is_negative_number(node): + return value symbol = _GLPYTHON_OP_SYMBOLS.get(type(node.op), "?") _glpython_record(node, "eval", f"{symbol}{_glpython_safe_repr(operand, 40)}", _glpython_safe_repr(value), _glpython_current_func()) return value @@ -647,30 +653,52 @@ def _eval_ifexp(node, scope): _glpython_record(node, "eval", _glpython_node_source(node), _glpython_safe_repr(value), _glpython_current_func()) return value -def _eval_list(node, scope): - values = [_glpython_eval(item, scope) for item in node.elts] - _glpython_record(node, "eval", _glpython_node_source(node), _glpython_safe_repr(values), _glpython_current_func()) +# A display ("[1, 2]", "(a, b)", "{x}", "{k: v}") has no step of its own: +# once its parts are values, it already reads as its value, as on paper - so +# a display of literals has no steps at all, and "[1 + 2, 3]" only the one of +# "1 + 2". Only where the value reads differently is there a step: +# unpacking ("[*a, 2]", "{**d}") and a set or dict whose duplicates go. +def _glpython_record_display(node, value, rewrites): + if rewrites: + _glpython_record(node, "eval", _glpython_node_source(node), _glpython_safe_repr(value), _glpython_current_func()) + return value + +def _glpython_display_items(node, scope): + values = [] + for item in node.elts: + if isinstance(item, ast.Starred): + values.extend(_glpython_eval(item, scope)) + else: + values.append(_glpython_eval(item, scope)) return values +def _glpython_has_starred(node): + return any(isinstance(item, ast.Starred) for item in node.elts) + +def _eval_list(node, scope): + return _glpython_record_display(node, _glpython_display_items(node, scope), _glpython_has_starred(node)) + def _eval_tuple(node, scope): - values = tuple(_glpython_eval(item, scope) for item in node.elts) - _glpython_record(node, "eval", _glpython_node_source(node), _glpython_safe_repr(values), _glpython_current_func()) - return values + return _glpython_record_display(node, tuple(_glpython_display_items(node, scope)), _glpython_has_starred(node)) def _eval_set(node, scope): - values = {_glpython_eval(item, scope) for item in node.elts} - _glpython_record(node, "eval", _glpython_node_source(node), _glpython_safe_repr(values), _glpython_current_func()) - return values + values = _glpython_display_items(node, scope) + result = set(values) + return _glpython_record_display(node, result, _glpython_has_starred(node) or len(result) < len(values)) def _eval_dict(node, scope): result = {} + entries = 0 for key_node, value_node in zip(node.keys, node.values): if key_node is None: result.update(_glpython_eval(value_node, scope)) + entries = None else: - result[_glpython_eval(key_node, scope)] = _glpython_eval(value_node, scope) - _glpython_record(node, "eval", _glpython_node_source(node), _glpython_safe_repr(result), _glpython_current_func()) - return result + key = _glpython_eval(key_node, scope) + result[key] = _glpython_eval(value_node, scope) + if entries is not None: + entries += 1 + return _glpython_record_display(node, result, entries is None or len(result) < entries) def _eval_attribute(node, scope): target = _glpython_eval(node.value, scope)