Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ npm run build # tsc -b && vite build && copy src/style.css -> dist/ (li
npm run dev # vite build --watch, for use with npm link/yalc in a host shell
npm run lint # oxlint
npm run test:protocol # builds the library, then runs scripts/check-embed-protocol.mjs against dist/
npm run test:runtime # the runtime under CPython: scripts/check-runtime-{columns,values,doctests,coverage,calls,hints,fidelity,replay,…}.py
npm run test:runtime # the runtime under CPython: scripts/check-runtime-{columns,values,doctests,coverage,calls,hints,fidelity,replay,lists,…}.py
npm run test:hints # the error hints' wording (de/en) against the built dist/ (run a build first)
npm run test:console # the console in Chrome with Pyodide, against dist-embed/ (run build:embed first)
npm run build:embed # builds the iframe app + demo page into dist-embed/ (plus embed/public/: coi-serviceworker.js)
Expand Down Expand Up @@ -53,6 +53,8 @@ There is no unit test framework. The automated checks are `test:protocol` (plain
- `_GlpythonFunction` calls a stub with the same parameters first, so a call with wrong arguments raises Python's own `TypeError` (same message) at the call site.
- Columns: `ast` counts UTF-8 bytes, `tokenize` characters, the UI UTF-16 code units. Every offset into a line goes through `_glpython_text_before` (the text in front of it, for slicing) or `_glpython_ui_column` (a column for the UI); never slice a line by an `ast` offset directly.
- Values are shown through `_glpython_safe_repr`, which writes types as learners do (`int`, not `<class 'int'>`); 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.

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.
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,35 @@ The mode last chosen is remembered per browser. A host can preset it with the
texts are `visualizerModeLabel`, `visualizerModeBox` and
`visualizerModeSubstitution`, all optional like `visualizerBoxHint`.

### Lists and long values

Indexing a variable is one step to the element, in both modes:
`dauer[0] + dauer[1] → 1 + dauer[1] → 1 + 2 → 3`, while the list itself stays in
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.

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]`.
- **Shown completely:** a list, tuple, set or dict of up to 10 elements, in a
value of up to 160 characters (so are the course's expression trees, e.g.
`['+', ['*', 2, ['-', 7, 3]], ['/', ['+', 1, 2], 3]]`), and a string of up to
80 characters.
- **Abbreviated:** a longer container keeps its first 5 elements and its last.
If that is still longer than 160 characters, it keeps fewer and shortens long
elements too. A long string is shortened in the middle.
- **In full:** an abbreviated value is underlined with dots. A click (or Enter)
shows it in full, with its number of elements or characters (`100 Werte`,
also below it in the variables panel). The runtime keeps up to 50 000
characters of it.

A long value in a line wraps within the code instead of running on below the
variables panel. Texts: `visualizerShowFullValue`, `visualizerValueLength`,
`visualizerFullValueTitle`, `visualizerFullValueCut`, `visualizerCloseFullValue`,
all optional.

### Copying the evaluation as a → chain

The **Copy → chain** item in the toolbar's **⋯ Options** menu copies the evaluation of the statement
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"test:protocol": "npm run build && node scripts/check-embed-protocol.mjs",
"test:hints": "node scripts/check-error-hints.mjs",
"test:console": "node scripts/check-console-browser.mjs",
"test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py && python3 -B scripts/check-runtime-coverage.py && python3 -B scripts/check-runtime-calls.py && python3 -B scripts/check-runtime-hints.py && python3 -B scripts/check-runtime-fidelity.py && python3 -B scripts/check-runtime-replay.py && python3 -B scripts/check-runtime-imports.py && python3 -B scripts/check-runtime-repl.py && python3 -B scripts/check-runtime-input.py",
"test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py && python3 -B scripts/check-runtime-coverage.py && python3 -B scripts/check-runtime-calls.py && python3 -B scripts/check-runtime-hints.py && python3 -B scripts/check-runtime-fidelity.py && python3 -B scripts/check-runtime-replay.py && python3 -B scripts/check-runtime-imports.py && python3 -B scripts/check-runtime-repl.py && python3 -B scripts/check-runtime-input.py && python3 -B scripts/check-runtime-lists.py",
"prepare": "npm run build"
},
"peerDependencies": {
Expand Down
54 changes: 54 additions & 0 deletions scripts/check-console-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
// input() (P36) asks in the console's input line where the parent page is
// cross-origin isolated (a recursive REPL, Stop and Ctrl+D → EOFError, the
// 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.
//
// Drives the built embed (dist-embed/, from npm run build:embed) in Chrome
// through its protocol, as the LiaScript macro does; Pyodide comes from its
Expand Down Expand Up @@ -227,6 +229,58 @@ try {
}
await page.close()
}
// The visualizer (P39), in Ersetzung: indexing a named list is one step,
// a named list passed to a call is still replaced by its value. A long
// list is abbreviated in the step and in the variables, within the code's
// width, and opens in full; an expression tree stays complete.
{
const page = await openEmbed({})
const frame = page.frameLocator('#embed')
const lines = () => frame.locator('.frame-box--root > .frame-code > .frame-line-row .frame-line').allInnerTexts()
const stepButton = (index) => frame.locator('.visualizer-controls button').nth(index)
try {
await page.evaluate((source) => window.send('visualize', source), 'def notenschnitt(noten):\n return sum(noten) / len(noten)\n\ndauer = [1, 2, 30]\nx = dauer[0] + dauer[1]\ny = [1, 2, 30][1]\nschnitt = notenschnitt(dauer)\n')
await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 })
const seen = { 5: [], 6: [], 7: [] }
while (!(await stepButton(2).isDisabled())) {
await stepButton(2).click()
const texts = await lines()
for (const line of [5, 6, 7]) if (seen[line].at(-1) !== texts[line - 1]) seen[line].push(texts[line - 1])
}
assert.deepEqual(seen[5], ['x = dauer[0] + dauer[1]', 'x = 1 + dauer[1]', 'x = 1 + 2', 'x = 3'], 'dauer[0] + dauer[1]: one step per element')
assert.deepEqual(seen[6], ['y = [1, 2, 30][1]', 'y = 2'], 'a list literal indexed directly')
assert.ok(seen[7].includes('schnitt = notenschnitt([1, 2, 30])'), `a named list passed to a call: ${JSON.stringify(seen[7])}`)
console.log('visualizer: a named list indexed in one step: ok')

await page.evaluate((source) => window.send('visualize', source), 'l = list(range(1, 101))\nbaum = ["+", ["*", 2, ["-", 7, 3]], ["/", ["+", 1, 2], 3]]\n')
await stepButton(3).click()
await frame.locator('.locals-row[data-name="baum"]').waitFor({ timeout: 30000 })
const short = '[1, 2, 3, 4, 5, …, 100]'
assert.equal((await lines())[0], `l = ${short}`, 'the step: abbreviated')
const panelValue = frame.locator('.locals-row[data-name="l"] .locals-value')
assert.equal(await panelValue.locator('.visual-value-more-text').innerText(), short, 'the variables: the same abbreviation')
assert.equal(await panelValue.locator('.locals-value-length').innerText(), '100 Werte', 'its length')
assert.equal(await frame.locator('.locals-row[data-name="baum"] .locals-value').innerText(), "['+', ['*', 2, ['-', 7, 3]], ['/', ['+', 1, 2], 3]]", 'an expression tree: complete')
const inline = frame.locator('.frame-box--root .visual-substituted-value.visual-value-more').first()
const codeBox = await frame.locator('.frame-box--root > .frame-code').boundingBox()
const valueBox = await inline.boundingBox()
assert.ok(valueBox.x + valueBox.width <= codeBox.x + codeBox.width, 'the step value stays within the code')
const full = `[${Array.from({ length: 100 }, (_, index) => index + 1).join(', ')}]`
for (const [label, target] of [['the variables', panelValue], ['the step', inline]]) {
await target.click()
const dialog = frame.getByRole('dialog')
assert.equal(await dialog.locator('.full-value-text').innerText(), full, `${label}: the full value`)
assert.match(await dialog.locator('.input-dialog-heading').innerText(), /100 Werte/, `${label}: its length`)
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'detached', timeout: 5000 })
}
console.log('visualizer: a long list abbreviated, in full on request: ok')
} catch (error) {
failed += 1
console.error(error.message)
}
await page.close()
}
// Without isolation (as in LiaScript): Python's error and the hint.
{
const page = await openEmbed({})
Expand Down
126 changes: 126 additions & 0 deletions scripts/check-runtime-lists.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Regression test: lists in the visualizer (P39). 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.
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.

Runs the visualizer runtime with CPython (no Pyodide needed):
python3 -B scripts/check-runtime-lists.py
"""
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RUNTIME = os.path.join(ROOT, "src", "runtime")
sys.path.insert(0, RUNTIME) # for "import gturtle"

namespace = {"__name__": "glpython_runtime"}
for name in ("hints.py", "session.py", "visualizer.py"):
with open(os.path.join(RUNTIME, name), encoding="utf-8") as handle:
exec(compile(handle.read(), name, "exec"), namespace)

failures = []


def expect(label, got, expected):
if got != expected:
failures.append(f"{label}: got {got!r}, expected {expected!r}")


def trace(source):
return json.loads(namespace["_glpython_trace_program"]("main.py", {"main.py": source}, None))


def evals_on_line(result, line):
return [(step["label"], step["valueText"]) for step in result["trace"] if step["kind"] == "eval" and step["line"] == line]


# One step to the element; the list stays in the variables panel.
result = trace("dauer = [1, 2, 30]\nx = dauer[1]\ny = dauer[0] + dauer[1]\nz = [1, 2, 30][1]\n")
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")])

# 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.
result = trace('t = (4, 5)\nw = "Hallo"\nd = {"a": 1}\nm = [[1, 2], [3, 4]]\ni = 1\nx = t[0] + len(w[1:3]) + d["a"] + m[i][0]\n')
labels = [label for label, _ in evals_on_line(result, 6)]
for unwanted in ("t", "w", "d", "m"):
expect(f"no step of its own for {unwanted}", unwanted in labels, False)
expect("m[i][0]", [step for step in evals_on_line(result, 6) if step[0] in ("i", "m[1]", "[3, 4][0]")], [("i", "1"), ("m[1]", "[3, 4]"), ("[3, 4][0]", "3")])

# A named list passed to a call is replaced by its value, as before.
NOTEN = '''def notenschnitt(noten):
return sum(noten) / len(noten)

noten = [5, 4.5, 6]
schnitt = notenschnitt(noten)
'''
result = trace(NOTEN)
expect("notenschnitt(noten)", evals_on_line(result, 5), [("noten", "[5, 4.5, 6]"), ("notenschnitt(...)", "5.166666666666667")])
enter = next(step for step in result["trace"] if step["kind"] == "call-enter")
expect("its short form fills in the list", [text for _, text in enter["shortForm"]["paramValues"]], ["[5, 4.5, 6]", "[5, 4.5, 6]"])

# An index that doesn't exist: the error step, with Python's message.
result = trace("dauer = [1, 2, 30]\nx = dauer[3]\n")
last = result["trace"][-1]
expect("dauer[3]", (last["kind"], last["line"], last["label"], result["errorMessage"]), ("error", 2, "IndexError: list index out of range", "IndexError: list index out of range"))
expect("dauer[3]: no step for the name", evals_on_line(result, 2), [])

# The chain's reason comment keeps an indexed name, as the step does.
result = trace("dauer = [1, 2, 30]\nif dauer[0] > 0:\n pass\n")
expect("condition text", [step.get("conditionText") for step in result["trace"] if step["kind"] == "branch"], ["dauer[0] > 0"])

# A long list: abbreviated in the step and in the panel, its full value on
# request.
result = trace("l = list(range(1, 101))\n")
short = "[1, 2, 3, 4, 5, …, 100]"
step = next(step for step in result["trace"] if step["label"] == "list(...)")
expect("long list: the step", step["valueText"], short)
expect("long list: the panel", result["trace"][-1]["locals"]["l"], short)
full = result["values"][result["trace"][-1]["fullValues"][short]]
expect("long list: the full value", full, {"text": repr(list(range(1, 101))), "cut": False, "length": 100, "kind": "list"})
expect("long list: the step has it too", step["fullValues"][short], result["trace"][-1]["fullValues"][short])

# Short values and the course's expression trees stay complete; nothing is
# noted for them.
repr_of = namespace["_glpython_safe_repr"]
for value in (list(range(10)), ["+", 1, ["*", 2, 3]], ["+", ["*", 2, ["-", 7, 3]], ["/", ["+", 1, 2], 3]], ("a", "b"), {"x": 1, "y": [1, 2]}, "Hallo Welt", [None, True, 2.5]):
expect(f"complete: {value!r}", repr_of(value), repr(value))
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]]"))

# 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 [
(list(range(11)), "[0, 1, 2, 3, 4, …, 10]"),
(tuple(range(20)), "(0, 1, 2, 3, 4, …, 19)"),
({i: i * i for i in range(12)}, "{0: 0, 1: 1, 2: 4, 3: 9, 4: 16, …, 11: 121}"),
(set(range(12)), "{0, 1, 2, 3, 4, …, 11}"),
([list(range(12)), [1]], "[[0, 1, 2, 3, 4, …, 11], [1]]"),
("x" * 100, "'" + "x" * 58 + "…" + "x" * 19 + "'"),
# Still too long with 5 elements: 3, and long numbers shortened.
([10 ** 30] * 12, "[10000000000…0000, 10000000000…0000, 10000000000…0000, …, 10000000000…0000]"),
]:
expect(f"abbreviated: {value!r:.40}", repr_of(value), expected)
recursive = [1]
recursive.append(recursive)
expect("recursive list", repr_of(recursive), "[1, [...]]")
# A string's length is in characters, a dict's in entries.
result = trace('s = "a" * 100\nd = {i: i for i in range(20)}\n')
expect("lengths", sorted((value["kind"], value["length"]) for value in result["values"]), [("dict", 20), ("str", 100)])

# A long value shows with the same abbreviation wherever it appears; each full
# text is stored once.
result = trace("l = list(range(1, 101))\nm = l\nn = len(l)\n")
expect("stored once", len(result["values"]), 1)

if failures:
print("\n".join(failures))
sys.exit(f"{len(failures)} list check(s) failed.")
print("Runtime list checks passed.")
Loading
Loading