Files

158 lines
6.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
});
});