Console panel: capture console.*, timestamped lines, resizable (#50)

A log layer (src/editor/console/log.ts) keeps a 500-line ring buffer and
wraps console.log/info/debug/warn/error, still calling the original. Notices
and behaviour runtime errors go straight to the layer instead of through
console, so editor tests stay quiet and errors can render red. world.log is
untouched: it is per-run structured state that runtime tests and e2e read
directly, not a text stream.

The panel sits under the visible tab, resizable with Splitter('y'), with a
CONSOLE header and a clear button. Each line carries its wall-clock time in
its own dimmed column, so locate() still finds the message text.

The taller centre area drops the graph fit zoom to 0.60, which broke six e2e
checks that assumed one screen pixel is one graph unit and that a fixed
offset from the canvas centre is empty. They now ask __gm.emptySpot(),
__gm.graph.emptySpot() and __gm.graph.zoom() instead of guessing.

locate/locateAll added the unscaled Pixi text width to the global position,
so click points in the scaled graph canvas landed too far right; they use
getBounds() now.

check.sh: 831 tests. Full e2e: ALL PASSED - 671 checks. Smokes 24/19/20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-12 19:35:40 +02:00
1 parent e7d82bd1a9
commit c47fd5e4dc
25 files changed
+690 -42

No files matched your search

+33 -17
View File
@@ -47,6 +47,8 @@ export async function graph(t) {
// 6b. Drag a node: clicking its title selects it, dragging moves it (undoable).
const axisBefore = await nodeOf('n-axis');
const axisPos = await json(`__gm.graph.nodeAt('n-axis')`);
// The fit zoom follows the canvas size, so 60 screen pixels are 60/zoom graph units, not 60.
const fitZoom = Number(await evaluate('__gm.graph.zoom()'));
await drag(axisPos.x, axisPos.y, axisPos.x + 60, axisPos.y + 40);
let s = await state();
const axisAfter = await nodeOf('n-axis');
@@ -54,8 +56,8 @@ export async function graph(t) {
'dragging a node selects and moves it',
s.selection?.kind === 'node' &&
s.selection.nodeId === 'n-axis' &&
axisAfter.x === axisBefore.x + 60 &&
axisAfter.y === axisBefore.y + 40,
Math.abs(axisAfter.x - (axisBefore.x + 60 / fitZoom)) <= 2 &&
Math.abs(axisAfter.y - (axisBefore.y + 40 / fitZoom)) <= 2,
`${JSON.stringify(s.selection)} ${axisBefore.x},${axisBefore.y} → ${axisAfter.x},${axisAfter.y}`,
);
check(
@@ -73,9 +75,10 @@ export async function graph(t) {
// 6c. Typing a query and then *clicking* a row adds exactly one node: the search field's
// blur-commit must not pick the first match as well.
const gcenter = await json('__gm.graph.center()');
// Bare canvas, searched rather than guessed: the console panel below shortens the canvas.
const bare = await json('__gm.graph.emptySpot()');
const beforeClickPick = await graphCounts();
await click(gcenter.x + 250, gcenter.y + 250, 'right');
await click(bare.x, bare.y, 'right');
await sleep(100);
check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
await typeText('stop');
@@ -95,7 +98,7 @@ export async function graph(t) {
// 6d. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
const before13 = await graphCounts();
await click(gcenter.x + 250, gcenter.y + 250, 'right');
await click(bare.x, bare.y, 'right');
await sleep(100);
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
await typeText('log');
@@ -167,7 +170,7 @@ export async function graph(t) {
// 13d3. Dropped into empty space, the drag opens the palette filtered to what it could reach,
// and the picked node arrives already wired – as one undo step.
const empty = { x: gcenter.x - 250, y: gcenter.y + 250 };
const empty = await json('__gm.graph.emptySpot()');
const beforeDrop = await graphCounts();
await mouse('mouseMoved', axisX.x, axisX.y);
await mouse('mousePressed', axisX.x, axisX.y);
@@ -362,6 +365,9 @@ export async function graph(t) {
await positions(),
);
// Fit again: the pan just now pushed a node out of view, and a band takes only what it sees.
await clickText('Fit');
await sleep(250);
// An empty spot: after a fit every node sits at least a margin away from the corner.
const corner = { x: area.x + 6, y: area.y + 6 };
const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 6 };
@@ -389,18 +395,21 @@ export async function graph(t) {
// 6l. The whole selection moves as one, and one undo takes all of it back.
const beforeMove = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }));
const grabAt = await json(`__gm.graph.nodeAt('n-axis')`);
// Screen pixels become graph units through the zoom, which the fit picks from the canvas size.
const units = async (px) => px / Number(await evaluate('__gm.graph.zoom()'));
const [mx, my] = [await units(40), await units(30)];
await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30);
const afterMove = await asset('mover');
const movedTogether = beforeMove.every((n) => {
const now = afterMove.graph.nodes.find((m) => m.id === n.id);
return now.x === n.x + 40 && now.y === n.y + 30;
return Math.abs(now.x - (n.x + mx)) <= 2 && Math.abs(now.y - (n.y + my)) <= 2;
});
check('dragging one of them moves the whole selection', movedTogether, await positions());
await undo();
check('one undo takes the group move back', (await positions()) === before13i, await positions());
// 13k2. Grid snapping. The graph keeps a zoom of 1 here (13k proved it with an exact +40/+30),
// so a screen delta is a graph delta.
// 13k2. Grid snapping. Distances are stated in graph units, which `units()` reads off the
// current zoom – the canvas is not wide enough here for a zoom of 1.
const GRID = 24;
const onGrid = (n) => n.x % GRID === 0 && n.y % GRID === 0;
check('snapping is off until it is asked for', (await json('__gm.graph.snapping()')) === false);
@@ -419,13 +428,14 @@ export async function graph(t) {
await sleep(150);
const snapPre = await nodeOf('n-axis');
const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`);
const snapBy = await units(50);
await drag(snapFrom.x, snapFrom.y, snapFrom.x + 50, snapFrom.y + 50);
const snapped = await nodeOf('n-axis');
check(
'a dragged node lands on the grid, not where the pointer let go',
onGrid(snapped) &&
Math.abs(snapped.x - (snapPre.x + 50)) <= GRID / 2 &&
Math.abs(snapped.y - (snapPre.y + 50)) <= GRID / 2,
Math.abs(snapped.x - (snapPre.x + snapBy)) <= GRID / 2 &&
Math.abs(snapped.y - (snapPre.y + snapBy)) <= GRID / 2,
`${snapPre.x},${snapPre.y} → ${snapped.x},${snapped.y}`,
);
const others = (await asset('mover')).graph.nodes.filter((n) => n.id !== 'n-axis');
@@ -445,6 +455,7 @@ export async function graph(t) {
JSON.stringify(groupPre.map((n) => `${n.id}:${n.x},${n.y}`)),
);
const grabFrom = await json(`__gm.graph.nodeAt('n-axis')`);
const groupBy = await units(30);
await drag(grabFrom.x, grabFrom.y, grabFrom.x + 30, grabFrom.y + 30);
const groupPost = (await asset('mover')).graph.nodes;
check(
@@ -452,7 +463,7 @@ export async function graph(t) {
groupPost.every(onGrid) &&
groupPre.every((n) => {
const now = groupPost.find((m) => m.id === n.id);
return Math.abs(now.x - (n.x + 30)) <= GRID / 2 && Math.abs(now.y - (n.y + 30)) <= GRID / 2;
return Math.abs(now.x - (n.x + groupBy)) <= GRID / 2 && Math.abs(now.y - (n.y + groupBy)) <= GRID / 2;
}),
await positions(),
);
@@ -467,13 +478,15 @@ export async function graph(t) {
await sleep(150);
const offPre = await nodeOf('n-axis');
const offFrom = await json(`__gm.graph.nodeAt('n-axis')`);
const offBy = await units(50);
await drag(offFrom.x, offFrom.y, offFrom.x + 50, offFrom.y + 50);
const offPost = await nodeOf('n-axis');
check(
'switched off, a node lands exactly where it was dropped again',
'switched off, a node lands where it was dropped again, off the grid',
(await json('__gm.graph.snapping()')) === false &&
offPost.x === offPre.x + 50 &&
offPost.y === offPre.y + 50,
!onGrid(offPost) &&
Math.abs(offPost.x - (offPre.x + offBy)) <= 2 &&
Math.abs(offPost.y - (offPre.y + offBy)) <= 2,
`${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`,
);
await undo();
@@ -522,8 +535,11 @@ export async function graph(t) {
// 6o. Fit really fits: proven against a node parked far outside, and against the fact that the
// graph is then wider than the canvas – otherwise centring alone would pass this check.
// The distance follows the canvas: a fixed 2500 units needs a zoom below the 0.25 floor once
// the canvas is short, and then Fit cannot fit anything however correct it is.
const park = Math.round(area.w);
await evaluate(
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += 2500; __gm.state.preview(); })()`,
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += ${park}; __gm.state.preview(); })()`,
);
await sleep(250);
await clickText('Fit');
@@ -547,7 +563,7 @@ export async function graph(t) {
);
await shot('22-graph-fitted');
await evaluate(
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= 2500; __gm.state.preview(); })()`,
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= ${park}; __gm.state.preview(); })()`,
);
await clickText('Fit');
await sleep(250);