2026-08-09 06:36:39 -07:00
|
|
|
import { describe, test, expect } from 'vitest';
|
|
|
|
|
import { findUnreachableNodeIds, repairOrphanNodes } from './orphan-repair';
|
|
|
|
|
|
|
|
|
|
/** Minimal Craft-shaped node. */
|
|
|
|
|
function node(over: Record<string, any> = {}) {
|
|
|
|
|
return {
|
|
|
|
|
type: { resolvedName: 'Container' },
|
|
|
|
|
isCanvas: false,
|
|
|
|
|
props: {},
|
|
|
|
|
displayName: 'Container',
|
|
|
|
|
custom: {},
|
|
|
|
|
hidden: false,
|
|
|
|
|
nodes: [],
|
|
|
|
|
linkedNodes: {},
|
|
|
|
|
parent: 'ROOT',
|
|
|
|
|
...over,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const healthy = JSON.stringify({
|
|
|
|
|
ROOT: node({ isCanvas: true, parent: null, nodes: ['a'] }),
|
|
|
|
|
a: node({ parent: 'ROOT' }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('findUnreachableNodeIds', () => {
|
|
|
|
|
test('a healthy tree has no unreachable nodes', () => {
|
|
|
|
|
expect(findUnreachableNodeIds(JSON.parse(healthy))).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a node ROOT does not list is unreachable even when its parent says ROOT', () => {
|
|
|
|
|
const nodes = JSON.parse(healthy);
|
|
|
|
|
nodes.stray = node({ parent: 'ROOT', displayName: 'HTML' });
|
|
|
|
|
expect(findUnreachableNodeIds(nodes)).toEqual(['stray']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a node whose parent no longer exists is unreachable', () => {
|
|
|
|
|
const nodes = JSON.parse(healthy);
|
|
|
|
|
nodes.stray = node({ parent: 'ghost' });
|
|
|
|
|
expect(findUnreachableNodeIds(nodes)).toEqual(['stray']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('children of an unreachable node are also unreachable', () => {
|
|
|
|
|
const nodes = JSON.parse(healthy);
|
|
|
|
|
nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] });
|
|
|
|
|
nodes.strayChild = node({ parent: 'stray' });
|
|
|
|
|
expect(findUnreachableNodeIds(nodes).sort()).toEqual(['stray', 'strayChild']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedNodes children count as reachable', () => {
|
|
|
|
|
const nodes = JSON.parse(healthy);
|
|
|
|
|
nodes.ROOT.linkedNodes = { inner: 'linked' };
|
|
|
|
|
nodes.linked = node({ parent: 'ROOT' });
|
|
|
|
|
expect(findUnreachableNodeIds(nodes)).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a cycle among orphans terminates instead of hanging', () => {
|
|
|
|
|
const nodes = JSON.parse(healthy);
|
|
|
|
|
nodes.x = node({ parent: 'y', nodes: ['y'] });
|
|
|
|
|
nodes.y = node({ parent: 'x', nodes: ['x'] });
|
|
|
|
|
expect(findUnreachableNodeIds(nodes).sort()).toEqual(['x', 'y']);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('repairOrphanNodes', () => {
|
|
|
|
|
test('a healthy tree is returned byte-identical with nothing repaired', () => {
|
|
|
|
|
const out = repairOrphanNodes(healthy);
|
|
|
|
|
expect(out.repaired).toEqual([]);
|
|
|
|
|
expect(out.state).toBe(healthy);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an orphan is appended to the end of ROOT.nodes and reparented', () => {
|
|
|
|
|
const nodes = JSON.parse(healthy);
|
|
|
|
|
nodes.stray = node({ parent: 'ghost', displayName: 'HTML' });
|
|
|
|
|
const out = repairOrphanNodes(JSON.stringify(nodes));
|
|
|
|
|
|
|
|
|
|
expect(out.repaired).toEqual(['stray']);
|
|
|
|
|
const parsed = JSON.parse(out.state);
|
|
|
|
|
expect(parsed.ROOT.nodes).toEqual(['a', 'stray']);
|
|
|
|
|
expect(parsed.stray.parent).toBe('ROOT');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('only the top of an orphan subtree is reattached; its children ride along', () => {
|
|
|
|
|
const nodes = JSON.parse(healthy);
|
|
|
|
|
nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] });
|
|
|
|
|
nodes.strayChild = node({ parent: 'stray' });
|
|
|
|
|
const out = repairOrphanNodes(JSON.stringify(nodes));
|
|
|
|
|
|
|
|
|
|
expect(out.repaired).toEqual(['stray']);
|
|
|
|
|
const parsed = JSON.parse(out.state);
|
|
|
|
|
expect(parsed.ROOT.nodes).toEqual(['a', 'stray']);
|
|
|
|
|
expect(parsed.strayChild.parent).toBe('stray');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('malformed JSON comes back unchanged rather than throwing', () => {
|
|
|
|
|
const out = repairOrphanNodes('{not json');
|
|
|
|
|
expect(out.state).toBe('{not json');
|
|
|
|
|
expect(out.repaired).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('state with no ROOT comes back unchanged', () => {
|
|
|
|
|
const orphanOnly = JSON.stringify({ a: node({ parent: null }) });
|
|
|
|
|
const out = repairOrphanNodes(orphanOnly);
|
|
|
|
|
expect(out.state).toBe(orphanOnly);
|
|
|
|
|
expect(out.repaired).toEqual([]);
|
|
|
|
|
});
|
2026-08-09 06:43:46 -07:00
|
|
|
|
|
|
|
|
// A cluster of orphans whose `parent` fields (and child lists) reference
|
|
|
|
|
// only each other has no member pointing "outside" the cluster, so the
|
|
|
|
|
// simple "reattach the top" rule finds no top at all. Regression coverage
|
|
|
|
|
// for the invariant: after repair, nothing in the returned state may still
|
|
|
|
|
// be unreachable -- see the loop comment in the implementation.
|
|
|
|
|
|
|
|
|
|
test('a 2-node orphan cycle is fully reachable after repair', () => {
|
|
|
|
|
const nodes = JSON.parse(healthy);
|
|
|
|
|
nodes.x = node({ parent: 'y', nodes: ['y'] });
|
|
|
|
|
nodes.y = node({ parent: 'x', nodes: ['x'] });
|
|
|
|
|
const out = repairOrphanNodes(JSON.stringify(nodes));
|
|
|
|
|
|
|
|
|
|
expect(out.repaired.length).toBeGreaterThan(0);
|
|
|
|
|
const parsed = JSON.parse(out.state);
|
|
|
|
|
expect(findUnreachableNodeIds(parsed)).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a 3-node orphan cycle is fully reachable after repair', () => {
|
|
|
|
|
const nodes = JSON.parse(healthy);
|
|
|
|
|
nodes.p = node({ parent: 'r', nodes: ['q'] });
|
|
|
|
|
nodes.q = node({ parent: 'p', nodes: ['r'] });
|
|
|
|
|
nodes.r = node({ parent: 'q', nodes: ['p'] });
|
|
|
|
|
const out = repairOrphanNodes(JSON.stringify(nodes));
|
|
|
|
|
|
|
|
|
|
expect(out.repaired.length).toBeGreaterThan(0);
|
|
|
|
|
const parsed = JSON.parse(out.state);
|
|
|
|
|
expect(findUnreachableNodeIds(parsed)).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an ordinary orphan subtree and a separate orphan cycle in the same document are both repaired', () => {
|
|
|
|
|
const nodes = JSON.parse(healthy);
|
|
|
|
|
nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] });
|
|
|
|
|
nodes.strayChild = node({ parent: 'stray' });
|
|
|
|
|
nodes.x = node({ parent: 'y', nodes: ['y'] });
|
|
|
|
|
nodes.y = node({ parent: 'x', nodes: ['x'] });
|
|
|
|
|
const out = repairOrphanNodes(JSON.stringify(nodes));
|
|
|
|
|
|
|
|
|
|
const parsed = JSON.parse(out.state);
|
|
|
|
|
expect(findUnreachableNodeIds(parsed)).toEqual([]);
|
|
|
|
|
// The ordinary subtree keeps its established "only the top is
|
|
|
|
|
// reattached" behaviour: stray is reparented, strayChild rides along
|
|
|
|
|
// untouched.
|
|
|
|
|
expect(out.repaired).toContain('stray');
|
|
|
|
|
expect(parsed.strayChild.parent).toBe('stray');
|
|
|
|
|
// Exactly one representative per component is force-reattached: stray
|
|
|
|
|
// (found via the normal rule) plus one of x/y (via the cycle fallback).
|
|
|
|
|
expect(out.repaired.length).toBe(2);
|
|
|
|
|
});
|
2026-08-09 06:36:39 -07:00
|
|
|
});
|