task(C1): render adapter interface with headless mock adapter
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user