src/viewport/ held three classes only src/editor/ ever used, and framing.ts sat under graph/ although the scene view wants the same bounds sum. The graph, the texture and the container tab repeated the same header, subscription and disposal code with two silent divergences: the graph tab said "nothing shown" with null instead of '', and the texture tab refell back to the first asset on a selection change but not on a project event. These arrive together because GraphCanvas ties them: it reads framing from its new home, hands its fold marks to folding.ts, and its '' change is what GraphEditor must agree with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
8.8 KiB
TypeScript
220 lines
8.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { CommandError } from '../../src/core/commands/Command';
|
||
import { ConnectCommand, FoldEdgeCommand } from '../../src/core/commands/graphCommands';
|
||
import { GraphInterpreter } from '../../src/core/graph';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||
import {
|
||
buildFoldMarks,
|
||
foldedEdgeShown,
|
||
type FoldMarkEnd,
|
||
foldMarkAt,
|
||
foldMarkKey,
|
||
foldMarkLabel,
|
||
foldMarkRadius,
|
||
} from '../../src/editor/graph/folding';
|
||
import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
|
||
|
||
/** "On start → Move(self, dx = 7)": two edges, so a fold can hide one and leave the other. */
|
||
function mover() {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'Mover');
|
||
const edit = graphEditor(fixture, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const self = edit.addNode('Self');
|
||
const move = edit.addNode('Move', { dx: 7 });
|
||
edit.wire([
|
||
[start, 'exec', move, 'exec'],
|
||
[self, 'object', move, 'object'],
|
||
]);
|
||
return { ...fixture, behavior, edit, nodes: { start, self, move } };
|
||
}
|
||
|
||
describe('folding an edge', () => {
|
||
it('changes nothing about what the graph does – the value still arrives', () => {
|
||
const { project, stack, behavior } = mover();
|
||
for (const edge of behavior.graph.edges) stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true));
|
||
expect(behavior.graph.edges.every((e) => e.folded === true)).toBe(true);
|
||
|
||
const ctx = fakeContext();
|
||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||
expect(ctx.self.x).toBe(7);
|
||
// And the edges themselves are untouched apart from the flag.
|
||
expect(findAsset(project, behavior.id)).toBe(behavior);
|
||
expect(behavior.graph.edges).toHaveLength(2);
|
||
});
|
||
|
||
it('survives the save, and unfolding leaves no trace in the file', () => {
|
||
const { project, stack, behavior } = mover();
|
||
const [first, second] = behavior.graph.edges;
|
||
stack.execute(new FoldEdgeCommand(behavior.id, first!.id, true));
|
||
|
||
const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
|
||
expect(loaded.graph.edges.find((e) => e.id === first!.id)!.folded).toBe(true);
|
||
expect('folded' in loaded.graph.edges.find((e) => e.id === second!.id)!).toBe(false);
|
||
|
||
stack.execute(new FoldEdgeCommand(behavior.id, first!.id, false));
|
||
const again = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
|
||
expect(again.graph.edges.every((e) => !('folded' in e))).toBe(true);
|
||
});
|
||
|
||
it('is undoable, and undo restores the state the edge really had', () => {
|
||
const { stack, behavior } = mover();
|
||
const edge = behavior.graph.edges[0]!;
|
||
stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true));
|
||
// Folding an already folded edge must not make undo unfold it.
|
||
stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true));
|
||
stack.undo();
|
||
expect(edge.folded).toBe(true);
|
||
stack.undo();
|
||
expect(edge.folded).toBeUndefined();
|
||
});
|
||
|
||
it('reports a missing edge as a CommandError instead of throwing from the constructor', () => {
|
||
const { project, behavior } = mover();
|
||
const command = new FoldEdgeCommand(behavior.id, 'no-such-edge', true);
|
||
expect(command.label).toBe('Hide link');
|
||
expect(() => command.execute(project)).toThrow(CommandError);
|
||
});
|
||
|
||
it('belongs to the edge, not to the port: a replacement starts out shown', () => {
|
||
const { stack, behavior, edit, nodes } = mover();
|
||
const edge = behavior.graph.edges.find((e) => e.to.port === 'object')!;
|
||
stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true));
|
||
|
||
const owner = edit.addNode('Owner');
|
||
stack.execute(
|
||
new ConnectCommand(
|
||
behavior.id,
|
||
{ node: owner.id, port: 'object' },
|
||
{ node: nodes.move.id, port: 'object' },
|
||
),
|
||
);
|
||
const now = behavior.graph.edges.find((e) => e.to.port === 'object')!;
|
||
expect(now.id).not.toBe(edge.id);
|
||
expect(now.folded).toBeUndefined();
|
||
// Undo puts the old edge back exactly as it was – folded.
|
||
stack.undo();
|
||
expect(behavior.graph.edges.find((e) => e.to.port === 'object')!.folded).toBe(true);
|
||
});
|
||
|
||
it('ignores a folded flag in the file that is not literally true', () => {
|
||
const { project, behavior } = mover();
|
||
const raw = serializeProject(project) as {
|
||
assets: { id: string; graph?: { edges: Record<string, unknown>[] } }[];
|
||
};
|
||
const edges = raw.assets.find((a) => a.id === behavior.id)!.graph!.edges;
|
||
edges[0]!.folded = 'yes';
|
||
edges[1]!.folded = false;
|
||
const loaded = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset;
|
||
expect(loaded.graph.edges.every((e) => !('folded' in e))).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('when a folded edge shows its curve anyway', () => {
|
||
const edge = { id: 'e', from: { node: 'a', port: 'out' }, to: { node: 'b', port: 'in' } };
|
||
|
||
it('stays hidden while neither end is selected', () => {
|
||
expect(foldedEdgeShown(edge, new Set(['c']), false)).toBe(false);
|
||
expect(foldedEdgeShown(edge, new Set(), false)).toBe(false);
|
||
});
|
||
|
||
it('comes back for either end, not just the source', () => {
|
||
expect(foldedEdgeShown(edge, new Set(['a']), false)).toBe(true);
|
||
expect(foldedEdgeShown(edge, new Set(['b']), false)).toBe(true);
|
||
});
|
||
|
||
it('always shows while a pulse is on it, so the live trace keeps its thread', () => {
|
||
expect(foldedEdgeShown(edge, new Set(), true)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('several hidden links at one port', () => {
|
||
it('share a mark, while a different port or side gets its own', () => {
|
||
const out = foldMarkKey({ node: 'a', port: 'value' }, true);
|
||
expect(foldMarkKey({ node: 'a', port: 'value' }, true)).toBe(out);
|
||
expect(foldMarkKey({ node: 'a', port: 'other' }, true)).not.toBe(out);
|
||
expect(foldMarkKey({ node: 'b', port: 'value' }, true)).not.toBe(out);
|
||
// The same port on the other side of the node is a mark of its own.
|
||
expect(foldMarkKey({ node: 'a', port: 'value' }, false)).not.toBe(out);
|
||
});
|
||
|
||
it('cannot be confused by a node or port name that contains the separator', () => {
|
||
expect(foldMarkKey({ node: 'a b', port: 'c' }, true)).not.toBe(
|
||
foldMarkKey({ node: 'a', port: 'b c' }, true),
|
||
);
|
||
});
|
||
|
||
it('name one far node and count the rest, so nothing is written on top of anything', () => {
|
||
expect(foldMarkLabel(['Add'])).toBe('Add');
|
||
expect(foldMarkLabel(['Add', 'Compare'])).toBe('Add +1');
|
||
expect(foldMarkLabel(['Add', 'Compare', 'Multiply'])).toBe('Add +2');
|
||
expect(foldMarkLabel([])).toBe('');
|
||
});
|
||
});
|
||
|
||
/** The bundling that keeps two hidden links out of the same port from stacking on one pixel. */
|
||
describe('fold mark bundling', () => {
|
||
const end = (over: Partial<FoldMarkEnd> & { edgeId: string; key: string }): FoldMarkEnd => ({
|
||
other: 'far',
|
||
otherLabel: 'Far',
|
||
isOutput: true,
|
||
portX: 100,
|
||
portY: 50,
|
||
color: 1,
|
||
hot: false,
|
||
...over,
|
||
});
|
||
|
||
it('gives one port one mark, however many links it hides', () => {
|
||
const marks = buildFoldMarks(
|
||
[
|
||
end({ edgeId: 'e1', key: 'a', other: 'n1', otherLabel: 'Add' }),
|
||
end({ edgeId: 'e2', key: 'a', other: 'n2', otherLabel: 'Compare' }),
|
||
end({ edgeId: 'e3', key: 'b', other: 'n3', otherLabel: 'Move' }),
|
||
],
|
||
16,
|
||
);
|
||
expect(marks).toHaveLength(2);
|
||
expect(marks[0]!.edgeIds).toEqual(['e1', 'e2']);
|
||
expect(foldMarkLabel(marks[0]!.names)).toBe('Add +1');
|
||
expect(marks[1]!.edgeIds).toEqual(['e3']);
|
||
});
|
||
|
||
it('dedupes the far nodes, so two links to one node select it once', () => {
|
||
const marks = buildFoldMarks(
|
||
[end({ edgeId: 'e1', key: 'a', other: 'n1' }), end({ edgeId: 'e2', key: 'a', other: 'n1' })],
|
||
16,
|
||
);
|
||
expect(marks[0]!.others).toEqual(['n1']);
|
||
});
|
||
|
||
it('puts the mark a stub out of its port, on the side the port faces', () => {
|
||
const [out] = buildFoldMarks([end({ edgeId: 'e1', key: 'a', isOutput: true })], 16);
|
||
const [into] = buildFoldMarks([end({ edgeId: 'e2', key: 'b', isOutput: false })], 16);
|
||
expect(out!.x).toBe(116);
|
||
expect(into!.x).toBe(84);
|
||
});
|
||
|
||
it('lets a pulse anywhere in the bundle colour the whole mark', () => {
|
||
const marks = buildFoldMarks(
|
||
[
|
||
end({ edgeId: 'e1', key: 'a', color: 1, hot: false }),
|
||
end({ edgeId: 'e2', key: 'a', color: 9, hot: true }),
|
||
],
|
||
16,
|
||
);
|
||
expect(marks[0]!.hot).toBe(true);
|
||
expect(marks[0]!.color).toBe(9);
|
||
});
|
||
|
||
it('grows the dot for a bundle and finds it under a click', () => {
|
||
expect(foldMarkRadius(1, 5)).toBe(5);
|
||
expect(foldMarkRadius(2, 5)).toBe(7);
|
||
const marks = [{ edgeIds: ['e1', 'e2'], x: 100, y: 50 }];
|
||
expect(foldMarkAt(marks, { x: 108, y: 50 }, 5)).toBe(marks[0]);
|
||
expect(foldMarkAt(marks, { x: 120, y: 50 }, 5)).toBeNull();
|
||
});
|
||
});
|