From c962e755ec90f878988fd8b689586498948e34f1 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 27 Aug 2026 08:43:19 +0200 Subject: [PATCH] task(C1): render adapter interface with headless mock adapter --- frontend/src/render/mock/MockAdapter.ts | 115 ++++++++++++++++++++++++ frontend/src/render/types.ts | 115 ++++++++++++++++++++++++ frontend/tests/mockAdapter.test.ts | 82 +++++++++++++++++ 3 files changed, 312 insertions(+) create mode 100644 frontend/src/render/mock/MockAdapter.ts create mode 100644 frontend/src/render/types.ts create mode 100644 frontend/tests/mockAdapter.test.ts diff --git a/frontend/src/render/mock/MockAdapter.ts b/frontend/src/render/mock/MockAdapter.ts new file mode 100644 index 0000000..ad2019e --- /dev/null +++ b/frontend/src/render/mock/MockAdapter.ts @@ -0,0 +1,115 @@ +/** + * MockAdapter – Headless-Implementierung des RenderAdapter (Task C1). + * Zeichnet nichts; protokolliert alle Aufrufe für assertions in Unit-Tests. + */ +import type { + RenderAdapter, + Viewport, + GridConfig, + BackgroundDrawConfig, + RulerConfig, + LayerStyle, + OverlayPainter, + SelectionState, +} from '../types'; +import type { CADElement, CADLayer } from '../../types/cad.types'; +import type { Pt } from '../../tools/modification/geometry'; + +export interface RecordedCall { + method: string; + args: unknown[]; +} + +export class MockAdapter implements RenderAdapter { + readonly calls: RecordedCall[] = []; + private canvas: HTMLCanvasElement | null = null; + nextHitTestResult: CADElement | null = null; + + // ── Recording helper ───────────────────────────────────── + + private rec(method: string, args: unknown[]): void { + this.calls.push({ method, args }); + } + + /** Test-Helfer: alle Aufrufe einer Methode. */ + callsOf(method: string): RecordedCall[] { + return this.calls.filter((c) => c.method === method); + } + + /** Test-Helfer: Anzahl Aufrufe einer Methode. */ + countCalls(method: string): number { + return this.callsOf(method).length; + } + + reset(): void { + this.calls.length = 0; + this.nextHitTestResult = null; + } + + // ── RenderAdapter Implementierung (reines Recording) ───── + + init(canvas: HTMLCanvasElement): void { + this.canvas = canvas; + this.rec('init', [canvas]); + } + + resize(width: number, height: number, dpr: number): void { + this.rec('resize', [width, height, dpr]); + } + + setViewport(vp: Viewport): void { + this.rec('setViewport', [vp]); + } + + destroy(): void { + this.rec('destroy', []); + } + + clear(color: string): void { + this.rec('clear', [color]); + } + + drawGrid(cfg: GridConfig): void { + this.rec('drawGrid', [cfg]); + } + + drawBackground(img: HTMLImageElement | null, cfg: BackgroundDrawConfig): void { + this.rec('drawBackground', [img, cfg]); + } + + drawRuler(cfg: RulerConfig): void { + this.rec('drawRuler', [cfg]); + } + + drawElement(el: CADElement, layer: CADLayer, style: LayerStyle): void { + this.rec('drawElement', [el, layer, style]); + } + + drawPreview(el: CADElement | null): void { + this.rec('drawPreview', [el]); + } + + setSelection(sel: SelectionState): void { + this.rec('setSelection', [sel]); + } + + setSnapPoints(pts: Array<{ x: number; y: number; type: string }>): void { + this.rec('setSnapPoints', [pts]); + } + + overlay(draw: (g: OverlayPainter) => void): void { + const painter: OverlayPainter = { + line(a: Pt, b: Pt, color: string, width?: number) {}, + rect(x: number, y: number, w: number, h: number, strokeColor: string) {}, + circle(center: Pt, radius: number, strokeColor: string) {}, + text(text: string, at: Pt, color: string, fontSize?: number) {}, + }; + draw(painter); + this.rec('overlay', [null]); + } + + hitTest(world: Pt, tolerancePx: number): CADElement | null { + this.rec('hitTest', [world, tolerancePx]); + return this.nextHitTestResult; + } +} diff --git a/frontend/src/render/types.ts b/frontend/src/render/types.ts new file mode 100644 index 0000000..b7a97a6 --- /dev/null +++ b/frontend/src/render/types.ts @@ -0,0 +1,115 @@ +/** + * render/types.ts – RenderAdapter-Schnittstelle (Task C1). + * + * Ziel-Architektur: ALLES Rendering läuft gegen diese schmale API. + * Implementierungen: + * - render/pixi/PixiRenderer.ts (WebGL, Task C2+) + * - tests/helpers/mockAdapter.ts (Headless-Recorder für Unit-Tests) + * + * Phase D/E-Erweiterungen (Plugins, Custom-Elemente) nutzen denselben Vertrag + * über drawOverlay/drawCustom. + */ +import type { CADElement, CADLayer } from '../types/cad.types'; +import type { Pt } from '../tools/modification/geometry'; + +/** Aktueller Ausschnitt in Weltkoordinaten + Skalierung. */ +export interface Viewport { + x: number; + y: number; + scale: number; + width: number; + height: number; +} + +/** Zeichenraster-Konfiguration. */ +export interface GridConfig { + enabled: boolean; + size: number; + color: string; +} + +/** Hintergrundbild-Konfiguration (Spiegelung der Legacy RenderOptions-Felder). */ +export interface BackgroundDrawConfig { + src?: string; + opacity?: number; + offsetX?: number; + offsetY?: number; + scale?: number; + rotation?: number; +} + +/** Lineal-Konfiguration. */ +export interface RulerConfig { + enabled: boolean; + unit: string; + scaleFactor: number; +} + +/** Stil pro CAD-Layer (Farbe/Linientyp/Transparenz). */ +export interface LayerStyle { + color: string; + lineType: string; + transparency: number; + visible: boolean; +} + +/** Freihand-Zeichner für temporäre Overlays (Marquee, Tool-Hilfslinien). */ +export interface OverlayPainter { + line(a: Pt, b: Pt, color: string, width?: number): void; + rect(x: number, y: number, w: number, h: number, strokeColor: string): void; + circle(center: Pt, radius: number, strokeColor: string): void; + text(text: string, at: Pt, color: string, fontSize?: number): void; +} + +/** Selektionszustand für die Anzeige von Handles. */ +export interface SelectionState { + selectedIds: string[]; +} + +/** + * Der zentrale Rendervertrag. Implementierungen MÜSSEN alle Methoden bereitstellen, + * auch wenn einzelne initial No-Ops sind (z.B. drawBackground ohne Bild). + */ +export interface RenderAdapter { + /** Bindet den Renderer an ein Canvas-Element (einmalig). */ + init(canvas: HTMLCanvasElement): void; + + /** Reaktion auf Größenänderungen (DPR-bewusst). */ + resize(width: number, height: number, dpr: number): void; + + /** Setzt den aktuellen Ausschnitt; danach ist ein neuer Frame nötig. */ + setViewport(vp: Viewport): void; + + /// Gibt WebGL-/DOM-Ressourcen frei. */ + destroy(): void; + + /** Leert die Zeichenfläche mit einer Grundfarbe. */ + clear(color: string): void; + + /** Zeichnet das Raster. */ + drawGrid(cfg: GridConfig): void; + + /** Zeichnet das Hintergrundbild, falls konfiguriert. */ + drawBackground(img: HTMLImageElement | null, cfg: BackgroundDrawConfig): void; + + /** Zeichnet das Lineal. */ + drawRuler(cfg: RulerConfig): void; + + /** Zeichnet ein Element im Kontext seines Layers (Stil, Sichtbarkeit). */ + drawElement(el: CADElement, layer: CADLayer, style: LayerStyle): void; + + /** Zeichnet ein Element in Transformation / Live-Vorschau. */ + drawPreview(el: CADElement | null): void; + + /** Synchronisiert die Selektionsanzeige (Handles, Umrandungen). */ + setSelection(sel: SelectionState): void; + + /** Snap-Markierungen setzen/entfernen. */ + setSnapPoints(pts: Array<{ x: number; y: number; type: string }>): void; + + /** Öffnet einen Overlay-Pass für freihand-Overlays (Tools). */ + overlay(draw: (g: OverlayPainter) => void): void; + + /** Treffer abfragen (Delegation an SpatialIndex; kein GPU-Picking). */ + hitTest(world: Pt, tolerancePx: number): CADElement | null; +} diff --git a/frontend/tests/mockAdapter.test.ts b/frontend/tests/mockAdapter.test.ts new file mode 100644 index 0000000..3c45bb0 --- /dev/null +++ b/frontend/tests/mockAdapter.test.ts @@ -0,0 +1,82 @@ +/** + * MockAdapter Smoke-Tests (Task C1). + * Headless-Mock erfüllt RenderAdapter-Vertrag und protokolliert Aufrufe — + * Grundlage für zukünftige Renderer-/Tool-Unit-Tests. + */ +import { describe, it, expect } from 'vitest'; +import { MockAdapter } from '../src/render/mock/MockAdapter'; +import type { RenderAdapter, Viewport } from '../src/render/types'; +import type { CADElement, CADLayer } from '../src/types/cad.types'; +import type { Pt } from '../src/tools/modification/geometry'; + +function makeLayer(): CADLayer { + return { + id: 'layer-1', name: 'L1', visible: true, locked: false, + color: '#ffffff', lineType: 'solid', transparency: 0, + sortOrder: 0, + parentId: null, + } as unknown as CADLayer; +} + +function makeEl(id = 'e1'): CADElement { + return { + id, + type: 'line', + layerId: 'layer-1', + x: 10, + y: 10, + width: 20, + height: 20, + properties: {}, + } as unknown as CADElement; +} + +describe('MockAdapter implements RenderAdapter', () => { + it('ist einem RenderAdapter zuweisbar (Compile-proof)', () => { + const adapter = new MockAdapter(); + const asAdapter: RenderAdapter = adapter; + expect(asAdapter).toBeDefined(); + }); + + it('protokolliert init/resize/clear/drawElement in Reihenfolge', () => { + const adapter = new MockAdapter(); + const canvas = document.createElement('canvas'); + adapter.init(canvas); + adapter.resize(800, 600, 2); + const vp: Viewport = { x: 0, y: 0, scale: 1, width: 800, height: 600 }; + adapter.setViewport(vp); + adapter.clear('#101018'); + adapter.drawElement(makeEl(), makeLayer(), { + color: '#fff', lineType: 'solid', transparency: 0, visible: true, + }); + + expect(adapter.calls.map((c) => c.method)).toEqual([ + 'init', 'resize', 'setViewport', 'clear', 'drawElement', + ]); + expect(adapter.countCalls('drawElement')).toBe(1); + }); + + it('hitTest liefert konfigurierbaren Treffer; overlay-Callback wird ausgefuehrt', () => { + const adapter = new MockAdapter(); + const el = makeEl('target'); + adapter.nextHitTestResult = el; + let drewRect = false; + adapter.overlay(() => { + drewRect = true; + }); + const pt: Pt = { x: 5, y: 5 }; + const hit = adapter.hitTest(pt, 3); + expect(hit?.id).toBe('target'); + expect(drewRect).toBe(true); + }); + + it('reset leert Protokoll und Treffer-Konfiguration', () => { + const adapter = new MockAdapter(); + adapter.nextHitTestResult = makeEl('tmp'); + adapter.overlay(() => {}); + adapter.reset(); + expect(adapter.calls.length).toBe(0); + const pt: Pt = { x: 0, y: 0 }; + expect(adapter.hitTest(pt, 1)).toBeNull(); + }); +});