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:
1 parent
f140c2fd77
commit
d54a620ebd
113 files changed
+5049
-666
No files matched your search
+112
-29
@@ -1,7 +1,20 @@
|
||||
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
|
||||
import { edgesInto, type Graph, type GraphNode, inlineInputs, inlineValue, type NodeCategory, type NodeDef, type PortDef, type PortType } from '../../core/graph';
|
||||
import {
|
||||
asString,
|
||||
edgesInto,
|
||||
type Graph,
|
||||
type GraphNode,
|
||||
inlineInputs,
|
||||
inlineValue,
|
||||
type NodeCategory,
|
||||
type NodeDef,
|
||||
type PortDef,
|
||||
type PortType,
|
||||
type Value,
|
||||
} from '../../core/graph';
|
||||
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Theme, textStyle, type TextStyleToken, Widget } from '../../ui';
|
||||
import { keyLabel } from '../inspector/fieldFactory';
|
||||
@@ -22,23 +35,34 @@ const PAD = 6;
|
||||
export function categoryColor(category: NodeCategory): number {
|
||||
const c = Theme.colors;
|
||||
switch (category) {
|
||||
case 'event': return c.categoryEvent;
|
||||
case 'flow': return c.categoryFlow;
|
||||
case 'data': return c.categoryData;
|
||||
case 'variable': return c.categoryVariable;
|
||||
case 'action': return c.categoryAction;
|
||||
case 'event':
|
||||
return c.categoryEvent;
|
||||
case 'flow':
|
||||
return c.categoryFlow;
|
||||
case 'data':
|
||||
return c.categoryData;
|
||||
case 'variable':
|
||||
return c.categoryVariable;
|
||||
case 'action':
|
||||
return c.categoryAction;
|
||||
}
|
||||
}
|
||||
|
||||
export function portColor(type: PortType): number {
|
||||
const c = Theme.colors;
|
||||
switch (type) {
|
||||
case 'exec': return c.portExec;
|
||||
case 'number': return c.portNumber;
|
||||
case 'boolean': return c.portBoolean;
|
||||
case 'string': return c.portString;
|
||||
case 'object': return c.portObject;
|
||||
case 'any': return c.portAny;
|
||||
case 'exec':
|
||||
return c.portExec;
|
||||
case 'number':
|
||||
return c.portNumber;
|
||||
case 'boolean':
|
||||
return c.portBoolean;
|
||||
case 'string':
|
||||
return c.portString;
|
||||
case 'object':
|
||||
return c.portObject;
|
||||
case 'any':
|
||||
return c.portAny;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +86,10 @@ export class NodeView extends Widget {
|
||||
/** Data inputs that currently have a connection (their inline value is hidden). */
|
||||
private connected = new Set<string>();
|
||||
|
||||
constructor(readonly node: GraphNode, readonly def: NodeDef) {
|
||||
constructor(
|
||||
readonly node: GraphNode,
|
||||
readonly def: NodeDef,
|
||||
) {
|
||||
super();
|
||||
this.cursor = 'grab';
|
||||
this.addChild(this.box, this.ports);
|
||||
@@ -77,9 +104,16 @@ export class NodeView extends Widget {
|
||||
|
||||
/** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn, Is asset). */
|
||||
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
|
||||
this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`);
|
||||
this.connected = new Set(inlineInputs(this.def).filter((p) => edgesInto(graph, node.id, p.key).length > 0).map((p) => p.key));
|
||||
const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
|
||||
this.paramLines = this.def.params.map(
|
||||
(p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`,
|
||||
);
|
||||
this.connected = new Set(
|
||||
inlineInputs(this.def)
|
||||
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
|
||||
.map((p) => p.key),
|
||||
);
|
||||
const height =
|
||||
TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
|
||||
this.rebuildTexts();
|
||||
this.resize(NODE_WIDTH, height);
|
||||
this.position.set(node.x, node.y);
|
||||
@@ -103,8 +137,12 @@ export class NodeView extends Widget {
|
||||
|
||||
/** Every port with its local centre – inputs on the left edge, outputs on the right. */
|
||||
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
|
||||
for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
|
||||
for (let i = 0; i < list.length; i++) yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
|
||||
for (const [list, isOutput] of [
|
||||
[this.def.inputs, false],
|
||||
[this.def.outputs, true],
|
||||
] as const) {
|
||||
for (let i = 0; i < list.length; i++)
|
||||
yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +157,14 @@ export class NodeView extends Widget {
|
||||
private rebuildTexts(): void {
|
||||
for (const t of this.texts) t.destroy();
|
||||
this.texts.length = 0;
|
||||
const add = (text: string, style: TextStyleToken, x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2) => {
|
||||
const add = (
|
||||
text: string,
|
||||
style: TextStyleToken,
|
||||
x: number,
|
||||
y: number,
|
||||
align: 'left' | 'right' = 'left',
|
||||
maxWidth = NODE_WIDTH - PAD * 2,
|
||||
) => {
|
||||
const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) });
|
||||
node.position.set(align === 'left' ? x : x - node.width, y);
|
||||
this.addChild(node);
|
||||
@@ -141,9 +186,24 @@ export class NodeView extends Widget {
|
||||
const value = inlineValue(this.node, input);
|
||||
if (value !== null && value !== '') label += ` ${formatValue(value)}`;
|
||||
}
|
||||
add(label, input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20);
|
||||
add(
|
||||
label,
|
||||
input.type === 'exec' ? 'body' : 'muted',
|
||||
PAD + PORT_RADIUS + 4,
|
||||
rowTop,
|
||||
'left',
|
||||
NODE_WIDTH / 2 + 20,
|
||||
);
|
||||
}
|
||||
if (output) add(output.label, output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10);
|
||||
if (output)
|
||||
add(
|
||||
output.label,
|
||||
output.type === 'exec' ? 'body' : 'muted',
|
||||
this.w - PAD - PORT_RADIUS - 4,
|
||||
rowTop,
|
||||
'right',
|
||||
NODE_WIDTH / 2 - 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,16 +213,27 @@ export class NodeView extends Widget {
|
||||
this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });
|
||||
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node);
|
||||
this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category));
|
||||
this.box.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius).fill(categoryColor(this.def.category));
|
||||
this.box
|
||||
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
|
||||
.fill(categoryColor(this.def.category));
|
||||
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : c.nodeBorder;
|
||||
this.box.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
|
||||
this.box
|
||||
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
|
||||
.stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
|
||||
|
||||
this.ports.clear();
|
||||
const r = PORT_RADIUS;
|
||||
for (const { port, x, y } of this.portCentres()) {
|
||||
if (port.type === 'exec') {
|
||||
// A pentagon pointing right: the Blueprint-style exec pin.
|
||||
this.ports.moveTo(x - r, y - r).lineTo(x + 1, y - r).lineTo(x + r + 1, y).lineTo(x + 1, y + r).lineTo(x - r, y + r).closePath().fill(portColor('exec'));
|
||||
this.ports
|
||||
.moveTo(x - r, y - r)
|
||||
.lineTo(x + 1, y - r)
|
||||
.lineTo(x + r + 1, y)
|
||||
.lineTo(x + 1, y + r)
|
||||
.lineTo(x - r, y + r)
|
||||
.closePath()
|
||||
.fill(portColor('exec'));
|
||||
} else {
|
||||
this.ports.circle(x, y, r).fill(portColor(port.type));
|
||||
this.ports.circle(x, y, r).stroke({ width: 1, color: c.node });
|
||||
@@ -172,21 +243,32 @@ export class NodeView extends Widget {
|
||||
}
|
||||
|
||||
/** The value of a setting as shown on the node; references that no longer resolve get a "?". */
|
||||
function formatParam(param: PropertyDef, value: unknown, asset: BehaviorAsset | null, project: Project | null): string {
|
||||
function formatParam(
|
||||
param: PropertyDef,
|
||||
value: PropertyValue | undefined,
|
||||
asset: BehaviorAsset | null,
|
||||
project: Project | null,
|
||||
): string {
|
||||
if (value === undefined || value === '') return '—';
|
||||
if (param.kind === 'key') return keyLabel(String(value));
|
||||
if (param.kind === 'enum' && param.key === 'asset') {
|
||||
const target = project ? findAsset(project, String(value)) : undefined;
|
||||
return target ? target.name : `${value}?`;
|
||||
}
|
||||
if (param.kind === 'enum' && param.key === 'variable' && asset && !asset.variables.some((v) => v.key === value)) return `${value}?`;
|
||||
if (
|
||||
param.kind === 'enum' &&
|
||||
param.key === 'variable' &&
|
||||
asset &&
|
||||
!asset.variables.some((v) => v.key === value)
|
||||
)
|
||||
return `${value}?`;
|
||||
return formatValue(value);
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
function formatValue(value: Value): string {
|
||||
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
return String(value);
|
||||
return asString(value);
|
||||
}
|
||||
|
||||
/** Truncates with an ellipsis so the text fits `maxWidth`. */
|
||||
@@ -194,6 +276,7 @@ function fit(text: string, style: TextStyleToken, maxWidth: number): string {
|
||||
const s = textStyle(style);
|
||||
if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text;
|
||||
let cut = text;
|
||||
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth) cut = cut.slice(0, -1);
|
||||
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth)
|
||||
cut = cut.slice(0, -1);
|
||||
return cut + '…';
|
||||
}
|
||||
Reference in new issue
Block a user