mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
feat(styles+uba): STYLE-005 wiring + UBA-001/002
STYLE-005: ElementStyleSectionHost wraps ElementStyleSection with SuggestionBanner. Uses useStyleSuggestions + executeSuggestionAction. propertyeditor.ts updated to use host (drop-in, zero API change). UBA-001: types.ts — Field discriminated union (8 types), BackendMetadata, Section, Condition, ParseResult<T>. Zero external deps. UBA-002: SchemaParser.ts validates pre-parsed objects against UBA v1. Forward-compat: unknown field types/versions produce warnings not errors. 22 unit tests in tests/models/UBASchemaParser.test.ts.
This commit is contained in:
311
packages/noodl-editor/tests/models/UBASchemaParser.test.ts
Normal file
311
packages/noodl-editor/tests/models/UBASchemaParser.test.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// ─── 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 (!result.success) {
|
||||
expect(result.errors[0].path).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects array input', () => {
|
||||
const result = parser.parse([]);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing schema_version', () => {
|
||||
const { schema_version: _sv, ...noVersion } = minimalValid;
|
||||
const result = parser.parse(noVersion);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
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', () => {
|
||||
const { backend: _b, ...noBackend } = minimalValid;
|
||||
const result = parser.parse(noBackend);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
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 (!result.success) {
|
||||
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 (!result.success) {
|
||||
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 (!result.success) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user