import { describe, test, expect } from 'vitest'; import { findUnreachableNodeIds, repairOrphanNodes } from './orphan-repair'; /** Minimal Craft-shaped node. */ function node(over: Record = {}) { 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([]); }); // 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); }); });