Merge branch 'cline-dev-richard' into cline-dev

This commit is contained in:
Richard Osborne
2026-02-18 21:30:07 +01:00
83 changed files with 8528 additions and 2711 deletions

View File

@@ -1,536 +0,0 @@
const { ComponentsPanelView } = require('@noodl-views/panels/componentspanel/ComponentsPanel');
const { ProjectModel } = require('@noodl-models/projectmodel');
const { UndoQueue } = require('@noodl-models/undo-queue-model');
const NodeGraphEditor = require('@noodl-views/nodegrapheditor').NodeGraphEditor;
const ViewerConnection = require('../../src/editor/src/ViewerConnection');
describe('Components panel unit tests', function () {
var cp;
var p1;
var project = {
components: [
{
name: 'Root',
graph: {}
},
{
name: '/test/f1/a',
graph: {}
},
{
name: '/test/f2/a',
graph: {}
},
{
name: '/b',
graph: {}
},
{
name: '/test/ff/a',
graph: {}
},
{
name: '/q',
graph: {}
},
{
name: '/a',
graph: {}
},
{
name: '/dup/f1/a',
graph: {}
},
// Undo tests
{
name: '/delete_folder/delete_comp',
graph: {}
},
{
name: '/rename_folder/rename_comp',
graph: {}
},
{
name: '/drop/a',
graph: {}
},
{
name: '/drop2/a',
graph: {}
},
{
name: '/dropundo',
graph: {}
},
{
name: '/nested-target/a',
graph: {}
},
{
name: '/nested-dropme/test/b',
graph: {}
},
{
name: '/delete-me/with-content/a',
graph: {}
},
{
name: '/delete-me/b',
graph: {}
}
]
};
beforeAll(() => {
// Mock node graph editor
NodeGraphEditor.instance = {
getActiveComponent() {
return p1.getComponentWithName('Root');
},
on() {},
off() {},
switchToComponent() {}
};
// Viewerconnection mock
ViewerConnection.instance = {
on() {},
off() {}
};
});
afterAll(() => {
NodeGraphEditor.instance = undefined;
ViewerConnection.instance = undefined;
});
beforeEach(() => {
p1 = ProjectModel.instance = ProjectModel.fromJSON(project);
cp = new ComponentsPanelView({});
cp.setNodeGraphEditor(NodeGraphEditor.instance);
cp.render();
});
afterEach(() => {
cp.dispose();
ProjectModel.instance = undefined;
});
it('can setup view', function () {
expect(cp).not.toBe(undefined);
});
it('can add new folders', function () {
// Existing folder
expect(
cp.performAdd({
type: 'folder',
name: 'test'
}).success
).toBe(false);
// Empty name
expect(
cp.performAdd({
type: 'folder',
name: ''
}).success
).toBe(false);
// Add
expect(
cp.performAdd({
type: 'folder',
name: 'f3'
}).success
).toBe(true);
expect(cp.getFolderWithPath('/f3/')).not.toBe(undefined);
});
it('can add components', function () {
// Existing name
expect(
cp.performAdd({
type: 'component',
name: 'b',
parentPath: '/'
}).success
).toBe(false);
// Empty name
expect(
cp.performAdd({
type: 'component',
name: ''
}).success
).toBe(false);
// Add
expect(
cp.performAdd({
type: 'component',
name: 'c',
parentPath: '/'
}).success
).toBe(true);
expect(p1.getComponentWithName('/c')).not.toBe(undefined);
expect(cp.getFolderWithPath('/').hasComponentWithName('c')).toBe(true);
// Add to sub directory
expect(
cp.performAdd({
type: 'component',
name: 'subsub',
parentPath: '/test/ff/'
}).success
).toBe(true);
expect(p1.getComponentWithName('/test/ff/subsub')).not.toBe(undefined);
expect(cp.getFolderWithPath('/test/ff/').hasComponentWithName('subsub')).toBe(true);
});
it('can rename folders', function () {
// Existing name
expect(
cp.performRename({
type: 'folder',
name: 'f2',
folder: cp.getFolderWithPath('/test/ff/')
}).success
).toBe(false);
// Empty name
expect(
cp.performRename({
type: 'folder',
name: '',
folder: cp.getFolderWithPath('/test/ff/')
}).success
).toBe(false);
// Empty name
expect(
cp.performRename({
type: 'folder',
name: 'f4',
folder: cp.getFolderWithPath('/test/ff/')
}).success
).toBe(true);
expect(p1.getComponentWithName('/test/ff/a')).toBe(undefined);
expect(p1.getComponentWithName('/test/f4/a')).not.toBe(undefined);
expect(cp.getFolderWithPath('/test/ff/')).toBe(undefined);
expect(cp.getFolderWithPath('/test/f4/')).not.toBe(undefined);
});
it('can rename components', function () {
// Existing name
expect(
cp.performRename({
type: 'component',
name: 'b',
folder: cp.getFolderWithPath('/'),
component: p1.getComponentWithName('/q')
}).success
).toBe(false);
// Empty name
expect(
cp.performRename({
type: 'component',
name: '',
folder: cp.getFolderWithPath('/'),
component: p1.getComponentWithName('/q')
}).success
).toBe(false);
// Empty name
expect(
cp.performRename({
type: 'component',
name: 'q2',
folder: cp.getFolderWithPath('/'),
component: p1.getComponentWithName('/q')
}).success
).toBe(true);
expect(p1.getComponentWithName('/q')).toBe(undefined);
expect(p1.getComponentWithName('/q2')).not.toBe(undefined);
});
it('can detect duplicates', function () {
// Cannot move to folder containing a comp with same name
expect(
cp.getAcceptableDropType({
type: 'component',
component: p1.getComponentWithName('/a'),
targetFolder: cp.getFolderWithPath('/test/f1/')
})
).toBe(false);
// Cannot move folder to folder containing a folder with same name
expect(
cp.getAcceptableDropType({
type: 'folder',
folder: cp.getFolderWithPath('/dup/f1/'),
targetFolder: cp.getFolderWithPath('/test/')
})
).toBe(false);
});
it('can make correct drops of folders', function () {
// Can move a folder into a folder
expect(
cp.getAcceptableDropType({
type: 'folder',
folder: cp.getFolderWithPath('/test/f1/'),
targetFolder: cp.getFolderWithPath('/test/f2/')
})
).toBe('folder');
// Make the move
cp.dropOn({
type: 'folder',
folder: cp.getFolderWithPath('/test/f1/'),
targetFolder: cp.getFolderWithPath('/test/f2/')
});
expect(p1.getComponentWithName('/test/f2/f1/a')).not.toBe(undefined);
expect(cp.getFolderWithPath('/test/f2/f1/').name).toBe('f1');
// expect(cp.getFolderWithPath('/test/f1/')).toBe(undefined);
// Moving to an ancestor or same folder should not be acceptable
expect(
cp.getAcceptableDropType({
type: 'folder',
folder: cp.getFolderWithPath('/test/f2/'),
targetFolder: cp.getFolderWithPath('/test/f2/f1/')
})
).toBe(false);
expect(
cp.getAcceptableDropType({
type: 'folder',
folder: cp.getFolderWithPath('/test/f2/'),
targetFolder: cp.getFolderWithPath('/test/f2/')
})
).toBe(false);
});
it('can make correct drops of components', function () {
// Can move into a new folder
expect(
cp.getAcceptableDropType({
type: 'component',
folder: cp.getFolderWithPath('/'),
component: p1.getComponentWithName('/b'),
targetFolder: cp.getFolderWithPath('/test/f2/')
})
).toBe('component');
// Cannot drop to same folder
expect(
cp.getAcceptableDropType({
type: 'component',
folder: cp.getFolderWithPath('/'),
component: p1.getComponentWithName('/b'),
targetFolder: cp.getFolderWithPath('/')
})
).toBe(false);
// Make the drop
cp.dropOn({
type: 'component',
folder: cp.getFolderWithPath('/'),
component: p1.getComponentWithName('/b'),
targetFolder: cp.getFolderWithPath('/test/f2/')
});
expect(p1.getComponentWithName('/test/f2/b')).not.toBe(undefined);
expect(cp.getFolderWithPath('/').hasComponentWithName('b')).toBe(false);
expect(cp.getFolderWithPath('/test/f2/').hasComponentWithName('b')).toBe(true);
expect(p1.getComponentWithName('/b')).toBe(undefined);
});
//TODO: empty folders are removed when moved, but the undo function does not restore them. This is a bug.
xit('can drop empty folders', function () {
cp.performAdd({
type: 'folder',
name: 'empty_folder',
parentFolder: cp.getFolderWithPath('/')
});
expect(cp.getFolderWithPath('/empty_folder/')).not.toBe(undefined);
// Drop empty folder
cp.dropOn({
type: 'folder',
folder: cp.getFolderWithPath('/empty_folder/'),
targetFolder: cp.getFolderWithPath('/test/')
});
expect(cp.getFolderWithPath('/empty_folder/')).toBe(undefined);
//empty folders are removed when moved
expect(cp.getFolderWithPath('/test/empty_folder/')).toBe(undefined);
UndoQueue.instance.undo();
expect(cp.getFolderWithPath('/empty_folder/')).not.toBe(undefined);
// expect(cp.getFolderWithPath('/test/empty_folder/')).toBe(undefined);
});
it('can undo add/delete/rename component and folder', function () {
// Add component
expect(
cp.performAdd({
type: 'component',
name: 'undome',
parentPath: '/'
}).success
).toBe(true);
expect(p1.getComponentWithName('/undome')).not.toBe(undefined);
expect(UndoQueue.instance.undo().label).toBe('add component');
expect(p1.getComponentWithName('/undome')).toBe(undefined);
// Add folder
expect(
cp.performAdd({
type: 'folder',
name: 'undome',
parentPath: '/'
}).success
).toBe(true);
expect(cp.getFolderWithPath('/undome/')).not.toBe(undefined);
expect(UndoQueue.instance.undo().label).toBe('add folder');
expect(cp.getFolderWithPath('/undome/')).toBe(undefined);
// Delete component
expect(
cp.performDelete({
type: 'component',
folder: cp.getFolderWithPath('/delete_folder/'),
component: p1.getComponentWithName('/delete_folder/delete_comp')
}).success
).toBe(true);
expect(p1.getComponentWithName('/delete_folder/delete_comp')).toBe(undefined);
expect(UndoQueue.instance.undo().label).toBe('delete component');
expect(p1.getComponentWithName('/delete_folder/delete_comp')).not.toBe(undefined);
expect(UndoQueue.instance.redo().label).toBe('delete component'); // Folder must be empty for next test to run
// Delete folder
expect(
cp.performDelete({
type: 'folder',
folder: cp.getFolderWithPath('/delete_folder/')
}).success
).toBe(true);
expect(cp.getFolderWithPath('/delete_folder/')).toBe(undefined);
expect(UndoQueue.instance.undo().label).toBe('delete folder');
expect(cp.getFolderWithPath('/delete_folder/')).not.toBe(undefined);
// Rename component
expect(
cp.performRename({
type: 'component',
name: 'newname',
folder: cp.getFolderWithPath('/rename_folder/'),
component: p1.getComponentWithName('/rename_folder/rename_comp')
}).success
).toBe(true);
expect(p1.getComponentWithName('/rename_folder/newname')).not.toBe(undefined);
expect(p1.getComponentWithName('/rename_folder/rename_comp')).toBe(undefined);
expect(UndoQueue.instance.undo().label).toBe('rename component');
expect(p1.getComponentWithName('/rename_folder/newname')).toBe(undefined);
expect(p1.getComponentWithName('/rename_folder/rename_comp')).not.toBe(undefined);
// Rename folder
expect(
cp.performRename({
type: 'folder',
name: 'newname',
folder: cp.getFolderWithPath('/rename_folder/')
}).success
).toBe(true);
expect(p1.getComponentWithName('/newname/rename_comp')).not.toBe(undefined);
expect(p1.getComponentWithName('/rename_folder/rename_comp')).toBe(undefined);
expect(cp.getFolderWithPath('/rename_folder/')).toBe(undefined);
expect(cp.getFolderWithPath('/newname/')).not.toBe(undefined);
expect(UndoQueue.instance.undo().label).toBe('rename folder');
expect(p1.getComponentWithName('/newname/rename_comp')).toBe(undefined);
expect(p1.getComponentWithName('/rename_folder/rename_comp')).not.toBe(undefined);
expect(cp.getFolderWithPath('/rename_folder/')).not.toBe(undefined);
expect(cp.getFolderWithPath('/newname/')).toBe(undefined);
});
it('can undo drop on folder', function () {
// Component on folder
cp.dropOn({
type: 'component',
folder: cp.getFolderWithPath('/'),
component: p1.getComponentWithName('/dropundo'),
targetFolder: cp.getFolderWithPath('/drop/')
});
expect(p1.getComponentWithName('/drop/dropundo')).not.toBe(undefined);
expect(UndoQueue.instance.undo().label).toBe('move component to folder');
// expect(p1.getComponentWithName('/drop/dropundo')).toBe(undefined);
expect(p1.getComponentWithName('/dropundo')).not.toBe(undefined);
expect(cp.getFolderWithPath('/drop/').hasComponentWithName('dropundo')).toBe(false);
// Folder on folder
cp.dropOn({
type: 'folder',
folder: cp.getFolderWithPath('/drop/'),
targetFolder: cp.getFolderWithPath('/drop2/')
});
expect(cp.getFolderWithPath('/drop2/drop/')).not.toBe(undefined);
expect(UndoQueue.instance.undo().label).toBe('move folder to folder');
// expect(cp.getFolderWithPath('/drop2/drop/')).toBe(undefined);
});
it('can make correct drops of nested folders and undo', function () {
cp.dropOn({
type: 'folder',
folder: cp.getFolderWithPath('/nested-dropme/'),
targetFolder: cp.getFolderWithPath('/nested-target/')
});
expect(cp.getFolderWithPath('/nested-target/nested-dropme/')).not.toBe(undefined);
expect(p1.getComponentWithName('/nested-target/nested-dropme/test/b')).not.toBe(undefined);
expect(p1.getComponentWithName('/nested-dropme/test/b')).toBe(undefined);
// expect(cp.getFolderWithPath('/nested-dropme/')).toBe(undefined);
UndoQueue.instance.undo();
// expect(cp.getFolderWithPath('/nested-target/nested-dropme/')).toBe(undefined);
expect(p1.getComponentWithName('/nested-target/nested-dropme/test/b')).toBe(undefined);
expect(p1.getComponentWithName('/nested-dropme/test/b')).not.toBe(undefined);
expect(cp.getFolderWithPath('/nested-dropme/')).not.toBe(undefined);
});
it('can delete folder with content', function () {
// Delete folder
expect(
cp.performDelete({
type: 'folder',
folder: cp.getFolderWithPath('/delete-me/')
}).success
).toBe(true);
expect(cp.getFolderWithPath('/delete-me/')).toBe(undefined);
expect(cp.getFolderWithPath('/delete-me/with-content/')).toBe(undefined);
expect(p1.getComponentWithName('/delete-me/with-content/a')).toBe(undefined);
expect(p1.getComponentWithName('/delete-me/b')).toBe(undefined);
UndoQueue.instance.undo();
expect(cp.getFolderWithPath('/delete-me/')).not.toBe(undefined);
expect(cp.getFolderWithPath('/delete-me/with-content/')).not.toBe(undefined);
expect(p1.getComponentWithName('/delete-me/with-content/a')).not.toBe(undefined);
expect(p1.getComponentWithName('/delete-me/b')).not.toBe(undefined);
});
});

View File

@@ -1,7 +1,8 @@
export * from './componentconnections';
export * from './componentinstances';
export * from './componentports';
export * from './componentspanel';
// componentspanel test removed - tests legacy Backbone ComponentsPanelView which
// has been archived to ComponentsPanelNew/ComponentsPanel.ts.legacy (not webpack-resolvable)
export * from './conditionalports';
export * from './dynamicports';
export * from './expandedports';

View File

@@ -5,11 +5,13 @@ import '@noodl/platform-electron';
export * from './cloud';
export * from './components';
export * from './git';
export * from './models';
export * from './nodegraph';
export * from './platform';
export * from './project';
export * from './projectmerger';
export * from './projectpatcher';
export * from './services';
export * from './utils';
export * from './schemas';
export * from './io';

View File

@@ -0,0 +1,215 @@
/**
* ProjectCreationWizard — Unit tests for wizard state management
*
* Tests the step-sequencing logic and validation rules defined in WizardContext.
* These are pure logic tests — no DOM or React renderer required.
*
* The functions below mirror the private helpers in WizardContext.tsx.
* If the context logic changes, update both files.
*/
import { describe, it, expect } from '@jest/globals';
// ---- Step sequencing (mirrors WizardContext.getStepSequence) ---------------
function getStepSequence(mode) {
switch (mode) {
case 'quick':
return ['basics'];
case 'guided':
return ['basics', 'preset', 'review'];
case 'ai':
return ['basics', 'preset', 'review'];
default:
return ['basics'];
}
}
// ---- Validation (mirrors WizardContext.isStepValid) ------------------------
function isStepValid(step, state) {
switch (step) {
case 'entry':
return true;
case 'basics':
return state.projectName.trim().length > 0 && state.location.length > 0;
case 'preset':
return state.selectedPresetId.length > 0;
case 'review':
return true;
default:
return false;
}
}
// ---- Step navigation (mirrors WizardContext goNext/goBack logic) -----------
function goNext(state) {
if (state.currentStep === 'entry') {
const seq = getStepSequence(state.mode);
return seq[0];
}
const seq = getStepSequence(state.mode);
const idx = seq.indexOf(state.currentStep);
if (idx === -1 || idx >= seq.length - 1) return state.currentStep;
return seq[idx + 1];
}
function goBack(state) {
if (state.currentStep === 'entry') return 'entry';
const seq = getStepSequence(state.mode);
const idx = seq.indexOf(state.currentStep);
if (idx <= 0) return 'entry';
return seq[idx - 1];
}
// ---- Whether the current step is the last one before creation --------------
function isLastStep(mode, step) {
return step === 'review' || (mode === 'quick' && step === 'basics');
}
// ============================================================================
// Tests
// ============================================================================
describe('WizardContext: step sequences', () => {
it('quick mode only visits basics', () => {
expect(getStepSequence('quick')).toEqual(['basics']);
});
it('guided mode visits basics, preset, review', () => {
expect(getStepSequence('guided')).toEqual(['basics', 'preset', 'review']);
});
it('ai mode uses same sequence as guided (V1 stub)', () => {
expect(getStepSequence('ai')).toEqual(['basics', 'preset', 'review']);
});
});
describe('WizardContext: validation', () => {
const baseState = {
mode: 'quick',
currentStep: 'basics',
projectName: '',
description: '',
location: '',
selectedPresetId: 'modern'
};
it('entry step is always valid', () => {
expect(isStepValid('entry', { ...baseState, currentStep: 'entry' })).toBe(true);
});
it('review step is always valid', () => {
expect(isStepValid('review', { ...baseState, currentStep: 'review' })).toBe(true);
});
it('basics step requires projectName and location', () => {
expect(isStepValid('basics', baseState)).toBe(false);
});
it('basics step passes with name and location', () => {
expect(isStepValid('basics', { ...baseState, projectName: 'My Project', location: '/tmp' })).toBe(true);
});
it('basics step trims whitespace on projectName', () => {
expect(isStepValid('basics', { ...baseState, projectName: ' ', location: '/tmp' })).toBe(false);
});
it('preset step requires selectedPresetId', () => {
expect(isStepValid('preset', { ...baseState, selectedPresetId: '' })).toBe(false);
});
it('preset step passes with a preset id', () => {
expect(isStepValid('preset', { ...baseState, selectedPresetId: 'minimal' })).toBe(true);
});
});
describe('WizardContext: goNext navigation', () => {
const baseState = {
mode: 'quick',
currentStep: 'entry',
projectName: 'Test',
description: '',
location: '/tmp',
selectedPresetId: 'modern'
};
it('quick: entry advances to basics', () => {
expect(goNext({ ...baseState, mode: 'quick', currentStep: 'entry' })).toBe('basics');
});
it('quick: basics stays (is the last step)', () => {
expect(goNext({ ...baseState, mode: 'quick', currentStep: 'basics' })).toBe('basics');
});
it('guided: entry advances to basics', () => {
expect(goNext({ ...baseState, mode: 'guided', currentStep: 'entry' })).toBe('basics');
});
it('guided: basics advances to preset', () => {
expect(goNext({ ...baseState, mode: 'guided', currentStep: 'basics' })).toBe('preset');
});
it('guided: preset advances to review', () => {
expect(goNext({ ...baseState, mode: 'guided', currentStep: 'preset' })).toBe('review');
});
it('guided: review stays (is the last step)', () => {
expect(goNext({ ...baseState, mode: 'guided', currentStep: 'review' })).toBe('review');
});
});
describe('WizardContext: goBack navigation', () => {
const baseState = {
mode: 'guided',
currentStep: 'review',
projectName: 'Test',
description: '',
location: '/tmp',
selectedPresetId: 'modern'
};
it('entry stays on entry when going back', () => {
expect(goBack({ ...baseState, currentStep: 'entry' })).toBe('entry');
});
it('guided: basics goes back to entry', () => {
expect(goBack({ ...baseState, currentStep: 'basics' })).toBe('entry');
});
it('guided: preset goes back to basics', () => {
expect(goBack({ ...baseState, currentStep: 'preset' })).toBe('basics');
});
it('guided: review goes back to preset', () => {
expect(goBack({ ...baseState, currentStep: 'review' })).toBe('preset');
});
it('quick: basics goes back to entry', () => {
expect(goBack({ ...baseState, mode: 'quick', currentStep: 'basics' })).toBe('entry');
});
});
describe('isLastStep: determines when to show Create Project button', () => {
it('quick mode: basics is the last step', () => {
expect(isLastStep('quick', 'basics')).toBe(true);
});
it('quick mode: entry is not the last step', () => {
expect(isLastStep('quick', 'entry')).toBe(false);
});
it('guided mode: review is the last step', () => {
expect(isLastStep('guided', 'review')).toBe(true);
});
it('guided mode: basics is not the last step', () => {
expect(isLastStep('guided', 'basics')).toBe(false);
});
it('guided mode: preset is not the last step', () => {
expect(isLastStep('guided', 'preset')).toBe(false);
});
});

View File

@@ -0,0 +1,294 @@
/**
* STYLE-005: StyleAnalyzer Unit Tests
*
* Tests the pure logic of the analyzer — value detection, threshold handling,
* and suggestion generation — without touching Electron or the real ProjectModel.
*/
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { StyleAnalyzer } from '../../src/editor/src/services/StyleAnalyzer/StyleAnalyzer';
// ─── Mock ProjectModel before importing StyleAnalyzer ───────────────────────
type MockNode = {
id: string;
typename: string;
parameters: Record<string, unknown>;
};
let mockNodes: MockNode[] = [];
jest.mock('@noodl-models/projectmodel', () => ({
ProjectModel: {
instance: {
getComponents: () => [
{
forEachNode: (cb: (node: MockNode) => void) => {
mockNodes.forEach(cb);
}
}
],
findNodeWithId: (id: string) => mockNodes.find((n) => n.id === id) ?? null
}
}
}));
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeNode(id: string, typename: string, params: Record<string, unknown>): MockNode {
return { id, typename, parameters: params };
}
function resetNodes(...nodes: MockNode[]) {
mockNodes = nodes;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('StyleAnalyzer', () => {
beforeEach(() => {
mockNodes = [];
});
// ─── Color Detection ───────────────────────────────────────────────────────
describe('repeated color detection', () => {
it('does NOT flag a value that appears fewer than 3 times', () => {
resetNodes(
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' })
);
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedColors).toHaveLength(0);
});
it('flags a hex color appearing 3+ times', () => {
resetNodes(
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' }),
makeNode('n3', 'Group', { backgroundColor: '#3b82f6' })
);
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedColors).toHaveLength(1);
expect(result.repeatedColors[0].value).toBe('#3b82f6');
expect(result.repeatedColors[0].count).toBe(3);
});
it('ignores CSS var() references — they are already tokenised', () => {
resetNodes(
makeNode('n1', 'Group', { backgroundColor: 'var(--primary)' }),
makeNode('n2', 'Group', { backgroundColor: 'var(--primary)' }),
makeNode('n3', 'Group', { backgroundColor: 'var(--primary)' })
);
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedColors).toHaveLength(0);
});
it('handles rgb() and rgba() color values', () => {
const color = 'rgb(59, 130, 246)';
resetNodes(
makeNode('n1', 'Group', { backgroundColor: color }),
makeNode('n2', 'Group', { backgroundColor: color }),
makeNode('n3', 'Group', { backgroundColor: color })
);
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedColors).toHaveLength(1);
expect(result.repeatedColors[0].value).toBe(color);
});
it('groups by exact value — different shades are separate suggestions', () => {
resetNodes(
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' }),
makeNode('n3', 'Group', { backgroundColor: '#3b82f6' }),
makeNode('n4', 'Group', { backgroundColor: '#2563eb' }),
makeNode('n5', 'Group', { backgroundColor: '#2563eb' }),
makeNode('n6', 'Group', { backgroundColor: '#2563eb' })
);
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedColors).toHaveLength(2);
});
});
// ─── Spacing Detection ─────────────────────────────────────────────────────
describe('repeated spacing detection', () => {
it('flags px spacing appearing 3+ times', () => {
resetNodes(
makeNode('n1', 'Group', { paddingTop: '16px' }),
makeNode('n2', 'Group', { paddingTop: '16px' }),
makeNode('n3', 'Group', { paddingTop: '16px' })
);
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedSpacing).toHaveLength(1);
expect(result.repeatedSpacing[0].value).toBe('16px');
});
it('flags rem spacing', () => {
resetNodes(
makeNode('n1', 'Group', { paddingLeft: '1rem' }),
makeNode('n2', 'Group', { paddingLeft: '1rem' }),
makeNode('n3', 'Group', { paddingLeft: '1rem' })
);
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedSpacing).toHaveLength(1);
});
it('does NOT flag zero — 0 is universally used and not worth tokenising', () => {
// '0' is technically a valid spacing value but would create noise
// Our regex requires px/rem/em suffix OR it's just a number
// This test ensures we understand the current behaviour
resetNodes(
makeNode('n1', 'Group', { paddingTop: '0px' }),
makeNode('n2', 'Group', { paddingTop: '0px' }),
makeNode('n3', 'Group', { paddingTop: '0px' })
);
const result = StyleAnalyzer.analyzeProject();
// '0px' IS a raw spacing value — currently flagged. This is expected.
// In future we may want to suppress this specific case.
expect(result.repeatedSpacing).toHaveLength(1);
});
});
// ─── Variant Candidates ────────────────────────────────────────────────────
describe('variant candidate detection', () => {
it('flags a node with 3+ raw style overrides', () => {
resetNodes(
makeNode('n1', 'net.noodl.controls.button', {
backgroundColor: '#3b82f6',
color: '#ffffff',
borderRadius: '8px'
})
);
const result = StyleAnalyzer.analyzeProject();
expect(result.variantCandidates).toHaveLength(1);
expect(result.variantCandidates[0].overrideCount).toBe(3);
});
it('does NOT flag a node with fewer than 3 raw overrides', () => {
resetNodes(
makeNode('n1', 'net.noodl.controls.button', {
backgroundColor: '#3b82f6',
color: '#ffffff'
})
);
const result = StyleAnalyzer.analyzeProject();
expect(result.variantCandidates).toHaveLength(0);
});
it('does NOT count token references as custom overrides', () => {
resetNodes(
makeNode('n1', 'net.noodl.controls.button', {
backgroundColor: 'var(--primary)', // token — excluded
color: 'var(--primary-foreground)', // token — excluded
borderRadius: '8px', // raw — counts
paddingTop: '12px', // raw — counts
fontSize: '14px' // raw — counts
})
);
const result = StyleAnalyzer.analyzeProject();
// 3 raw overrides → IS a candidate
expect(result.variantCandidates).toHaveLength(1);
expect(result.variantCandidates[0].overrideCount).toBe(3);
});
});
// ─── toSuggestions ────────────────────────────────────────────────────────
describe('toSuggestions()', () => {
it('orders suggestions by count descending', () => {
resetNodes(
// 4 occurrences of red
makeNode('n1', 'Group', { backgroundColor: '#ff0000' }),
makeNode('n2', 'Group', { backgroundColor: '#ff0000' }),
makeNode('n3', 'Group', { backgroundColor: '#ff0000' }),
makeNode('n4', 'Group', { backgroundColor: '#ff0000' }),
// 3 occurrences of blue
makeNode('n5', 'Group', { backgroundColor: '#0000ff' }),
makeNode('n6', 'Group', { backgroundColor: '#0000ff' }),
makeNode('n7', 'Group', { backgroundColor: '#0000ff' })
);
const result = StyleAnalyzer.analyzeProject();
const suggestions = StyleAnalyzer.toSuggestions(result);
expect(suggestions[0].repeatedValue?.value).toBe('#ff0000');
expect(suggestions[1].repeatedValue?.value).toBe('#0000ff');
});
it('assigns stable IDs to suggestions', () => {
resetNodes(
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' }),
makeNode('n3', 'Group', { backgroundColor: '#3b82f6' })
);
const result = StyleAnalyzer.analyzeProject();
const suggestions = StyleAnalyzer.toSuggestions(result);
expect(suggestions[0].id).toBe('repeated-color:#3b82f6');
});
it('returns empty array when no issues found', () => {
resetNodes(makeNode('n1', 'Group', { backgroundColor: 'var(--primary)' }));
const result = StyleAnalyzer.analyzeProject();
const suggestions = StyleAnalyzer.toSuggestions(result);
expect(suggestions).toHaveLength(0);
});
});
// ─── Edge Cases ───────────────────────────────────────────────────────────
describe('edge cases', () => {
it('returns empty results when there are no nodes', () => {
resetNodes();
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedColors).toHaveLength(0);
expect(result.repeatedSpacing).toHaveLength(0);
expect(result.variantCandidates).toHaveLength(0);
});
it('does not scan non-visual nodes (e.g. logic nodes without style params)', () => {
resetNodes(
makeNode('n1', 'For Each', { items: '[1,2,3]' }),
makeNode('n2', 'For Each', { items: '[1,2,3]' }),
makeNode('n3', 'For Each', { items: '[1,2,3]' })
);
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedColors).toHaveLength(0);
expect(result.variantCandidates).toHaveLength(0);
});
it('deduplicates variant candidates if the same node appears in multiple components', () => {
// Simulate same node ID coming from two components
const node = makeNode('shared-id', 'net.noodl.controls.button', {
backgroundColor: '#3b82f6',
color: '#fff',
borderRadius: '8px'
});
mockNodes = [node, node]; // same node ref twice
const result = StyleAnalyzer.analyzeProject();
// Even though forEachNode visits it twice, we deduplicate by nodeId
expect(result.variantCandidates).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,175 @@
/**
* UBA-003/UBA-004: Unit tests for Conditions.ts
*
* Tests:
* - getNestedValue dot-path lookups
* - setNestedValue immutable path writes
* - isEmpty edge cases
* - evaluateCondition — all 6 operators
*/
import { describe, expect, it } from '@jest/globals';
import { evaluateCondition, getNestedValue, isEmpty, setNestedValue } from '../../src/editor/src/models/UBA/Conditions';
// ─── getNestedValue ───────────────────────────────────────────────────────────
describe('getNestedValue', () => {
it('returns top-level value', () => {
expect(getNestedValue({ foo: 'bar' }, 'foo')).toBe('bar');
});
it('returns nested value via dot path', () => {
expect(getNestedValue({ auth: { type: 'bearer' } }, 'auth.type')).toBe('bearer');
});
it('returns undefined for missing path', () => {
expect(getNestedValue({ auth: {} }, 'auth.type')).toBeUndefined();
});
it('returns undefined for deeply missing path', () => {
expect(getNestedValue({}, 'a.b.c')).toBeUndefined();
});
it('returns undefined for empty path', () => {
expect(getNestedValue({ foo: 'bar' }, '')).toBeUndefined();
});
});
// ─── setNestedValue ───────────────────────────────────────────────────────────
describe('setNestedValue', () => {
it('sets top-level key', () => {
const result = setNestedValue({}, 'foo', 'bar');
expect(result).toEqual({ foo: 'bar' });
});
it('sets nested key', () => {
const result = setNestedValue({}, 'auth.type', 'bearer');
expect(result).toEqual({ auth: { type: 'bearer' } });
});
it('merges with existing nested object', () => {
const result = setNestedValue({ auth: { key: 'abc' } }, 'auth.type', 'bearer');
expect(result).toEqual({ auth: { key: 'abc', type: 'bearer' } });
});
it('does not mutate the original', () => {
const original = { foo: 'bar' };
setNestedValue(original, 'foo', 'baz');
expect(original.foo).toBe('bar');
});
});
// ─── isEmpty ─────────────────────────────────────────────────────────────────
describe('isEmpty', () => {
it.each([
[null, true],
[undefined, true],
['', true],
[' ', true],
[[], true],
['hello', false],
[0, false],
[false, false],
[['a'], false],
[{}, false]
])('isEmpty(%o) === %s', (value, expected) => {
expect(isEmpty(value)).toBe(expected);
});
});
// ─── evaluateCondition ────────────────────────────────────────────────────────
describe('evaluateCondition', () => {
const values = {
'auth.type': 'bearer',
'auth.token': 'abc123',
'auth.enabled': true,
'features.list': ['a', 'b'],
'features.empty': []
};
it('returns true when condition is undefined', () => {
expect(evaluateCondition(undefined, values)).toBe(true);
});
describe('operator "="', () => {
it('returns true when field matches value', () => {
expect(evaluateCondition({ field: 'auth.type', operator: '=', value: 'bearer' }, values)).toBe(true);
});
it('returns false when field does not match', () => {
expect(evaluateCondition({ field: 'auth.type', operator: '=', value: 'api_key' }, values)).toBe(false);
});
});
describe('operator "!="', () => {
it('returns true when field differs', () => {
expect(evaluateCondition({ field: 'auth.type', operator: '!=', value: 'api_key' }, values)).toBe(true);
});
it('returns false when field matches', () => {
expect(evaluateCondition({ field: 'auth.type', operator: '!=', value: 'bearer' }, values)).toBe(false);
});
});
describe('operator "in"', () => {
it('returns true when value is in array', () => {
expect(evaluateCondition({ field: 'auth.type', operator: 'in', value: ['bearer', 'api_key'] }, values)).toBe(
true
);
});
it('returns false when value is not in array', () => {
expect(evaluateCondition({ field: 'auth.type', operator: 'in', value: ['basic', 'none'] }, values)).toBe(false);
});
it('returns false when condition value is not an array', () => {
expect(evaluateCondition({ field: 'auth.type', operator: 'in', value: 'bearer' }, values)).toBe(false);
});
});
describe('operator "not_in"', () => {
it('returns true when value is not in array', () => {
expect(evaluateCondition({ field: 'auth.type', operator: 'not_in', value: ['basic', 'none'] }, values)).toBe(
true
);
});
it('returns false when value is in the array', () => {
expect(evaluateCondition({ field: 'auth.type', operator: 'not_in', value: ['bearer', 'api_key'] }, values)).toBe(
false
);
});
});
describe('operator "exists"', () => {
it('returns true when field has a value', () => {
expect(evaluateCondition({ field: 'auth.token', operator: 'exists' }, values)).toBe(true);
});
it('returns false when field is missing', () => {
expect(evaluateCondition({ field: 'auth.missing', operator: 'exists' }, values)).toBe(false);
});
it('returns false when field is empty array', () => {
expect(evaluateCondition({ field: 'features.empty', operator: 'exists' }, values)).toBe(false);
});
});
describe('operator "not_exists"', () => {
it('returns true when field is missing', () => {
expect(evaluateCondition({ field: 'auth.missing', operator: 'not_exists' }, values)).toBe(true);
});
it('returns false when field has a value', () => {
expect(evaluateCondition({ field: 'auth.token', operator: 'not_exists' }, values)).toBe(false);
});
it('returns true when field is empty array', () => {
expect(evaluateCondition({ field: 'features.empty', operator: 'not_exists' }, values)).toBe(true);
});
});
});

View File

@@ -0,0 +1,319 @@
/**
* UBA-002: Unit tests for SchemaParser
*
* Tests run against pure JS objects (no YAML parsing needed).
* Covers: happy path, required field errors, optional fields,
* field type validation, warnings for unknown types/versions.
*/
import { describe, it, expect, beforeEach } from '@jest/globals';
import { SchemaParser } from '../../src/editor/src/models/UBA/SchemaParser';
import type { ParseResult } from '../../src/editor/src/models/UBA/types';
/** Type guard: narrows ParseResult to the failure branch (webpack ts-loader friendly) */
function isFailure<T>(result: ParseResult<T>): result is Extract<ParseResult<T>, { success: false }> {
return !result.success;
}
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const minimalValid = {
schema_version: '1.0',
backend: {
id: 'test-backend',
name: 'Test Backend',
version: '1.0.0',
endpoints: {
config: 'https://example.com/config'
}
},
sections: []
};
function makeValid(overrides: Record<string, unknown> = {}) {
return { ...minimalValid, ...overrides };
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('SchemaParser', () => {
let parser: SchemaParser;
beforeEach(() => {
parser = new SchemaParser();
});
// ─── Root validation ───────────────────────────────────────────────────────
describe('root validation', () => {
it('rejects null input', () => {
const result = parser.parse(null);
expect(result.success).toBe(false);
if (isFailure(result)) {
expect(result.errors[0].path).toBe('');
}
});
it('rejects array input', () => {
const result = parser.parse([]);
expect(result.success).toBe(false);
});
it('rejects missing schema_version', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { schema_version: _, ...noVersion } = minimalValid;
const result = parser.parse(noVersion);
expect(result.success).toBe(false);
if (isFailure(result)) {
expect(result.errors.some((e) => e.path === 'schema_version')).toBe(true);
}
});
it('warns on unknown major version', () => {
const result = parser.parse(makeValid({ schema_version: '2.0' }));
// Should still succeed (best-effort) but include a warning
expect(result.success).toBe(true);
if (result.success) {
expect(result.warnings?.some((w) => w.includes('2.0'))).toBe(true);
}
});
it('accepts a minimal valid schema', () => {
const result = parser.parse(minimalValid);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.schema_version).toBe('1.0');
expect(result.data.backend.id).toBe('test-backend');
expect(result.data.sections).toEqual([]);
}
});
});
// ─── Backend validation ────────────────────────────────────────────────────
describe('backend validation', () => {
it('errors when backend is missing', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { backend: _, ...noBackend } = minimalValid;
const result = parser.parse(noBackend);
expect(result.success).toBe(false);
if (isFailure(result)) {
expect(result.errors.some((e) => e.path === 'backend')).toBe(true);
}
});
it('errors when backend.id is missing', () => {
const data = makeValid({ backend: { ...minimalValid.backend, id: undefined } });
const result = parser.parse(data);
expect(result.success).toBe(false);
});
it('errors when backend.endpoints.config is missing', () => {
const data = makeValid({
backend: { ...minimalValid.backend, endpoints: { health: '/health' } }
});
const result = parser.parse(data);
expect(result.success).toBe(false);
if (isFailure(result)) {
expect(result.errors.some((e) => e.path === 'backend.endpoints.config')).toBe(true);
}
});
it('accepts optional backend fields', () => {
const data = makeValid({
backend: {
...minimalValid.backend,
description: 'My backend',
icon: 'https://example.com/icon.png',
homepage: 'https://example.com',
auth: { type: 'bearer' },
capabilities: { hot_reload: true, debug: false }
}
});
const result = parser.parse(data);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.backend.description).toBe('My backend');
expect(result.data.backend.auth?.type).toBe('bearer');
expect(result.data.backend.capabilities?.hot_reload).toBe(true);
}
});
it('errors on invalid auth type', () => {
const data = makeValid({
backend: { ...minimalValid.backend, auth: { type: 'oauth2' } }
});
const result = parser.parse(data);
expect(result.success).toBe(false);
if (isFailure(result)) {
expect(result.errors.some((e) => e.path === 'backend.auth.type')).toBe(true);
}
});
});
// ─── Sections validation ───────────────────────────────────────────────────
describe('sections validation', () => {
it('errors when sections is not an array', () => {
const data = makeValid({ sections: 'not-an-array' });
const result = parser.parse(data);
expect(result.success).toBe(false);
});
it('accepts a section with minimal fields', () => {
const data = makeValid({
sections: [{ id: 'general', name: 'General', fields: [] }]
});
const result = parser.parse(data);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sections[0].id).toBe('general');
expect(result.data.sections[0].fields).toEqual([]);
}
});
it('skips section without id and collects error', () => {
const data = makeValid({
sections: [{ name: 'Missing ID', fields: [] }]
});
const result = parser.parse(data);
expect(result.success).toBe(false);
if (isFailure(result)) {
expect(result.errors.some((e) => e.path.includes('id'))).toBe(true);
}
});
});
// ─── Field type validation ─────────────────────────────────────────────────
describe('field types', () => {
function sectionWith(fields: unknown[]) {
return makeValid({ sections: [{ id: 's', name: 'S', fields }] });
}
it('parses a string field', () => {
const result = parser.parse(sectionWith([{ id: 'host', name: 'Host', type: 'string', default: 'localhost' }]));
expect(result.success).toBe(true);
if (result.success) {
const field = result.data.sections[0].fields[0];
expect(field.type).toBe('string');
if (field.type === 'string') expect(field.default).toBe('localhost');
}
});
it('parses a boolean field', () => {
const result = parser.parse(sectionWith([{ id: 'ssl', name: 'SSL', type: 'boolean', default: true }]));
expect(result.success).toBe(true);
if (result.success) {
const field = result.data.sections[0].fields[0];
expect(field.type).toBe('boolean');
if (field.type === 'boolean') expect(field.default).toBe(true);
}
});
it('parses a select field with options', () => {
const result = parser.parse(
sectionWith([
{
id: 'region',
name: 'Region',
type: 'select',
options: [
{ value: 'eu-west-1', label: 'EU West' },
{ value: 'us-east-1', label: 'US East' }
],
default: 'eu-west-1'
}
])
);
expect(result.success).toBe(true);
if (result.success) {
const field = result.data.sections[0].fields[0];
expect(field.type).toBe('select');
if (field.type === 'select') {
expect(field.options).toHaveLength(2);
expect(field.default).toBe('eu-west-1');
}
}
});
it('errors when select field has no options array', () => {
const result = parser.parse(sectionWith([{ id: 'x', name: 'X', type: 'select' }]));
expect(result.success).toBe(false);
});
it('warns on unknown field type and skips it', () => {
const result = parser.parse(sectionWith([{ id: 'x', name: 'X', type: 'color_picker' }]));
// Section-level parse succeeds (unknown field is skipped, not fatal)
if (result.success) {
expect(result.data.sections[0].fields).toHaveLength(0);
expect(result.warnings?.some((w) => w.includes('color_picker'))).toBe(true);
}
});
it('parses a number field with min/max', () => {
const result = parser.parse(
sectionWith([{ id: 'port', name: 'Port', type: 'number', default: 5432, min: 1, max: 65535 }])
);
expect(result.success).toBe(true);
if (result.success) {
const field = result.data.sections[0].fields[0];
if (field.type === 'number') {
expect(field.default).toBe(5432);
expect(field.min).toBe(1);
expect(field.max).toBe(65535);
}
}
});
it('parses a secret field', () => {
const result = parser.parse(sectionWith([{ id: 'api_key', name: 'API Key', type: 'secret' }]));
expect(result.success).toBe(true);
if (result.success) {
const field = result.data.sections[0].fields[0];
expect(field.type).toBe('secret');
}
});
it('parses a multi_select field', () => {
const result = parser.parse(
sectionWith([
{
id: 'roles',
name: 'Roles',
type: 'multi_select',
options: [
{ value: 'read', label: 'Read' },
{ value: 'write', label: 'Write' }
]
}
])
);
expect(result.success).toBe(true);
if (result.success) {
const field = result.data.sections[0].fields[0];
expect(field.type).toBe('multi_select');
}
});
});
// ─── Debug schema ──────────────────────────────────────────────────────────
describe('debug schema', () => {
it('parses an enabled debug block', () => {
const data = makeValid({
debug: {
enabled: true,
event_schema: [{ id: 'query_time', name: 'Query Time', type: 'number' }]
}
});
const result = parser.parse(data);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.debug?.enabled).toBe(true);
expect(result.data.debug?.event_schema).toHaveLength(1);
}
});
});
});

View File

@@ -0,0 +1,4 @@
// NOTE: UBAConditions.test, UBASchemaParser.test, ElementConfigRegistry.test
// use @jest/globals and are Jest-only tests. They run via `npm run test:editor`.
// Do NOT re-add them here - the Electron Jasmine runner will crash on import.
export {};

View File

@@ -0,0 +1,394 @@
/**
* STYLE-005: Unit tests for StyleAnalyzer
*
* Tests:
* - toSuggestions — pure conversion, ordering, message format
* - analyzeProject — repeated colour/spacing detection, threshold, var() skipping
* - analyzeNode — per-node variant candidate detection
*
* ProjectModel.instance is monkey-patched per test; restored in afterEach.
*/
import { afterEach, beforeEach, describe, expect, it } from '@jest/globals';
import { ProjectModel } from '../../src/editor/src/models/projectmodel';
import { StyleAnalyzer } from '../../src/editor/src/services/StyleAnalyzer/StyleAnalyzer';
import { SUGGESTION_THRESHOLDS, type StyleAnalysisResult } from '../../src/editor/src/services/StyleAnalyzer/types';
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Build a minimal mock node for the analyzer. */
function makeNode(id: string, typename: string, parameters: Record<string, string>) {
return { id, typename, parameters };
}
/**
* Build a mock ProjectModel.instance with components containing the given nodes.
* Supports multiple components if needed (pass array of node arrays).
*/
function makeMockProject(nodeGroups: ReturnType<typeof makeNode>[][]) {
return {
getComponents: () =>
nodeGroups.map((nodes) => ({
forEachNode: (fn: (node: ReturnType<typeof makeNode>) => void) => {
nodes.forEach(fn);
}
})),
findNodeWithId: (id: string) => {
for (const nodes of nodeGroups) {
const found = nodes.find((n) => n.id === id);
if (found) return found;
}
return undefined;
}
};
}
// ─── toSuggestions ────────────────────────────────────────────────────────────
describe('StyleAnalyzer.toSuggestions', () => {
it('returns empty array for empty result', () => {
const result: StyleAnalysisResult = {
repeatedColors: [],
repeatedSpacing: [],
variantCandidates: []
};
expect(StyleAnalyzer.toSuggestions(result)).toEqual([]);
});
it('converts repeated-color to suggestion with correct id and type', () => {
const result: StyleAnalysisResult = {
repeatedColors: [
{
value: '#3b82f6',
count: 4,
elements: [],
suggestedTokenName: '--color-3b82f6'
}
],
repeatedSpacing: [],
variantCandidates: []
};
const suggestions = StyleAnalyzer.toSuggestions(result);
expect(suggestions).toHaveLength(1);
expect(suggestions[0].type).toBe('repeated-color');
expect(suggestions[0].id).toBe('repeated-color:#3b82f6');
expect(suggestions[0].acceptLabel).toBe('Create Token');
expect(suggestions[0].message).toContain('#3b82f6');
expect(suggestions[0].message).toContain('4 elements');
});
it('converts repeated-spacing to suggestion — uses "Switch to Token" when matchingToken present', () => {
const result: StyleAnalysisResult = {
repeatedColors: [],
repeatedSpacing: [
{
value: '16px',
count: 5,
elements: [],
suggestedTokenName: '--spacing-16px',
matchingToken: '--spacing-4'
}
],
variantCandidates: []
};
const suggestions = StyleAnalyzer.toSuggestions(result);
expect(suggestions[0].type).toBe('repeated-spacing');
expect(suggestions[0].acceptLabel).toBe('Switch to Token');
expect(suggestions[0].message).toContain('--spacing-4');
});
it('converts repeated-spacing without matchingToken — uses "Create Token"', () => {
const result: StyleAnalysisResult = {
repeatedColors: [],
repeatedSpacing: [
{
value: '24px',
count: 3,
elements: [],
suggestedTokenName: '--spacing-24px'
}
],
variantCandidates: []
};
const suggestions = StyleAnalyzer.toSuggestions(result);
expect(suggestions[0].acceptLabel).toBe('Create Token');
});
it('converts variant-candidate to suggestion with correct type and id', () => {
const result: StyleAnalysisResult = {
repeatedColors: [],
repeatedSpacing: [],
variantCandidates: [
{
nodeId: 'node-1',
nodeLabel: 'Button',
nodeType: 'net.noodl.controls.button',
overrideCount: 4,
overrides: { backgroundColor: '#22c55e', color: '#fff', borderRadius: '9999px', padding: '12px' },
suggestedVariantName: 'custom'
}
]
};
const suggestions = StyleAnalyzer.toSuggestions(result);
expect(suggestions[0].type).toBe('variant-candidate');
expect(suggestions[0].id).toBe('variant-candidate:node-1');
expect(suggestions[0].acceptLabel).toBe('Save as Variant');
expect(suggestions[0].message).toContain('4 custom values');
});
it('orders repeated-color suggestions by count descending', () => {
const result: StyleAnalysisResult = {
repeatedColors: [
{ value: '#aaaaaa', count: 3, elements: [], suggestedTokenName: '--color-aaa' },
{ value: '#bbbbbb', count: 7, elements: [], suggestedTokenName: '--color-bbb' },
{ value: '#cccccc', count: 5, elements: [], suggestedTokenName: '--color-ccc' }
],
repeatedSpacing: [],
variantCandidates: []
};
const suggestions = StyleAnalyzer.toSuggestions(result);
const counts = suggestions.map((s) => s.repeatedValue!.count);
expect(counts).toEqual([7, 5, 3]);
});
it('orders variant candidates by override count descending', () => {
const result: StyleAnalysisResult = {
repeatedColors: [],
repeatedSpacing: [],
variantCandidates: [
{ nodeId: 'a', nodeLabel: 'A', nodeType: 'Group', overrideCount: 3, overrides: {}, suggestedVariantName: 'c' },
{ nodeId: 'b', nodeLabel: 'B', nodeType: 'Group', overrideCount: 8, overrides: {}, suggestedVariantName: 'c' },
{ nodeId: 'c', nodeLabel: 'C', nodeType: 'Group', overrideCount: 5, overrides: {}, suggestedVariantName: 'c' }
]
};
const suggestions = StyleAnalyzer.toSuggestions(result);
const counts = suggestions.map((s) => s.variantCandidate!.overrideCount);
expect(counts).toEqual([8, 5, 3]);
});
it('colors come before spacing come before variants in output order', () => {
const result: StyleAnalysisResult = {
repeatedColors: [{ value: '#ff0000', count: 3, elements: [], suggestedTokenName: '--color-ff0000' }],
repeatedSpacing: [{ value: '8px', count: 3, elements: [], suggestedTokenName: '--spacing-8px' }],
variantCandidates: [
{ nodeId: 'x', nodeLabel: 'X', nodeType: 'Group', overrideCount: 3, overrides: {}, suggestedVariantName: 'c' }
]
};
const suggestions = StyleAnalyzer.toSuggestions(result);
expect(suggestions[0].type).toBe('repeated-color');
expect(suggestions[1].type).toBe('repeated-spacing');
expect(suggestions[2].type).toBe('variant-candidate');
});
});
// ─── analyzeProject ───────────────────────────────────────────────────────────
describe('StyleAnalyzer.analyzeProject', () => {
let originalInstance: unknown;
beforeEach(() => {
originalInstance = (ProjectModel as unknown as { instance: unknown }).instance;
});
afterEach(() => {
(ProjectModel as unknown as { instance: unknown }).instance = originalInstance;
});
it('returns empty result when ProjectModel.instance is null', () => {
(ProjectModel as unknown as { instance: unknown }).instance = null;
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedColors).toHaveLength(0);
expect(result.repeatedSpacing).toHaveLength(0);
expect(result.variantCandidates).toHaveLength(0);
});
it('detects repeated colour above threshold (3+)', () => {
const nodes = [
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' }),
makeNode('n3', 'Group', { backgroundColor: '#3b82f6' })
];
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedColors).toHaveLength(1);
expect(result.repeatedColors[0].value).toBe('#3b82f6');
expect(result.repeatedColors[0].count).toBe(3);
expect(result.repeatedColors[0].elements).toHaveLength(3);
});
it('does NOT report repeated colour below threshold (<3)', () => {
const nodes = [
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' })
];
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedColors).toHaveLength(0);
});
it('skips CSS var() token references', () => {
const nodes = [
makeNode('n1', 'Group', { backgroundColor: 'var(--primary)' }),
makeNode('n2', 'Group', { backgroundColor: 'var(--primary)' }),
makeNode('n3', 'Group', { backgroundColor: 'var(--primary)' })
];
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
const result = StyleAnalyzer.analyzeProject();
// var() references must never appear in repeated values
expect(result.repeatedColors).toHaveLength(0);
});
it('detects repeated spacing value above threshold', () => {
const nodes = [
makeNode('n1', 'Group', { paddingTop: '16px' }),
makeNode('n2', 'Group', { paddingTop: '16px' }),
makeNode('n3', 'Group', { paddingTop: '16px' })
];
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedSpacing).toHaveLength(1);
expect(result.repeatedSpacing[0].value).toBe('16px');
expect(result.repeatedSpacing[0].count).toBe(3);
});
it('detects variant candidate when node has 3+ non-token overrides', () => {
const nodes = [
makeNode('n1', 'net.noodl.controls.button', {
backgroundColor: '#22c55e',
color: '#ffffff',
borderRadius: '9999px'
// exactly SUGGESTION_THRESHOLDS.variantCandidateMinOverrides
})
];
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
const result = StyleAnalyzer.analyzeProject();
expect(result.variantCandidates).toHaveLength(1);
expect(result.variantCandidates[0].nodeId).toBe('n1');
expect(result.variantCandidates[0].overrideCount).toBe(3);
});
it('does NOT report variant candidate below threshold', () => {
const nodes = [
makeNode('n1', 'net.noodl.controls.button', {
backgroundColor: '#22c55e',
color: '#ffffff'
// only 2 overrides — below threshold of 3
})
];
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
const result = StyleAnalyzer.analyzeProject();
expect(result.variantCandidates).toHaveLength(0);
});
it('counts each occurrence across multiple nodes', () => {
const nodes = [
makeNode('n1', 'Group', { backgroundColor: '#ff0000' }),
makeNode('n2', 'Group', { backgroundColor: '#ff0000' }),
makeNode('n3', 'Group', { backgroundColor: '#ff0000' }),
makeNode('n4', 'Group', { backgroundColor: '#ff0000' }),
makeNode('n5', 'Group', { backgroundColor: '#ff0000' })
];
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
const result = StyleAnalyzer.analyzeProject();
expect(result.repeatedColors[0].count).toBe(5);
});
it('matches repeated value to existing token via tokenModel', () => {
const nodes = [
makeNode('n1', 'Group', { backgroundColor: '#3b82f6' }),
makeNode('n2', 'Group', { backgroundColor: '#3b82f6' }),
makeNode('n3', 'Group', { backgroundColor: '#3b82f6' })
];
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([nodes]);
const mockTokenModel = {
getTokens: () => [{ name: '--brand-primary' }],
resolveToken: (name: string) => (name === '--brand-primary' ? '#3b82f6' : undefined)
};
const result = StyleAnalyzer.analyzeProject({ tokenModel: mockTokenModel });
expect(result.repeatedColors[0].matchingToken).toBe('--brand-primary');
});
it('reports SUGGESTION_THRESHOLDS values as expected', () => {
expect(SUGGESTION_THRESHOLDS.repeatedValueMinCount).toBe(3);
expect(SUGGESTION_THRESHOLDS.variantCandidateMinOverrides).toBe(3);
});
});
// ─── analyzeNode ──────────────────────────────────────────────────────────────
describe('StyleAnalyzer.analyzeNode', () => {
let originalInstance: unknown;
beforeEach(() => {
originalInstance = (ProjectModel as unknown as { instance: unknown }).instance;
});
afterEach(() => {
(ProjectModel as unknown as { instance: unknown }).instance = originalInstance;
});
it('returns empty when ProjectModel.instance is null', () => {
(ProjectModel as unknown as { instance: unknown }).instance = null;
const result = StyleAnalyzer.analyzeNode('any-id');
expect(result.variantCandidates).toHaveLength(0);
});
it('returns empty when node not found', () => {
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([[]]);
const result = StyleAnalyzer.analyzeNode('nonexistent');
expect(result.variantCandidates).toHaveLength(0);
});
it('returns variant candidate for node with 3+ non-token overrides', () => {
const node = makeNode('btn-1', 'net.noodl.controls.button', {
backgroundColor: '#22c55e',
color: '#ffffff',
borderRadius: '9999px',
fontSize: '14px'
});
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([[node]]);
const result = StyleAnalyzer.analyzeNode('btn-1');
expect(result.variantCandidates).toHaveLength(1);
expect(result.variantCandidates[0].nodeId).toBe('btn-1');
expect(result.variantCandidates[0].overrideCount).toBe(4);
});
it('returns empty for node with fewer than threshold overrides', () => {
const node = makeNode('btn-2', 'net.noodl.controls.button', {
backgroundColor: '#22c55e',
color: '#ffffff'
// 2 overrides — below threshold
});
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([[node]]);
const result = StyleAnalyzer.analyzeNode('btn-2');
expect(result.variantCandidates).toHaveLength(0);
});
it('ignores var() token references when counting overrides', () => {
const node = makeNode('btn-3', 'net.noodl.controls.button', {
backgroundColor: 'var(--primary)', // token — not counted
color: 'var(--text-on-primary)', // token — not counted
borderRadius: '9999px', // raw
fontSize: '14px', // raw
paddingTop: '12px' // raw
});
(ProjectModel as unknown as { instance: unknown }).instance = makeMockProject([[node]]);
const result = StyleAnalyzer.analyzeNode('btn-3');
// Only 3 raw overrides count — should hit threshold exactly
expect(result.variantCandidates).toHaveLength(1);
expect(result.variantCandidates[0].overrideCount).toBe(3);
});
});

View File

@@ -0,0 +1,4 @@
// NOTE: StyleAnalyzer.test uses @jest/globals and is a Jest-only test.
// It runs via `npm run test:editor`.
// Do NOT re-add it here - the Electron Jasmine runner will crash on import.
export {};