Files
feuerwerk/tests/core/folded-edge.test.ts
T
rootandClaude Opus 5 11981b40d0 Give the three asset tabs one base, and move the viewports into the editor
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>
2026-09-10 07:14:38 +02:00

220 lines
8.8 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.
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();
});
});