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: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ jobs:
# The trees' layout (our port of van der Ploeg's algorithm) against
# d3-flextree, on the course's trees and random ones.
- run: npm run test:layout
# The visualizer's mode «Baum» on traces of the runtime: its steps are
# «Ersetzung»'s plus open and close, its text the chain's, and a
# reduction moves no node.
- run: npm run test:tree
# The console in Chrome (on the runner) with Pyodide from its CDN,
# driving the embed built above: Tests → edit → Tests shows the new
# report (P20).
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ npm run test:runtime # the runtime under CPython: scripts/check-runtime-{colum
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 test:layout # the trees' layout (src/treeLayout.ts) against d3-flextree (a devDependency, only for this check)
npm run test:tree # the visualizer's mode «Baum» (src/treeMode.ts) on traces of the runtime under CPython
npm run build:embed # builds the iframe app + demo page into dist-embed/ (plus embed/public/: coi-serviceworker.js)
npm run preview:embed # serves dist-embed/ with COOP/COEP headers; open /demo.html
```
Expand Down Expand Up @@ -75,7 +76,8 @@ Worker messages:
- `collapseDeepCallChain` collapses the middle of 8+ open boxes into a "… N more calls" marker; deeply nested boxes used to freeze the page's layout.
- `substitutionModeSteps` filters the trace down to visible substitutions.
- **Rendering always replays the full trace up to the current step's `step` number.** In substitution mode the scrubber moves through a filtered step list, so use list indices only for the scrubber, never as step numbers.
- **Visualizer modes:** in `box` mode every call is a `Frame` box at its call site. In `substitution` mode (the default; prop `visualizerMode`, embed `?visualizer=`), a call with `shortForm` renders as `Frame` scope `'inline'`: its return expression with parameters substituted, in parentheses in operator context.
- **Visualizer modes:** in `box` mode every call is a `Frame` box at its call site. In `substitution` mode (the default; prop `visualizerMode`, embed `?visualizer=`), a call with `shortForm` renders as `Frame` scope `'inline'`: its return expression with parameters substituted, in parentheses in operator context. The outermost box says why it isn't replaced (the call-enter step's `boxReason`, `visualizerBoxReason`).
- **Tree mode («Baum», capability `tree`, embed `features=tree`):** `src/treeMode.ts`. `treeModeSteps` takes the substitution mode's steps and adds a step of its own (`treePhase` `'open'`/`'close'`, a copy of a nearby step with `step` set to the state it shows) before and after each statement expression's reductions (`TreeEpisode`: open in one frame between `openIndex` and `closeIndex`). It is also a substitution mode (`substitutionMode` is true), so inline calls, pending boxes and hosts work as there. `Frame` splices the open tree into its line like a child (`childRanges` entry with `frame: null`, `ExpressionTreeBox`) and hangs the boxes of the calls in it below. `buildExpressionTree` builds the tree from the AST and `buildSubstitutionsUpTo` (so it can't diverge from the chain; node keys `frameId:nodeId`, an inline call's body with the callee's frame). `episodeTreeLayout` lays a tree out where it opened or last grew, with room for every later text, so reductions move nothing; `TreeView` then takes that `layout`. Check changes with `test:tree` and the «Baum» cases in `test:console`.
- **Steady height (compact hosts):** while visualizing, the canvas keeps the height of the tallest step from the start (`steadyHeightCandidates`: up to 120 likely tallest steps, drawn hidden in `.visualizer-measure` through the same `rootFrame(stepViewAt(…))` as the visible step, and measured), and only grows; the console reserves the last step's output (`.visual-output-steady`). The embed caps the visualizer at 80% of `windowHeight` (the macro's). A step's view is `StepView`; compute it with `stepViewAt`, not inline, so the measurement and the visible canvas stay the same.
- **Layout and scrolling:** nested boxes show their variables below their code (CSS). The scroll effect follows the active box horizontally. It sets `scrollLeft`/`scrollTop` on the visualizer view only; don't use `scrollIntoView`, which also scrolls the LiaScript page around the embed and moves the step buttons.
- **Console and Stop:** output, REPL inputs, Tests reports and notes form one transcript (`ConsoleEntry` list after `output`). While Python is busy for more than `STOP_BUTTON_DELAY`, the run button is **Stop**: `stopPython` terminates the worker and calls `startWorker` again, so the REPL namespace is gone after a stop - except while `input()` waits: then Stop only cancels the wait (`EOFError`). The embed reports this to its parent (protocol version 4, `glpython:busy`/`glpython:stop`).
Expand Down
49 changes: 42 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,47 @@ values, as in the course's paper notation:
`quadrat(a + 2) → quadrat(5 + 2) → quadrat(7) → 7 * 7 → 49`. Every click is one
substitution, and a replacing expression gets parentheses where an operator
works on it (`1 * (3 * 2)`). Any other function still opens a box, and the
outermost box shows `visualizerBoxHint`. In **box** mode every call opens a box.
outermost box says why at its top: `notenschnitt hat lokale Variablen – der
Aufruf wird als Kasten gezeigt.` (`visualizerBoxReason`, one sentence per
reason: local variables or a loop, an `if` choosing what runs, several
statements, no `return <expression>`, an expression that can't be written out;
the runtime's `boxReason` on the call's step). In **box** mode every call opens
a box.

The mode last chosen is remembered per browser. A host can preset it with the
`visualizerMode` prop (`'box'` or `'substitution'`), and the embed with the
`visualizer=box` or `visualizer=substitution` query parameter. The toggle's
texts are `visualizerModeLabel`, `visualizerModeBox` and
`visualizerModeSubstitution`, all optional like `visualizerBoxHint`.
`visualizerMode` prop (`'box'`, `'substitution'` or `'tree'`), and the embed with
the `visualizer=box`, `visualizer=substitution` or `visualizer=tree` query
parameter. The toggle's texts are `visualizerModeLabel`, `visualizerModeBox`,
`visualizerModeSubstitution` and `visualizerModeTree`, all optional like
`visualizerBoxHint` (the box's note without a reason).

### Tree mode (`tree`)

With the capability `tree` (off by default; the embed's `features=tree`), the
switch has a third mode, **Baum**: an expression is shown as its syntax tree
while it is evaluated. Without the capability there is no third button, and a
stored or preset `'tree'` shows the substitution mode. For an expression `e`
(the value of an expression statement, an assignment or a `return`, an `if`'s
or `while`'s condition; not a bare name or literal):
1. **Open:** one step replaces `e` by its tree, in a box at its place in the line.
2. **Reductions:** exactly the substitution mode's steps, each one a subtree
becoming its value; a node without a step of its own (a display, `-x`)
becomes its value with its last child. A call with a single
`return <expression>` is replaced by that expression's tree, any other call
stays a node whose box hangs below the tree (with the note why).
3. **Close:** one step from the trivial tree to the value in the line.

So `x = 2 + 3 * 4` takes 5 steps instead of 3. Labels are Python's (`+`,
`quadrat`, `[ ]`, a name is a leaf); there are no parentheses, so
`(2 + 3) * 4` and `2 + 3 * 4` differ only in shape. A tree keeps its nodes in
place from step to step: it is laid out where it opens (and where a call is
replaced) with room for the widest value each node takes. With the
visualizer's animations, a reduced subtree's nodes move up into it and fade
(not with reduced motion). For screen readers each tree is described by its
text at that step, as the → chain writes it; a tree wider than the visualizer
scrolls as in `listTree` (root in the middle, hints at the edges, arrow keys).
The steps come from `treeModeSteps`, the trees from `buildExpressionTree`
(`src/treeMode.ts`); `npm run test:tree` checks them on traces of the runtime.

### Lists and long values

Expand Down Expand Up @@ -434,8 +468,9 @@ Optional query parameters adapt the embed to a host such as the LiaScript
macro: `features=run,repl,visualize,…` hides the REPL (`repl`), the visualizer
(`visualize`) or its → chain export (`export`) unless listed (capabilities
`repl`, `visualization`, `export`); `coverage` in that list switches on the
coverage after Tests (capability `coverage`), and `listtree` lists drawn as
trees (capability `listTree`), which a missing `features` doesn't; and `size=large` with
coverage after Tests (capability `coverage`), `listtree` lists drawn as
trees (capability `listTree`) and `tree` the visualizer's mode «Baum»
(capability `tree`), which a missing `features` doesn't; and `size=large` with
`visualizerHeight=<px>` shows bigger code and a visualizer at least that tall.
`windowHeight=<px>` is the host window's height, which the iframe can't see:
the visualizer is at most 80% of it (without it: of the screen's height).
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"test:hints": "node scripts/check-error-hints.mjs",
"test:console": "node scripts/check-console-browser.mjs",
"test:layout": "node --experimental-strip-types --no-warnings scripts/check-tree-layout.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 && python3 -B scripts/check-runtime-lists.py && python3 -B scripts/check-runtime-main.py && python3 -B scripts/check-runtime-trees.py",
"test:tree": "node --experimental-strip-types --no-warnings scripts/check-tree-mode.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 && python3 -B scripts/check-runtime-lists.py && python3 -B scripts/check-runtime-main.py && python3 -B scripts/check-runtime-trees.py && python3 -B scripts/check-runtime-box-reasons.py",
"prepare": "npm run build"
},
"peerDependencies": {
Expand Down
104 changes: 104 additions & 0 deletions scripts/check-console-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
// A steady height: while stepping through a visualization the embed keeps
// one height (the visualizer the tallest step's, at most 80% of the window,
// the console the last step's output), and the current line stays in view.
// The mode «Baum» (with "tree"): «Ersetzung»'s steps plus open and close
// for each tree, nodes that stay in place, one height, and the hint at a
// call that stays a box (also in «Ersetzung»); without "tree" no button.
//
// 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 @@ -563,6 +566,107 @@ try {
}
await page.close()
}
// The visualizer's mode «Baum» (P34 A): only with "tree" in features= -
// without it no mode button, and a stored or preset «Baum» shows
// «Ersetzung». With it, each example takes «Ersetzung»'s steps plus one to
// open and one to close each tree; a reduction moves no node of the tree;
// each tree is described by its text, step by step as the chain; the embed
// keeps one height; and a call that stays a box says why, in «Ersetzung»
// and in «Baum».
{
const page = await openEmbed({}, '?visualizer=tree')
const frame = page.frameLocator('#embed')
try {
await page.evaluate((source) => window.send('visualize', source), 'x = 2 + 3 * 4\n')
await measured(frame)
assert.deepEqual(await frame.locator('.visualizer-mode button').allInnerTexts(), ['Kasten', 'Ersetzung'], 'the mode buttons')
assert.equal(await frame.locator('.visualizer-mode button[aria-pressed="true"]').innerText(), 'Ersetzung', 'the mode shown')
console.log('Baum: without "tree" no mode button: ok')
} catch (error) {
failed += 1
console.error(`Baum: without "tree": ${error.message}`)
}
await page.close()
}
{
const notenschnitt = 'def notenschnitt(noten):\n summe = sum(noten)\n anzahl = len(noten)\n return summe / anzahl\n\nnotenschnitt([4, 5, 4.5])\n'
const page = await openEmbed({}, '?features=visualize,tree&windowHeight=900')
const frame = page.frameLocator('#embed')
const stepCount = () => frame.locator('.visualizer-step-count').innerText()
const mode = (name) => frame.locator('.visualizer-mode button', { hasText: name }).click()
// A mode switch stays at the same point of the program, maybe already at
// the last step.
const lastStep = async () => {
const button = frame.locator('.visualizer-controls button').nth(3)
if (await button.isEnabled()) await button.click()
return Number((await stepCount()).match(/von (\d+)/)[1])
}
for (const [label, source, ersetzung, trees, texts] of [
['2 + 3 * 4', 'x = 2 + 3 * 4\n', 3, 1, ['2 + 3 * 4', '2 + 12', '14']],
['(2 + 3) * 4', 'x = (2 + 3) * 4\n', 3, 1, ['(2 + 3) * 4', '5 * 4', '20']],
['quadrat(2 + 1)', 'def quadrat(x):\n return x * x\n\ny = quadrat(2 + 1)\n', 5, 1, ['quadrat(2 + 1)', 'quadrat(3)', '3 * 3', '9']],
['notenschnitt([4, 5, 4.5])', notenschnitt, 13, 4, null],
]) {
try {
await page.evaluate((program) => window.send('visualize', program), source)
await frame.locator('.visualizer-step-count', { hasText: 'Vor der' }).waitFor({ timeout: 60000 })
await measured(frame)
await mode('Ersetzung')
assert.equal(await lastStep(), ersetzung, 'the steps of «Ersetzung»')
await mode('Baum')
await measured(frame)
assert.equal(await lastStep(), ersetzung + 2 * trees, '«Ersetzung» plus open and close')
await frame.locator('.visualizer-controls button').nth(0).click()
const state = () => frame.locator('.embed-app').evaluate((app) => {
const tree = app.querySelector('.visualizer-canvas > .frame-box > .frame-code .value-tree-scroll')
return {
height: Math.round(app.getBoundingClientRect().height),
text: tree?.getAttribute('aria-label') ?? null,
// Each node's centre (a value takes its subtree's place, whatever its width) and row.
nodes: tree ? Object.fromEntries([...tree.querySelectorAll('[data-tree-key]')].map((node) => [node.dataset.treeKey, `${parseFloat(node.style.left) + parseFloat(node.style.width) / 2} ${node.style.top}`])) : null,
}
})
const states = [await state()]
const next = frame.locator('.visualizer-controls button').nth(2)
while (await next.isEnabled()) {
await next.click()
states.push(await state())
}
assert.deepEqual([...new Set(states.map((step) => step.height))], [states[0].height], 'one height for every step')
if (texts) assert.deepEqual(states.map((step) => step.text).filter((text, index, all) => text !== null && text !== all[index - 1]), texts, 'the tree described by its text, as the chain')
// A reduction (no new node) leaves every node where it was.
states.forEach((step, index) => {
const before = states[index - 1]?.nodes
if (!before || !step.nodes || !Object.keys(step.nodes).every((key) => key in before)) return
for (const [key, place] of Object.entries(step.nodes)) assert.equal(place, before[key], `node ${key} moved at step ${index}`)
})
console.log(`Baum: ${label}: ok`)
} catch (error) {
failed += 1
console.error(`Baum: ${label}: ${error.message}`)
}
}
// The hint at a call that stays a box, in both modes.
for (const name of ['Ersetzung', 'Baum']) {
try {
await mode(name)
await measured(frame)
await frame.locator('.visualizer-controls button').nth(0).click()
const next = frame.locator('.visualizer-controls button').nth(2)
for (let index = 0; index < 6; index++) await next.click()
// Not a closing box's fading copy (.visualizer-closing-boxes).
const hint = frame.locator('.visualizer-view > .visualizer-canvas > .frame-box .frame-hint[data-box-reason="locals"]')
await hint.waitFor({ timeout: 5000 })
assert.equal(await hint.innerText(), 'notenschnitt hat lokale Variablen – der Aufruf wird als Kasten gezeigt.')
assert.equal(await frame.locator('.visualizer-mode button', { hasText: name }).getAttribute('aria-pressed'), 'true')
console.log(`Baum: the hint at the box in «${name}»: ok`)
} catch (error) {
failed += 1
console.error(`Baum: the hint in «${name}»: ${error.message}`)
}
}
await page.close()
}
// P37: the run file is __main__, in Pyodide too.
{
const page = await openEmbed({})
Expand Down
Loading
Loading