mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
Merge branch 'cline-dev-richard' into cline-dev
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
215
packages/noodl-editor/tests/models/ProjectCreationWizard.test.ts
Normal file
215
packages/noodl-editor/tests/models/ProjectCreationWizard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
294
packages/noodl-editor/tests/models/StyleAnalyzer.test.ts
Normal file
294
packages/noodl-editor/tests/models/StyleAnalyzer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
175
packages/noodl-editor/tests/models/UBAConditions.test.ts
Normal file
175
packages/noodl-editor/tests/models/UBAConditions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
319
packages/noodl-editor/tests/models/UBASchemaParser.test.ts
Normal file
319
packages/noodl-editor/tests/models/UBASchemaParser.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
4
packages/noodl-editor/tests/models/index.ts
Normal file
4
packages/noodl-editor/tests/models/index.ts
Normal 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 {};
|
||||
394
packages/noodl-editor/tests/services/StyleAnalyzer.test.ts
Normal file
394
packages/noodl-editor/tests/services/StyleAnalyzer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
4
packages/noodl-editor/tests/services/index.ts
Normal file
4
packages/noodl-editor/tests/services/index.ts
Normal 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 {};
|
||||
Reference in New Issue
Block a user