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'; /** * Die Tastaturwege "Massstab" und "Haltlinie" der Zeichenflaeche. * * BEFUND (Fassung 5.7.0): `tests/ui/zeichenflaecheTastatur.test.ts` und * `tests/ui/lageplanTastatur.test.ts` fahren ausschliesslich das Werkzeug * "Fahrlinie" - jeder `waehleWerkzeug`-Aufruf lautet dort `'Fahrlinie'`. Von * den vier Werkzeugen waren damit zwei nirgends abgesichert, und es sind * ausgerechnet die beiden, die am Anfang stehen: * * - Der MASSSTAB ist die Kalibrierung. Aus ihm folgt jede Laengenangabe des * Lageplans, und daraus folgen Raeum- und Einfahrweg, aus denen jede * Zwischenzeit gerechnet wird. Ein Massstab, der sich mit der Tastatur * nicht mehr setzen liesse, machte die ganze Vermessung ohne Maus * unmoeglich - die Werkzeuge "Haltlinie" und "Fahrlinie" sind bis dahin * gesperrt. * - Die HALTLINIE ist der Nullpunkt, an dem die Stroeme beginnen, und * zugleich die Zuordnung zur Zufahrt, aus der die Vertraeglichkeit folgt. * * Beide funktionieren heute. Sie wuerden still ausfallen: Kein Test wuerde rot, * und bemerkt haette es erst der Anwender, der ohne Maus arbeitet. * * WCAG 2.1 Erfolgskriterium 2.1.1 "Tastatur" (Stufe A) und 4.1.3 * "Statusmeldungen" (AA) - denn eine gesetzte Marke, die nicht angesagt wird, * ist auf einer Zeichenflaeche nicht wahrnehmbar. * * WIE HIER GETIPPT WIRD: an dem Element, das GERADE DEN FOKUS HAT, so wie ein * Browser den Tastendruck zustellt. Wer unmittelbar an die Zeichenflaeche * sendet, prueft eine Zustellung, die es ohne Fokussierbarkeit gar nicht gaebe. * * MASSE: In jsdom hat der Rahmen keine Breite; die Flaeche faellt auf ihre * Mindestmasse von 320 x 320 zurueck, die Sicht ist die Startsicht ohne Zoom. * Ein Bildschirmpunkt ist damit ein Bildpunkt, der Tastenzeiger beginnt bei * (160, 160), und ein Pfeiltastenanschlag traegt fuenf Punkte weit. */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; /** Schrittweite eines gewoehnlichen Pfeiltastenanschlags in Bildpunkten. */ const SCHRITT = 5; /** * Ein Plan mit Hintergrundbild, aber ohne Massstab - der Ausgangszustand. * * Ohne Bild ist "Massstab" gesperrt; die Bildangaben muessen deshalb stehen. * Geladen wird das Bild in jsdom nie - das braucht der Fall auch nicht: Die * Sperre haengt am Projekt, nicht am geladenen Bild. */ 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: [], }; } /** Derselbe Plan, nachdem der Massstab steht: 100 Bildpunkte auf 20 m. */ function planMitMassstab(): Lageplan { return { ...planOhneMassstab(), kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: 20, herkunft: 'gemessen', }, }; } interface Ansicht { readonly store: ProjectStore; readonly wurzel: HTMLElement; readonly leinwand: HTMLCanvasElement; readonly aufraeumen: () => void; } function baueAnsicht(lageplan: Lageplan): 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: () => { leinwand.blur(); schliessen(); wurzel.remove(); }, }; } afterEach(() => { // Ein Fenster, das ein Fall stehen laesst, wuerde den naechsten mit seinem // stillgelegten Hintergrund empfangen. document.body.replaceChildren(); }); /** Werkzeugknopf ueber seine sichtbare Beschriftung - so wie ein Anwender auch. */ function werkzeugKnopf(ansicht: Ansicht, beschriftung: string): HTMLButtonElement { const knopf = [ ...ansicht.wurzel.querySelectorAll('.lageplan-werkzeuge button'), ].find((b) => (b.textContent ?? '').trim() === beschriftung); if (knopf === undefined) throw new Error(`Kein Werkzeug "${beschriftung}"`); return knopf; } function waehleWerkzeug(ansicht: Ansicht, beschriftung: string): void { werkzeugKnopf(ansicht, beschriftung).click(); } /** Tippt auf das Element, das gerade den Fokus hat. */ 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 })); } } /** Text des Ansageraums unter der Zeichenflaeche. */ function ansage(ansicht: Ansicht): string { return ansicht.wurzel.querySelector('.lageplan-rahmen [role="status"]')?.textContent ?? ''; } /** Das offene Fenster - Massstab und Haltlinie fragen beide in einem nach. */ function fenster(): HTMLElement { const dialog = document.querySelector('.dialog'); if (dialog === null) throw new Error('Es steht kein Fenster offen.'); return dialog; } function knopfImFenster(beschriftung: string): HTMLButtonElement { const knopf = [...fenster().querySelectorAll('button')].find( (b) => (b.textContent ?? '').trim() === beschriftung, ); if (knopf === undefined) throw new Error(`Kein Knopf "${beschriftung}" im Fenster`); return knopf; } describe('Werkzeug "Maßstab" mit der Tastatur', () => { it('ist ohne Hintergrundbild gesperrt und mit Bild bedienbar', () => { // Die Reihenfolge der Arbeit steckt in den Sperren: erst Bild, dann // Massstab, dann Haltlinie, dann Fahrlinie. Fiele die Sperre weg, setzte // ein Anwender eine Kalibrierstrecke auf einer leeren Flaeche. const ohne = baueAnsicht({ ...planOhneMassstab(), bild: null }); try { expect(werkzeugKnopf(ohne, 'Maßstab').disabled).toBe(true); } finally { ohne.aufraeumen(); } const mit = baueAnsicht(planOhneMassstab()); try { expect(werkzeugKnopf(mit, 'Maßstab').disabled).toBe(false); expect(werkzeugKnopf(mit, 'Haltlinie').disabled, 'Haltlinie ohne Maßstab').toBe(true); expect(werkzeugKnopf(mit, 'Fahrlinie').disabled, 'Fahrlinie ohne Maßstab').toBe(true); } finally { mit.aufraeumen(); } }); it('sagt nach dem ersten Punkt an, was als Nächstes zu tun ist', () => { const ansicht = baueAnsicht(planOhneMassstab()); try { waehleWerkzeug(ansicht, 'Maßstab'); ansicht.leinwand.focus(); tippe(' '); // Ohne diesen Satz ist auf einer Zeichenflaeche nicht zu erfahren, dass // ueberhaupt etwas gesetzt wurde - es gibt nichts zu sehen und nichts // anzufassen. expect(ansage(ansicht)).toContain('Erster Punkt der Maßstabsstrecke gesetzt'); expect(ansage(ansicht)).toContain('Jetzt das andere Ende'); } finally { ansicht.aufraeumen(); } }); it('setzt aus zwei Punkten und einer Länge den Maßstab des Projekts', () => { const ansicht = baueAnsicht(planOhneMassstab()); try { waehleWerkzeug(ansicht, 'Maßstab'); ansicht.leinwand.focus(); tippe(' '); // Zwanzig Anschlaege nach rechts: 100 Bildpunkte. tippe('ArrowRight', 20); tippe(' '); expect(ansage(ansicht)).toContain('Zweiter Punkt der Maßstabsstrecke gesetzt'); const feld = fenster().querySelector('input[type="number"]'); expect(feld, 'kein Laengenfeld im Fenster').not.toBeNull(); if (feld !== null) { feld.value = '25'; feld.dispatchEvent(new Event('change')); } knopfImFenster('Übernehmen').click(); const kalibrierung = ansicht.store.getProject().lageplan.kalibrierung; expect(kalibrierung, 'kein Maßstab im Projekt').not.toBeNull(); // Genau die beiden Punkte, die der Zeiger angefahren hat - Maus und // Tastatur laufen durch dieselbe Funktion `setzePunkt`, und diese Zahlen // sind der Beleg dafuer. expect(kalibrierung?.von).toEqual({ x: 160, y: 160 }); expect(kalibrierung?.bis).toEqual({ x: 160 + 20 * SCHRITT, y: 160 }); expect(kalibrierung?.laengeMeter).toBe(25); expect(kalibrierung?.herkunft).toBe('gemessen'); } finally { ansicht.aufraeumen(); } }); it('gibt nach dem Übernehmen die Werkzeuge frei, die den Maßstab brauchen', () => { const ansicht = baueAnsicht(planOhneMassstab()); try { waehleWerkzeug(ansicht, 'Maßstab'); ansicht.leinwand.focus(); tippe(' '); tippe('ArrowRight', 20); tippe(' '); knopfImFenster('Übernehmen').click(); expect(werkzeugKnopf(ansicht, 'Haltlinie').disabled).toBe(false); expect(werkzeugKnopf(ansicht, 'Fahrlinie').disabled).toBe(false); } finally { ansicht.aufraeumen(); } }); it('nimmt eine halb gesetzte Strecke mit Escape zurück', () => { const ansicht = baueAnsicht(planOhneMassstab()); try { waehleWerkzeug(ansicht, 'Maßstab'); ansicht.leinwand.focus(); tippe(' '); tippe('Escape'); expect(ansage(ansicht)).toContain('Zeichnen abgebrochen'); // Der halbe Anfang darf nicht liegen bleiben: Der naechste Leertastendruck // im wieder gewaehlten Werkzeug wuerde sonst eine Strecke zu dem Punkt // schliessen, den der Anwender laengst verworfen hat. waehleWerkzeug(ansicht, 'Maßstab'); tippe(' '); expect(ansage(ansicht)).toContain('Erster Punkt der Maßstabsstrecke gesetzt'); expect(document.querySelector('.dialog'), 'Fenster nach nur einem Punkt').toBeNull(); } finally { ansicht.aufraeumen(); } }); it('setzt nichts, solange die Zeichenfläche den Fokus nicht hat', () => { const ansicht = baueAnsicht(planOhneMassstab()); try { waehleWerkzeug(ansicht, 'Maßstab'); // Der Klick auf den Werkzeugknopf hat den Fokus dort gelassen. expect(document.activeElement).not.toBe(ansicht.leinwand); tippe(' '); tippe('ArrowRight', 20); tippe(' '); expect(ansicht.store.getProject().lageplan.kalibrierung).toBeNull(); expect(document.querySelector('.dialog')).toBeNull(); } finally { ansicht.aufraeumen(); } }); }); describe('Werkzeug "Haltlinie" mit der Tastatur', () => { it('sagt nach dem ersten Punkt an, wohin der zweite gehört', () => { const ansicht = baueAnsicht(planMitMassstab()); try { waehleWerkzeug(ansicht, 'Haltlinie'); ansicht.leinwand.focus(); tippe(' '); expect(ansage(ansicht)).toContain('Erster Punkt der Haltlinie gesetzt'); expect(ansage(ansicht)).toContain('der andere Fahrbahnrand'); } finally { ansicht.aufraeumen(); } }); it('legt aus zwei Punkten eine Haltlinie mit Namen und Zuordnung an', () => { const ansicht = baueAnsicht(planMitMassstab()); try { waehleWerkzeug(ansicht, 'Haltlinie'); ansicht.leinwand.focus(); tippe(' '); // Zwoelf Anschlaege nach unten: 60 Bildpunkte quer ueber die Fahrbahn. tippe('ArrowDown', 12); tippe(' '); knopfImFenster('Haltlinie anlegen').click(); const haltlinien = ansicht.store.getProject().lageplan.haltlinien; expect(haltlinien).toHaveLength(1); expect(haltlinien[0]?.von).toEqual({ x: 160, y: 160 }); expect(haltlinien[0]?.bis).toEqual({ x: 160, y: 160 + 12 * SCHRITT }); // Art und Zufahrt werden im selben Zug gefragt: Beides entscheidet ueber // die Vertraeglichkeit der Stroeme, die spaeter daran beginnen. expect(haltlinien[0]?.art).toBe('zufahrt'); expect(haltlinien[0]?.armId).not.toBeNull(); expect((haltlinien[0]?.name ?? '').trim()).not.toBe(''); } finally { ansicht.aufraeumen(); } }); it('führt nach dem Anlegen zum nächsten Arbeitsschritt weiter', () => { const ansicht = baueAnsicht(planMitMassstab()); try { waehleWerkzeug(ansicht, 'Haltlinie'); ansicht.leinwand.focus(); tippe(' '); tippe('ArrowDown', 12); tippe(' '); knopfImFenster('Haltlinie anlegen').click(); /* * Ohne diesen Satz weiss ein Anwender ohne Sicht auf die Zeichnung nicht, * dass das Werkzeug gewechselt hat - der naechste Leertastendruck setzt * dann keinen Haltlinienpunkt mehr, sondern beginnt eine Fahrlinie. * * Er steht in der Kurzmeldung und nicht in der Zeigerlage: Die Zeigerlage * sagt, wo der Zeiger steht, und wird bei jedem Anschlag ueberschrieben; * eine Meldung, die den naechsten Arbeitsschritt nennt, waere darin nach * dem ersten Pfeiltastendruck weg. Der Meldungsstapel bleibt stehen. */ const meldung = document.querySelector('.meldungen [role="status"]')?.textContent ?? ''; expect(meldung).toContain('angelegt'); expect(meldung).toContain('Fahrlinien'); expect(werkzeugKnopf(ansicht, 'Fahrlinie').getAttribute('aria-pressed')).toBe('true'); expect(werkzeugKnopf(ansicht, 'Haltlinie').getAttribute('aria-pressed')).toBe('false'); } finally { ansicht.aufraeumen(); } }); it('nimmt einen halb gesetzten Querschnitt mit Escape zurück', () => { const ansicht = baueAnsicht(planMitMassstab()); try { waehleWerkzeug(ansicht, 'Haltlinie'); ansicht.leinwand.focus(); tippe(' '); tippe('Escape'); expect(ansage(ansicht)).toContain('Zeichnen abgebrochen'); waehleWerkzeug(ansicht, 'Haltlinie'); tippe(' '); expect(ansage(ansicht)).toContain('Erster Punkt der Haltlinie gesetzt'); expect(document.querySelector('.dialog'), 'Fenster nach nur einem Punkt').toBeNull(); expect(ansicht.store.getProject().lageplan.haltlinien).toHaveLength(0); } finally { ansicht.aufraeumen(); } }); it('setzt nichts, solange die Zeichenfläche den Fokus nicht hat', () => { const ansicht = baueAnsicht(planMitMassstab()); try { waehleWerkzeug(ansicht, 'Haltlinie'); expect(document.activeElement).not.toBe(ansicht.leinwand); tippe(' '); tippe('ArrowDown', 12); tippe(' '); expect(ansicht.store.getProject().lageplan.haltlinien).toHaveLength(0); expect(document.querySelector('.dialog')).toBeNull(); } finally { ansicht.aufraeumen(); } }); });