import { afterEach, describe, expect, it } from 'vitest'; import * as actions from '@/app/actions'; import { ProjectStore } from '@/app/store'; import type { Lageplan } from '@/domain/geometrie/lageplan'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import { lageplanView } from '@/ui/views/lageplanView'; /** * Mausbedienung der Zeichenflaeche: Rad, Verschieben, Zoomen waehrend des * Verschiebens (Befunde 52, 53, 64). * * WIE DIE SICHT HIER ABGELESEN WIRD * * Zoom und Versatz der Ansicht sind ansichtsinterne Groessen; von aussen sind * sie nur an ihrer Wirkung zu erkennen. Die Wirkung ist die Umrechnung von * Bildschirm- in Bildpunkte - und die schlaegt sich in den Punkten der * Massstabsstrecke nieder, die im Projekt landen. Genau darueber wird hier * gemessen: gesetzt wird ueber Tastatur oder Maus, abgelesen wird die * Kalibrierung. * * MASSE: In jsdom hat der Rahmen keine Breite, die Flaeche faellt auf ihre * Mindestmasse von 320 x 320 zurueck, und `getBoundingClientRect` liefert * lauter Nullen. Ein Bildschirmpunkt ist damit ein Bildpunkt, der * Tastaturzeiger beginnt bei (160, 160), und clientX/clientY sind unmittelbar * Bildschirmpunkte der Flaeche. Das Hintergrundbild wird in jsdom nie * geladen - die Sicht bleibt deshalb die Startsicht, bis eine Bedienung sie * aendert. */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; /** Schrittweite eines gewoehnlichen Pfeiltastenanschlags in Bildpunkten. */ const SCHRITT = 5; /** Bitmuster von MouseEvent.buttons: 2 ist die rechte, 4 die mittlere Taste. */ const RECHTE_TASTE = 2; const MITTLERE_TASTE = 4; /** Plan mit Hintergrundbild, aber ohne Massstab - das Werkzeug ist damit frei. */ function planOhneMassstab(): Lageplan { return { arbeitsbereiche: [], signalgeber: [], bild: { datenUrl: 'data:image/png;base64,iVBORw0KGgo=', breite: 800, hoehe: 600, herkunft: 'Prüfbild', geladenAm: FESTES_DATUM, }, kalibrierung: null, haltlinien: [], linien: [], }; } interface Ansicht { readonly store: ProjectStore; readonly wurzel: HTMLElement; readonly leinwand: HTMLCanvasElement; readonly aufraeumen: () => void; } function baueAnsicht(): Ansicht { const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM)); store.update((p) => actions.setLageplan(p, planOhneMassstab()), { label: 'Pruefplan' }); const wurzel = document.createElement('div'); document.body.append(wurzel); const schliessen = lageplanView.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); const leinwand = wurzel.querySelector('canvas'); if (leinwand === null) throw new Error('Die Ansicht hat keine Zeichenflaeche.'); return { store, wurzel, leinwand, aufraeumen: () => { leinwand.blur(); schliessen(); wurzel.remove(); }, }; } afterEach(() => { document.body.replaceChildren(); }); function waehleWerkzeug(ansicht: Ansicht, beschriftung: string): void { const knopf = [ ...ansicht.wurzel.querySelectorAll('.lageplan-werkzeuge button'), ].find((b) => (b.textContent ?? '').trim() === beschriftung); if (knopf === undefined) throw new Error(`Kein Werkzeug "${beschriftung}"`); knopf.click(); } function tippe(key: string, male = 1): void { for (let i = 0; i < male; i += 1) { const ziel = (document.activeElement as HTMLElement | null) ?? document.body; ziel.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); } } function maus( ansicht: Ansicht, art: string, init: MouseEventInit & { readonly ziel?: 'fenster' }, ): void { const ereignis = new MouseEvent(art, { bubbles: true, cancelable: true, ...init }); if (init.ziel === 'fenster') window.dispatchEvent(ereignis); else ansicht.leinwand.dispatchEvent(ereignis); } /** Dreht am Rad; false heisst, die Geste wurde abgefangen. */ function rad(ansicht: Ansicht, init: WheelEventInit): boolean { return ansicht.leinwand.dispatchEvent( new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init }), ); } /** * Setzt mit der Tastatur eine Massstabsstrecke und liefert ihren Anfangspunkt * IN BILDPUNKTEN. * * Der Anfang liegt in der Mitte der Flaeche - bei unveraenderter Sicht also bei * (160, 160). Jede Abweichung ist die Sicht, die sich zwischenzeitlich * verschoben oder verkleinert hat. */ function anfangDerMassstabsstrecke(ansicht: Ansicht): { x: number; y: number } { waehleWerkzeug(ansicht, 'Maßstab'); ansicht.leinwand.focus(); tippe(' '); tippe('ArrowRight', 20); tippe(' '); uebernimmLaenge(25); const kalibrierung = ansicht.store.getProject().lageplan.kalibrierung; if (kalibrierung === null) throw new Error('Der Maßstab wurde nicht übernommen.'); return kalibrierung.von; } function uebernimmLaenge(meter: number): void { const fenster = document.querySelector('.dialog'); if (fenster === null) throw new Error('Es steht kein Fenster offen.'); const feld = fenster.querySelector('input[type="number"]'); if (feld !== null) { feld.value = String(meter); feld.dispatchEvent(new Event('change')); } const knopf = [...fenster.querySelectorAll('button')].find( (b) => (b.textContent ?? '').trim() === 'Übernehmen', ); if (knopf === undefined) throw new Error('Kein Knopf "Übernehmen" im Fenster'); knopf.click(); } describe('Waagerechtes Mausrad (Befund 52)', () => { it('lässt die Ansicht in Ruhe, statt sie zu verkleinern', () => { const ansicht = baueAnsicht(); try { // Chromium legt das waagerechte Wischen auf deltaX und laesst deltaY auf // 0. Eine einzige Wischbewegung erzeugt Dutzende solcher Ereignisse; // faellt jedes in den Verkleinern-Zweig, ist der Plan nach wenigen Zuegen // an der unteren Zoomschranke verschwunden. const zugestellt = rad(ansicht, { deltaX: 120, deltaY: 0, clientX: 40, clientY: 40 }); expect(zugestellt, 'die waagerechte Geste wurde geschluckt').toBe(true); expect(anfangDerMassstabsstrecke(ansicht)).toEqual({ x: 160, y: 160 }); } finally { ansicht.aufraeumen(); } }); it('zoomt bei senkrechtem Rad weiter wie bisher', () => { const ansicht = baueAnsicht(); try { const zugestellt = rad(ansicht, { deltaX: 0, deltaY: -100, clientX: 0, clientY: 0 }); // Hier gehoert die Geste der Zeichenflaeche - sie darf die Seite nicht // zusaetzlich wegrollen. expect(zugestellt, 'das Zoomen wurde nicht abgefangen').toBe(false); const anfang = anfangDerMassstabsstrecke(ansicht); // Um 1,15 vergroessert, Ankerpunkt (0;0): 160 / 1,15. expect(anfang.x).toBeCloseTo(160 / 1.15, 6); expect(anfang.y).toBeCloseTo(160 / 1.15, 6); } finally { ansicht.aufraeumen(); } }); }); describe('Verschieben ohne gedrückte Taste (Befund 53)', () => { it('hört auf zu verschieben, sobald keine Taste mehr gedrückt ist', () => { const ansicht = baueAnsicht(); try { maus(ansicht, 'mousedown', { button: 1, buttons: MITTLERE_TASTE, clientX: 10, clientY: 10 }); maus(ansicht, 'mousemove', { buttons: MITTLERE_TASTE, clientX: 60, clientY: 40 }); // Alt+Tab, dort die Taste losgelassen, zurueck zum Fenster: Das // Loslassen erreicht den Renderer nie. Die naechste Bewegung meldet // keine gedrueckte Taste mehr - und darf den Plan nicht mitziehen. maus(ansicht, 'mousemove', { buttons: 0, clientX: 160, clientY: 140 }); // Verschoben wurde nur der erste Zug: 50 nach rechts, 30 nach unten. expect(anfangDerMassstabsstrecke(ansicht)).toEqual({ x: 160 - 50, y: 160 - 30 }); } finally { ansicht.aufraeumen(); } }); it('räumt den Ziehzustand, wenn das Fenster den Fokus verliert', () => { const ansicht = baueAnsicht(); try { maus(ansicht, 'mousedown', { button: 2, buttons: RECHTE_TASTE, clientX: 10, clientY: 10 }); window.dispatchEvent(new Event('blur')); maus(ansicht, 'mousemove', { buttons: RECHTE_TASTE, clientX: 110, clientY: 110 }); expect(anfangDerMassstabsstrecke(ansicht)).toEqual({ x: 160, y: 160 }); } finally { ansicht.aufraeumen(); } }); }); describe('Zoomen während eines laufenden Verschiebens (Befund 64)', () => { it('hält den Punkt unter dem Mauszeiger fest, statt zurückzuspringen', () => { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Maßstab'); // Rechte Taste halten, am Rad drehen, danach die Maus bewegen. maus(ansicht, 'mousedown', { button: 2, buttons: RECHTE_TASTE, clientX: 100, clientY: 100 }); rad(ansicht, { deltaY: -100, clientX: 100, clientY: 100 }); maus(ansicht, 'mousemove', { buttons: RECHTE_TASTE, clientX: 100, clientY: 100 }); maus(ansicht, 'mouseup', { button: 2, buttons: 0, ziel: 'fenster' }); // Die Zusage im Quelltext lautet "Der Punkt unter dem Mauszeiger bleibt // stehen". Der Zeiger stand beim Zoomen auf Bildpunkt (100;100) und hat // sich seither nicht bewegt - also muss ein Klick an derselben Stelle // wieder Bildpunkt (100;100) treffen. maus(ansicht, 'click', { button: 0, clientX: 100, clientY: 100 }); maus(ansicht, 'click', { button: 0, clientX: 200, clientY: 100 }); uebernimmLaenge(25); const kalibrierung = ansicht.store.getProject().lageplan.kalibrierung; expect(kalibrierung, 'kein Maßstab im Projekt').not.toBeNull(); expect(kalibrierung?.von.x ?? 0).toBeCloseTo(100, 6); expect(kalibrierung?.von.y ?? 0).toBeCloseTo(100, 6); } finally { ansicht.aufraeumen(); } }); }); describe('Ungestörtes Verschieben bleibt unverändert', () => { it('zieht den Plan mit, solange die Taste gehalten wird', () => { const ansicht = baueAnsicht(); try { maus(ansicht, 'mousedown', { button: 2, buttons: RECHTE_TASTE, clientX: 20, clientY: 20 }); maus(ansicht, 'mousemove', { buttons: RECHTE_TASTE, clientX: 60, clientY: 50 }); maus(ansicht, 'mousemove', { buttons: RECHTE_TASTE, clientX: 100, clientY: 80 }); maus(ansicht, 'mouseup', { button: 2, buttons: 0, ziel: 'fenster' }); // 80 nach rechts, 60 nach unten verschoben. expect(anfangDerMassstabsstrecke(ansicht)).toEqual({ x: 160 - 80, y: 160 - 60 }); const bis = ansicht.store.getProject().lageplan.kalibrierung?.bis; expect(bis).toEqual({ x: 160 - 80 + 20 * SCHRITT, y: 160 - 60 }); } finally { ansicht.aufraeumen(); } }); });