From 7bd9b4c3e631fffafd0a3447a2662d900919b8f0 Mon Sep 17 00:00:00 2001 From: Richard Osborne Date: Wed, 18 Feb 2026 19:58:30 +0100 Subject: [PATCH] feat(sprint-2): STYLE-005 StyleAnalyzer tests + UBA-005 UBAClient HTTP service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/editor/src/services/UBA/UBAClient.ts | 341 +++++++++++++++ .../src/editor/src/services/UBA/index.ts | 2 + packages/noodl-editor/tests/index.ts | 2 + packages/noodl-editor/tests/models/index.ts | 3 + .../tests/services/StyleAnalyzer.test.ts | 394 ++++++++++++++++++ packages/noodl-editor/tests/services/index.ts | 1 + 6 files changed, 743 insertions(+) create mode 100644 packages/noodl-editor/src/editor/src/services/UBA/UBAClient.ts create mode 100644 packages/noodl-editor/src/editor/src/services/UBA/index.ts create mode 100644 packages/noodl-editor/tests/models/index.ts create mode 100644 packages/noodl-editor/tests/services/StyleAnalyzer.test.ts create mode 100644 packages/noodl-editor/tests/services/index.ts diff --git a/packages/noodl-editor/src/editor/src/services/UBA/UBAClient.ts b/packages/noodl-editor/src/editor/src/services/UBA/UBAClient.ts new file mode 100644 index 0000000..4a7bfd7 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/services/UBA/UBAClient.ts @@ -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 + * - '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 { + 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, + auth?: AuthConfig, + credentials?: { token?: string; username?: string; password?: string } + ): Promise { + 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 { + 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).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 { + 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); + } + } +} diff --git a/packages/noodl-editor/src/editor/src/services/UBA/index.ts b/packages/noodl-editor/src/editor/src/services/UBA/index.ts new file mode 100644 index 0000000..21af852 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/services/UBA/index.ts @@ -0,0 +1,2 @@ +export { UBAClient, UBAClientError } from './UBAClient'; +export type { ConfigureResult, DebugEvent, DebugStreamHandle, DebugStreamOptions, HealthResult } from './UBAClient'; diff --git a/packages/noodl-editor/tests/index.ts b/packages/noodl-editor/tests/index.ts index 9db6932..c3d510e 100644 --- a/packages/noodl-editor/tests/index.ts +++ b/packages/noodl-editor/tests/index.ts @@ -5,9 +5,11 @@ 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'; diff --git a/packages/noodl-editor/tests/models/index.ts b/packages/noodl-editor/tests/models/index.ts new file mode 100644 index 0000000..298f509 --- /dev/null +++ b/packages/noodl-editor/tests/models/index.ts @@ -0,0 +1,3 @@ +export * from './UBAConditions.test'; +export * from './UBASchemaParser.test'; +export * from './ElementConfigRegistry.test'; diff --git a/packages/noodl-editor/tests/services/StyleAnalyzer.test.ts b/packages/noodl-editor/tests/services/StyleAnalyzer.test.ts new file mode 100644 index 0000000..d046218 --- /dev/null +++ b/packages/noodl-editor/tests/services/StyleAnalyzer.test.ts @@ -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) { + 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[][]) { + return { + getComponents: () => + nodeGroups.map((nodes) => ({ + forEachNode: (fn: (node: ReturnType) => 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); + }); +}); diff --git a/packages/noodl-editor/tests/services/index.ts b/packages/noodl-editor/tests/services/index.ts new file mode 100644 index 0000000..b31dd5d --- /dev/null +++ b/packages/noodl-editor/tests/services/index.ts @@ -0,0 +1 @@ +export * from './StyleAnalyzer.test';