Files
feuerwerk/tests/core/reorder.test.ts
T
adminandClaude Opus 5.5 2c19c89878
game-maker / gate (push) Successful in 3m10s
game-maker / suite (push) Failing after 19m34s
Let variables, fields, ports and list items be dragged into order (#310)
Variables of a behavior, the fields of a struct, the ports of a start
or end node and the items of a list could only be added and removed.
Changing their order meant removing and adding them again. Now each of
them is dragged by the mark in front of its row.

- sortBlocks (ui/blockDrag.ts) makes the rows of one column sortable.
  A block is every row it covers, so a struct moves with its fields and
  a list item with its nested list. The block is dimmed while it is
  carried, an accent line shows where it lands, and a scrolled panel
  follows the pointer at its edge. Less than 4 px stays a click.
- The arithmetic is pure and lives in ui/sortable.ts: blockGap,
  gapLineY, dropSlot and edgeScroll. dropIndex of the tile strip moved
  there too, so the reorder maths sits in one file.
- MoveVariableCommand, MoveStructFieldCommand and MovePortCommand
  build on the existing MoveCommand. Undo moves back.
- Lists need no command. InlineList replaces the list as a whole, as
  it does for every other edit of its items.
- The grip is the kind mark, for a list item its number. Moving only
  happens among siblings: a field stays in its struct.
- On a Struct asset the move runs through WithStructTypes. Typed
  variables, Pack/Expand ports and split outputs follow the new order.
- Values and edges hang on names, so only the order changes. Nodes
  running a behavior read their ports from the start node, the new
  order reaches them without a copy.
- Not sortable by design: the variables of a placed object, the fields
  of a typed struct (moved on the Struct asset) and the picked ports
  of Variables/Properties nodes.
- Test hook __gm.inspectorMark(label) finds the mark of a row.

Tests:
- Gate on melektaus-atx: 235 files, 3572 tests, CHECK EXIT 0 (+9).
- tests/core/reorder.test.ts: a variable, a field two levels deep, a
  Struct asset reaching a typed variable and Pack, and start ports
  reaching the calling node with edges and result kept.
- e2e 22: ALL PASSED, 39 checks (+4, sum 1739). 22f-iii drags a
  variable past a struct of several rows, then a field inside it.
- Counter-probes: dropSlot by top edge instead of middle and a field
  move that ignores its path fail 3 unit tests. With sortBlocks
  switched off, the three order checks of 22f-iii fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:45:04 +02:00

179 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Reordering what the inspector lets be dragged (#310): variables, the fields of a struct at any
* depth, a Struct asset's fields with every place of its type, and the declared ports of a start node.
* Only the order may change – values, edges and what runs stay as they were.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import { AddPortCommand, MovePortCommand } from '../../src/core/commands/portCommands';
import { SetStructTypeCommand, WithStructTypes } from '../../src/core/commands/structTypeCommands';
import {
AddStructFieldCommand,
AddVariableCommand,
MoveStructFieldCommand,
MoveVariableCommand,
} from '../../src/core/commands/variableCommands';
import {
BEHAVIOR_END,
BEHAVIOR_PARAM,
BEHAVIOR_START,
GraphInterpreter,
RUN_BEHAVIOR,
resolveDef,
} from '../../src/core/graph';
import type { BehaviorAsset, StructAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup';
import type { StructDef } from '../../src/core/properties/PropertySchema';
import { fakeContext, fire, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
const keys = (list: readonly { key: string }[] | undefined) => (list ?? []).map((item) => item.key);
describe('reordering variables and struct fields', () => {
it('moves a variable and leaves the values alone; undo moves it back', () => {
const f = makeProject();
const behavior = makeBehavior(f, 'B', ['a', 'b', 'c']);
const props = structuredClone(behavior.props);
f.stack.execute(new MoveVariableCommand(behavior.id, 0, 2));
expect(keys(behavior.variables)).toEqual(['b', 'c', 'a']);
expect(behavior.props).toEqual(props);
f.stack.undo();
expect(keys(behavior.variables)).toEqual(['a', 'b', 'c']);
});
it('refuses a place the list does not have', () => {
const f = makeProject();
const behavior = makeBehavior(f, 'B', ['a', 'b']);
expect(() => new MoveVariableCommand(behavior.id, 0, 2).execute(f.project)).toThrow(CommandError);
expect(keys(behavior.variables)).toEqual(['a', 'b']);
});
it('moves a field inside a struct inside a struct, the rest of the declaration as it was', () => {
const f = makeProject();
const behavior = makeBehavior(f, 'B');
f.stack.execute(new AddVariableCommand(behavior.id, 'hero', 'struct'));
for (const field of ['name', 'pos'])
f.stack.execute(
new AddStructFieldCommand(
behavior.id,
'hero',
field === 'pos' ? { key: field, kind: 'struct', fields: [] } : { key: field, kind: 'text' },
),
);
for (const field of ['x', 'y', 'z'])
f.stack.execute(
new AddStructFieldCommand(behavior.id, 'hero', { key: field, kind: 'number' }, ['pos']),
);
const hero = () => behavior.variables[0] as StructDef;
const pos = () => hero().fields.find((field) => field.key === 'pos') as StructDef;
const value = structuredClone(behavior.props.hero);
f.stack.execute(new MoveStructFieldCommand(behavior.id, 'hero', 2, 0, ['pos']));
expect(keys(pos().fields)).toEqual(['z', 'x', 'y']);
expect(keys(hero().fields)).toEqual(['name', 'pos']);
expect(behavior.props.hero).toEqual(value);
f.stack.execute(new MoveStructFieldCommand(behavior.id, 'hero', 1, 0));
expect(keys(hero().fields)).toEqual(['pos', 'name']);
f.stack.undo();
f.stack.undo();
expect(keys(hero().fields)).toEqual(['name', 'pos']);
expect(keys(pos().fields)).toEqual(['x', 'y', 'z']);
});
});
/** `Loot` = { Name: "Coin", Amount: 1 } */
function lootType(f: ProjectFixture): StructAsset {
const create = new CreateAssetCommand('struct', 'Loot');
f.stack.execute(create);
const id = create.asset.id;
f.stack.execute(new AddVariableCommand(id, 'Name', 'text'));
f.stack.execute(new AddVariableCommand(id, 'Amount', 'number'));
f.stack.execute(new SetAssetPropertyCommand(id, 'Amount', 1));
return create.asset as StructAsset;
}
describe('reordering the fields of a Struct asset', () => {
it('reaches every place of the type: typed variables and the ports of Pack, edges kept', () => {
const f = makeProject();
const loot = lootType(f);
const chest = makeBehavior(f, 'Chest');
f.stack.execute(new AddVariableCommand(chest.id, 'drop', 'struct'));
f.stack.execute(
new SetStructTypeCommand({ kind: 'variable', ownerId: chest.id, key: 'drop', path: [] }, loot.id),
);
const g = graphEditor(f, chest);
const pack = g.addNode('PackStruct');
f.stack.execute(new SetStructTypeCommand({ kind: 'node', assetId: chest.id, nodeId: pack.id }, loot.id));
const number = g.addNode('Number');
g.connect(number, 'value', pack, 'Amount');
const drop = () => chest.variables[0] as StructDef;
f.stack.execute(new WithStructTypes(new MoveVariableCommand(loot.id, 1, 0)));
expect(keys(loot.variables)).toEqual(['Amount', 'Name']);
expect(keys(drop().fields)).toEqual(['Amount', 'Name']);
expect(keys(pack.ports)).toEqual(['Amount', 'Name']);
expect(chest.graph.edges.map((e) => `${e.from.port}>${e.to.port}`)).toEqual(['value>Amount']);
expect(chest.props.drop).toEqual({ Name: '', Amount: 1 });
f.stack.undo();
expect(keys(drop().fields)).toEqual(['Name', 'Amount']);
expect(keys(pack.ports)).toEqual(['Name', 'Amount']);
});
});
/** Hands `a - b` back: two inputs whose order matters to the arithmetic, not to the wiring. */
function subtracter(f: ProjectFixture): BehaviorAsset {
const asset = makeBehavior(f, 'Subtract');
const edit = graphEditor(f, asset);
const start = edit.addNode(BEHAVIOR_START);
const end = edit.addNode(BEHAVIOR_END);
f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'a', kind: 'number' }));
f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'b', kind: 'number' }));
f.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'out1', kind: 'number' }));
const minus = edit.addNode('Subtract');
edit.wire([
[start, 'exec', end, 'exec'],
[start, 'a', minus, 'a'],
[start, 'b', minus, 'b'],
[minus, 'result', end, 'out1'],
]);
return asset;
}
describe('reordering the ports of a start node', () => {
it('changes the order on the calling node and nothing else: edges, values and result stay', () => {
const f = makeProject();
const callee = subtracter(f);
const caller = makeBehavior(f, 'Caller', ['result']);
const edit = graphEditor(f, caller);
const onStart = edit.addNode('OnStart');
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, a: 10, b: 3 });
const set = edit.variables(['result'], { write: true });
edit.wire([
[onStart, 'exec', call, 'exec'],
[call, 'exec', set, 'exec'],
[call, 'out1', set, 'result'],
]);
const start = callee.graph.nodes.find((n) => n.type === BEHAVIOR_START)!;
const edges = structuredClone(callee.graph.edges);
const inputs = () => keys(resolveDef(call, caller.graph, graphLookup(f.project))!.inputs);
const run = () => {
const ctx = fakeContext({ variables: { result: 0 } });
fire(new GraphInterpreter(caller.graph, graphLookup(f.project)), 'OnStart', ctx);
return ctx.variables.result;
};
expect(inputs()).toEqual(['exec', 'a', 'b']);
f.stack.execute(new MovePortCommand(callee.id, start.id, 1, 0));
expect(keys(start.ports)).toEqual(['b', 'a']);
expect(inputs()).toEqual(['exec', 'b', 'a']);
expect(callee.graph.edges).toEqual(edges);
expect(call.params).toMatchObject({ a: 10, b: 3 });
expect(run()).toBe(7);
f.stack.undo();
expect(inputs()).toEqual(['exec', 'a', 'b']);
});
});