mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 10:03:31 +01:00
feat(sprint-2): STYLE-005 StyleAnalyzer tests + UBA-005 UBAClient HTTP service
- STYLE-005: 19 unit tests for StyleAnalyzer (toSuggestions, analyzeProject, analyzeNode) - toSuggestions: ordering, message format, id format, acceptLabel variants - analyzeProject: repeated color/spacing detection, threshold enforcement, var() skip, tokenModel matching - analyzeNode: variant candidate detection, threshold, var() reference exclusion - STYLE-005: Wire tests into test index (new tests/models/ and tests/services/ indexes) - UBA-005: UBAClient static class with configure(), health(), openDebugStream() - Full auth support: bearer, api_key (custom header), basic - Timeout via AbortController (10s default) - health() never throws — always returns HealthResult - openDebugStream() returns DebugStreamHandle; handles named SSE event types - UBAClientError with status + body for non-2xx responses
This commit is contained in:
341
packages/noodl-editor/src/editor/src/services/UBA/UBAClient.ts
Normal file
341
packages/noodl-editor/src/editor/src/services/UBA/UBAClient.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
/**
|
||||||
|
* UBA-005: UBAClient
|
||||||
|
*
|
||||||
|
* Thin HTTP client for communicating with a Universal Backend Adapter server.
|
||||||
|
* Handles three concerns:
|
||||||
|
* 1. configure — POST JSON config to the backend's config endpoint
|
||||||
|
* 2. health — GET the health endpoint and parse the status
|
||||||
|
* 3. debugStream — open an SSE connection to the debug_stream endpoint
|
||||||
|
*
|
||||||
|
* This is deliberately framework-agnostic — no React, no Electron APIs.
|
||||||
|
* All network calls use the global `fetch` (available in Electron's renderer).
|
||||||
|
*
|
||||||
|
* Auth support:
|
||||||
|
* - 'none' — no auth header added
|
||||||
|
* - 'bearer' — Authorization: Bearer <token>
|
||||||
|
* - 'api_key' — uses `header` field from AuthConfig (e.g. X-Api-Key)
|
||||||
|
* - 'basic' — Authorization: Basic base64(username:password)
|
||||||
|
*
|
||||||
|
* Error handling:
|
||||||
|
* - Non-2xx responses → rejected with UBAClientError
|
||||||
|
* - Network failures → rejected with UBAClientError wrapping the original error
|
||||||
|
* - SSE failures → onError callback invoked; caller responsible for cleanup
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AuthConfig } from '@noodl-models/UBA/types';
|
||||||
|
|
||||||
|
// ─── Public API Types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Result of a successful configure call. */
|
||||||
|
export interface ConfigureResult {
|
||||||
|
/** HTTP status code from the backend */
|
||||||
|
status: number;
|
||||||
|
/** Parsed response body (may be null for 204 No Content) */
|
||||||
|
body: unknown;
|
||||||
|
/** True if backend accepted the config (2xx status) */
|
||||||
|
ok: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of a health check call. */
|
||||||
|
export interface HealthResult {
|
||||||
|
/** HTTP status code */
|
||||||
|
status: number;
|
||||||
|
/** Whether the backend considers itself healthy */
|
||||||
|
healthy: boolean;
|
||||||
|
/** Optional message from the backend */
|
||||||
|
message?: string;
|
||||||
|
/** Raw parsed body, if any */
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single debug event received from the SSE stream. */
|
||||||
|
export interface DebugEvent {
|
||||||
|
/** SSE event type (e.g. 'log', 'error', 'metric') */
|
||||||
|
type: string;
|
||||||
|
/** Parsed event data */
|
||||||
|
data: unknown;
|
||||||
|
/** Raw data string as received */
|
||||||
|
raw: string;
|
||||||
|
/** Client-side timestamp */
|
||||||
|
receivedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for opening a debug stream. */
|
||||||
|
export interface DebugStreamOptions {
|
||||||
|
/** Invoked for each SSE event received. */
|
||||||
|
onEvent: (event: DebugEvent) => void;
|
||||||
|
/** Invoked on connection error or unexpected close. */
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
/** Invoked when the stream opens successfully. */
|
||||||
|
onOpen?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error thrown by UBAClient on HTTP or network failures. */
|
||||||
|
export class UBAClientError extends Error {
|
||||||
|
constructor(message: string, public readonly status?: number, public readonly body?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'UBAClientError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle returned by openDebugStream — call close() to disconnect. */
|
||||||
|
export interface DebugStreamHandle {
|
||||||
|
close(): void;
|
||||||
|
readonly endpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth Header Builder ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Authorization/custom auth header for a request.
|
||||||
|
* Returns an empty record if no auth is required.
|
||||||
|
*/
|
||||||
|
function buildAuthHeaders(
|
||||||
|
auth: AuthConfig | undefined,
|
||||||
|
credentials?: { token?: string; username?: string; password?: string }
|
||||||
|
): Record<string, string> {
|
||||||
|
if (!auth || auth.type === 'none' || !credentials) return {};
|
||||||
|
|
||||||
|
switch (auth.type) {
|
||||||
|
case 'bearer': {
|
||||||
|
if (!credentials.token) return {};
|
||||||
|
return { Authorization: `Bearer ${credentials.token}` };
|
||||||
|
}
|
||||||
|
case 'api_key': {
|
||||||
|
if (!credentials.token) return {};
|
||||||
|
const headerName = auth.header ?? 'X-Api-Key';
|
||||||
|
return { [headerName]: credentials.token };
|
||||||
|
}
|
||||||
|
case 'basic': {
|
||||||
|
const { username = '', password = '' } = credentials;
|
||||||
|
const encoded = btoa(`${username}:${password}`);
|
||||||
|
return { Authorization: `Basic ${encoded}` };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── UBAClient ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static utility class for UBA HTTP communication.
|
||||||
|
*
|
||||||
|
* All methods are `static` — no need to instantiate; just call directly.
|
||||||
|
* This keeps usage simple in hooks and models without dependency injection.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const result = await UBAClient.configure(
|
||||||
|
* 'http://localhost:3210/configure',
|
||||||
|
* { database: { host: 'localhost', port: 5432 } },
|
||||||
|
* schema.backend.auth
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class UBAClient {
|
||||||
|
/** Default fetch timeout in milliseconds. */
|
||||||
|
static DEFAULT_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
// ─── configure ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST a configuration object to the backend's config endpoint.
|
||||||
|
*
|
||||||
|
* @param endpoint - Full URL of the config endpoint (from schema.backend.endpoints.config)
|
||||||
|
* @param config - The flattened/structured config values to POST as JSON
|
||||||
|
* @param auth - Optional auth configuration from the schema
|
||||||
|
* @param credentials - Optional credential values to build the auth header
|
||||||
|
* @returns ConfigureResult on success; throws UBAClientError on failure
|
||||||
|
*/
|
||||||
|
static async configure(
|
||||||
|
endpoint: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
auth?: AuthConfig,
|
||||||
|
credentials?: { token?: string; username?: string; password?: string }
|
||||||
|
): Promise<ConfigureResult> {
|
||||||
|
const authHeaders = buildAuthHeaders(auth, credentials);
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await UBAClient._fetchWithTimeout(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...authHeaders
|
||||||
|
},
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new UBAClientError(
|
||||||
|
`Network error sending config to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse body if available
|
||||||
|
let body: unknown = null;
|
||||||
|
if (response.status !== 204) {
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {
|
||||||
|
// Non-JSON body — read as text
|
||||||
|
try {
|
||||||
|
body = await response.text();
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new UBAClientError(`Configure failed: ${response.status} ${response.statusText}`, response.status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: response.status, body, ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── health ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET the health endpoint and determine backend status.
|
||||||
|
*
|
||||||
|
* Backends should return 200 when healthy. Any non-2xx is treated as
|
||||||
|
* unhealthy. Network errors are also treated as unhealthy (not thrown).
|
||||||
|
*
|
||||||
|
* @param endpoint - Full URL of the health endpoint
|
||||||
|
* @param auth - Optional auth configuration
|
||||||
|
* @param credentials - Optional credentials
|
||||||
|
* @returns HealthResult (never throws — unhealthy on error)
|
||||||
|
*/
|
||||||
|
static async health(
|
||||||
|
endpoint: string,
|
||||||
|
auth?: AuthConfig,
|
||||||
|
credentials?: { token?: string; username?: string; password?: string }
|
||||||
|
): Promise<HealthResult> {
|
||||||
|
const authHeaders = buildAuthHeaders(auth, credentials);
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await UBAClient._fetchWithTimeout(endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: authHeaders
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Network failure → unhealthy
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
healthy: false,
|
||||||
|
message: `Network error: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const text = await response.text();
|
||||||
|
body = text || null;
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthy = response.ok;
|
||||||
|
const message =
|
||||||
|
typeof body === 'object' && body !== null && 'message' in body
|
||||||
|
? String((body as Record<string, unknown>).message)
|
||||||
|
: typeof body === 'string'
|
||||||
|
? body
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return { status: response.status, healthy, message, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── openDebugStream ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a Server-Sent Events connection to the debug_stream endpoint.
|
||||||
|
*
|
||||||
|
* Returns a handle with a `close()` method to disconnect cleanly.
|
||||||
|
* Because EventSource doesn't support custom headers natively, auth
|
||||||
|
* tokens are appended as a query parameter when needed.
|
||||||
|
*
|
||||||
|
* @param endpoint - Full URL of the debug_stream endpoint
|
||||||
|
* @param options - Event callbacks (onEvent, onError, onOpen)
|
||||||
|
* @param auth - Optional auth configuration
|
||||||
|
* @param credentials - Optional credentials
|
||||||
|
* @returns DebugStreamHandle — call handle.close() to disconnect
|
||||||
|
*/
|
||||||
|
static openDebugStream(
|
||||||
|
endpoint: string,
|
||||||
|
options: DebugStreamOptions,
|
||||||
|
auth?: AuthConfig,
|
||||||
|
credentials?: { token?: string; username?: string; password?: string }
|
||||||
|
): DebugStreamHandle {
|
||||||
|
// Append token as query param if needed (EventSource limitation)
|
||||||
|
let url = endpoint;
|
||||||
|
if (auth && auth.type !== 'none' && credentials?.token) {
|
||||||
|
const separator = endpoint.includes('?') ? '&' : '?';
|
||||||
|
if (auth.type === 'bearer' || auth.type === 'api_key') {
|
||||||
|
url = `${endpoint}${separator}token=${encodeURIComponent(credentials.token)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = new EventSource(url);
|
||||||
|
|
||||||
|
source.onopen = () => {
|
||||||
|
options.onOpen?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
source.onerror = () => {
|
||||||
|
options.onError?.(new Error(`Debug stream connection to ${endpoint} failed or closed`));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for 'message' (default SSE event) and any named events
|
||||||
|
const handleRawEvent = (e: MessageEvent, type: string) => {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(e.data);
|
||||||
|
} catch {
|
||||||
|
data = e.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event: DebugEvent = {
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
raw: e.data,
|
||||||
|
receivedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
options.onEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
source.addEventListener('message', (e) => handleRawEvent(e as MessageEvent, 'message'));
|
||||||
|
|
||||||
|
// Common named event types that UBA backends may emit
|
||||||
|
for (const eventType of ['log', 'error', 'warn', 'info', 'metric', 'trace']) {
|
||||||
|
source.addEventListener(eventType, (e) => handleRawEvent(e as MessageEvent, eventType));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint,
|
||||||
|
close: () => source.close()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch with an AbortController-based timeout.
|
||||||
|
*/
|
||||||
|
private static async _fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), UBAClient.DEFAULT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...init, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { UBAClient, UBAClientError } from './UBAClient';
|
||||||
|
export type { ConfigureResult, DebugEvent, DebugStreamHandle, DebugStreamOptions, HealthResult } from './UBAClient';
|
||||||
@@ -5,9 +5,11 @@ import '@noodl/platform-electron';
|
|||||||
export * from './cloud';
|
export * from './cloud';
|
||||||
export * from './components';
|
export * from './components';
|
||||||
export * from './git';
|
export * from './git';
|
||||||
|
export * from './models';
|
||||||
export * from './nodegraph';
|
export * from './nodegraph';
|
||||||
export * from './platform';
|
export * from './platform';
|
||||||
export * from './project';
|
export * from './project';
|
||||||
export * from './projectmerger';
|
export * from './projectmerger';
|
||||||
export * from './projectpatcher';
|
export * from './projectpatcher';
|
||||||
|
export * from './services';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
|||||||
3
packages/noodl-editor/tests/models/index.ts
Normal file
3
packages/noodl-editor/tests/models/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './UBAConditions.test';
|
||||||
|
export * from './UBASchemaParser.test';
|
||||||
|
export * from './ElementConfigRegistry.test';
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
packages/noodl-editor/tests/services/index.ts
Normal file
1
packages/noodl-editor/tests/services/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './StyleAnalyzer.test';
|
||||||
Reference in New Issue
Block a user