refactor(builder): extract shared tree-flattener into craft-tree.ts
PageContext.treeToState, apply-ai-response.buildNodeTree, and the dead
serializeTreeForCraft each re-implemented the same ColumnLayout-linkedNodes +
SHELL_INNER walk and had drifted (buildNodeTree gained guards the others
lacked). Move sanitizeAiTree (resolvedName allowlist + id/ROOT repair) here
too and add flattenTreeForCraft, the shared structural walk (linkedNodes,
SHELL_INNER wrapping, style:[]->{} normalization) both real callers will
consolidate onto.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import type { NodeTree, Node } from '@craftjs/core';
|
||||
import { regenerateTreeIds } from './craft-tree';
|
||||
import type { SerializedTreeNode } from '../types/sitesmith';
|
||||
import { regenerateTreeIds, flattenTreeForCraft, sanitizeAiTree } from './craft-tree';
|
||||
|
||||
function makeNode(overrides: Partial<Node>): Node {
|
||||
return {
|
||||
@@ -147,3 +148,115 @@ describe('regenerateTreeIds', () => {
|
||||
expect(input.nodes['root-1'].data.props).toEqual({ alignment: 'left' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenTreeForCraft', () => {
|
||||
test('style: [] normalizes to {}', () => {
|
||||
const tree: SerializedTreeNode = {
|
||||
type: { resolvedName: 'Heading' },
|
||||
props: { node_id: 'h1', style: [] as unknown as Record<string, unknown> },
|
||||
nodes: [],
|
||||
};
|
||||
const { nodes } = flattenTreeForCraft(tree);
|
||||
expect(nodes['h1'].props.style).toEqual({});
|
||||
});
|
||||
|
||||
test('a ColumnLayout with direct children flattens them into col-N linkedNodes', () => {
|
||||
const tree: SerializedTreeNode = {
|
||||
type: { resolvedName: 'ColumnLayout' },
|
||||
props: { node_id: 'cols' },
|
||||
nodes: [
|
||||
{ type: { resolvedName: 'Heading' }, props: { node_id: 'a' }, nodes: [] },
|
||||
{ type: { resolvedName: 'Heading' }, props: { node_id: 'b' }, nodes: [] },
|
||||
],
|
||||
};
|
||||
const { nodes } = flattenTreeForCraft(tree);
|
||||
expect(nodes['cols'].nodes).toEqual([]);
|
||||
expect(nodes['cols'].linkedNodes).toEqual({ 'col-0': 'a', 'col-1': 'b' });
|
||||
expect(nodes['cols'].props.columns).toBe(2);
|
||||
expect(nodes['a'].parent).toBe('cols');
|
||||
expect(nodes['a'].isCanvas).toBe(true);
|
||||
expect(nodes['b'].parent).toBe('cols');
|
||||
});
|
||||
|
||||
test('a Section with direct children wraps them in a synthetic section-inner Container', () => {
|
||||
const tree: SerializedTreeNode = {
|
||||
type: { resolvedName: 'Section' },
|
||||
props: { node_id: 'sec' },
|
||||
nodes: [
|
||||
{ type: { resolvedName: 'Heading' }, props: { node_id: 'h1' }, nodes: [] },
|
||||
],
|
||||
};
|
||||
const { rootNodeId, nodes, syntheticIds } = flattenTreeForCraft(tree);
|
||||
expect(rootNodeId).toBe('sec');
|
||||
const innerId = nodes['sec'].linkedNodes['section-inner'];
|
||||
expect(innerId).toBe('sec__section-inner');
|
||||
expect(syntheticIds.has(innerId)).toBe(true);
|
||||
expect(nodes[innerId].nodes).toEqual(['h1']);
|
||||
expect(nodes[innerId].type.resolvedName).toBe('Container');
|
||||
expect(nodes['h1'].parent).toBe(innerId);
|
||||
expect(nodes['sec'].nodes).toEqual([]);
|
||||
});
|
||||
|
||||
test('a small tree with both a ColumnLayout and a SHELL_INNER wrapper flattens the same way regardless of caller intent', () => {
|
||||
// Same shape both PageContext.treeToCraftState and apply-ai-response's
|
||||
// buildNodeTree feed through this helper — the walk itself must not care
|
||||
// which one is calling it.
|
||||
const tree: SerializedTreeNode = {
|
||||
type: { resolvedName: 'Section' },
|
||||
props: { node_id: 'sec' },
|
||||
nodes: [
|
||||
{
|
||||
type: { resolvedName: 'ColumnLayout' },
|
||||
props: { node_id: 'cols' },
|
||||
nodes: [
|
||||
{ type: { resolvedName: 'Heading' }, props: { node_id: 'a' }, nodes: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const run1 = flattenTreeForCraft(tree);
|
||||
const run2 = flattenTreeForCraft(tree);
|
||||
expect(run1.rootNodeId).toBe(run2.rootNodeId);
|
||||
expect(Object.keys(run1.nodes).sort()).toEqual(Object.keys(run2.nodes).sort());
|
||||
const innerId = run1.nodes['sec'].linkedNodes['section-inner'];
|
||||
expect(run1.nodes[innerId].linkedNodes).toEqual({});
|
||||
expect(run1.nodes[innerId].nodes).toEqual(['cols']);
|
||||
expect(run1.nodes['cols'].linkedNodes).toEqual({ 'col-0': 'a' });
|
||||
expect(run1.nodes['a'].parent).toBe('cols');
|
||||
});
|
||||
|
||||
test('auto-generates an id when node_id is missing', () => {
|
||||
const tree: SerializedTreeNode = { type: { resolvedName: 'Heading' }, props: {}, nodes: [] };
|
||||
const { rootNodeId, nodes } = flattenTreeForCraft(tree);
|
||||
expect(typeof rootNodeId).toBe('string');
|
||||
expect(nodes[rootNodeId]).toBeDefined();
|
||||
});
|
||||
|
||||
test('isCanvas is true only for Container', () => {
|
||||
const containerTree: SerializedTreeNode = { type: { resolvedName: 'Container' }, props: { node_id: 'c1' }, nodes: [] };
|
||||
const headingTree: SerializedTreeNode = { type: { resolvedName: 'Heading' }, props: { node_id: 'h1' }, nodes: [] };
|
||||
expect(flattenTreeForCraft(containerTree).nodes['c1'].isCanvas).toBe(true);
|
||||
expect(flattenTreeForCraft(headingTree).nodes['h1'].isCanvas).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeAiTree (re-exported for treeToCraftState parity with buildNodeTree)', () => {
|
||||
test('unknown resolvedName at the root is rejected (returns null)', () => {
|
||||
const tree = { type: { resolvedName: 'NotARealComponent' }, props: {}, nodes: [] };
|
||||
expect(sanitizeAiTree(tree, new Set())).toBeNull();
|
||||
});
|
||||
|
||||
test('unknown resolvedName on a nested child drops only that subtree', () => {
|
||||
const tree = {
|
||||
type: { resolvedName: 'Section' },
|
||||
props: { node_id: 's1' },
|
||||
nodes: [
|
||||
{ type: { resolvedName: 'NotARealComponent' }, props: {}, nodes: [] },
|
||||
{ type: { resolvedName: 'Heading' }, props: { node_id: 'h1' }, nodes: [] },
|
||||
],
|
||||
};
|
||||
const out = sanitizeAiTree(tree, new Set());
|
||||
expect(out!.nodes!.length).toBe(1);
|
||||
expect(out!.nodes![0].props.node_id).toBe('h1');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user