import { afterEach, describe, expect, it, vi } from 'vitest'; import * as actions from '@/app/actions'; import { ProjectStore } from '@/app/store'; import type { Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import { START_SICHT, zeichne, zeigerSchritt } from '@/ui/lageplan/leinwand'; import { lageplanView } from '@/ui/views/lageplanView'; /** * Bedienung der Zeichenflaeche mit der Tastatur. * * WAS HIER ABGESICHERT WIRD * * Ohne Maus liess sich im Lageplan kein Punkt setzen - und mit ihm entfielen * Haltlinie, Fahrlinie und Massstab, also die gesamte Vermessung: Raeumweg, * Einfahrweg, Schnittwinkel, Vertraeglichkeitsvorschlaege. Die Zusage lautet * jetzt, dass die Tastatur dasselbe leistet wie die Maus. "Dasselbe" ist dabei * woertlich zu nehmen und wird auch so geprueft: Dieselbe Stelle im Plan muss * ueber beide Wege dieselbe Linie ergeben. Zwei getrennte Wege durch dieselben * Entscheidungen - Fangregel, Deckelung des Fangradius, Zuordnung zur * Haltlinie - liefen sonst auseinander, und niemand koennte sagen, welcher * Raeumweg der richtige ist. * * DIE MASSE DER PRUEFFLAECHE * * In jsdom hat der Rahmen keine Breite; die Zeichenflaeche faellt damit auf * ihre Mindestmasse von 320 x 320 Bildschirmpunkten zurueck, die Sicht bleibt * die Startsicht ohne Zoom. Ein Bildschirmpunkt ist deshalb genau ein * Bildpunkt, und der Zeiger beginnt in der Mitte bei (160, 160). */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; /** Mitte der Zeichenflaeche - dort beginnt der Tastaturzeiger. */ const MITTE = { x: 160, y: 160 }; /** * Ein Plan mit Massstab und einer Haltlinie quer darunter. * * Der Massstab ist mit Absicht krumm gewaehlt: 100 Bildpunkte auf 20 m ergeben * 0,2 m je Bildpunkt. Damit deckelt MAX_FANG_METER (2,5 m) den Fangradius auf * 12,5 Bildpunkte, waehrend die Bildschirmumrechnung 15 Bildpunkte ergaebe - * die Deckelung ist an dieser Unterlage also wirksam und pruefbar. */ function planMitHaltlinie(): Lageplan { return { arbeitsbereiche: [], signalgeber: [], bild: null, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: 20, herkunft: 'gemessen', }, haltlinien: [ { id: 'hl1', name: 'Nord', art: 'zufahrt', armId: null, von: { x: 100, y: 200 }, bis: { x: 220, y: 200 }, }, ], linien: [], }; } interface Ansicht { readonly store: ProjectStore; readonly wurzel: HTMLElement; readonly leinwand: HTMLCanvasElement; readonly aufraeumen: () => void; } function baueAnsicht(lageplan: Lageplan = planMitHaltlinie()): Ansicht { const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM)); store.update((p) => actions.setLageplan(p, lageplan), { 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: () => { schliessen(); wurzel.remove(); }, }; } /** Waehlt ein Werkzeug ueber seine Schaltflaeche - so wie ein Anwender auch. */ function waehleWerkzeug(ansicht: Ansicht, beschriftung: string): void { const knopf = [...ansicht.wurzel.querySelectorAll('button')].find( (b) => (b.textContent ?? '').trim() === beschriftung, ); if (knopf === undefined) throw new Error(`Kein Werkzeug "${beschriftung}"`); knopf.click(); } interface Zusatztasten { readonly shiftKey?: boolean; readonly ctrlKey?: boolean; } function taste(ansicht: Ansicht, key: string, zusatz: Zusatztasten = {}, male = 1): void { for (let i = 0; i < male; i += 1) { ansicht.leinwand.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...zusatz })); } } function klick(ansicht: Ansicht, x: number, y: number): void { ansicht.leinwand.dispatchEvent( new MouseEvent('click', { button: 0, detail: 1, clientX: x, clientY: y, bubbles: true }), ); } /** Text der Live-Region unter der Zeichenflaeche. */ function ansage(ansicht: Ansicht): string { return ansicht.wurzel.querySelector('[role="status"]')?.textContent ?? ''; } function linien(ansicht: Ansicht): readonly Planlinie[] { return ansicht.store.getProject().lageplan.linien; } /** Alles ausser der Kennung - die ist zufaellig und vergleicht sich nicht. */ function ohneId(linie: Planlinie): Omit { const { id: _id, ...rest } = linie; return rest; } afterEach(() => { vi.useRealTimers(); }); describe('Punkte setzen ohne Maus', () => { it('zieht eine vollstaendige Fahrlinie allein mit der Tastatur', () => { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); // Von der Mitte 8 Schritte nach unten: genau auf die Haltlinie bei y=200. taste(ansicht, 'ArrowDown', {}, 8); taste(ansicht, ' '); // Und 16 Schritte zurueck nach oben, in den Knotenpunkt hinein. taste(ansicht, 'ArrowUp', {}, 16); taste(ansicht, ' '); taste(ansicht, 'Enter'); expect(linien(ansicht)).toHaveLength(1); const linie = linien(ansicht)[0]!; expect(linie.punkte).toEqual([ { x: 160, y: 200 }, { x: 160, y: 120 }, ]); expect(linie.haltlinieId).toBe('hl1'); } finally { ansicht.aufraeumen(); } }); it('setzt den ersten Punkt auch ohne vorherige Zeigerbewegung', () => { // Die Leertaste muss vom ersten Anschlag an wirken - sonst faengt der // Anwender mit einer Taste an, die scheinbar nichts tut. const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); taste(ansicht, ' '); taste(ansicht, 'ArrowRight', {}, 4); taste(ansicht, ' '); taste(ansicht, 'Enter'); expect(linien(ansicht)[0]?.punkte).toEqual([MITTE, { x: 180, y: 160 }]); } finally { ansicht.aufraeumen(); } }); }); describe('Schrittweiten des Zeigers', () => { /** Zieht eine Linie von der Mitte aus mit `anzahl` Schritten nach rechts. */ function zweiterPunktNachRechts(zusatz: Zusatztasten, anzahl: number): { x: number; y: number } { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); taste(ansicht, ' '); taste(ansicht, 'ArrowRight', zusatz, anzahl); taste(ansicht, ' '); taste(ansicht, 'Enter'); const punkte = linien(ansicht)[0]?.punkte ?? []; return punkte[1] ?? { x: Number.NaN, y: Number.NaN }; } finally { ansicht.aufraeumen(); } } it('bewegt normal um 5 Bildpunkte je Anschlag', () => { expect(zweiterPunktNachRechts({}, 3)).toEqual({ x: 175, y: 160 }); }); it('bewegt mit Umschalt fein um einen Bildpunkt', () => { // Die feine Stufe ist die, mit der sich eine Markierung im Luftbild // treffen laesst; ohne sie waere der kleinste Schritt 1 m am Boden. expect(zweiterPunktNachRechts({ shiftKey: true }, 3)).toEqual({ x: 163, y: 160 }); }); it('bewegt mit Strg grob um 25 Bildpunkte', () => { expect(zweiterPunktNachRechts({ ctrlKey: true }, 3)).toEqual({ x: 235, y: 160 }); }); it('rechnet die Schrittweite ueber den Zoom um und bleibt bei einem Bildpunkt stehen', () => { // Auf dem Schirm festgelegt, in Bildpunkte umgerechnet - dieselbe Regel wie // bei der Maus. Bei starker Vergroesserung greift die Untergrenze: Feiner // als ein Bildpunkt der Unterlage waere Scheingenauigkeit. expect(zeigerSchritt(START_SICHT, 'normal')).toBe(5); expect(zeigerSchritt({ ...START_SICHT, zoom: 5 }, 'grob')).toBe(5); expect(zeigerSchritt({ ...START_SICHT, zoom: 10 }, 'fein')).toBe(1); }); }); describe('Einrasten auf eine Haltlinie', () => { /** Setzt den ersten Punkt `abwaerts` Schritte unter der Mitte. */ function ersteLinie(abwaerts: number): Planlinie | undefined { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); taste(ansicht, 'ArrowDown', {}, abwaerts); taste(ansicht, ' '); taste(ansicht, 'ArrowUp', {}, 10); taste(ansicht, ' '); taste(ansicht, 'Enter'); return linien(ansicht)[0]; } finally { ansicht.aufraeumen(); } } it('zieht den ersten Punkt auf die Haltlinie und ordnet die Zufahrt zu', () => { // 6 Schritte: y=190, also 10 Bildpunkte = 2,0 m neben der Haltlinie. const linie = ersteLinie(6); expect(linie?.punkte[0]).toEqual({ x: 160, y: 200 }); expect(linie?.haltlinieId).toBe('hl1'); expect(linie?.startT).toBeCloseTo(0.5, 6); }); it('haelt die Deckelung MAX_FANG_METER auch mit der Tastatur ein', () => { // 5 Schritte: y=185, also 15 Bildpunkte = 3,0 m neben der Haltlinie. Nach // der reinen Bildschirmumrechnung (15 Bildschirmpunkte) wuerde der Punkt // hier noch einrasten; am Boden sind 3,0 m aber mehr als eine // Fahrstreifenbreite, und der Nullpunkt des Raeumwegs saesse falsch. const linie = ersteLinie(5); expect(linie?.punkte[0]).toEqual({ x: 160, y: 185 }); expect(linie?.haltlinieId).toBeNull(); }); }); describe('Ansage der Zeigerlage', () => { it('nennt Abstand, Haltlinie und Lage darauf in Metern', () => { const ansicht = baueAnsicht(); try { taste(ansicht, 'ArrowDown'); // y=165: 35 Bildpunkte = 7,0 m von der Haltlinie, deren Anfang bei x=100 // liegt - der Lotfusspunkt also 12,0 m dahinter. expect(ansage(ansicht)).toContain('Haltlinie „Nord“'); expect(ansage(ansicht)).toContain('7,0 m'); expect(ansage(ansicht)).toContain('12,0 m ab ihrem Anfang'); // Eine Bildkoordinate hilft niemandem, der die Zeichnung nicht sieht. expect(ansage(ansicht)).not.toContain('165'); } finally { ansicht.aufraeumen(); } }); it('meldet das bevorstehende Einrasten, sobald es das Ergebnis aendert', () => { vi.useFakeTimers(); const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); taste(ansicht, 'ArrowDown', {}, 6); // Die Drosselung haelt die Zwischenstaende zurueck; angesagt wird der // erreichte Stand. vi.advanceTimersByTime(500); expect(ansage(ansicht)).toContain('rastet auf Haltlinie „Nord“ ein'); } finally { ansicht.aufraeumen(); } }); it('drosselt die Ansage und meldet dann den erreichten Stand', () => { // Eine Live-Region, die jeden Tastendruck meldet, haengt der Wirklichkeit // hinterher: Die Sprachausgabe arbeitet die Warteschlange ab, waehrend der // Zeiger laengst woanders steht. vi.useFakeTimers(); const ansicht = baueAnsicht(); try { taste(ansicht, 'ArrowUp'); const erste = ansage(ansicht); expect(erste).toContain('9,0 m'); taste(ansicht, 'ArrowUp', {}, 3); expect(ansage(ansicht)).toBe(erste); vi.advanceTimersByTime(400); // y=140: 60 Bildpunkte = 12,0 m von der Haltlinie. expect(ansage(ansicht)).toContain('12,0 m'); expect(ansage(ansicht)).not.toBe(erste); } finally { ansicht.aufraeumen(); } }); it('meldet den gesetzten Punkt sofort, ohne Drosselung', () => { vi.useFakeTimers(); const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); taste(ansicht, 'ArrowDown', {}, 6); taste(ansicht, ' '); // Ohne Ablauf einer Frist: Ein gesetzter Punkt ist kein Zwischenstand. expect(ansage(ansicht)).toContain('Erster Punkt gesetzt'); expect(ansage(ansicht)).toContain('Haltlinie „Nord“'); } finally { ansicht.aufraeumen(); } }); }); describe('Tastatur und Maus liefern dasselbe', () => { it('ergibt dieselbe Fahrlinie wie dieselben Stellen mit der Maus', () => { // Der Kern der Zusage. Beide Wege setzen den ersten Punkt 5 Bildpunkte // neben die Haltlinie - er muss beide Male auf sie einrasten - und den // zweiten 80 Bildpunkte weiter in den Knotenpunkt. const mitMaus = baueAnsicht(); let mausLinie: Planlinie | undefined; try { waehleWerkzeug(mitMaus, 'Fahrlinie'); klick(mitMaus, 160, 205); klick(mitMaus, 160, 120); taste(mitMaus, 'Enter'); mausLinie = linien(mitMaus)[0]; } finally { mitMaus.aufraeumen(); } const mitTastatur = baueAnsicht(); let tastaturLinie: Planlinie | undefined; try { waehleWerkzeug(mitTastatur, 'Fahrlinie'); taste(mitTastatur, 'ArrowDown', {}, 9); taste(mitTastatur, ' '); taste(mitTastatur, 'ArrowUp', {}, 17); taste(mitTastatur, ' '); taste(mitTastatur, 'Enter'); tastaturLinie = linien(mitTastatur)[0]; } finally { mitTastatur.aufraeumen(); } expect(mausLinie).toBeDefined(); expect(tastaturLinie).toBeDefined(); expect(ohneId(tastaturLinie!)).toEqual(ohneId(mausLinie!)); // Und nicht nur gleich, sondern auch richtig: eingerastet auf die // Haltlinie, denn daran haengt der Nullpunkt des Raeumwegs. expect(tastaturLinie!.punkte[0]).toEqual({ x: 160, y: 200 }); expect(tastaturLinie!.haltlinieId).toBe('hl1'); }); }); describe('Der Zeiger ist zu sehen', () => { /** Farbe des weissen Casings aus leinwand.ts - daran ist es zu erkennen. */ const CASING = 'rgba(255,255,255,0.92)'; /** Radius des Rings, der das Einrasten anzeigt. */ const RING = 9; /** * Zeichenkontext, der Striche und Kreisboegen mitschreibt. * * jsdom bringt keine Zeichenflaeche mit - und ein Bild waere hier auch nicht * zu gebrauchen: Gefragt ist, WAS gestrichen wurde, nicht wie es aussieht. */ class Zaehler { readonly striche: { farbe: string; breite: number }[] = []; readonly boegen: number[] = []; strokeStyle = ''; fillStyle = ''; lineWidth = 0; lineCap = 'butt'; lineJoin = 'miter'; globalAlpha = 1; font = ''; textAlign = 'left'; imageSmoothingQuality = 'low'; save(): void {} restore(): void {} clearRect(): void {} fillRect(): void {} strokeRect(): void {} drawImage(): void {} beginPath(): void {} moveTo(): void {} lineTo(): void {} fill(): void {} setLineDash(): void {} fillText(): void {} measureText(): { width: number } { return { width: 24 }; } arc(_x: number, _y: number, radius: number): void { this.boegen.push(radius); } stroke(): void { this.striche.push({ farbe: String(this.strokeStyle), breite: this.lineWidth }); } } function zeichneMitZeiger(eingerastet: boolean | null): Zaehler { const ctx = new Zaehler(); zeichne(ctx as unknown as CanvasRenderingContext2D, 320, 320, { lageplan: planMitHaltlinie(), sicht: START_SICHT, bild: { naturalWidth: 400, naturalHeight: 300 } as unknown as HTMLImageElement, aktiveLinieId: null, aktiveHaltlinieId: null, haltlinienStart: null, entwurf: [], kalibrierStart: null, zeiger: null, tastenzeiger: eingerastet === null ? null : { punkt: MITTE, eingerastet }, dunkel: false, }); return ctx; } it('legt den Zeiger auf ein weisses Casing', () => { // Auf einem Luftbild kommt jede Helligkeit vor: Ein dunkler Strich allein // verschwindet ueber Beton, ein heller ueber Asphalt. const ohne = zeichneMitZeiger(null); expect(ohne.striche.some((s) => s.farbe === CASING)).toBe(false); const mit = zeichneMitZeiger(false); const letzte = mit.striche.slice(-2); expect(letzte).toEqual([ { farbe: CASING, breite: 5 }, { farbe: '#111827', breite: 2 }, ]); }); it('zeigt das Einrasten durch eine Form, nicht durch eine Farbe', () => { expect(zeichneMitZeiger(false).boegen).not.toContain(RING); expect(zeichneMitZeiger(true).boegen).toContain(RING); }); }); describe('Auffindbarkeit der Tastenbelegung', () => { it('stellt die Belegung sichtbar unter die Zeichenflaeche', () => { const ansicht = baueAnsicht(); try { const aufklapper = ansicht.wurzel.querySelector('details'); expect(aufklapper?.querySelector('summary')?.textContent).toContain('Tastenbedienung'); const text = aufklapper?.textContent ?? ''; expect(text).toContain('Leertaste'); expect(text).toContain('Umschalt'); expect(text).toContain('Strg'); expect(text).toContain('Einpassen'); } finally { ansicht.aufraeumen(); } }); it('behauptet im Hinweis der Zeichenflaeche nicht mehr das Gegenteil', () => { const ansicht = baueAnsicht(); try { const hinweis = ansicht.wurzel.querySelector('#lageplan-flaeche-hinweis')?.textContent ?? ''; expect(hinweis).not.toContain('nicht möglich'); expect(hinweis).toContain('Leertaste'); // Der Ausweichweg bleibt richtig und bleibt stehen: Der Lageplan ist ein // Hilfsmittel, kein Pflichtteil. expect(hinweis).toContain('Zwischenzeiten'); } finally { ansicht.aufraeumen(); } }); });