From cba9358235aafe5e40b2d731bafba2c4ee8f86e0 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 26 Aug 2026 21:04:17 +0200 Subject: [PATCH] task(A0): logger and test helpers --- frontend/src/utils/logger.ts | 69 +++++++++++++++++++++++++ frontend/tests/helpers.smoke.test.ts | 74 +++++++++++++++++++++++++++ frontend/tests/helpers/inMemoryDoc.ts | 52 +++++++++++++++++++ frontend/tests/helpers/mockAdapter.ts | 69 +++++++++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 frontend/src/utils/logger.ts create mode 100644 frontend/tests/helpers.smoke.test.ts create mode 100644 frontend/tests/helpers/inMemoryDoc.ts create mode 100644 frontend/tests/helpers/mockAdapter.ts diff --git a/frontend/src/utils/logger.ts b/frontend/src/utils/logger.ts new file mode 100644 index 0000000..949033c --- /dev/null +++ b/frontend/src/utils/logger.ts @@ -0,0 +1,69 @@ +/** + * Minimaler Level-basierter Logger. + * Ziel: direktes `console.log` im Produktivcode ersetzen (ROADMAP Teil-4-Regel), + * damit sich Logs zentral filtern/abschalten lassen. + * + * Nutzung: + * import { logger } from '../utils/logger'; + * logger.info('Element erstellt', el.id); + * + * Level über localStorage['webcad.logLevel'] ('silent'|'error'|'warn'|'info'|'debug', + * Default: 'info'). `logger.debug` bleibt auf Default still. + */ + +export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'; + +const LEVEL_ORDER: Record = { + silent: 100, + error: 40, + warn: 30, + info: 20, + debug: 10, +}; + +function readConfiguredLevel(): LogLevel { + try { + // Browser und jsdom haben localStorage; in reinen Node-Tests schlägt der Zugriff fehl + const raw = globalThis.localStorage?.getItem('webcad.logLevel'); + if (raw && raw in LEVEL_ORDER) return raw as LogLevel; + } catch { + /* Umgebung ohne localStorage → Default */ + } + return 'info'; +} + +let currentLevel: LogLevel = readConfiguredLevel(); + +/** Setzt das Level programmatisch (überschreibt localStorage bis zum Reload). */ +export function setLogLevel(level: LogLevel): void { + currentLevel = level; +} + +/** Aktuell aktives Log-Level. */ +export function getLogLevel(): LogLevel { + return currentLevel; +} + +class Logger { + /** Fehlerhafte Zustände — melden außer bei silent. */ + error(...args: unknown[]): void { + if (LEVEL_ORDER[currentLevel] <= LEVEL_ORDER.error) console.error('[web-cad]', ...args); + } + + /** Warnungen. */ + warn(...args: unknown[]): void { + if (LEVEL_ORDER[currentLevel] <= LEVEL_ORDER.warn) console.warn('[web-cad]', ...args); + } + + /** Normale Informationsmeldungen (Default-Level aktiv). */ + info(...args: unknown[]): void { + if (LEVEL_ORDER[currentLevel] <= LEVEL_ORDER.info) console.log('[web-cad]', ...args); + } + + /** Debug-Ausgaben — nur wenn Level debug konfiguriert ist. */ + debug(...args: unknown[]): void { + if (LEVEL_ORDER[currentLevel] <= LEVEL_ORDER.debug) console.log('[web-cad][debug]', ...args); + } +} + +export const logger = new Logger(); diff --git a/frontend/tests/helpers.smoke.test.ts b/frontend/tests/helpers.smoke.test.ts new file mode 100644 index 0000000..7f3b55f --- /dev/null +++ b/frontend/tests/helpers.smoke.test.ts @@ -0,0 +1,74 @@ +/** + * Smoke-Tests für die Test-Helfer aus Task A0. + * Stellt sicher, dass Logger, MockRenderEngine und InMemoryDoc funktionieren, + * BEVOR spätere Tasks auf ihnen aufbauen. + */ +import { describe, it, expect, vi } from 'vitest'; +import { logger, setLogLevel } from '../src/utils/logger'; +import { MockRenderEngine } from './helpers/mockAdapter'; +import { createInMemoryDoc, makeTestLayer } from './helpers/inMemoryDoc'; +import type { CADElement } from '../src/types/cad.types'; + +describe('A0 helpers – logger', () => { + it('schreibt info im Default-Level in die Testumgebung', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + setLogLevel('info'); + logger.info('smoke test message'); + expect(spy).toHaveBeenCalledWith('[web-cad]', 'smoke test message'); + spy.mockRestore(); + }); + + it('debug bleibt bei Level info still und erscheint bei debug', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + setLogLevel('info'); + logger.debug('hidden'); + expect(spy).not.toHaveBeenCalled(); + setLogLevel('debug'); + logger.debug('visible'); + expect(spy).toHaveBeenCalledWith('[web-cad][debug]', 'visible'); + spy.mockRestore(); + }); +}); + +describe('A0 helpers – MockRenderEngine', () => { + it('protokolliert Aufrufe mit Reihenfolge und Argumenten', () => { + const mock = new MockRenderEngine(); + mock.render(); + mock.hitTest(10, 20, 5); + mock.setPreviewElement(null); + + expect(mock.countCalls('render')).toBe(1); + expect(mock.countCalls('hitTest')).toBe(1); + expect(mock.lastArgsOf('hitTest')).toEqual([10, 20, 5]); + expect(mock.calls.map((c) => c.method)).toEqual(['render', 'hitTest', 'setPreviewElement']); + }); + + it('liefert konfigurierbare hitTest-Ergebnisse', () => { + const mock = new MockRenderEngine(); + const el = { id: 'e1', type: 'line' } as unknown as CADElement; + mock.nextHitTestResult = el; + expect(mock.hitTest(0, 0)).toBe(el); + mock.reset(); + expect(mock.hitTest(0, 0)).toBeNull(); + }); +}); + +describe('A0 helpers – createInMemoryDoc', () => { + it('erstellt netzwerkfreies Dokument mit Seeding', () => { + const { ydoc, addAll } = createInMemoryDoc(); + ydoc.data.layers.set('layer-1', makeTestLayer() as never); + const el = { id: 'el1', type: 'rect', layerId: 'layer-1', x: 0, y: 0, width: 1, height: 1, properties: {} } as unknown as CADElement; + addAll([el]); + expect(ydoc.getElements().length).toBe(1); + expect(ydoc.getLayers().length).toBe(1); + }); + + it('clear entfernt alle Elemente', () => { + const { ydoc, addAll, clear } = createInMemoryDoc(); + const el = { id: 'el1', type: 'rect', layerId: 'layer-1', x: 0, y: 0, width: 1, height: 1, properties: {} } as unknown as CADElement; + addAll([el]); + expect(ydoc.getElements().length).toBe(1); + clear(); + expect(ydoc.getElements().length).toBe(0); + }); +}); diff --git a/frontend/tests/helpers/inMemoryDoc.ts b/frontend/tests/helpers/inMemoryDoc.ts new file mode 100644 index 0000000..2afebd2 --- /dev/null +++ b/frontend/tests/helpers/inMemoryDoc.ts @@ -0,0 +1,52 @@ +/** + * InMemoryDoc-Helper – Netzwerkfreie YjsDocument-Fabrik für Tests. + * + * YjsDocument läuft bereits komplett In-Memory (Y.Doc ohne Provider); + * dieser Wrapper bietet bequemes Seeding und Reset für isolierte Tests. + * Genutzt ab A3 (Tool-Workflow-Tests) und A2b (CADDocument-Tests). + */ +import { YjsDocument } from '../../src/crdt/YjsDocument'; +import type { CADElement } from '../../src/types/cad.types'; + +export interface SeededDoc { + ydoc: YjsDocument; + /** Fügt Elemente in EINER Transaktion hinzu (1 Undo-Schritt). */ + addAll(elements: CADElement[]): void; + /** Entfernt alle Elemente/Layer-Inhalte (Dokument bleibt verwendbar). */ + clear(): void; +} + +/** Erzeugt ein frisches, leeres Test-Dokument. */ +export function createInMemoryDoc(): SeededDoc { + const ydoc = new YjsDocument(); + return { + ydoc, + addAll(elements) { + ydoc.doc.transact(() => { + for (const el of elements) ydoc.data.elements.set(el.id, el); + }); + }, + clear() { + ydoc.doc.transact(() => { + for (const id of Array.from(ydoc.data.elements.keys())) { + ydoc.data.elements.delete(id); + } + }); + }, + }; +} + +/** Basis-CADLayer für Tests. */ +export function makeTestLayer(id = 'layer-1', name = 'Test-Layer') { + return { + id, + name, + visible: true, + locked: false, + color: '#ffffff', + lineType: 'solid', + transparency: 0, + sortOrder: 0, + parentId: null, + }; +} diff --git a/frontend/tests/helpers/mockAdapter.ts b/frontend/tests/helpers/mockAdapter.ts new file mode 100644 index 0000000..a0b7429 --- /dev/null +++ b/frontend/tests/helpers/mockAdapter.ts @@ -0,0 +1,69 @@ +/** + * MockAdapter – Headless-Doppel der RenderEngine für Workflow-Tests. + * Zeichnet nichts; protokolliert alle Aufrufe in `calls` (Reihenfolge getreu). + * + * Methodenumfang = genau das, was InteractionEngine derzeit an RenderEngine + * ruft (verifiziert in Task A0): hitTest, render, setActiveSnapPoint, + * setOptions, setPreviewElement, setSnapPoints. + * Wird in C1 (RenderAdapter-Interface) um die vollen Adapter-Methoden erweitert. + */ +import type { CADElement } from '../../src/types/cad.types'; + +export interface RecordedCall { + method: string; + args: unknown[]; +} + +export class MockRenderEngine { + /** Alle Aufrufe in Reihenfolge. */ + readonly calls: RecordedCall[] = []; + + /** Von Tests konfigurierbare hitTest-Rückgabe. */ + nextHitTestResult: CADElement | null = null; + + // ── Von InteractionEngine genutzte Fläche ──────────────── + + hitTest(x: number, y: number, tolerance?: number): CADElement | null { + this.calls.push({ method: 'hitTest', args: [x, y, tolerance] }); + return this.nextHitTestResult; + } + + render(): void { + this.calls.push({ method: 'render', args: [] }); + } + + setActiveSnapPoint(pt: unknown): void { + this.calls.push({ method: 'setActiveSnapPoint', args: [pt] }); + } + + setOptions(opts: Record): void { + this.calls.push({ method: 'setOptions', args: [opts] }); + } + + setPreviewElement(el: CADElement | null): void { + this.calls.push({ method: 'setPreviewElement', args: [el] }); + } + + setSnapPoints(pts: unknown[]): void { + this.calls.push({ method: 'setSnapPoints', args: [pts] }); + } + + // ── Test-Helfer ────────────────────────────────────────── + + /** Anzahl Aufrufe einer Methode. */ + countCalls(method: string): number { + return this.calls.filter((c) => c.method === method).length; + } + + /** Letzte aufgezeichnete Argumentliste einer Methode (oder undefined). */ + lastArgsOf(method: string): unknown[] | undefined { + const found = this.calls.filter((c) => c.method === method); + return found.length ? found[found.length - 1].args : undefined; + } + + /** Protokoll leeren. */ + reset(): void { + this.calls.length = 0; + this.nextHitTestResult = null; + } +}