158 lines
6.2 KiB
TypeScript
158 lines
6.2 KiB
TypeScript
/**
|
||
* Task G2 – LayoutBar UI + Viewport-Widget + ZoomPanController.setView.
|
||
*
|
||
* LayoutBar listet Layouts des aktiven Dokuments, zeigt je Layout ein
|
||
* Minimap-Widget (Modellbereich + Viewport-Rechteck), Klick = zoom-to,
|
||
* „+“ speichert aktuellen Viewport als neues Layout, Löschen entfernt.
|
||
* ZoomPanController.setView(center, scale) setzt die Absolute Ansicht.
|
||
*/
|
||
import { describe, it, expect, vi } from 'vitest';
|
||
import { render, screen, fireEvent } from '@testing-library/react';
|
||
import LayoutBar, { computeMinimapRects } from '../src/components/LayoutBar';
|
||
import { ZoomPanController } from '../src/canvas/ZoomPanController';
|
||
import type { LayoutDefinition } from '../src/types/cad.types';
|
||
|
||
function createMockCanvas(w = 800, h = 600): HTMLCanvasElement {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w;
|
||
canvas.height = h;
|
||
return canvas;
|
||
}
|
||
|
||
function fakeDoc(layouts: LayoutDefinition[]) {
|
||
return {
|
||
getAllLayouts: () => [...layouts],
|
||
addLayout: vi.fn((l: LayoutDefinition) => { layouts.push(l); }),
|
||
deleteLayout: vi.fn((id: string) => {
|
||
const i = layouts.findIndex((l) => l.id === id);
|
||
if (i >= 0) layouts.splice(i, 1);
|
||
}),
|
||
};
|
||
}
|
||
|
||
function fakeBridge(view = { center: { x: 10, y: 20 }, scale: 2 }) {
|
||
return {
|
||
zoomTo: vi.fn(),
|
||
getView: vi.fn(() => view),
|
||
};
|
||
}
|
||
|
||
const L1: LayoutDefinition = {
|
||
id: 'layout-1', name: 'A4 Plan',
|
||
viewport: { center: { x: 100, y: 50 }, scale: 0.5 },
|
||
};
|
||
|
||
// ─── ZoomPanController.setView ─────────────────────────────
|
||
|
||
describe('G2: ZoomPanController.setView', () => {
|
||
it('setView(center, scale) zentriert Welt-Punkt exakt, Scale direkt', () => {
|
||
const zpc = new ZoomPanController(createMockCanvas(800, 600));
|
||
zpc.setView({ x: 100, y: 50 }, 2);
|
||
expect(zpc.getScale()).toBe(2);
|
||
const vp = zpc.getViewport();
|
||
const cx = (vp.minX + vp.maxX) / 2;
|
||
const cy = (vp.minY + vp.maxY) / 2;
|
||
expect(cx).toBeCloseTo(100, 5);
|
||
expect(cy).toBeCloseTo(50, 5);
|
||
});
|
||
|
||
it('setView mit scale 1 bei Origin deckt (0,0) als Zentrum ab', () => {
|
||
const zpc = new ZoomPanController(createMockCanvas(800, 600));
|
||
zpc.setView({ x: 0, y: 0 }, 1);
|
||
const vp = zpc.getViewport();
|
||
expect((vp.minX + vp.maxX) / 2).toBeCloseTo(0, 5);
|
||
expect((vp.minY + vp.maxY) / 2).toBeCloseTo(0, 5);
|
||
});
|
||
|
||
it('zweiter setView-Aufruf überschreibt ersten komplett', () => {
|
||
const zpc = new ZoomPanController(createMockCanvas(800, 600));
|
||
zpc.setView({ x: 500, y: 500 }, 4);
|
||
zpc.setView({ x: 0, y: 0 }, 0.5);
|
||
expect(zpc.getScale()).toBe(0.5);
|
||
const vp = zpc.getViewport();
|
||
expect((vp.minX + vp.maxX) / 2).toBeCloseTo(0, 5);
|
||
});
|
||
});
|
||
|
||
// ─── computeMinimapRects (Viewport-Widget-Geometrie) ───────
|
||
|
||
describe('G2: computeMinimapRects', () => {
|
||
const elements = [
|
||
{ x: 50, y: 50, width: 100, height: 100 }, // bbox 0..100
|
||
];
|
||
|
||
it('Modellbbox füllt Widget (mit Padding), leer → Volles Widget', () => {
|
||
const r = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 1 }, 100, 80);
|
||
expect(r.model.w).toBeGreaterThan(80);
|
||
expect(r.model.h).toBeGreaterThan(60);
|
||
const empty = computeMinimapRects([], { center: { x: 0, y: 0 }, scale: 1 }, 100, 80);
|
||
expect(empty.model.w).toBe(100);
|
||
expect(empty.model.h).toBe(80);
|
||
});
|
||
|
||
it('Viewport-Zentrum liegt im View-Rechteck; höherer Scale = kleinerer Ausschnitt', () => {
|
||
const r1 = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 1 }, 100, 80);
|
||
const cx = r1.model.x + (r1.view.x + r1.view.w / 2 - r1.model.x) / r1.model.w * r1.model.w;
|
||
// View-Zentrum in Widget-Koordinaten:
|
||
const viewCX = r1.view.x + r1.view.w / 2;
|
||
const modelCX = r1.model.x + r1.model.w / 2;
|
||
expect(Math.abs(viewCX - modelCX)).toBeLessThan(1); // 50,50 = BBox-Zentrum
|
||
const r2 = computeMinimapRects(elements, { center: { x: 50, y: 50 }, scale: 4 }, 100, 80);
|
||
expect(r2.view.w).toBeLessThan(r1.view.w);
|
||
expect(r2.view.h).toBeLessThan(r1.view.h);
|
||
});
|
||
});
|
||
|
||
// ─── LayoutBar UI ──────────────────────────────────────────
|
||
|
||
describe('G2: LayoutBar', () => {
|
||
it('ohne Doc: Empty-State ohne Crash', () => {
|
||
render(<LayoutBar doc={null} bridge={null} />);
|
||
expect(screen.getByText(/Keine Layouts/)).toBeTruthy();
|
||
});
|
||
|
||
it('listet Layouts mit Name + Minimap-SVG', () => {
|
||
const doc = fakeDoc([L1]);
|
||
render(<LayoutBar doc={doc as any} bridge={fakeBridge() as any} />);
|
||
expect(screen.getByText('A4 Plan')).toBeTruthy();
|
||
expect(screen.getByTestId('layout-minimap-layout-1')).toBeTruthy();
|
||
});
|
||
|
||
it('Klick auf Layout-Eintrag ruft bridge.zoomTo mit gespeichertem Viewport', () => {
|
||
const bridge = fakeBridge();
|
||
render(<LayoutBar doc={fakeDoc([L1]) as any} bridge={bridge as any} />);
|
||
fireEvent.click(screen.getByTestId('layout-entry-layout-1'));
|
||
expect(bridge.zoomTo).toHaveBeenCalledWith(
|
||
{ x: 100, y: 50 },
|
||
0.5,
|
||
);
|
||
});
|
||
|
||
it('+ Button speichert aktuellen Viewport als neues Layout', () => {
|
||
const layouts: LayoutDefinition[] = [];
|
||
const doc = fakeDoc(layouts);
|
||
const bridge = fakeBridge();
|
||
render(<LayoutBar doc={doc as any} bridge={bridge as any} />);
|
||
fireEvent.click(screen.getByTitle('Layout hinzufügen'));
|
||
expect(doc.addLayout).toHaveBeenCalledTimes(1);
|
||
const added = (doc.addLayout as ReturnType<typeof vi.fn>).mock.calls[0][0] as LayoutDefinition;
|
||
expect(added.viewport.center.x).toBe(10);
|
||
expect(added.viewport.center.y).toBe(20);
|
||
expect(added.viewport.scale).toBe(2);
|
||
expect(added.name).toMatch(/Layout \d/);
|
||
expect(added.id).toBeTruthy();
|
||
});
|
||
|
||
it('Löschen-Button entfernt Layout über doc.deleteLayout', () => {
|
||
const doc = fakeDoc([L1]);
|
||
render(<LayoutBar doc={doc as any} bridge={fakeBridge() as any} />);
|
||
fireEvent.click(screen.getByTitle('Layout A4 Plan löschen'));
|
||
expect(doc.deleteLayout).toHaveBeenCalledWith('layout-1');
|
||
});
|
||
|
||
it('ohne Bridge: Klick wirft nicht (no-op)', () => {
|
||
render(<LayoutBar doc={fakeDoc([L1]) as any} bridge={null} />);
|
||
expect(() => fireEvent.click(screen.getByTestId('layout-entry-layout-1'))).not.toThrow();
|
||
});
|
||
});
|