import { describe, expect, it } from 'vitest'; import * as actions from '@/app/actions'; import { ProjectStore } from '@/app/store'; import type { Lageplan, Planlinie } from '@/domain/geometrie/lageplan'; import type { Punkt } from '@/domain/geometrie/vermessung'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import { lageplanView } from '@/ui/views/lageplanView'; /** * Die Zeichenflaeche als BEDIENELEMENT - Erreichbarkeit, Name, Rolle, Belegung. * * WAS HIER ABGESICHERT WIRD UND WARUM ES NICHT SCHON DASTAND * * tests/ui/lageplanTastatur.test.ts prueft, was die einzelnen Tasten bewirken - * aber es schickt die Tastendruecke unmittelbar an die Zeichenflaeche, ohne sie * vorher zu fokussieren. Damit haengt der gesamte bisherige Nachweis an einer * Voraussetzung, die nirgends geprueft ist: dass die Flaeche den Fokus * ueberhaupt bekommen kann. Fiele das `tabindex="0"` aus lageplanView.ts * heraus, bliebe der ganze Testbestand gruen, waehrend die Flaeche mit der * Tastatur nicht mehr erreichbar waere - kein Tabulatorstopp, kein * Tastenzeiger, keine Vermessung ohne Maus. * * Deshalb wird hier ausschliesslich ueber das FOKUSSIERTE Element getippt, so * wie ein Browser die Taste zustellt. Ein Tastendruck, der die Flaeche nicht * erreicht, darf nichts zeichnen - und genau das ist der Fall, sobald der * Fokus woanders steht. * * DIE MASSE DER PRUEFFLAECHE * * In jsdom hat der Rahmen keine Breite; die Zeichenflaeche faellt auf ihre * Mindestmasse von 320 x 320 Bildschirmpunkten zurueck, die Sicht bleibt die * Startsicht ohne Zoom. Ein Bildschirmpunkt ist damit genau ein Bildpunkt, und * der Tastenzeiger beginnt in der Mitte bei (160, 160). Eine Zeichenflaeche * bringt jsdom nicht mit - die Ansicht meldet das fehlende getContext auf der * Konsole und baut den Rest trotzdem auf; geprueft wird ohnehin nicht das Bild, * sondern was im Projekt landet und was angesagt wird. */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; /** Mitte der Zeichenflaeche - dort beginnt der Tastaturzeiger. */ const MITTE: Punkt = { x: 160, y: 160 }; /** * Plan mit Massstab und einer waagerechten Haltlinie bei y = 200. * * 100 Bildpunkte auf 20 m ergeben 0,2 m je Bildpunkt. Der Massstab ist noetig, * damit das Werkzeug "Fahrlinie" ueberhaupt freigegeben ist. */ 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: () => { // Sonst bliebe der Fokus des einen Falls im naechsten stehen. leinwand.blur(); schliessen(); wurzel.remove(); }, }; } /** Waehlt ein Werkzeug ueber seine Schaltflaeche - so wie ein Anwender auch. */ function waehleWerkzeug(ansicht: Ansicht, beschriftung: string): void { const knopf = werkzeugKnopf(ansicht, beschriftung); knopf.click(); } function werkzeugKnopf(ansicht: Ansicht, beschriftung: string): HTMLButtonElement { const knopf = [...ansicht.wurzel.querySelectorAll('button')].find( (b) => (b.textContent ?? '').trim() === beschriftung, ); if (knopf === undefined) throw new Error(`Kein Werkzeug "${beschriftung}"`); return knopf; } interface Zusatztasten { readonly shiftKey?: boolean; readonly ctrlKey?: boolean; } /** * Tippt auf das Element, das GERADE DEN FOKUS HAT. * * Das ist der Kern dieser Datei: Ein Browser stellt einen Tastendruck dem * fokussierten Element zu, nicht demjenigen, das der Test gern haette. Wer * unmittelbar an die Zeichenflaeche sendet, prueft eine Zustellung, die es ohne * Fokussierbarkeit gar nicht gaebe. * * Zurueck kommt, ob der letzte Anschlag abgefangen wurde - ohne * preventDefault rollt die Seite unter der Zeichenflaeche weg. */ function tippe(key: string, zusatz: Zusatztasten = {}, male = 1): boolean { let abgefangen = false; for (let i = 0; i < male; i += 1) { const ziel = (document.activeElement as HTMLElement | null) ?? document.body; const ereignis = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...zusatz, }); ziel.dispatchEvent(ereignis); abgefangen = ereignis.defaultPrevented; } return abgefangen; } /** 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; } describe('Die Zeichenfläche ist mit der Tastatur erreichbar', () => { it('liegt in der Tabulatorfolge und nimmt den Fokus tatsächlich an', () => { const ansicht = baueAnsicht(); try { // Beides zusammen: Das Attribut allein sagte nur, dass da etwas steht - // die Zustellung des Fokus ist die Zusage. expect(ansicht.leinwand.getAttribute('tabindex')).toBe('0'); expect(ansicht.leinwand.tabIndex).toBe(0); expect(document.activeElement).not.toBe(ansicht.leinwand); ansicht.leinwand.focus(); expect(document.activeElement).toBe(ansicht.leinwand); } finally { ansicht.aufraeumen(); } }); it('trägt eine Rolle und einen zugänglichen Namen', () => { const ansicht = baueAnsicht(); try { // "application" und nicht "img": Die Flaeche nimmt Tastendruecke entgegen. // Von dieser Rolle haengt ab, dass eine Sprachausgabe die Pfeiltasten // durchreicht, statt mit ihnen selbst durch den Text zu wandern. expect(ansicht.leinwand.getAttribute('role')).toBe('application'); const namensId = ansicht.leinwand.getAttribute('aria-labelledby'); expect(namensId).not.toBeNull(); const name = document.getElementById(namensId!); // Ein Verweis auf ein Element, das es nicht gibt, ergibt keinen Namen. expect(name).not.toBeNull(); expect(ansicht.wurzel.contains(name)).toBe(true); expect((name!.textContent ?? '').trim()).not.toBe(''); expect(name!.textContent).toContain('Zeichenfläche'); } finally { ansicht.aufraeumen(); } }); it('beschreibt beim Betreten, womit sich die Fläche bedienen lässt', () => { const ansicht = baueAnsicht(); try { const hinweisId = ansicht.leinwand.getAttribute('aria-describedby'); expect(hinweisId).not.toBeNull(); const hinweis = document.getElementById(hinweisId!); expect(hinweis).not.toBeNull(); const text = hinweis!.textContent ?? ''; expect(text).toContain('Pfeiltasten'); expect(text).toContain('Leertaste'); } finally { ansicht.aufraeumen(); } }); }); describe('Ein Tastendruck wirkt nur auf der fokussierten Zeichenfläche', () => { /** Dieselbe Folge in beiden Fällen: zwei Punkte setzen und abschließen. */ function zeichneMitTasten(): void { tippe('ArrowDown', {}, 2); tippe(' '); tippe('ArrowUp', {}, 4); tippe(' '); tippe('Enter'); } it('zeichnet nichts, solange der Seitenkörper den Fokus hat', () => { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); // Kein focus() auf die Flaeche: Der Fokus liegt auf dem Seitenkoerper, // und dorthin stellt der Browser die Taste zu. expect(document.activeElement).toBe(document.body); zeichneMitTasten(); expect(linien(ansicht)).toHaveLength(0); // Auch der Zeiger hat sich nicht geruehrt - die Live-Region steht noch // auf ihrem Ruhetext. expect(ansage(ansicht)).toContain('noch nicht gesetzt'); } finally { ansicht.aufraeumen(); } }); it('zeichnet, sobald die Fläche den Fokus hat', () => { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); ansicht.leinwand.focus(); zeichneMitTasten(); expect(linien(ansicht)).toHaveLength(1); expect(linien(ansicht)[0]?.punkte).toEqual([ { x: 160, y: 170 }, { x: 160, y: 150 }, ]); } finally { ansicht.aufraeumen(); } }); it('hört wieder auf zu wirken, wenn die Fläche den Fokus abgibt', () => { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); ansicht.leinwand.focus(); tippe(' '); expect(ansage(ansicht)).toContain('Erster Punkt gesetzt'); ansicht.leinwand.blur(); expect(document.activeElement).toBe(document.body); tippe('ArrowRight', {}, 4); tippe(' '); tippe('Enter'); // Die Linie ist bei einem Punkt stehen geblieben und wurde nie // abgeschlossen. expect(linien(ansicht)).toHaveLength(0); expect(ansage(ansicht)).toContain('Erster Punkt gesetzt'); } finally { ansicht.aufraeumen(); } }); }); describe('Tastenbelegung der Zeichenfläche', () => { /** * Fuehrt die Tasten auf der fokussierten Flaeche aus, setzt dort einen Punkt * und gibt dessen Lage IM BILD zurueck. * * Der zweite Punkt und der Abschluss dienen nur dazu, die Linie ins Projekt * zu bringen - gefragt ist, wo der erste liegt. */ function ersterPunktNach(...tasten: readonly string[]): Punkt | undefined { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); ansicht.leinwand.focus(); for (const taste of tasten) tippe(taste); tippe(' '); tippe('ArrowDown'); tippe(' '); tippe('Enter'); return linien(ansicht)[0]?.punkte[0]; } finally { ansicht.aufraeumen(); } } it('bewegt den Zeiger mit den vier Pfeiltasten in ihre Richtung', () => { // Fuenf Bildpunkte je Anschlag, ausgehend von der Mitte. expect(ersterPunktNach('ArrowLeft')).toEqual({ x: 155, y: 160 }); expect(ersterPunktNach('ArrowRight')).toEqual({ x: 165, y: 160 }); expect(ersterPunktNach('ArrowUp')).toEqual({ x: 160, y: 155 }); expect(ersterPunktNach('ArrowDown')).toEqual({ x: 160, y: 165 }); }); it('fängt Pfeiltasten und Leertaste ab, damit die Seite nicht wegrollt', () => { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); ansicht.leinwand.focus(); expect(tippe('ArrowDown')).toBe(true); expect(tippe(' ')).toBe(true); } finally { ansicht.aufraeumen(); } }); it('setzt mit der Leertaste einen Punkt und schließt mit der Eingabetaste ab', () => { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); ansicht.leinwand.focus(); tippe(' '); expect(ansage(ansicht)).toContain('Erster Punkt gesetzt'); // Ein Punkt ist noch keine Linie: Erst der Abschluss traegt sie ein. expect(linien(ansicht)).toHaveLength(0); tippe('ArrowRight', {}, 4); tippe(' '); expect(ansage(ansicht)).toContain('Punkt 2 gesetzt'); expect(linien(ansicht)).toHaveLength(0); tippe('Enter'); expect(linien(ansicht)).toHaveLength(1); expect(linien(ansicht)[0]?.punkte).toEqual([MITTE, { x: 180, y: 160 }]); } finally { ansicht.aufraeumen(); } }); it('bricht mit Esc ab und lässt nichts von der Zeichnung stehen', () => { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); ansicht.leinwand.focus(); tippe(' '); tippe('ArrowRight', {}, 4); tippe(' '); tippe('Escape'); expect(ansage(ansicht)).toContain('Zeichnen abgebrochen'); // Das Werkzeug steht wieder auf "Ansehen" - und zwar ablesbar, nicht nur // an der Farbe des Knopfes. expect(werkzeugKnopf(ansicht, 'Ansehen').getAttribute('aria-pressed')).toBe('true'); expect(werkzeugKnopf(ansicht, 'Fahrlinie').getAttribute('aria-pressed')).toBe('false'); // Der Entwurf ist fort: Die Eingabetaste schliesst nichts mehr ab. tippe('Enter'); expect(linien(ansicht)).toHaveLength(0); } finally { ansicht.aufraeumen(); } }); it('nimmt mit der Rückschritttaste den letzten Punkt zurück', () => { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); ansicht.leinwand.focus(); tippe(' '); tippe('ArrowRight', {}, 4); tippe(' '); tippe('Backspace'); expect(ansage(ansicht)).toContain('Letzten Punkt zurückgenommen'); expect(ansage(ansicht)).toContain('1 Punkt(e) verbleiben'); // Nachweis, dass wirklich nur noch ein Punkt steht: Der Abschluss // verweigert sich, eine Fahrlinie braucht zwei. tippe('Enter'); expect(linien(ansicht)).toHaveLength(0); } finally { ansicht.aufraeumen(); } }); it('nimmt mit „z“ zurück, mit Strg+z dagegen nicht', () => { // Strg+Z gehoert dem globalen Rueckgaengig. Wer beim Zeichnen aus // Gewohnheit danach greift, darf nicht zugleich einen Punkt verlieren. const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Fahrlinie'); ansicht.leinwand.focus(); tippe(' '); tippe('ArrowRight', {}, 4); tippe(' '); tippe('z', { ctrlKey: true }); expect(ansage(ansicht)).toContain('Punkt 2 gesetzt'); tippe('z'); expect(ansage(ansicht)).toContain('Letzten Punkt zurückgenommen'); } finally { ansicht.aufraeumen(); } }); it('vergrößert und verkleinert mit Plus und Minus und nennt die neue Schrittweite', () => { const ansicht = baueAnsicht(); try { ansicht.leinwand.focus(); tippe('+'); expect(ansage(ansicht)).toContain('Ansicht vergrößert'); // Ein Schritt sind 5 Bildschirmpunkte; bei 1,25-fachem Zoom also 4 // Bildpunkte und damit 0,8 m am Boden. expect(ansage(ansicht)).toContain('0,8 m'); tippe('-'); expect(ansage(ansicht)).toContain('Ansicht verkleinert'); } finally { ansicht.aufraeumen(); } }); it('lässt den Zeiger beim Vergrößern stehen, wo er stand', () => { // Die Zusage der Tastenhilfe: „vergrößern / verkleinern – der Zeiger bleibt // stehen". Wanderte er mit, finge das Anfahren des Ziels von vorn an. expect(ersterPunktNach('+', '+')).toEqual(MITTE); expect(ersterPunktNach('-')).toEqual(MITTE); }); it('sagt bei „0“, dass ohne Bild nichts einzupassen ist', () => { const ansicht = baueAnsicht(); try { ansicht.leinwand.focus(); tippe('0'); expect(ansage(ansicht)).toContain('Einpassen nicht möglich'); } finally { ansicht.aufraeumen(); } }); it('verschiebt die Sicht mit Bild auf, Bild ab, Pos 1 und Ende', () => { // Ein Viertel der Zeichenflaeche je Anschlag - bei 320 Bildpunkten also 80. // Der Zeiger behaelt seine Stelle auf dem Schirm und wandert im Bild mit; // gemessen wird das an der Stelle, an der der Punkt dann landet. expect(ersterPunktNach('ArrowDown', 'PageUp')).toEqual({ x: 160, y: 85 }); expect(ersterPunktNach('ArrowDown', 'PageDown')).toEqual({ x: 160, y: 245 }); expect(ersterPunktNach('ArrowDown', 'Home')).toEqual({ x: 80, y: 165 }); expect(ersterPunktNach('ArrowDown', 'End')).toEqual({ x: 240, y: 165 }); }); });