Add ESLint and Prettier, and close the node test gaps

Tooling:
- Prettier as the single formatting authority (110 columns, single quotes,
  measured against the existing style); Markdown excluded because table-cell
  padding turns the README glossary into 1000-character lines.
- Type-aware ESLint ending in eslint-config-prettier, so a rule always means
  "this is a bug", never "this looks different".
- .editorconfig and an `npm run check` that runs typecheck, lint, format and
  tests in one go.

Findings fixed on the first run: three more duplicate imports, four
String(unknown) calls (parameter types narrowed instead of casts), two dead
initialisers, two redundant casts, a console.log for a real error. Two
warnings were false here and are disabled with a reason in place.

Tests 132 -> 269:
- node-catalog.test.ts covers every node with a run(), with a guard that fails
  when a new node has no row.
- behavior-events.test.ts covers the ten event nodes through a running World,
  checking timing rather than existence.
- Input's state machine (press/release/releaseAll) is reachable without DOM
  events now; the DOM handlers are thin adapters.

Compare fell back to == without a configured operator although the schema
default is <; aligned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 10:02:22 +02:00
1 parent f140c2fd77
commit d54a620ebd
113 files changed
+5049 -666

No files matched your search

+92 -19
View File
@@ -1,10 +1,22 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { AddNodeCommand, ConnectCommand, DisconnectCommand, MoveNodeCommand, RemoveNodeCommand } from '../../core/commands/graphCommands';
import { edgesInto, edgesOutOf, type GraphEdge, type NodeDef, nodeDef, portsCompatible } from '../../core/graph';
import {
AddNodeCommand,
ConnectCommand,
DisconnectCommand,
MoveNodeCommand,
RemoveNodeCommand,
} from '../../core/commands/graphCommands';
import {
edgesInto,
edgesOutOf,
type GraphEdge,
type NodeDef,
nodeDef,
portsCompatible,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import type { Trace } from '../../runtime/systems/BehaviorSystem';
import { TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
import type { EditorState } from '../state';
import { NodePalette } from './NodePalette';
@@ -47,7 +59,12 @@ export class GraphEditor extends Widget {
private pan = { x: 0, y: 0 };
private zoom = 1;
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
private dragging: { nodeId: string; start: { x: number; y: number }; pointer: { x: number; y: number }; moved: boolean } | null = null;
private dragging: {
nodeId: string;
start: { x: number; y: number };
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
private needsFit = true;
@@ -78,7 +95,10 @@ export class GraphEditor extends Widget {
});
state.on('selection', () => this.followSelection());
state.on('load', () => { this.assetId = null; this.followSelection(); });
state.on('load', () => {
this.assetId = null;
this.followSelection();
});
state.on('project', () => this.sync());
state.on('mode', () => this.redraw());
}
@@ -183,11 +203,13 @@ export class GraphEditor extends Widget {
if (e.target !== this) return;
const local = this.toLocal(e.global);
if (e.button === 2) {
if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
if (this.assetId && this.state.mode === 'edit')
this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
return;
}
if (e.button === 0 || e.button === 1) {
if (e.button === 0 && this.assetId && this.state.mode === 'edit') this.state.select({ kind: 'asset', assetId: this.assetId });
if (e.button === 0 && this.assetId && this.state.mode === 'edit')
this.state.select({ kind: 'asset', assetId: this.assetId });
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
}
}
@@ -205,13 +227,21 @@ export class GraphEditor extends Widget {
if (e.button !== 0) return;
if (hit) {
const p = this.toLocal(e.global);
this.connecting = { from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, pointer: this.screenToGraph(p.x, p.y) };
this.connecting = {
from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput },
pointer: this.screenToGraph(p.x, p.y),
};
this.redraw();
return;
}
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
const p = this.toLocal(e.global);
this.dragging = { nodeId: view.node.id, start: { x: view.node.x, y: view.node.y }, pointer: this.screenToGraph(p.x, p.y), moved: false };
this.dragging = {
nodeId: view.node.id,
start: { x: view.node.x, y: view.node.y },
pointer: this.screenToGraph(p.x, p.y),
moved: false,
};
this.nodeLayer.addChild(view); // raise
}
@@ -261,7 +291,13 @@ export class GraphEditor extends Widget {
if (target && target.isOutput !== connecting.from.isOutput) {
const out = connecting.from.isOutput ? connecting.from : target;
const inp = connecting.from.isOutput ? target : connecting.from;
this.state.execute(new ConnectCommand(this.assetId, { node: out.nodeId, port: out.port }, { node: inp.nodeId, port: inp.port }));
this.state.execute(
new ConnectCommand(
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
);
}
}
this.redraw();
@@ -282,7 +318,8 @@ export class GraphEditor extends Widget {
new NodePalette((def: NodeDef) => {
if (!this.assetId) return;
const command = new AddNodeCommand(this.assetId, def.type, at.x, at.y);
if (this.state.execute(command)) this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
if (this.state.execute(command))
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
}).open(globalX, globalY);
}
@@ -290,15 +327,37 @@ export class GraphEditor extends Widget {
const assetId = this.assetId;
if (!assetId) return;
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
new Menu([{ label: 'Delete node', danger: true, onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)) }]).open(globalX, globalY);
new Menu([
{
label: 'Delete node',
danger: true,
onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)),
},
]).open(globalX, globalY);
}
private openPortMenu(view: NodeView, port: string, isOutput: boolean, globalX: number, globalY: number): void {
private openPortMenu(
view: NodeView,
port: string,
isOutput: boolean,
globalX: number,
globalY: number,
): void {
const asset = this.asset();
if (!asset) return;
const edges = isOutput ? edgesOutOf(asset.graph, view.node.id, port) : edgesInto(asset.graph, view.node.id, port);
const edges = isOutput
? edgesOutOf(asset.graph, view.node.id, port)
: edgesInto(asset.graph, view.node.id, port);
if (edges.length === 0) return;
new Menu([{ label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`, danger: true, onSelect: () => { for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id)); } }]).open(globalX, globalY);
new Menu([
{
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
danger: true,
onSelect: () => {
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
},
},
]).open(globalX, globalY);
}
// --- drawing -----------------------------------------------------------------------------
@@ -320,7 +379,10 @@ export class GraphEditor extends Widget {
return;
}
const playing = this.state.mode === 'play';
this.hint.text = asset.graph.nodes.length === 0 ? `${asset.name} · right-click to add a node` : `${asset.name}${playing ? ' · live' : ''}`;
this.hint.text =
asset.graph.nodes.length === 0
? `${asset.name} · right-click to add a node`
: `${asset.name}${playing ? ' · live' : ''}`;
const trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider();
@@ -362,7 +424,11 @@ export class GraphEditor extends Widget {
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
const valid = portsCompatible(type, inType);
bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
this.edges.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, alpha: type === 'exec' || hot ? 1 : 0.85 });
this.edges.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger,
alpha: type === 'exec' || hot ? 1 : 0.85,
});
}
private syncGrid(): void {
@@ -371,7 +437,14 @@ export class GraphEditor extends Widget {
if (step < MIN_GRID_STEP) return;
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
// marking, so no axis lines.
drawGrid(this.grid, { originX: this.pan.x, originY: this.pan.y, step, w: this.w, h: this.h, alpha: 0.04 });
drawGrid(this.grid, {
originX: this.pan.x,
originY: this.pan.y,
step,
w: this.w,
h: this.h,
alpha: 0.04,
});
}
// --- test hooks -----------------------------------------------------------------------------