A variable or a state machine declared `global` keeps one value for the whole run instead of one per behavior instance. The scope sits on the declaration -- `PropertyDef.scope` and `StateMachineAsset.scope`, both optional, absence meaning `local` -- and `local` is never written, so every project made before this saves back unchanged. **The name is the key.** Two behaviors declaring a global `score` mean one variable; a behavior declaring `score` locally keeps its own. Kinds that disagree about one global name stop the run before it starts, with the two behaviors named; starting values that disagree are a warning on the behavior that declares it, and the first declaration wins (`globalConflicts`). The runtime needs no redirection layer. A global variable falls out of `schemaForObject`, so it never reaches `entity.props` -- and then the whole rule is "whoever declares the name takes their own value" (`graph/sharedState.ts`). The `Variables` node is untouched and stays transparent: switching a variable to global breaks no existing graph, because `GraphInterpreter.readAll` builds a fresh object per read anyway and only asks `port.key in all`. That same cut answers the inspector. A shared value has no owner, so the placed object shows no row for it and can hold no override -- `SetVariableScopeCommand` takes the deviations that were there and undo gives them back. Both places say it in words rather than leaving a glyph to be guessed: the declaration explains the mark below the list, and the placed object names the variable it no longer holds instead of claiming the behavior declares none -- which is what it said before, untruthfully. Screenshots of the e2e run turned that up; no check had been looking at it. `SharedState` hangs off `NodeContext`, is built in `BehaviorSystem.start()` from the *declarations* rather than from any instance (a global variable exists before its behavior is placed), and is thrown away with the run: nothing is written back, not even in the editor run. `GraphInterpreter.call` passes it on through `...ctx` while nested calls keep getting fresh local variables, which is why `program.defaults` is stripped of global keys. Two traps this hit. `defSignature`/`assetSignature` have to carry the scope or the inspector does not rebuild after the switch -- and a state machine has no rows at all, so its scope is appended to the signature by hand. And the object inspector does not go through `schemaForObject` for behaviors, it reads `asset.variables` directly; filtering in one place was not enough, and no unit test covered that path. The e2e caught it (`global: true` where false belonged), and the rule now lives in one named function, `objectRows`. Scopes `object` and `scene` are deliberately not built. The object case already has a route (`Object by name` -> `Properties`), and a run knows exactly one scene, so `scene` would be indistinguishable from `global` until #39 exists. Closes #59: a machine's scope is a word on the machine asset, not a setting per `Set state` node, so the same machine cannot live in two places at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
5.5 KiB
TypeScript
145 lines
5.5 KiB
TypeScript
/**
|
||
* One builder draws a variable on its asset (renamable) and on a placed object (names are labels).
|
||
* `NumberField extends TextField`, so the tests go by the name's widget rather than counting by type.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { createEmptyProject } from '../../src/core/model/Project';
|
||
import type { PropertyDef, StructDef } from '../../src/core/properties/PropertySchema';
|
||
import { HStack, Label, TextField, Theme, VStack, type Widget } from '../../src/ui';
|
||
import { type FieldHandlers, labelledRow } from '../../src/editor/inspector/fieldFactory';
|
||
import type { InspectorHost } from '../../src/editor/inspector/host';
|
||
import { buildVariable, type VariableEditing } from '../../src/editor/inspector/variableRows';
|
||
import { EditorState } from '../../src/editor/state';
|
||
|
||
const STRUCT: StructDef = {
|
||
key: 'piece',
|
||
label: 'piece',
|
||
kind: 'struct',
|
||
default: { x: 0, tag: '' },
|
||
fields: [
|
||
{ key: 'x', kind: 'number' },
|
||
{ key: 'tag', kind: 'text' },
|
||
],
|
||
};
|
||
|
||
const VALUE = { x: 30, tag: 'tee' };
|
||
|
||
const PLAIN: PropertyDef[] = [
|
||
{ key: 'speed', label: 'speed', kind: 'number', default: 0 },
|
||
{ key: 'title', label: 'title', kind: 'text', default: '' },
|
||
{ key: 'alive', label: 'alive', kind: 'boolean', default: true },
|
||
{ key: 'cells', label: 'cells', kind: 'list', default: [] },
|
||
{ key: 'a_rather_long_variable_name', label: 'a_rather_long_variable_name', kind: 'number', default: 0 },
|
||
];
|
||
|
||
function makeHost(): InspectorHost {
|
||
const state = new EditorState();
|
||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||
return { state, body: new VStack(), fields: new Map(), nameField: null, project: () => state.project };
|
||
}
|
||
|
||
const noHandlers = (): FieldHandlers => ({ onInput: () => {}, onChange: () => {} });
|
||
const withFields: FieldHandlers = { ...noHandlers(), structFieldHandlers: noHandlers };
|
||
|
||
const EDIT: VariableEditing = {
|
||
rename: () => {},
|
||
remove: () => {},
|
||
setScope: () => {},
|
||
renameField: () => {},
|
||
removeField: () => {},
|
||
addField: () => {},
|
||
};
|
||
|
||
const rows = (host: InspectorHost): Widget[][] =>
|
||
host.body.children.map((row) => ('children' in row ? (row.children as Widget[]) : []));
|
||
|
||
/** Every widget of the body that carries `text` as its own content, name or value. */
|
||
const carrying = (host: InspectorHost, text: string): Widget[] =>
|
||
rows(host)
|
||
.flat()
|
||
.filter((w) => (w instanceof Label && w.text === text) || (w instanceof TextField && w.value === text));
|
||
|
||
const isName = (w: Widget, name: string) =>
|
||
(w instanceof Label && w.text === name) || (w instanceof TextField && w.value === name);
|
||
|
||
/** Where the widget after the name starts – summed from the measured cells before it. */
|
||
function valueX(host: InspectorHost, name: string): number {
|
||
for (const row of host.body.children) {
|
||
if (!(row instanceof HStack)) continue;
|
||
const items = row.items;
|
||
const at = items.findIndex((w) => isName(w, name));
|
||
if (at < 0) continue;
|
||
return items.slice(0, at + 1).reduce((x, w) => x + w.measure().w + row.gap, row.padding);
|
||
}
|
||
throw new Error(`no row named ${name}`);
|
||
}
|
||
|
||
function buildAll(edit?: VariableEditing): InspectorHost {
|
||
const host = makeHost();
|
||
for (const def of PLAIN) buildVariable(host, def, def.default, noHandlers(), { edit });
|
||
buildVariable(host, STRUCT, VALUE, withFields, { edit });
|
||
return host;
|
||
}
|
||
|
||
const NAMES = [...PLAIN.map((d) => d.key), 'x', 'tag'];
|
||
|
||
describe('the column grid', () => {
|
||
it('starts every value at the same x on a placed object, whatever the kind or depth', () => {
|
||
const host = buildAll();
|
||
const xs = NAMES.map((name) => valueX(host, name));
|
||
expect(new Set(xs), JSON.stringify(xs)).toEqual(new Set([xs[0]]));
|
||
});
|
||
|
||
it('and at that same x on the asset, where the names are editable fields', () => {
|
||
const placed = valueX(buildAll(), 'speed');
|
||
const host = buildAll(EDIT);
|
||
const xs = NAMES.map((name) => valueX(host, name));
|
||
expect(xs, JSON.stringify(xs)).toEqual(NAMES.map(() => placed));
|
||
});
|
||
|
||
it('which is where a plain labelled row starts its value too', () => {
|
||
const { label } = labelledRow('Enabled');
|
||
expect(label.minWidth + Theme.gap).toBe(valueX(buildAll(), 'speed'));
|
||
});
|
||
});
|
||
|
||
describe('a struct on a placed object', () => {
|
||
it('is a head row plus one row per field', () => {
|
||
const host = makeHost();
|
||
buildVariable(host, STRUCT, VALUE, withFields);
|
||
expect(host.body.children.length).toBe(1 + STRUCT.fields.length);
|
||
});
|
||
|
||
it('names the bundle and its fields with labels, so they cannot be edited', () => {
|
||
const host = makeHost();
|
||
buildVariable(host, STRUCT, VALUE, withFields);
|
||
for (const name of ['piece', 'x', 'tag']) {
|
||
const [widget] = carrying(host, name);
|
||
expect(widget, name).toBeInstanceOf(Label);
|
||
}
|
||
});
|
||
|
||
it('still shows the values in editable fields', () => {
|
||
const host = makeHost();
|
||
buildVariable(host, STRUCT, VALUE, withFields);
|
||
expect(carrying(host, 'tee')[0]).toBeInstanceOf(TextField);
|
||
});
|
||
});
|
||
|
||
describe('the same struct on the asset that declares it', () => {
|
||
it('names the bundle and its fields with text fields, so they can be renamed', () => {
|
||
const host = makeHost();
|
||
buildVariable(host, STRUCT, VALUE, withFields, { edit: EDIT });
|
||
for (const name of ['piece', 'x', 'tag']) {
|
||
const [widget] = carrying(host, name);
|
||
expect(widget, name).toBeInstanceOf(TextField);
|
||
}
|
||
});
|
||
|
||
it('keeps the same row count as the placed one', () => {
|
||
const host = makeHost();
|
||
buildVariable(host, STRUCT, VALUE, withFields, { edit: EDIT });
|
||
expect(host.body.children.length).toBe(1 + STRUCT.fields.length);
|
||
});
|
||
});
|