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:
1 parent
e7d82bd1a9
commit
c47fd5e4dc
25 files changed
+690
-42
No files matched your search
@@ -47,6 +47,15 @@ from texture* and *Delete…*. In
|
|||||||
**Scene objects** it offers *Select asset*, *Add collider*, *Create container*, *Unpack
|
**Scene objects** it offers *Select asset*, *Add collider*, *Create container*, *Unpack
|
||||||
container*, *Unparent*, *Expand all* / *Collapse all* and *Remove*.
|
container*, *Unparent*, *Expand all* / *Collapse all* and *Remove*.
|
||||||
|
|
||||||
|
**The console.** Under whichever centre tab is showing sits the **Console** panel, always
|
||||||
|
visible and resized by the divider above it – dragged all the way shut it keeps its own header
|
||||||
|
to be dragged open by. It mirrors everything that reaches the browser console: a `Log` node's
|
||||||
|
message, Pixi's warnings, an uncaught error, and the editor's own notices (a failure in red, a
|
||||||
|
hint in plain text; the *Playing · …* status line is a mode rather than an event and stays out).
|
||||||
|
Each line carries the clock time of its call (`HH:MM:SS`, muted, in its own column, so the
|
||||||
|
message stays exactly what was logged). The newest 500 lines are kept, *Clear* empties them,
|
||||||
|
and new lines follow the tail only while the tail is what you are looking at.
|
||||||
|
|
||||||
**Editing while playing.** Play opens the Game tab but leaves the editor fully usable: the
|
**Editing while playing.** Play opens the Game tab but leaves the editor fully usable: the
|
||||||
Scene tab shows the live positions (symbols, gizmos and dragging included), the hierarchy and
|
Scene tab shows the live positions (symbols, gizmos and dragging included), the hierarchy and
|
||||||
inspector show and edit the running objects, the graph can be changed, undo/redo work on the
|
inspector show and edit the running objects, the graph can be changed, undo/redo work on the
|
||||||
@@ -752,6 +761,7 @@ src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture +
|
|||||||
src/editor/animation/ AnimationEditor (header + picker), AnimationCanvas (preview + frame strip), strip (its geometry)
|
src/editor/animation/ AnimationEditor (header + picker), AnimationCanvas (preview + frame strip), strip (its geometry)
|
||||||
src/editor/statemachine/ StateMachineEditor (header + picker, on the graph canvas)
|
src/editor/statemachine/ StateMachineEditor (header + picker, on the graph canvas)
|
||||||
src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back)
|
src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back)
|
||||||
|
src/editor/console/ log (ring buffer, argument formatting, the console.* capture), ConsolePanel (the tail under the tabs)
|
||||||
api/ PHP backend data/projects/ persisted projects
|
api/ PHP backend data/projects/ persisted projects
|
||||||
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/, assets/ (the walk demo's sheet)
|
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/, assets/ (the walk demo's sheet)
|
||||||
```
|
```
|
||||||
@@ -783,7 +793,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
|
|||||||
# code is lost (tail's is reported), so a red run reads as green
|
# code is lost (tail's is reported), so a red run reads as green
|
||||||
npm run build # writes dist/ and regenerates index.html
|
npm run build # writes dist/ and regenerates index.html
|
||||||
./build.sh # the same under `set -o pipefail`, for callers that pipe the output
|
./build.sh # the same under `set -o pipefail`, for callers that pipe the output
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 657 editor checks, screenshots in /tmp/gm-e2e-*.png
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # 671 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||||
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
|
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
|
||||||
./e2e.sh start [GM_STEPS] # the same run detached, see below
|
./e2e.sh start [GM_STEPS] # the same run detached, see below
|
||||||
./e2e.sh status # live progress, or the last run's result
|
./e2e.sh status # live progress, or the last run's result
|
||||||
@@ -832,7 +842,7 @@ to touch it.
|
|||||||
The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing,
|
The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing,
|
||||||
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
|
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
|
||||||
container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable,
|
container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable,
|
||||||
variable nodes, property nodes, animations, state machines) that build on each other's
|
variable nodes, property nodes, animations, state machines, console) that build on each other's
|
||||||
scene state and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`,
|
scene state and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`,
|
||||||
`object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it
|
`object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it
|
||||||
exercises.
|
exercises.
|
||||||
|
|||||||
+21
-3
@@ -2,7 +2,8 @@ import type { Application } from 'pixi.js';
|
|||||||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||||
import type { TextureStore } from '../core/store/TextureStore';
|
import type { TextureStore } from '../core/store/TextureStore';
|
||||||
import { Button, HStack, Splitter, Theme, ui, VStack, type Widget } from '../ui';
|
import { Button, clamp, HStack, Panel, Splitter, Theme, ui, VStack, type Widget } from '../ui';
|
||||||
|
import { ConsolePanel } from './console/ConsolePanel';
|
||||||
import { GameViewport } from './viewport/GameViewport';
|
import { GameViewport } from './viewport/GameViewport';
|
||||||
import { SceneViewport } from './viewport/SceneViewport';
|
import { SceneViewport } from './viewport/SceneViewport';
|
||||||
import { attachAutosave } from './autosave';
|
import { attachAutosave } from './autosave';
|
||||||
@@ -52,6 +53,9 @@ const MIN_LEFT_WIDTH = 160;
|
|||||||
const MAX_LEFT_WIDTH = 500;
|
const MAX_LEFT_WIDTH = 500;
|
||||||
const MIN_RIGHT_WIDTH = 200;
|
const MIN_RIGHT_WIDTH = 200;
|
||||||
const MAX_RIGHT_WIDTH = 600;
|
const MAX_RIGHT_WIDTH = 600;
|
||||||
|
// The floor is the console's own header: dragged all the way shut it stays visible to drag open.
|
||||||
|
const MIN_CONSOLE_HEIGHT = Panel.HEADER_HEIGHT;
|
||||||
|
const MAX_CONSOLE_HEIGHT = 600;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composes toolbar, panels, viewports and graph editor into the one application that edits and
|
* Composes toolbar, panels, viewports and graph editor into the one application that edits and
|
||||||
@@ -72,6 +76,7 @@ export class Editor implements PlayHost {
|
|||||||
readonly hierarchy: HierarchyPanel;
|
readonly hierarchy: HierarchyPanel;
|
||||||
readonly assets: AssetPanel;
|
readonly assets: AssetPanel;
|
||||||
readonly inspector: Inspector;
|
readonly inspector: Inspector;
|
||||||
|
readonly console = new ConsolePanel();
|
||||||
tab: CentreTab = 'scene';
|
tab: CentreTab = 'scene';
|
||||||
/** Frame rate, refreshed twice a second; null until the first measurement. */
|
/** Frame rate, refreshed twice a second; null until the first measurement. */
|
||||||
fps: number | null = null;
|
fps: number | null = null;
|
||||||
@@ -81,6 +86,7 @@ export class Editor implements PlayHost {
|
|||||||
private readonly leftColumn: VStack;
|
private readonly leftColumn: VStack;
|
||||||
private readonly leftSplitter = new Splitter('x');
|
private readonly leftSplitter = new Splitter('x');
|
||||||
private readonly rightSplitter = new Splitter('x');
|
private readonly rightSplitter = new Splitter('x');
|
||||||
|
readonly consoleSplitter = new Splitter('y');
|
||||||
private readonly workspace: HStack;
|
private readonly workspace: HStack;
|
||||||
private readonly centre: VStack;
|
private readonly centre: VStack;
|
||||||
private readonly tabButtons: Record<CentreTab, Button>;
|
private readonly tabButtons: Record<CentreTab, Button>;
|
||||||
@@ -88,8 +94,9 @@ export class Editor implements PlayHost {
|
|||||||
private readonly toolbar: Toolbar;
|
private readonly toolbar: Toolbar;
|
||||||
private fpsFrames = 0;
|
private fpsFrames = 0;
|
||||||
private fpsElapsed = 0;
|
private fpsElapsed = 0;
|
||||||
private leftWidth = 240;
|
private leftWidth = 250;
|
||||||
private rightWidth = 300;
|
private rightWidth = 350;
|
||||||
|
private consoleHeight = 300;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
app: Application,
|
app: Application,
|
||||||
@@ -169,6 +176,16 @@ export class Editor implements PlayHost {
|
|||||||
this.panes[tab].visible = tab === 'scene';
|
this.panes[tab].visible = tab === 'scene';
|
||||||
}
|
}
|
||||||
this.tabButtons.scene.setToggled(true);
|
this.tabButtons.scene.setToggled(true);
|
||||||
|
// The console sits below whichever tab is showing, so it is a sibling of the panes, not of
|
||||||
|
// the workspace: switching tabs must not move it.
|
||||||
|
this.centre.add(this.consoleSplitter);
|
||||||
|
this.centre.add(this.console);
|
||||||
|
this.console.minHeight = this.consoleHeight;
|
||||||
|
this.consoleSplitter.onDrag = (delta) => {
|
||||||
|
this.consoleHeight = clamp(this.consoleHeight - delta, MIN_CONSOLE_HEIGHT, MAX_CONSOLE_HEIGHT);
|
||||||
|
this.console.minHeight = this.consoleHeight;
|
||||||
|
this.centre.relayout();
|
||||||
|
};
|
||||||
|
|
||||||
this.leftColumn.minWidth = this.leftWidth;
|
this.leftColumn.minWidth = this.leftWidth;
|
||||||
this.inspector.minWidth = this.rightWidth;
|
this.inspector.minWidth = this.rightWidth;
|
||||||
@@ -263,6 +280,7 @@ export class Editor implements PlayHost {
|
|||||||
this.measureFps(dt);
|
this.measureFps(dt);
|
||||||
this.play.tick(dt);
|
this.play.tick(dt);
|
||||||
this.editorRun.tick(dt);
|
this.editorRun.tick(dt);
|
||||||
|
this.console.refresh();
|
||||||
if (this.tab === 'animation') this.animationEditor.tick(dt);
|
if (this.tab === 'animation') this.animationEditor.tick(dt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export class EditorRun {
|
|||||||
if (!world.stopRequested) return false;
|
if (!world.stopRequested) return false;
|
||||||
const error = behaviorError(world);
|
const error = behaviorError(world);
|
||||||
this.stop();
|
this.stop();
|
||||||
this.state.setNotice(error ?? 'A behavior running in the editor stopped it.');
|
this.state.setNotice(error ?? 'A behavior running in the editor stopped it.', 'error');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export class PlaySession {
|
|||||||
if (!this.world) return;
|
if (!this.world) return;
|
||||||
this.state.setPaused(!this.state.paused);
|
this.state.setPaused(!this.state.paused);
|
||||||
ui().focus.blur();
|
ui().focus.blur();
|
||||||
this.state.setNotice(this.playNotice());
|
this.state.setNotice(this.playNotice(), 'quiet');
|
||||||
}
|
}
|
||||||
|
|
||||||
traceFor(assetId: string): Trace | null {
|
traceFor(assetId: string): Trace | null {
|
||||||
@@ -92,7 +92,7 @@ export class PlaySession {
|
|||||||
this.behaviors = addGameSystems(world, runtime, this.input);
|
this.behaviors = addGameSystems(world, runtime, this.input);
|
||||||
this.host.gameView.setWorld(world);
|
this.host.gameView.setWorld(world);
|
||||||
this.host.showGame();
|
this.host.showGame();
|
||||||
this.state.setNotice(this.playNotice());
|
this.state.setNotice(this.playNotice(), 'quiet');
|
||||||
if (world.stopRequested) this.stop();
|
if (world.stopRequested) this.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ export class PlaySession {
|
|||||||
this.state.exitPlay();
|
this.state.exitPlay();
|
||||||
this.host.leaveGame();
|
this.host.leaveGame();
|
||||||
const lastError = world && behaviorError(world);
|
const lastError = world && behaviorError(world);
|
||||||
this.state.setNotice(lastError ?? '');
|
this.state.setNotice(lastError ?? '', 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(dt: number): void {
|
tick(dt: number): void {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export class ProjectSession {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.state.setNotice(
|
this.state.setNotice(
|
||||||
`Could not load project: ${error instanceof Error ? error.message : String(error)}`,
|
`Could not load project: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
'error',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,7 @@ export class ProjectSession {
|
|||||||
];
|
];
|
||||||
new Menu(items).openBelow(anchor);
|
new Menu(items).openBelow(anchor);
|
||||||
};
|
};
|
||||||
void open().catch((error) => this.state.setNotice(String(error)));
|
void open().catch((error) => this.state.setNotice(String(error), 'error'));
|
||||||
}
|
}
|
||||||
|
|
||||||
openSceneMenu(anchor: Widget): void {
|
openSceneMenu(anchor: Widget): void {
|
||||||
|
|||||||
@@ -30,10 +30,13 @@ export function attachAutosave(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof StaleRevisionError) {
|
if (error instanceof StaleRevisionError) {
|
||||||
state.setSaveStatus('conflict');
|
state.setSaveStatus('conflict');
|
||||||
state.setNotice('This project was changed elsewhere. Reload to continue.');
|
state.setNotice('This project was changed elsewhere. Reload to continue.', 'error');
|
||||||
} else {
|
} else {
|
||||||
state.setSaveStatus('error');
|
state.setSaveStatus('error');
|
||||||
state.setNotice(`Saving failed: ${error instanceof Error ? error.message : String(error)}`);
|
state.setNotice(
|
||||||
|
`Saving failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
saving = null;
|
saving = null;
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Button, HStack, Label, Panel, ScrollContainer, type TextStyleToken, VStack } from '../../ui';
|
||||||
|
import { consoleLog, type ConsoleLog, formatTime, type LogLevel } from './log';
|
||||||
|
|
||||||
|
const ROW_HEIGHT = 18;
|
||||||
|
|
||||||
|
/** A row is time plus message, not one string: a check that locates a line goes by its text alone. */
|
||||||
|
interface Row {
|
||||||
|
readonly box: HStack;
|
||||||
|
readonly time: Label;
|
||||||
|
readonly text: Label;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLE_OF: Record<LogLevel, TextStyleToken> = {
|
||||||
|
info: 'mono',
|
||||||
|
warn: 'monoWarn',
|
||||||
|
error: 'monoError',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tail of `ConsoleLog` under the centre tabs. Rebuilding runs from `refresh()` rather than
|
||||||
|
* from the log event, so a burst of lines costs one rebuild per frame instead of one per line.
|
||||||
|
*/
|
||||||
|
export class ConsolePanel extends Panel {
|
||||||
|
private readonly scroll = new ScrollContainer();
|
||||||
|
private readonly rows = new VStack();
|
||||||
|
private readonly views: Row[] = [];
|
||||||
|
private stale = true;
|
||||||
|
|
||||||
|
constructor(private readonly log: ConsoleLog = consoleLog) {
|
||||||
|
super({ title: 'Console', padding: 0, gap: 0 });
|
||||||
|
this.header.add(new Button('Clear', { variant: 'ghost', onClick: () => this.log.clear() }));
|
||||||
|
this.scroll.setContent(this.rows);
|
||||||
|
this.content.add(this.scroll, 1);
|
||||||
|
this.log.onChange(() => (this.stale = true));
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(): void {
|
||||||
|
if (!this.stale) return;
|
||||||
|
this.stale = false;
|
||||||
|
const lines = this.log.lines;
|
||||||
|
// Following the tail is only wanted while the tail is what you are looking at.
|
||||||
|
const atBottom = this.scroll.offset >= this.scroll.maxScroll - 1;
|
||||||
|
while (this.views.length > lines.length) {
|
||||||
|
const row = this.views.pop();
|
||||||
|
if (row) this.rows.removeChild(row.box);
|
||||||
|
}
|
||||||
|
while (this.views.length < lines.length) {
|
||||||
|
const time = new Label('', { style: 'monoMuted', padding: 6 });
|
||||||
|
const text = new Label('', { style: 'mono' });
|
||||||
|
const box = new HStack({ gap: 6 });
|
||||||
|
box.minHeight = ROW_HEIGHT;
|
||||||
|
time.minHeight = ROW_HEIGHT;
|
||||||
|
text.minHeight = ROW_HEIGHT;
|
||||||
|
box.add(time);
|
||||||
|
box.add(text, 1);
|
||||||
|
this.views.push({ box, time, text });
|
||||||
|
this.rows.add(box);
|
||||||
|
}
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
const row = this.views[i]!;
|
||||||
|
row.time.setText(formatTime(line.time));
|
||||||
|
row.text.setText(line.text);
|
||||||
|
row.text.setStyle(STYLE_OF[line.level]);
|
||||||
|
});
|
||||||
|
this.scroll.relayoutContent();
|
||||||
|
if (atBottom) this.scroll.scrollTo(this.scroll.maxScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the check scripts read: the lines as drawn, newest last. */
|
||||||
|
debugInfo(): { lines: { level: LogLevel; text: string; at: string }[]; height: number } {
|
||||||
|
return {
|
||||||
|
lines: this.log.lines.map((line) => ({
|
||||||
|
level: line.level,
|
||||||
|
text: line.text,
|
||||||
|
at: formatTime(line.time),
|
||||||
|
})),
|
||||||
|
height: this.h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `offset === max` is the state in which new lines follow the tail. */
|
||||||
|
scrollInfo(): { offset: number; max: number } {
|
||||||
|
return { offset: Math.round(this.scroll.offset), max: Math.round(this.scroll.maxScroll) };
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.log.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
export type LogLevel = 'info' | 'warn' | 'error';
|
||||||
|
|
||||||
|
export interface LogLine {
|
||||||
|
readonly level: LogLevel;
|
||||||
|
readonly text: string;
|
||||||
|
/** Wall clock of the call, in ms; the panel draws it as `HH:MM:SS`. */
|
||||||
|
readonly time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local time of `ms` as `HH:MM:SS` – a console tail is read within the session, not across days. */
|
||||||
|
export function formatTime(ms: number): string {
|
||||||
|
const at = new Date(ms);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oldest lines are dropped past this; the panel is a tail, not an archive. */
|
||||||
|
export const LOG_CAPACITY = 500;
|
||||||
|
|
||||||
|
const MAX_VALUE = 200;
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
// An Error prints its whole stack, which would bury the message under frames nobody reads here.
|
||||||
|
if (value instanceof Error) return value.stack?.split('\n')[0] ?? `${value.name}: ${value.message}`;
|
||||||
|
if (typeof value === 'function') return `[function ${value.name || 'anonymous'}]`;
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
let json: string;
|
||||||
|
try {
|
||||||
|
json = JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return '[unserialisable]';
|
||||||
|
}
|
||||||
|
return json.length > MAX_VALUE ? `${json.slice(0, MAX_VALUE)}…` : json;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line out of the arguments of a `console.*` call, the way the browser joins them. */
|
||||||
|
export function formatArgs(args: readonly unknown[]): string {
|
||||||
|
return args.map(formatValue).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConsoleLog {
|
||||||
|
private buffer: LogLine[] = [];
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(private readonly now: () => number = Date.now) {}
|
||||||
|
|
||||||
|
/** Appends `text`; an embedded newline starts a new line, so a stack stays readable. */
|
||||||
|
push(level: LogLevel, text: string): void {
|
||||||
|
const time = this.now();
|
||||||
|
for (const part of text.split('\n')) this.buffer.push({ level, text: part, time });
|
||||||
|
if (this.buffer.length > LOG_CAPACITY) this.buffer = this.buffer.slice(-LOG_CAPACITY);
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
get lines(): readonly LogLine[] {
|
||||||
|
return this.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
if (this.buffer.length === 0) return;
|
||||||
|
this.buffer = [];
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(listener: () => void): void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const consoleLog = new ConsoleLog();
|
||||||
|
|
||||||
|
const METHODS = ['log', 'info', 'debug', 'warn', 'error'] as const;
|
||||||
|
type ConsoleMethod = (typeof METHODS)[number];
|
||||||
|
|
||||||
|
export type ConsoleLike = Record<ConsoleMethod, (...args: unknown[]) => void>;
|
||||||
|
|
||||||
|
const LEVEL_OF: Record<ConsoleMethod, LogLevel> = {
|
||||||
|
log: 'info',
|
||||||
|
info: 'info',
|
||||||
|
debug: 'info',
|
||||||
|
warn: 'warn',
|
||||||
|
error: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors every `console.*` call into `log` and keeps calling the original, so devtools and the
|
||||||
|
* check scripts still see it. Returns the undo, which only the tests use.
|
||||||
|
*/
|
||||||
|
export function captureConsole(target: ConsoleLike = console, log: ConsoleLog = consoleLog): () => void {
|
||||||
|
const originals = METHODS.map((method) => [method, target[method]] as const);
|
||||||
|
for (const [method, original] of originals) {
|
||||||
|
target[method] = (...args: unknown[]) => {
|
||||||
|
original.apply(target, args);
|
||||||
|
log.push(LEVEL_OF[method], formatArgs(args));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
for (const [method, original] of originals) target[method] = original;
|
||||||
|
};
|
||||||
|
}
|
||||||
+40
-2
@@ -24,8 +24,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
const walk = (node: Container): void => {
|
const walk = (node: Container): void => {
|
||||||
if (!node.visible) return;
|
if (!node.visible) return;
|
||||||
if (node instanceof Text && node.text === text) {
|
if (node instanceof Text && node.text === text) {
|
||||||
const p = node.getGlobalPosition();
|
// Global bounds, not `width`/`height`: those are unscaled, and inside the graph canvas
|
||||||
hits.push({ x: p.x + node.width / 2, y: p.y + node.height / 2 });
|
// (which is scaled by the zoom) that put the point right of the text it was meant to hit.
|
||||||
|
const box = node.getBounds();
|
||||||
|
hits.push({ x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const child of node.children) walk(child);
|
for (const child of node.children) walk(child);
|
||||||
@@ -180,6 +182,30 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
labelsDropped,
|
labelsDropped,
|
||||||
shownLabel,
|
shownLabel,
|
||||||
viewportCenter: () => centreOf(viewport),
|
viewportCenter: () => centreOf(viewport),
|
||||||
|
/** Its drawn box – a step that wants an empty spot must stay inside what the console left over. */
|
||||||
|
viewportRect: () => {
|
||||||
|
const p = viewport.getGlobalPosition();
|
||||||
|
return { x: p.x, y: p.y, w: viewport.w, h: viewport.h };
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* A point in the viewport that no object covers – steps click there to deselect. A fixed
|
||||||
|
* offset from the centre lands on an object as soon as the viewport changes size.
|
||||||
|
*/
|
||||||
|
emptySpot: () => {
|
||||||
|
const p = viewport.getGlobalPosition();
|
||||||
|
const scene = state.activeScene;
|
||||||
|
const taken = scene
|
||||||
|
? objectsOf(scene.id)
|
||||||
|
.map((object) => viewport.objectRect(object.id))
|
||||||
|
.filter((rect): rect is NonNullable<typeof rect> => rect !== null)
|
||||||
|
: [];
|
||||||
|
const clear = (x: number, y: number) =>
|
||||||
|
!taken.some((r) => x >= r.x - 12 && x <= r.x + r.w + 12 && y >= r.y - 12 && y <= r.y + r.h + 12);
|
||||||
|
for (let y = p.y + viewport.h - 24; y > p.y + 24; y -= 24) {
|
||||||
|
for (let x = p.x + 24; x < p.x + viewport.w - 24; x += 24) if (clear(x, y)) return { x, y };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
viewportInfo: () => viewport.debugInfo(),
|
viewportInfo: () => viewport.debugInfo(),
|
||||||
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
|
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
|
||||||
dropPreview: () => viewport.dropInfo(),
|
dropPreview: () => viewport.dropInfo(),
|
||||||
@@ -216,6 +242,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
|
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
|
||||||
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
|
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
|
||||||
canvasRect: () => graphEditor.canvas.canvasRect(),
|
canvasRect: () => graphEditor.canvas.canvasRect(),
|
||||||
|
/** Bare canvas below every node – where a step right-clicks to open the palette. */
|
||||||
|
emptySpot: () => graphEditor.canvas.emptySpotGlobal(),
|
||||||
selected: () => state.selectedNodeIds,
|
selected: () => state.selectedNodeIds,
|
||||||
dropTarget: () => graphEditor.canvas.pendingDropTarget(),
|
dropTarget: () => graphEditor.canvas.pendingDropTarget(),
|
||||||
danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(),
|
danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(),
|
||||||
@@ -228,6 +256,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
pickerWidth: () => graphEditor.picker.w,
|
pickerWidth: () => graphEditor.picker.w,
|
||||||
pickerAt: () => centreOf(graphEditor.picker),
|
pickerAt: () => centreOf(graphEditor.picker),
|
||||||
snapping: () => graphEditor.canvas.snapEnabled,
|
snapping: () => graphEditor.canvas.snapEnabled,
|
||||||
|
zoom: () => graphEditor.canvas.zoomLevel,
|
||||||
crumbs: () => graphEditor.breadcrumb.crumbsDrawn(),
|
crumbs: () => graphEditor.breadcrumb.crumbsDrawn(),
|
||||||
},
|
},
|
||||||
stateMachine: {
|
stateMachine: {
|
||||||
@@ -240,6 +269,15 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
center: () => centreOf(editor.stateMachineEditor.canvas),
|
center: () => centreOf(editor.stateMachineEditor.canvas),
|
||||||
snapping: () => editor.stateMachineEditor.canvas.snapEnabled,
|
snapping: () => editor.stateMachineEditor.canvas.snapEnabled,
|
||||||
},
|
},
|
||||||
|
/** The console panel: its lines as kept, its drawn height, and the divider above it. */
|
||||||
|
console: {
|
||||||
|
lines: () => editor.console.debugInfo().lines,
|
||||||
|
height: () => editor.console.h,
|
||||||
|
clear: () => editor.console.clear(),
|
||||||
|
scroll: () => editor.console.scrollInfo(),
|
||||||
|
centre: () => centreOf(editor.console),
|
||||||
|
splitterAt: () => centreOf(editor.consoleSplitter),
|
||||||
|
},
|
||||||
/** The open image picker: what it offers, what is picked, and where a tile sits. */
|
/** The open image picker: what it offers, what is picked, and where a tile sits. */
|
||||||
imagePicker: () => currentImagePicker()?.debugInfo() ?? null,
|
imagePicker: () => currentImagePicker()?.debugInfo() ?? null,
|
||||||
imagePickerTile: (file: string) => currentImagePicker()?.tileGlobal(file) ?? null,
|
imagePickerTile: (file: string) => currentImagePicker()?.tileGlobal(file) ?? null,
|
||||||
|
|||||||
@@ -188,6 +188,11 @@ export class GraphCanvas extends Widget {
|
|||||||
return this.snapping;
|
return this.snapping;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Screen pixels per graph unit; the fit picks it from the canvas size, so a check cannot assume 1. */
|
||||||
|
get zoomLevel(): number {
|
||||||
|
return this.zoom;
|
||||||
|
}
|
||||||
|
|
||||||
override measure(): Size {
|
override measure(): Size {
|
||||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||||
}
|
}
|
||||||
@@ -797,6 +802,25 @@ export class GraphCanvas extends Widget {
|
|||||||
return globalRect(this, this.w, this.h);
|
return globalRect(this, this.w, this.h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A drawn point no node covers, with room below and right for a node created there and for the
|
||||||
|
* palette that creates it. Searched from the bottom up, so no edge between nodes crosses it.
|
||||||
|
*/
|
||||||
|
emptySpotGlobal(): { x: number; y: number } | null {
|
||||||
|
const rect = this.canvasRect();
|
||||||
|
const room = { below: NODE_HEIGHT_ESTIMATE * this.zoom + 40, right: NODE_WIDTH * this.zoom + 40 };
|
||||||
|
const taken = [...this.views.keys()].map((id) => this.nodeRect(id)).filter((r): r is Rect => r !== null);
|
||||||
|
const clear = (x: number, y: number) =>
|
||||||
|
!taken.some(
|
||||||
|
(r) => x >= r.x - 16 && x <= r.x + r.w + 16 && y >= r.y - 16 && y <= r.y + r.h + room.below + 16,
|
||||||
|
);
|
||||||
|
for (let y = rect.y + rect.h - room.below; y > rect.y + 40; y -= 20) {
|
||||||
|
// Right to left: a node created here must stay inside the view a later marquee spans.
|
||||||
|
for (let x = rect.x + rect.w - room.right; x > rect.x + 40; x -= 20) if (clear(x, y)) return { x, y };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** A nested-behavior node takes its title from the behavior it runs. */
|
/** A nested-behavior node takes its title from the behavior it runs. */
|
||||||
nodeLabel(nodeId: string): string | null {
|
nodeLabel(nodeId: string): string | null {
|
||||||
return this.views.get(nodeId)?.def.label ?? null;
|
return this.views.get(nodeId)?.def.label ?? null;
|
||||||
|
|||||||
+10
-1
@@ -13,6 +13,7 @@ import {
|
|||||||
type Project,
|
type Project,
|
||||||
} from '../core/model/Project';
|
} from '../core/model/Project';
|
||||||
import { deserializeProject, serializeProject } from '../core/store/serialize';
|
import { deserializeProject, serializeProject } from '../core/store/serialize';
|
||||||
|
import { consoleLog } from './console/log';
|
||||||
|
|
||||||
export type Selection =
|
export type Selection =
|
||||||
| { kind: 'asset'; assetId: string }
|
| { kind: 'asset'; assetId: string }
|
||||||
@@ -90,6 +91,9 @@ export type EditorEvent =
|
|||||||
|
|
||||||
type Listener = () => void;
|
type Listener = () => void;
|
||||||
|
|
||||||
|
/** How a notice reaches the console panel: as a line, as a red line, or not at all. */
|
||||||
|
export type NoticeLevel = 'info' | 'error' | 'quiet';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single source of truth for the editor. Play mode swaps `project` for a deep clone (same ids)
|
* Single source of truth for the editor. Play mode swaps `project` for a deep clone (same ids)
|
||||||
* with its own undo stack; panels and commands keep working on it, and `exitPlay()` discards it.
|
* with its own undo stack; panels and commands keep working on it, and `exitPlay()` discards it.
|
||||||
@@ -392,8 +396,13 @@ export class EditorState {
|
|||||||
this.emit('save');
|
this.emit('save');
|
||||||
}
|
}
|
||||||
|
|
||||||
setNotice(message: string): void {
|
/**
|
||||||
|
* Shows `message` in the toolbar and, unless `quiet`, keeps it in the console panel. Quiet is
|
||||||
|
* for the play status lines, which state a mode rather than report an event.
|
||||||
|
*/
|
||||||
|
setNotice(message: string, level: NoticeLevel = 'info'): void {
|
||||||
this.notice = message;
|
this.notice = message;
|
||||||
|
if (message !== '' && level !== 'quiet') consoleLog.push(level, message);
|
||||||
this.emit('notice');
|
this.emit('notice');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export function pickImageFiles(): Promise<File[]> {
|
|||||||
export function pickAndImport(deps: TextureImportDeps): void {
|
export function pickAndImport(deps: TextureImportDeps): void {
|
||||||
void pickImageFiles()
|
void pickImageFiles()
|
||||||
.then((files) => importImages(files, deps))
|
.then((files) => importImages(files, deps))
|
||||||
.catch((error: unknown) => deps.state.setNotice(String(error)));
|
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -264,7 +264,7 @@ export function installTextureImport(target: HTMLElement, deps: TextureImportDep
|
|||||||
}
|
}
|
||||||
|
|
||||||
function run(files: readonly File[], deps: TextureImportDeps): void {
|
function run(files: readonly File[], deps: TextureImportDeps): void {
|
||||||
void importImages(files, deps).catch((error: unknown) => deps.state.setNotice(String(error)));
|
void importImages(files, deps).catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageFilesOf(data: DataTransfer | null): File[] {
|
function imageFilesOf(data: DataTransfer | null): File[] {
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import { Application, Container } from 'pixi.js';
|
|||||||
// Static imports on purpose: Pixi registers render pipes (graphics, text, sprite) as a
|
// Static imports on purpose: Pixi registers render pipes (graphics, text, sprite) as a
|
||||||
// module side effect, and a renderer only knows the pipes that exist when it is created.
|
// module side effect, and a renderer only knows the pipes that exist when it is created.
|
||||||
// Lazy chunks loaded after `app.init()` would therefore fail with "unknown render pipe".
|
// Lazy chunks loaded after `app.init()` would therefore fail with "unknown render pipe".
|
||||||
|
import { captureConsole } from './editor/console/log';
|
||||||
import { startEditor } from './editor/App';
|
import { startEditor } from './editor/App';
|
||||||
import { mountPlayground } from './playground';
|
import { mountPlayground } from './playground';
|
||||||
import { initUI, Theme, VStack } from './ui';
|
import { initUI, Theme, VStack } from './ui';
|
||||||
|
|
||||||
|
// Before anything else: a renderer warns while it is being created, and those warnings are
|
||||||
|
// exactly the ones worth having in the panel.
|
||||||
|
captureConsole();
|
||||||
|
|
||||||
// `desynchronized` bypasses the compositor queue (headless click-to-paint 72 → 16 ms) but may
|
// `desynchronized` bypasses the compositor queue (headless click-to-paint 72 → 16 ms) but may
|
||||||
// tear; `?vsync` opts out. Pixi has no option for it, hence our own context.
|
// tear; `?vsync` opts out. Pixi has no option for it, hence our own context.
|
||||||
function lowLatencyContext(): { canvas?: HTMLCanvasElement; context?: WebGL2RenderingContext } {
|
function lowLatencyContext(): { canvas?: HTMLCanvasElement; context?: WebGL2RenderingContext } {
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ export type TextStyleToken =
|
|||||||
| 'live'
|
| 'live'
|
||||||
| 'title'
|
| 'title'
|
||||||
| 'mono'
|
| 'mono'
|
||||||
|
| 'monoMuted'
|
||||||
|
| 'monoWarn'
|
||||||
|
| 'monoError'
|
||||||
| 'accent'
|
| 'accent'
|
||||||
| 'viewportLabel'
|
| 'viewportLabel'
|
||||||
| 'viewportLabelMuted';
|
| 'viewportLabelMuted';
|
||||||
@@ -124,6 +127,15 @@ export function textStyle(token: TextStyleToken = 'body'): TextStyle {
|
|||||||
case 'mono':
|
case 'mono':
|
||||||
style = new TextStyle({ ...base, fontFamily: Theme.monoFont });
|
style = new TextStyle({ ...base, fontFamily: Theme.monoFont });
|
||||||
break;
|
break;
|
||||||
|
case 'monoMuted':
|
||||||
|
style = new TextStyle({ ...base, fontFamily: Theme.monoFont, fill: Theme.colors.textMuted });
|
||||||
|
break;
|
||||||
|
case 'monoWarn':
|
||||||
|
style = new TextStyle({ ...base, fontFamily: Theme.monoFont, fill: Theme.colors.override });
|
||||||
|
break;
|
||||||
|
case 'monoError':
|
||||||
|
style = new TextStyle({ ...base, fontFamily: Theme.monoFont, fill: Theme.colors.danger });
|
||||||
|
break;
|
||||||
case 'accent':
|
case 'accent':
|
||||||
style = new TextStyle({ ...base, fill: Theme.colors.accentText, fontWeight: '600' });
|
style = new TextStyle({ ...base, fill: Theme.colors.accentText, fontWeight: '600' });
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface PanelOptions {
|
|||||||
|
|
||||||
/** A titled box; the header row can hold extra widgets through `header.add()`. */
|
/** A titled box; the header row can hold extra widgets through `header.add()`. */
|
||||||
export class Panel extends Widget {
|
export class Panel extends Widget {
|
||||||
|
/** Height of the title row – the floor for a panel that must stay grabbable when dragged shut. */
|
||||||
|
static readonly HEADER_HEIGHT = Theme.rowHeight + 4;
|
||||||
private readonly bg = new Graphics();
|
private readonly bg = new Graphics();
|
||||||
readonly header: HStack;
|
readonly header: HStack;
|
||||||
readonly content: VStack;
|
readonly content: VStack;
|
||||||
@@ -25,7 +27,7 @@ export class Panel extends Widget {
|
|||||||
this.chrome = options.chrome ?? true;
|
this.chrome = options.chrome ?? true;
|
||||||
this.addChild(this.bg);
|
this.addChild(this.bg);
|
||||||
this.header = new HStack({ padding: 0, gap: Theme.gap, align: 'center' });
|
this.header = new HStack({ padding: 0, gap: Theme.gap, align: 'center' });
|
||||||
this.header.minHeight = Theme.rowHeight + 4;
|
this.header.minHeight = Panel.HEADER_HEIGHT;
|
||||||
if (options.title !== undefined) {
|
if (options.title !== undefined) {
|
||||||
this.titleLabel = new Label(options.title.toUpperCase(), { style: 'title', padding: Theme.padding });
|
this.titleLabel = new Label(options.title.toUpperCase(), { style: 'title', padding: Theme.padding });
|
||||||
this.header.add(this.titleLabel, 1);
|
this.header.add(this.titleLabel, 1);
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ export class ScrollContainer extends Widget {
|
|||||||
return this.content ? this.content.h : 0;
|
return this.content ? this.content.h : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Current scroll offset; equal to `maxScroll` means the end of the content is showing. */
|
||||||
|
get offset(): number {
|
||||||
|
return this.scrollY;
|
||||||
|
}
|
||||||
|
|
||||||
/** Largest valid scroll offset; 0 while the content fits. */
|
/** Largest valid scroll offset; 0 while the content fits. */
|
||||||
get maxScroll(): number {
|
get maxScroll(): number {
|
||||||
return Math.max(0, this.contentHeight - this.h);
|
return Math.max(0, this.contentHeight - this.h);
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* The console panel's source: the ring buffer, the way `console.*` arguments become one line,
|
||||||
|
* and which notices are kept. The panel itself needs a browser and is covered by e2e step 27.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
captureConsole,
|
||||||
|
ConsoleLog,
|
||||||
|
type ConsoleLike,
|
||||||
|
consoleLog,
|
||||||
|
formatArgs,
|
||||||
|
formatTime,
|
||||||
|
LOG_CAPACITY,
|
||||||
|
} from '../../src/editor/console/log';
|
||||||
|
import { EditorState } from '../../src/editor/state';
|
||||||
|
|
||||||
|
function fakeConsole(): ConsoleLike {
|
||||||
|
return { log: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the log buffer', () => {
|
||||||
|
it('keeps lines newest last, each stamped with the time of its call', () => {
|
||||||
|
let clock = new Date(2026, 8, 12, 9, 5, 3).getTime();
|
||||||
|
const log = new ConsoleLog(() => clock);
|
||||||
|
log.push('info', 'first');
|
||||||
|
clock += 61_000;
|
||||||
|
log.push('error', 'second');
|
||||||
|
expect(log.lines).toEqual([
|
||||||
|
{ level: 'info', text: 'first', time: new Date(2026, 8, 12, 9, 5, 3).getTime() },
|
||||||
|
{ level: 'error', text: 'second', time: new Date(2026, 8, 12, 9, 6, 4).getTime() },
|
||||||
|
]);
|
||||||
|
expect(log.lines.map((l) => formatTime(l.time))).toEqual(['09:05:03', '09:06:04']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every line of a split message the one time it arrived', () => {
|
||||||
|
// A clock that moves on every read: asked once per line, the second would fall into the next day.
|
||||||
|
let reads = 0;
|
||||||
|
const log = new ConsoleLog(() => new Date(2026, 8, 12, 23, 59, 59).getTime() + reads++ * 1000);
|
||||||
|
log.push('error', 'boom\n at frame');
|
||||||
|
expect(log.lines.map((l) => formatTime(l.time))).toEqual(['23:59:59', '23:59:59']);
|
||||||
|
expect(reads).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the oldest lines past the capacity', () => {
|
||||||
|
const log = new ConsoleLog();
|
||||||
|
for (let i = 0; i < LOG_CAPACITY + 10; i += 1) log.push('info', `line ${i}`);
|
||||||
|
expect(log.lines).toHaveLength(LOG_CAPACITY);
|
||||||
|
expect(log.lines[0]?.text).toBe('line 10');
|
||||||
|
expect(log.lines.at(-1)?.text).toBe(`line ${LOG_CAPACITY + 9}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits an embedded newline into its own line', () => {
|
||||||
|
const log = new ConsoleLog();
|
||||||
|
log.push('error', 'boom\n at frame');
|
||||||
|
expect(log.lines.map((l) => l.text)).toEqual(['boom', ' at frame']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies on a push and on a clear, but not on an empty clear', () => {
|
||||||
|
const log = new ConsoleLog();
|
||||||
|
const seen = vi.fn();
|
||||||
|
log.onChange(seen);
|
||||||
|
log.push('info', 'one');
|
||||||
|
log.clear();
|
||||||
|
log.clear();
|
||||||
|
expect(seen).toHaveBeenCalledTimes(2);
|
||||||
|
expect(log.lines).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatting the arguments of a console call', () => {
|
||||||
|
it('joins them with a space and leaves strings alone', () => {
|
||||||
|
expect(formatArgs(['[behavior Crate]', 'hit', 3])).toBe('[behavior Crate] hit 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an Error as its first stack line, not the whole stack', () => {
|
||||||
|
const error = new Error('gone wrong');
|
||||||
|
error.stack = 'Error: gone wrong\n at one\n at two';
|
||||||
|
expect(formatArgs([error])).toBe('Error: gone wrong');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes an object as JSON and a circular one as a placeholder', () => {
|
||||||
|
expect(formatArgs([{ x: 1, y: [2, 3] }])).toBe('{"x":1,"y":[2,3]}');
|
||||||
|
const circular: Record<string, unknown> = {};
|
||||||
|
circular.self = circular;
|
||||||
|
expect(formatArgs([circular])).toBe('[unserialisable]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps null and undefined visible', () => {
|
||||||
|
expect(formatArgs([null, undefined, false])).toBe('null undefined false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('capturing console', () => {
|
||||||
|
it('mirrors every level into the log and still calls the original', () => {
|
||||||
|
const target = fakeConsole();
|
||||||
|
const log = new ConsoleLog();
|
||||||
|
const undo = captureConsole(target, log);
|
||||||
|
target.log('a');
|
||||||
|
target.info('b');
|
||||||
|
target.debug('c');
|
||||||
|
target.warn('d');
|
||||||
|
target.error('e');
|
||||||
|
expect(log.lines.map((l) => ({ level: l.level, text: l.text }))).toEqual([
|
||||||
|
{ level: 'info', text: 'a' },
|
||||||
|
{ level: 'info', text: 'b' },
|
||||||
|
{ level: 'info', text: 'c' },
|
||||||
|
{ level: 'warn', text: 'd' },
|
||||||
|
{ level: 'error', text: 'e' },
|
||||||
|
]);
|
||||||
|
undo();
|
||||||
|
target.log('after');
|
||||||
|
expect(log.lines).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the original reachable, so devtools and the check scripts still see the call', () => {
|
||||||
|
const target = fakeConsole();
|
||||||
|
const original = target.warn;
|
||||||
|
captureConsole(target, new ConsoleLog())();
|
||||||
|
expect(target.warn).toBe(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('notices in the console', () => {
|
||||||
|
it('keeps a plain notice, marks a failure red and stays quiet for a status line', () => {
|
||||||
|
const state = new EditorState();
|
||||||
|
consoleLog.clear();
|
||||||
|
state.setNotice('Only image files can be dropped into the editor.');
|
||||||
|
state.setNotice('Saving failed: 500', 'error');
|
||||||
|
state.setNotice('Playing · edits are not saved', 'quiet');
|
||||||
|
state.setNotice('');
|
||||||
|
expect(consoleLog.lines.map((l) => ({ level: l.level, text: l.text }))).toEqual([
|
||||||
|
{ level: 'info', text: 'Only image files can be dropped into the editor.' },
|
||||||
|
{ level: 'error', text: 'Saving failed: 500' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -103,9 +103,12 @@ export async function inspector(t) {
|
|||||||
// 2f. Type into X, then click straight into the empty viewport: the typed value must be
|
// 2f. Type into X, then click straight into the empty viewport: the typed value must be
|
||||||
// committed (not reverted) even though the click deselects and rebuilds the inspector.
|
// committed (not reverted) even though the click deselects and rebuilds the inspector.
|
||||||
const center = await json('__gm.viewportCenter()');
|
const center = await json('__gm.viewportCenter()');
|
||||||
|
// Searched, not guessed: with the console panel below, a fixed offset from the centre can
|
||||||
|
// land on an object instead of empty space.
|
||||||
|
const emptySpot = await json('__gm.emptySpot()');
|
||||||
await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected
|
await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected
|
||||||
await typeText('300');
|
await typeText('300');
|
||||||
await click(center.x, center.y + 250);
|
await click(emptySpot.x, emptySpot.y);
|
||||||
const xTyped = (await object('o2')).position.x;
|
const xTyped = (await object('o2')).position.x;
|
||||||
s = await state();
|
s = await state();
|
||||||
check(
|
check(
|
||||||
@@ -130,7 +133,7 @@ export async function inspector(t) {
|
|||||||
check('save status is saved', (await state()).save === 'saved');
|
check('save status is saved', (await state()).save === 'saved');
|
||||||
|
|
||||||
// 2h. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once.
|
// 2h. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once.
|
||||||
await click(center.x, center.y + 200); // click empty viewport → deselect, drop focus
|
await click(emptySpot.x, emptySpot.y); // click empty viewport → deselect, drop focus
|
||||||
for (let i = 0; i < 4; i++) await undo();
|
for (let i = 0; i < 4; i++) await undo();
|
||||||
const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x }));
|
const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x }));
|
||||||
check(
|
check(
|
||||||
|
|||||||
+33
-17
@@ -47,6 +47,8 @@ export async function graph(t) {
|
|||||||
// 6b. Drag a node: clicking its title selects it, dragging moves it (undoable).
|
// 6b. Drag a node: clicking its title selects it, dragging moves it (undoable).
|
||||||
const axisBefore = await nodeOf('n-axis');
|
const axisBefore = await nodeOf('n-axis');
|
||||||
const axisPos = await json(`__gm.graph.nodeAt('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);
|
await drag(axisPos.x, axisPos.y, axisPos.x + 60, axisPos.y + 40);
|
||||||
let s = await state();
|
let s = await state();
|
||||||
const axisAfter = await nodeOf('n-axis');
|
const axisAfter = await nodeOf('n-axis');
|
||||||
@@ -54,8 +56,8 @@ export async function graph(t) {
|
|||||||
'dragging a node selects and moves it',
|
'dragging a node selects and moves it',
|
||||||
s.selection?.kind === 'node' &&
|
s.selection?.kind === 'node' &&
|
||||||
s.selection.nodeId === 'n-axis' &&
|
s.selection.nodeId === 'n-axis' &&
|
||||||
axisAfter.x === axisBefore.x + 60 &&
|
Math.abs(axisAfter.x - (axisBefore.x + 60 / fitZoom)) <= 2 &&
|
||||||
axisAfter.y === axisBefore.y + 40,
|
Math.abs(axisAfter.y - (axisBefore.y + 40 / fitZoom)) <= 2,
|
||||||
`${JSON.stringify(s.selection)} ${axisBefore.x},${axisBefore.y} → ${axisAfter.x},${axisAfter.y}`,
|
`${JSON.stringify(s.selection)} ${axisBefore.x},${axisBefore.y} → ${axisAfter.x},${axisAfter.y}`,
|
||||||
);
|
);
|
||||||
check(
|
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
|
// 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.
|
// 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();
|
const beforeClickPick = await graphCounts();
|
||||||
await click(gcenter.x + 250, gcenter.y + 250, 'right');
|
await click(bare.x, bare.y, 'right');
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
|
check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
|
||||||
await typeText('stop');
|
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.
|
// 6d. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
|
||||||
const before13 = await graphCounts();
|
const before13 = await graphCounts();
|
||||||
await click(gcenter.x + 250, gcenter.y + 250, 'right');
|
await click(bare.x, bare.y, 'right');
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
|
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
|
||||||
await typeText('log');
|
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,
|
// 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.
|
// 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();
|
const beforeDrop = await graphCounts();
|
||||||
await mouse('mouseMoved', axisX.x, axisX.y);
|
await mouse('mouseMoved', axisX.x, axisX.y);
|
||||||
await mouse('mousePressed', axisX.x, axisX.y);
|
await mouse('mousePressed', axisX.x, axisX.y);
|
||||||
@@ -362,6 +365,9 @@ export async function graph(t) {
|
|||||||
await positions(),
|
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.
|
// 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 corner = { x: area.x + 6, y: area.y + 6 };
|
||||||
const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 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.
|
// 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 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')`);
|
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);
|
await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30);
|
||||||
const afterMove = await asset('mover');
|
const afterMove = await asset('mover');
|
||||||
const movedTogether = beforeMove.every((n) => {
|
const movedTogether = beforeMove.every((n) => {
|
||||||
const now = afterMove.graph.nodes.find((m) => m.id === n.id);
|
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());
|
check('dragging one of them moves the whole selection', movedTogether, await positions());
|
||||||
await undo();
|
await undo();
|
||||||
check('one undo takes the group move back', (await positions()) === before13i, await positions());
|
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),
|
// 13k2. Grid snapping. Distances are stated in graph units, which `units()` reads off the
|
||||||
// so a screen delta is a graph delta.
|
// current zoom – the canvas is not wide enough here for a zoom of 1.
|
||||||
const GRID = 24;
|
const GRID = 24;
|
||||||
const onGrid = (n) => n.x % GRID === 0 && n.y % GRID === 0;
|
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);
|
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);
|
await sleep(150);
|
||||||
const snapPre = await nodeOf('n-axis');
|
const snapPre = await nodeOf('n-axis');
|
||||||
const snapFrom = await json(`__gm.graph.nodeAt('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);
|
await drag(snapFrom.x, snapFrom.y, snapFrom.x + 50, snapFrom.y + 50);
|
||||||
const snapped = await nodeOf('n-axis');
|
const snapped = await nodeOf('n-axis');
|
||||||
check(
|
check(
|
||||||
'a dragged node lands on the grid, not where the pointer let go',
|
'a dragged node lands on the grid, not where the pointer let go',
|
||||||
onGrid(snapped) &&
|
onGrid(snapped) &&
|
||||||
Math.abs(snapped.x - (snapPre.x + 50)) <= GRID / 2 &&
|
Math.abs(snapped.x - (snapPre.x + snapBy)) <= GRID / 2 &&
|
||||||
Math.abs(snapped.y - (snapPre.y + 50)) <= GRID / 2,
|
Math.abs(snapped.y - (snapPre.y + snapBy)) <= GRID / 2,
|
||||||
`${snapPre.x},${snapPre.y} → ${snapped.x},${snapped.y}`,
|
`${snapPre.x},${snapPre.y} → ${snapped.x},${snapped.y}`,
|
||||||
);
|
);
|
||||||
const others = (await asset('mover')).graph.nodes.filter((n) => n.id !== 'n-axis');
|
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}`)),
|
JSON.stringify(groupPre.map((n) => `${n.id}:${n.x},${n.y}`)),
|
||||||
);
|
);
|
||||||
const grabFrom = await json(`__gm.graph.nodeAt('n-axis')`);
|
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);
|
await drag(grabFrom.x, grabFrom.y, grabFrom.x + 30, grabFrom.y + 30);
|
||||||
const groupPost = (await asset('mover')).graph.nodes;
|
const groupPost = (await asset('mover')).graph.nodes;
|
||||||
check(
|
check(
|
||||||
@@ -452,7 +463,7 @@ export async function graph(t) {
|
|||||||
groupPost.every(onGrid) &&
|
groupPost.every(onGrid) &&
|
||||||
groupPre.every((n) => {
|
groupPre.every((n) => {
|
||||||
const now = groupPost.find((m) => m.id === n.id);
|
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(),
|
await positions(),
|
||||||
);
|
);
|
||||||
@@ -467,13 +478,15 @@ export async function graph(t) {
|
|||||||
await sleep(150);
|
await sleep(150);
|
||||||
const offPre = await nodeOf('n-axis');
|
const offPre = await nodeOf('n-axis');
|
||||||
const offFrom = await json(`__gm.graph.nodeAt('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);
|
await drag(offFrom.x, offFrom.y, offFrom.x + 50, offFrom.y + 50);
|
||||||
const offPost = await nodeOf('n-axis');
|
const offPost = await nodeOf('n-axis');
|
||||||
check(
|
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 &&
|
(await json('__gm.graph.snapping()')) === false &&
|
||||||
offPost.x === offPre.x + 50 &&
|
!onGrid(offPost) &&
|
||||||
offPost.y === offPre.y + 50,
|
Math.abs(offPost.x - (offPre.x + offBy)) <= 2 &&
|
||||||
|
Math.abs(offPost.y - (offPre.y + offBy)) <= 2,
|
||||||
`${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`,
|
`${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`,
|
||||||
);
|
);
|
||||||
await undo();
|
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
|
// 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.
|
// 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(
|
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 sleep(250);
|
||||||
await clickText('Fit');
|
await clickText('Fit');
|
||||||
@@ -547,7 +563,7 @@ export async function graph(t) {
|
|||||||
);
|
);
|
||||||
await shot('22-graph-fitted');
|
await shot('22-graph-fitted');
|
||||||
await evaluate(
|
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 clickText('Fit');
|
||||||
await sleep(250);
|
await sleep(250);
|
||||||
|
|||||||
@@ -178,8 +178,8 @@ export async function nestedBehavior(t) {
|
|||||||
// in the list on the left, so the proof is the node that appears, not the row.)
|
// in the list on the left, so the proof is the node that appears, not the row.)
|
||||||
const nodeIds = async () => (await asset('nb-caller')).graph.nodes.map((n) => n.id);
|
const nodeIds = async () => (await asset('nb-caller')).graph.nodes.map((n) => n.id);
|
||||||
const idsBefore = await nodeIds();
|
const idsBefore = await nodeIds();
|
||||||
const centre = await json('__gm.graph.center()');
|
const bare = await json('__gm.graph.emptySpot()');
|
||||||
await click(centre.x + 250, centre.y + 250, 'right');
|
await click(bare.x, bare.y, 'right');
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
check('node palette opens', Boolean(await locate('Search nodes…')));
|
check('node palette opens', Boolean(await locate('Search nodes…')));
|
||||||
await typeText('Double');
|
await typeText('Double');
|
||||||
|
|||||||
@@ -308,9 +308,9 @@ export async function variableNodes(t) {
|
|||||||
// it runs `vz = vz + 1` once a frame – its own output feeding its input via Add.
|
// it runs `vz = vz + 1` once a frame – its own output feeding its input via Add.
|
||||||
const nodeIds = async () => (await asset('vp-logic')).graph.nodes.map((n) => n.id);
|
const nodeIds = async () => (await asset('vp-logic')).graph.nodes.map((n) => n.id);
|
||||||
const idsBefore = await nodeIds();
|
const idsBefore = await nodeIds();
|
||||||
const centre = await json('__gm.graph.center()');
|
// Bare canvas below every node, so the click cannot land on the Pack.
|
||||||
// Below the Owner node; centre + (250, 250) would land on the Pack.
|
const bareSpot = await json('__gm.graph.emptySpot()');
|
||||||
await click(centre.x - 250, centre.y + 250, 'right');
|
await click(bareSpot.x, bareSpot.y, 'right');
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
// Enter takes the first hit in category order, so search by a phrase only its description has.
|
// Enter takes the first hit in category order, so search by a phrase only its description has.
|
||||||
await typeText('switched to Set');
|
await typeText('switched to Set');
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { edge, node } from '../lib/builders.mjs';
|
||||||
|
import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console panel: every `console.*` call lands in it with its level, a Log node's message
|
||||||
|
* arrives through the very same path, the play status line deliberately does not, and the
|
||||||
|
* divider above it resizes down to the bare header. The step removes everything it made.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SPOKEN = 'the crate says hello';
|
||||||
|
|
||||||
|
/** `On start → Log`: the one path a project itself writes a line on. */
|
||||||
|
const talkerGraph = {
|
||||||
|
nodes: [node('c-start', 'OnStart', 0, 0), node('c-log', 'Log', 340, 0, { message: SPOKEN })],
|
||||||
|
edges: [edge('c-e1', 'c-start', 'exec', 'c-log', 'exec')],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function consolePanel(t) {
|
||||||
|
const { check, sleep, json, evaluate, shot, drag, wheel, clickText, play, stop } = t;
|
||||||
|
const lines = () => json('__gm.console.lines()');
|
||||||
|
const texts = async () => (await lines()).map((l) => l.text);
|
||||||
|
const height = () => json('__gm.console.height()');
|
||||||
|
const colorOf = (text) => json(`__gm.textColor(${JSON.stringify(text)})`);
|
||||||
|
/** The panel rebuilds on the frame after a push, so every read waits one out. */
|
||||||
|
const settle = () => sleep(300);
|
||||||
|
|
||||||
|
await evaluate('__gm.console.clear()');
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// 27a. The captured path: each console method with its own level, and the level drawn as colour.
|
||||||
|
await evaluate(`console.log('plain line 27'); console.warn('warned 27'); console.error('failed 27')`);
|
||||||
|
await settle();
|
||||||
|
const captured = await lines();
|
||||||
|
check(
|
||||||
|
'every console level lands in the panel, newest last',
|
||||||
|
JSON.stringify(captured.map((l) => ({ level: l.level, text: l.text }))) ===
|
||||||
|
JSON.stringify([
|
||||||
|
{ level: 'info', text: 'plain line 27' },
|
||||||
|
{ level: 'warn', text: 'warned 27' },
|
||||||
|
{ level: 'error', text: 'failed 27' },
|
||||||
|
]),
|
||||||
|
captured,
|
||||||
|
);
|
||||||
|
const [info, warn, error] = await Promise.all([
|
||||||
|
colorOf('plain line 27'),
|
||||||
|
colorOf('warned 27'),
|
||||||
|
colorOf('failed 27'),
|
||||||
|
]);
|
||||||
|
check(
|
||||||
|
'a warning is drawn amber and an error red, a plain line in the text colour',
|
||||||
|
info === '#e6e8ee' && warn === '#f2c84b' && error === '#e0553f',
|
||||||
|
{ info, warn, error },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 27a2. Every line carries the clock time of its call, drawn muted in its own column left of
|
||||||
|
// the message – so the message itself is still exactly what was logged.
|
||||||
|
const stamps = captured.map((l) => l.at);
|
||||||
|
const stampAt = await json(`__gm.locate(${JSON.stringify(captured[0].at)})`);
|
||||||
|
const messageAt = await json(`__gm.locate('plain line 27')`);
|
||||||
|
check(
|
||||||
|
'each line is stamped HH:MM:SS, drawn muted and left of the message',
|
||||||
|
stamps.every((at) => /^\d{2}:\d{2}:\d{2}$/.test(at)) &&
|
||||||
|
stampAt.x < messageAt.x &&
|
||||||
|
(await colorOf(captured[0].at)) === '#8a90a0',
|
||||||
|
JSON.stringify({ stamps, stampAt, messageAt }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 27b. Several arguments become one line, and an Error shows its message instead of [object].
|
||||||
|
await evaluate(`console.log('joined 27', 3, { a: 1 }); console.error(new Error('thrown 27'))`);
|
||||||
|
await settle();
|
||||||
|
const formatted = (await texts()).slice(-2);
|
||||||
|
check(
|
||||||
|
'arguments are joined and an Error reads as its message',
|
||||||
|
formatted[0] === 'joined 27 3 {"a":1}' && formatted[1]?.includes('thrown 27') === true,
|
||||||
|
formatted,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 27c. The real path a project uses: a Log node during play writes through `console.log`.
|
||||||
|
const rig = await scaffoldBehavior(t, {
|
||||||
|
assetId: 'talker',
|
||||||
|
name: 'Talker',
|
||||||
|
graph: talkerGraph,
|
||||||
|
boxId: 'c-box',
|
||||||
|
boxName: 'Talker box',
|
||||||
|
logicId: 'c-logic',
|
||||||
|
y: LANES.console,
|
||||||
|
});
|
||||||
|
await evaluate('__gm.console.clear()');
|
||||||
|
await play();
|
||||||
|
await settle();
|
||||||
|
const played = await texts();
|
||||||
|
check(
|
||||||
|
'a Log node reaches the panel with the behavior it came from',
|
||||||
|
played.some((line) => line === `[behavior Talker] ${SPOKEN}`),
|
||||||
|
played,
|
||||||
|
);
|
||||||
|
// 27d. The status line is a mode, not an event: the toolbar says it, the console stays out of it.
|
||||||
|
const notice = await json('__gm.state.notice');
|
||||||
|
check(
|
||||||
|
'the play status line shows in the toolbar but is kept out of the console',
|
||||||
|
notice.startsWith('Playing') && !played.some((line) => line.startsWith('Playing')),
|
||||||
|
{ notice, played },
|
||||||
|
);
|
||||||
|
await stop();
|
||||||
|
await settle();
|
||||||
|
await shot('27-console-after-play');
|
||||||
|
|
||||||
|
// 27e. Clear empties it, and only it – the toolbar notice is not the console's to touch.
|
||||||
|
await clickText('Clear');
|
||||||
|
await settle();
|
||||||
|
check('the Clear button empties the panel', (await lines()).length === 0);
|
||||||
|
|
||||||
|
// 27f. The ring buffer keeps the tail, not the head.
|
||||||
|
await evaluate(`for (let i = 0; i < 520; i += 1) console.log('ring ' + i)`);
|
||||||
|
await sleep(800);
|
||||||
|
const ring = await texts();
|
||||||
|
check(
|
||||||
|
'past its capacity the panel keeps the newest 500 lines',
|
||||||
|
ring.length === 500 && ring[0] === 'ring 20' && ring.at(-1) === 'ring 519',
|
||||||
|
{ length: ring.length, first: ring[0], last: ring.at(-1) },
|
||||||
|
);
|
||||||
|
const tail = await json('__gm.console.scroll()');
|
||||||
|
check('it follows the tail, so the newest line is the one showing', tail.offset === tail.max, tail);
|
||||||
|
|
||||||
|
// 27g. Reading back means the tail must stop pulling, or the line under the pointer runs away.
|
||||||
|
// A full buffer would hide that: dropping a line as one arrives leaves the height unchanged.
|
||||||
|
await evaluate('__gm.console.clear()');
|
||||||
|
await settle();
|
||||||
|
await evaluate(`for (let i = 0; i < 60; i += 1) console.log('scroll ' + i)`);
|
||||||
|
await settle();
|
||||||
|
const panel = await json('__gm.console.centre()');
|
||||||
|
await wheel(panel.x, panel.y, -200);
|
||||||
|
await settle();
|
||||||
|
const scrolledUp = await json('__gm.console.scroll()');
|
||||||
|
await evaluate(`console.log('arrived while reading 27')`);
|
||||||
|
await settle();
|
||||||
|
const stayed = await json('__gm.console.scroll()');
|
||||||
|
check(
|
||||||
|
'a line arriving while you read further up does not yank the view down',
|
||||||
|
scrolledUp.offset === scrolledUp.max - 200 &&
|
||||||
|
stayed.max > scrolledUp.max &&
|
||||||
|
stayed.offset === scrolledUp.offset,
|
||||||
|
JSON.stringify({ scrolledUp, stayed }),
|
||||||
|
);
|
||||||
|
await evaluate('__gm.console.clear()');
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// 27h. The divider resizes the panel, and shutting it leaves the header to grab it by again.
|
||||||
|
const start = await height();
|
||||||
|
const bar = await json('__gm.console.splitterAt()');
|
||||||
|
await drag(bar.x, bar.y, bar.x, bar.y - 60, { steps: 6 });
|
||||||
|
const taller = await height();
|
||||||
|
check('dragging the divider up makes the console taller', taller === start + 60, { start, taller });
|
||||||
|
const bar2 = await json('__gm.console.splitterAt()');
|
||||||
|
await drag(bar2.x, bar2.y, bar2.x, bar2.y + 400, { steps: 10 });
|
||||||
|
const shut = await height();
|
||||||
|
check('dragged shut it stops at its own header instead of vanishing', shut === 30, shut);
|
||||||
|
const bar3 = await json('__gm.console.splitterAt()');
|
||||||
|
await drag(bar3.x, bar3.y, bar3.x, bar3.y - (start - 30), { steps: 8 });
|
||||||
|
check('and it comes back to the height it started at', (await height()) === start, await height());
|
||||||
|
|
||||||
|
const left = await rig.remove();
|
||||||
|
check('the step leaves nothing behind', left.objects === 0 && left.assets === 0, left);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { variableNodes } from './23-variable-nodes.mjs';
|
|||||||
import { propertyNodes } from './24-property-nodes.mjs';
|
import { propertyNodes } from './24-property-nodes.mjs';
|
||||||
import { animation } from './25-animation.mjs';
|
import { animation } from './25-animation.mjs';
|
||||||
import { stateMachine } from './26-state-machine.mjs';
|
import { stateMachine } from './26-state-machine.mjs';
|
||||||
|
import { consolePanel } from './27-console.mjs';
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
hierarchy,
|
hierarchy,
|
||||||
@@ -57,4 +58,5 @@ export const STEPS = [
|
|||||||
propertyNodes,
|
propertyNodes,
|
||||||
animation,
|
animation,
|
||||||
stateMachine,
|
stateMachine,
|
||||||
|
consolePanel,
|
||||||
];
|
];
|
||||||
@@ -15,6 +15,7 @@ export const LANES = {
|
|||||||
nested: 700,
|
nested: 700,
|
||||||
keyRepeat: -300,
|
keyRepeat: -300,
|
||||||
stateMachine: -700,
|
stateMachine: -700,
|
||||||
|
console: -1100,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -174,6 +174,12 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
|||||||
}
|
}
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
};
|
};
|
||||||
|
/** Mouse wheel over (x, y); positive `dy` scrolls down. */
|
||||||
|
const wheel = async (x, y, dy) => {
|
||||||
|
await mouse('mouseMoved', x, y);
|
||||||
|
await mouse('mouseWheel', x, y, 'none', { deltaX: 0, deltaY: dy });
|
||||||
|
await sleep(150);
|
||||||
|
};
|
||||||
const clickText = async (text, button = 'left') => {
|
const clickText = async (text, button = 'left') => {
|
||||||
const p = await locate(text);
|
const p = await locate(text);
|
||||||
await click(p.x, p.y, button);
|
await click(p.x, p.y, button);
|
||||||
@@ -340,6 +346,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
|||||||
click,
|
click,
|
||||||
doubleClick,
|
doubleClick,
|
||||||
clickText,
|
clickText,
|
||||||
|
wheel,
|
||||||
drag,
|
drag,
|
||||||
dragAsset,
|
dragAsset,
|
||||||
press,
|
press,
|
||||||
|
|||||||
Reference in new issue
Block a user