diff --git a/craft/src/state/PageContext.orphan-repair-wiring.test.tsx b/craft/src/state/PageContext.orphan-repair-wiring.test.tsx new file mode 100644 index 0000000..56be720 --- /dev/null +++ b/craft/src/state/PageContext.orphan-repair-wiring.test.tsx @@ -0,0 +1,147 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { PageProvider, usePages } from './PageContext'; + +/** + * Task 8 review finding: `PageContext.orphan-repair.test.ts` (the brief's + * prescribed test) drives `renderEditorHarness()` + `repairOrphanNodes()` + * directly -- it never mounts `PageProvider`, so nothing in it actually + * exercises `loadState`. If the `repairOrphanNodes` call were deleted from + * `loadState` outright, that file would still pass in full. + * + * This file closes that gap: it mounts a real `PageProvider` (same + * `vi.mock('@craftjs/core', ...)` + `deserializeMock` pattern as + * `PageContext.pages-productivity.test.tsx`) and drives `switchPage` -- + * `loadState`'s only reachable-from-the-UI caller for an already-stored + * page -- against a page whose stored `craftState` contains a node with no + * path back to ROOT. It asserts on what `loadState` actually handed to + * `actions.deserialize` (mocked here, same as the sibling suite) rather than + * on Craft.js's own reconciliation, which `PageContext.orphan-repair.test.ts` + * already covers via the real editor. + */ + +let serializeReturn = '{}'; +const deserializeMock = vi.fn(); + +vi.mock('@craftjs/core', () => ({ + useEditor: () => ({ + query: { serialize: () => serializeReturn }, + actions: { deserialize: deserializeMock }, + }), +})); + +let container: HTMLDivElement; +let root: Root; + +function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render(ui); + }); +} + +function unmount() { + act(() => { + root.unmount(); + }); + container.remove(); +} + +async function flushTimers() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); +} + +beforeEach(() => { + serializeReturn = '{}'; + deserializeMock.mockClear(); +}); + +/** A ROOT with no children plus an orphan ('stray') whose `parent` points at + * an id that doesn't exist in the tree, and which no node's `nodes`/ + * `linkedNodes` lists -- unreachable by BFS from ROOT. */ +const ORPHAN_STATE = JSON.stringify({ + ROOT: { + type: { resolvedName: 'Container' }, + isCanvas: true, + props: { style: {}, tag: 'div' }, + displayName: 'Container', + custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null, + }, + stray: { + type: { resolvedName: 'HtmlBlock' }, + isCanvas: false, + props: { code: '
stranded
', style: {} }, + displayName: 'HTML', + custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost', + }, +}); + +describe('loadState (via switchPage) repairs an orphaned node before handing it to Craft', () => { + test('switching to a page whose stored state has an orphan reattaches it and warns', async () => { + let ctx: ReturnType