mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
Task: STRUCT-002 Branch: cline-dev-dishant Cross-branch notes: none no shared dependencies with Richard's phase 9/6 work - ProjectExporter class (pure, filesystem-agnostic) - Converts legacy project.json to v2 multi-file format - Outputs: nodegx.project.json, nodegx.routes.json, nodegx.styles.json, components/_registry.json, per-component component.json/nodes.json/connections.json - Helper exports: legacyNameToPath, inferComponentType, flattenNodes, countNodes - Full legacy type definitions (LegacyProject, LegacyComponent, LegacyNode, etc.) - 50 unit tests in tests/io/ProjectExporter.test.ts - Registered in tests/index.ts
449 lines
20 KiB
TypeScript
449 lines
20 KiB
TypeScript
/**
|
|
* ProjectExporter Tests -- STRUCT-002
|
|
*
|
|
* Tests for the export engine that converts legacy project.json
|
|
* to the v2 multi-file directory structure.
|
|
*/
|
|
|
|
import {
|
|
ProjectExporter,
|
|
legacyNameToPath,
|
|
inferComponentType,
|
|
flattenNodes,
|
|
countNodes,
|
|
LegacyProject,
|
|
LegacyComponent,
|
|
LegacyNode
|
|
} from '../../src/editor/src/io/ProjectExporter';
|
|
|
|
// ---- Fixtures ----------------------------------------------------------------
|
|
|
|
const makeNode = (id: string, type = 'Group', overrides: Partial<LegacyNode> = {}): LegacyNode => ({
|
|
id,
|
|
type,
|
|
x: 0,
|
|
y: 0,
|
|
parameters: {},
|
|
children: [],
|
|
...overrides
|
|
});
|
|
|
|
const makeComponent = (name: string, overrides: Partial<LegacyComponent> = {}): LegacyComponent => ({
|
|
name,
|
|
id: 'comp_' + name.replace(/[^a-z0-9]/gi, '_'),
|
|
graph: { roots: [], connections: [] },
|
|
...overrides
|
|
});
|
|
|
|
const makeProject = (overrides: Partial<LegacyProject> = {}): LegacyProject => ({
|
|
name: 'Test Project',
|
|
version: '4',
|
|
components: [],
|
|
variants: [],
|
|
...overrides
|
|
});
|
|
|
|
// ---- legacyNameToPath --------------------------------------------------------
|
|
|
|
describe('legacyNameToPath', () => {
|
|
it('strips leading slash and hash', () => {
|
|
expect(legacyNameToPath('/#Header')).toBe('Header');
|
|
});
|
|
it('strips leading slash only', () => {
|
|
expect(legacyNameToPath('/Pages/Home')).toBe('Pages/Home');
|
|
});
|
|
it('handles root component marker', () => {
|
|
expect(legacyNameToPath('/%rootcomponent')).toBe('%rootcomponent');
|
|
});
|
|
it('handles cloud component path', () => {
|
|
expect(legacyNameToPath('/#__cloud__/SendGrid/Send')).toBe('__cloud__/SendGrid/Send');
|
|
});
|
|
it('handles name without leading slash', () => {
|
|
expect(legacyNameToPath('Header')).toBe('Header');
|
|
});
|
|
it('handles nested path', () => {
|
|
expect(legacyNameToPath('/Shared/Button')).toBe('Shared/Button');
|
|
});
|
|
});
|
|
|
|
// ---- inferComponentType ------------------------------------------------------
|
|
|
|
describe('inferComponentType', () => {
|
|
it('returns root for %rootcomponent', () => {
|
|
expect(inferComponentType('/%rootcomponent')).toBe('root');
|
|
});
|
|
it('returns cloud for __cloud__ paths', () => {
|
|
expect(inferComponentType('/#__cloud__/SendGrid/Send')).toBe('cloud');
|
|
});
|
|
it('returns page for /Pages/ paths', () => {
|
|
expect(inferComponentType('/Pages/Home')).toBe('page');
|
|
});
|
|
it('returns page case-insensitively', () => {
|
|
expect(inferComponentType('/pages/profile')).toBe('page');
|
|
});
|
|
it('returns visual for regular components', () => {
|
|
expect(inferComponentType('/#Header')).toBe('visual');
|
|
});
|
|
it('returns visual for shared components', () => {
|
|
expect(inferComponentType('/Shared/Button')).toBe('visual');
|
|
});
|
|
});
|
|
|
|
// ---- countNodes --------------------------------------------------------------
|
|
|
|
describe('countNodes', () => {
|
|
it('returns 0 for empty roots', () => {
|
|
expect(countNodes([])).toBe(0);
|
|
});
|
|
it('counts a single root node', () => {
|
|
expect(countNodes([makeNode('n1')])).toBe(1);
|
|
});
|
|
it('counts root + children recursively', () => {
|
|
const root = makeNode('n1', 'Group', {
|
|
children: [
|
|
makeNode('n2', 'Text', { children: [makeNode('n3')] }),
|
|
makeNode('n4')
|
|
]
|
|
});
|
|
expect(countNodes([root])).toBe(4);
|
|
});
|
|
it('counts multiple roots', () => {
|
|
expect(countNodes([makeNode('n1'), makeNode('n2'), makeNode('n3')])).toBe(3);
|
|
});
|
|
});
|
|
|
|
// ---- flattenNodes ------------------------------------------------------------
|
|
|
|
describe('flattenNodes', () => {
|
|
it('returns empty array for no roots', () => {
|
|
expect(flattenNodes([])).toEqual([]);
|
|
});
|
|
it('flattens a single root node', () => {
|
|
const nodes = flattenNodes([makeNode('n1', 'Group')]);
|
|
expect(nodes).toHaveLength(1);
|
|
expect(nodes[0].id).toBe('n1');
|
|
expect(nodes[0].type).toBe('Group');
|
|
});
|
|
it('flattens nested children into flat array', () => {
|
|
const root = makeNode('root', 'Group', {
|
|
children: [makeNode('child1', 'Text'), makeNode('child2', 'Image')]
|
|
});
|
|
const nodes = flattenNodes([root]);
|
|
expect(nodes).toHaveLength(3);
|
|
expect(nodes.map((n) => n.id)).toEqual(['root', 'child1', 'child2']);
|
|
});
|
|
it('sets parent id on child nodes', () => {
|
|
const root = makeNode('root', 'Group', { children: [makeNode('child1')] });
|
|
const nodes = flattenNodes([root]);
|
|
const child = nodes.find((n) => n.id === 'child1');
|
|
expect(child?.parent).toBe('root');
|
|
});
|
|
it('root nodes have no parent field', () => {
|
|
const nodes = flattenNodes([makeNode('root')]);
|
|
expect(nodes[0].parent).toBeUndefined();
|
|
});
|
|
it('sets children array of ids on parent node', () => {
|
|
const root = makeNode('root', 'Group', { children: [makeNode('c1'), makeNode('c2')] });
|
|
const nodes = flattenNodes([root]);
|
|
const rootNode = nodes.find((n) => n.id === 'root');
|
|
expect(rootNode?.children).toEqual(['c1', 'c2']);
|
|
});
|
|
it('omits empty parameters object', () => {
|
|
const nodes = flattenNodes([makeNode('n1', 'Group', { parameters: {} })]);
|
|
expect(nodes[0].parameters).toBeUndefined();
|
|
});
|
|
it('preserves non-empty parameters', () => {
|
|
const nodes = flattenNodes([makeNode('n1', 'Group', { parameters: { layout: 'row' } })]);
|
|
expect(nodes[0].parameters).toEqual({ layout: 'row' });
|
|
});
|
|
it('preserves stateParameters', () => {
|
|
const nodes = flattenNodes([makeNode('n1', 'Group', { stateParameters: { hover: { opacity: 0.5 } } })]);
|
|
expect(nodes[0].stateParameters).toEqual({ hover: { opacity: 0.5 } });
|
|
});
|
|
it('preserves metadata', () => {
|
|
const nodes = flattenNodes([makeNode('n1', 'Group', { metadata: { comment: 'hello' } })]);
|
|
expect(nodes[0].metadata).toEqual({ comment: 'hello' });
|
|
});
|
|
it('handles deeply nested children', () => {
|
|
const deep = makeNode('l3');
|
|
const mid = makeNode('l2', 'Group', { children: [deep] });
|
|
const root = makeNode('l1', 'Group', { children: [mid] });
|
|
const nodes = flattenNodes([root]);
|
|
expect(nodes).toHaveLength(3);
|
|
const l3 = nodes.find((n) => n.id === 'l3');
|
|
expect(l3?.parent).toBe('l2');
|
|
});
|
|
});
|
|
|
|
// ---- ProjectExporter ---------------------------------------------------------
|
|
|
|
describe('ProjectExporter', () => {
|
|
let exporter: ProjectExporter;
|
|
|
|
beforeEach(() => {
|
|
exporter = new ProjectExporter();
|
|
});
|
|
|
|
describe('export() basic structure', () => {
|
|
it('always produces nodegx.project.json', () => {
|
|
const result = exporter.export(makeProject());
|
|
expect(result.files.map((f) => f.relativePath)).toContain('nodegx.project.json');
|
|
});
|
|
it('always produces components/_registry.json', () => {
|
|
const result = exporter.export(makeProject());
|
|
expect(result.files.map((f) => f.relativePath)).toContain('components/_registry.json');
|
|
});
|
|
it('does not produce routes file when no routes in metadata', () => {
|
|
const result = exporter.export(makeProject());
|
|
expect(result.files.map((f) => f.relativePath)).not.toContain('nodegx.routes.json');
|
|
});
|
|
it('does not produce styles file when no styles or variants', () => {
|
|
const result = exporter.export(makeProject());
|
|
expect(result.files.map((f) => f.relativePath)).not.toContain('nodegx.styles.json');
|
|
});
|
|
it('returns correct stats for empty project', () => {
|
|
const result = exporter.export(makeProject());
|
|
expect(result.stats).toEqual({ totalComponents: 0, totalNodes: 0, totalConnections: 0 });
|
|
});
|
|
});
|
|
|
|
describe('nodegx.project.json', () => {
|
|
const getProjectFile = (project: LegacyProject) => {
|
|
const result = exporter.export(project);
|
|
return result.files.find((f) => f.relativePath === 'nodegx.project.json')?.content as any;
|
|
};
|
|
|
|
it('includes project name', () => {
|
|
expect(getProjectFile(makeProject({ name: 'My App' })).name).toBe('My App');
|
|
});
|
|
it('includes version', () => {
|
|
expect(getProjectFile(makeProject({ version: '4' })).version).toBe('4');
|
|
});
|
|
it('includes runtimeVersion when set', () => {
|
|
expect(getProjectFile(makeProject({ runtimeVersion: 'react19' })).runtimeVersion).toBe('react19');
|
|
});
|
|
it('omits runtimeVersion when not set', () => {
|
|
expect(getProjectFile(makeProject()).runtimeVersion).toBeUndefined();
|
|
});
|
|
it('includes settings when present', () => {
|
|
expect(getProjectFile(makeProject({ settings: { bodyScroll: true } })).settings).toEqual({ bodyScroll: true });
|
|
});
|
|
it('includes $schema field', () => {
|
|
expect(getProjectFile(makeProject())['$schema']).toContain('project-v2');
|
|
});
|
|
it('strips styles and routes from metadata but keeps other keys', () => {
|
|
const meta = getProjectFile(makeProject({
|
|
metadata: { styles: { colors: {} }, routes: [], cloudservices: { id: 'abc' } }
|
|
})).metadata;
|
|
expect(meta?.styles).toBeUndefined();
|
|
expect(meta?.routes).toBeUndefined();
|
|
expect(meta?.cloudservices).toEqual({ id: 'abc' });
|
|
});
|
|
});
|
|
|
|
describe('nodegx.routes.json', () => {
|
|
it('produces routes file when metadata.routes is an array', () => {
|
|
const result = exporter.export(makeProject({ metadata: { routes: [{ path: '/home', component: 'pages/Home' }] } }));
|
|
expect(result.files.map((f) => f.relativePath)).toContain('nodegx.routes.json');
|
|
});
|
|
it('routes file contains the routes array', () => {
|
|
const routes = [{ path: '/home', component: 'pages/Home' }];
|
|
const result = exporter.export(makeProject({ metadata: { routes } }));
|
|
const file = result.files.find((f) => f.relativePath === 'nodegx.routes.json')?.content as any;
|
|
expect(file.routes).toEqual(routes);
|
|
});
|
|
it('does not produce routes file when metadata.routes is not an array', () => {
|
|
const result = exporter.export(makeProject({ metadata: { routes: {} } }));
|
|
expect(result.files.map((f) => f.relativePath)).not.toContain('nodegx.routes.json');
|
|
});
|
|
});
|
|
|
|
describe('nodegx.styles.json', () => {
|
|
it('produces styles file when metadata.styles has colors', () => {
|
|
const result = exporter.export(makeProject({ metadata: { styles: { colors: { primary: '#3B82F6' } } } }));
|
|
expect(result.files.map((f) => f.relativePath)).toContain('nodegx.styles.json');
|
|
});
|
|
it('produces styles file when variants exist', () => {
|
|
const result = exporter.export(makeProject({ variants: [{ name: 'primary', typename: 'Button' }] }));
|
|
expect(result.files.map((f) => f.relativePath)).toContain('nodegx.styles.json');
|
|
});
|
|
it('includes colors from metadata.styles', () => {
|
|
const result = exporter.export(makeProject({ metadata: { styles: { colors: { primary: '#3B82F6' } } } }));
|
|
const file = result.files.find((f) => f.relativePath === 'nodegx.styles.json')?.content as any;
|
|
expect(file.colors).toEqual({ primary: '#3B82F6' });
|
|
});
|
|
it('normalises legacy stateParamaters typo to stateParameters', () => {
|
|
const result = exporter.export(makeProject({
|
|
variants: [{ name: 'primary', typename: 'Button', stateParamaters: { hover: { opacity: 0.8 } } }]
|
|
}));
|
|
const file = result.files.find((f) => f.relativePath === 'nodegx.styles.json')?.content as any;
|
|
expect(file.variants[0].stateParameters).toEqual({ hover: { opacity: 0.8 } });
|
|
expect(file.variants[0].stateParamaters).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('component files', () => {
|
|
it('produces 3 files per component', () => {
|
|
const result = exporter.export(makeProject({ components: [makeComponent('/#Header')] }));
|
|
const paths = result.files.map((f) => f.relativePath);
|
|
expect(paths).toContain('components/Header/component.json');
|
|
expect(paths).toContain('components/Header/nodes.json');
|
|
expect(paths).toContain('components/Header/connections.json');
|
|
});
|
|
it('uses correct path for nested component', () => {
|
|
const result = exporter.export(makeProject({ components: [makeComponent('/Pages/Home')] }));
|
|
expect(result.files.map((f) => f.relativePath)).toContain('components/Pages/Home/component.json');
|
|
});
|
|
it('component.json has correct id', () => {
|
|
const comp = makeComponent('/#Header');
|
|
comp.id = 'comp_header_123';
|
|
const result = exporter.export(makeProject({ components: [comp] }));
|
|
const file = result.files.find((f) => f.relativePath === 'components/Header/component.json')?.content as any;
|
|
expect(file.id).toBe('comp_header_123');
|
|
});
|
|
it('component.json preserves original legacy path', () => {
|
|
const result = exporter.export(makeProject({ components: [makeComponent('/#Header')] }));
|
|
const file = result.files.find((f) => f.relativePath === 'components/Header/component.json')?.content as any;
|
|
expect(file.path).toBe('/#Header');
|
|
});
|
|
it('component.json infers correct type for page', () => {
|
|
const result = exporter.export(makeProject({ components: [makeComponent('/Pages/Home')] }));
|
|
const file = result.files.find((f) => f.relativePath === 'components/Pages/Home/component.json')?.content as any;
|
|
expect(file.type).toBe('page');
|
|
});
|
|
it('nodes.json has componentId', () => {
|
|
const comp = makeComponent('/#Header');
|
|
comp.id = 'comp_header_123';
|
|
const result = exporter.export(makeProject({ components: [comp] }));
|
|
const file = result.files.find((f) => f.relativePath === 'components/Header/nodes.json')?.content as any;
|
|
expect(file.componentId).toBe('comp_header_123');
|
|
});
|
|
it('nodes.json flattens nested nodes', () => {
|
|
const comp = makeComponent('/#Header');
|
|
comp.graph.roots = [makeNode('root', 'Group', { children: [makeNode('child', 'Text')] })];
|
|
const result = exporter.export(makeProject({ components: [comp] }));
|
|
const file = result.files.find((f) => f.relativePath === 'components/Header/nodes.json')?.content as any;
|
|
expect(file.nodes).toHaveLength(2);
|
|
});
|
|
it('connections.json maps legacy connections correctly', () => {
|
|
const comp = makeComponent('/#Header');
|
|
comp.graph.connections = [{ fromId: 'n1', fromProperty: 'onClick', toId: 'n2', toProperty: 'trigger' }];
|
|
const result = exporter.export(makeProject({ components: [comp] }));
|
|
const file = result.files.find((f) => f.relativePath === 'components/Header/connections.json')?.content as any;
|
|
expect(file.connections).toHaveLength(1);
|
|
expect(file.connections[0]).toEqual({ fromId: 'n1', fromProperty: 'onClick', toId: 'n2', toProperty: 'trigger' });
|
|
});
|
|
it('connections.json preserves annotation field', () => {
|
|
const comp = makeComponent('/#Header');
|
|
comp.graph.connections = [{ fromId: 'n1', fromProperty: 'out', toId: 'n2', toProperty: 'in', annotation: 'Created' }];
|
|
const result = exporter.export(makeProject({ components: [comp] }));
|
|
const file = result.files.find((f) => f.relativePath === 'components/Header/connections.json')?.content as any;
|
|
expect(file.connections[0].annotation).toBe('Created');
|
|
});
|
|
});
|
|
|
|
describe('_registry.json', () => {
|
|
it('lists all components', () => {
|
|
const result = exporter.export(makeProject({ components: [makeComponent('/#Header'), makeComponent('/Pages/Home')] }));
|
|
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
|
expect(Object.keys(file.components)).toHaveLength(2);
|
|
expect(file.components['Header']).toBeDefined();
|
|
expect(file.components['Pages/Home']).toBeDefined();
|
|
});
|
|
it('registry entry has correct path', () => {
|
|
const result = exporter.export(makeProject({ components: [makeComponent('/#Header')] }));
|
|
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
|
expect(file.components['Header'].path).toBe('Header');
|
|
});
|
|
it('registry entry has correct type', () => {
|
|
const result = exporter.export(makeProject({ components: [makeComponent('/Pages/Home')] }));
|
|
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
|
expect(file.components['Pages/Home'].type).toBe('page');
|
|
});
|
|
it('registry entry has nodeCount', () => {
|
|
const comp = makeComponent('/#Header');
|
|
comp.graph.roots = [makeNode('n1'), makeNode('n2')];
|
|
const result = exporter.export(makeProject({ components: [comp] }));
|
|
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
|
expect(file.components['Header'].nodeCount).toBe(2);
|
|
});
|
|
it('registry entry has connectionCount', () => {
|
|
const comp = makeComponent('/#Header');
|
|
comp.graph.connections = [
|
|
{ fromId: 'n1', fromProperty: 'out', toId: 'n2', toProperty: 'in' },
|
|
{ fromId: 'n2', fromProperty: 'out', toId: 'n3', toProperty: 'in' }
|
|
];
|
|
const result = exporter.export(makeProject({ components: [comp] }));
|
|
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
|
expect(file.components['Header'].connectionCount).toBe(2);
|
|
});
|
|
it('registry stats are correct', () => {
|
|
const comp1 = makeComponent('/#Header');
|
|
comp1.graph.roots = [makeNode('n1')];
|
|
comp1.graph.connections = [{ fromId: 'n1', fromProperty: 'out', toId: 'n2', toProperty: 'in' }];
|
|
const comp2 = makeComponent('/Pages/Home');
|
|
comp2.graph.roots = [makeNode('n2'), makeNode('n3')];
|
|
const result = exporter.export(makeProject({ components: [comp1, comp2] }));
|
|
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
|
expect(file.stats.totalComponents).toBe(2);
|
|
expect(file.stats.totalNodes).toBe(3);
|
|
expect(file.stats.totalConnections).toBe(1);
|
|
});
|
|
it('registry has version 1', () => {
|
|
const result = exporter.export(makeProject());
|
|
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
|
expect(file.version).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('ExportResult stats', () => {
|
|
it('stats match actual component/node/connection counts', () => {
|
|
const comp = makeComponent('/#Header');
|
|
comp.graph.roots = [makeNode('n1', 'Group', { children: [makeNode('n2')] })];
|
|
comp.graph.connections = [{ fromId: 'n1', fromProperty: 'out', toId: 'n2', toProperty: 'in' }];
|
|
const result = exporter.export(makeProject({ components: [comp] }));
|
|
expect(result.stats.totalComponents).toBe(1);
|
|
expect(result.stats.totalNodes).toBe(2);
|
|
expect(result.stats.totalConnections).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('handles component with no graph gracefully', () => {
|
|
const comp = makeComponent('/#Header');
|
|
(comp as any).graph = undefined;
|
|
expect(() => exporter.export(makeProject({ components: [comp] }))).not.toThrow();
|
|
});
|
|
it('handles component with empty graph', () => {
|
|
const comp = makeComponent('/#Header');
|
|
comp.graph = { roots: [], connections: [] };
|
|
const result = exporter.export(makeProject({ components: [comp] }));
|
|
const nodesFile = result.files.find((f) => f.relativePath === 'components/Header/nodes.json')?.content as any;
|
|
expect(nodesFile.nodes).toHaveLength(0);
|
|
});
|
|
it('handles multiple components correctly', () => {
|
|
const project = makeProject({
|
|
components: [makeComponent('/#Header'), makeComponent('/Pages/Home'), makeComponent('/Shared/Button')]
|
|
});
|
|
const result = exporter.export(project);
|
|
expect(result.stats.totalComponents).toBe(3);
|
|
const componentFiles = result.files.filter(
|
|
(f) => f.relativePath.startsWith('components/') && !f.relativePath.endsWith('_registry.json')
|
|
);
|
|
expect(componentFiles).toHaveLength(9);
|
|
});
|
|
it('cloud component gets correct type', () => {
|
|
const result = exporter.export(makeProject({ components: [makeComponent('/#__cloud__/SendGrid/Send')] }));
|
|
const file = result.files.find((f) => f.relativePath.includes('component.json'))?.content as any;
|
|
expect(file.type).toBe('cloud');
|
|
});
|
|
it('node with variant is preserved', () => {
|
|
const comp = makeComponent('/#Header');
|
|
comp.graph.roots = [makeNode('n1', 'Button', { variant: 'primary' })];
|
|
const result = exporter.export(makeProject({ components: [comp] }));
|
|
const nodesFile = result.files.find((f) => f.relativePath === 'components/Header/nodes.json')?.content as any;
|
|
expect(nodesFile.nodes[0].variant).toBe('primary');
|
|
});
|
|
});
|
|
});
|