import { afterAll, afterEach, beforeAll, describe, expect, it, vi } 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'; /** * Befund 22: Ein halb gesetzter Haltlinienpunkt ueberlebte den Werkzeugwechsel. * * WAS DER ANWENDER SONST ERLEBT * * Er waehlt "Haltlinie", setzt den ersten Fahrbahnrand und wechselt dann in der * Seitenleiste auf "Fahrlinie". Der halbe Anfang bleibt liegen, und daraus * folgen zwei Dinge: * * 1. Er zieht eine Fahrlinie durch den Knotenpunkt und SIEHT DAVON NICHTS - * `zeichneEntwurf` prueft den Haltlinienpunkt zuerst und kehrt danach * zurueck; gezeichnet wird nur noch ein gestricheltes Band von einer * laengst verworfenen Stelle zum Zeiger. * 2. Waehlt er spaeter wieder "Haltlinie", schliesst der naechste Punkt eine * Haltlinie zu dem alten Anfang ab. Die Haltlinie ist der Nullpunkt jeder * Wegmessung; liegt sie falsch, faellt der Raeumweg aller daran * beginnenden Stroeme zu kurz aus - die gefaehrliche Richtung. * * Escape (lageplanView.ts) und das Haltlinienfenster raeumen den halben Anfang * seit jeher; der Werkzeugknopf war die einzige Ausnahme. */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; /** Farbe des Entwurfs einer Fahrlinie. */ const ENTWURF = '#1f5fa8'; /** Farbe der Haltlinien - und damit auch des gestrichelten Haltlinienbandes. */ const HALTLINIE = '#111827'; interface Strich { readonly farbe: string; readonly breite: number; readonly strich: string; } /** * Zeichenkontext, der jeden Strich mitschreibt. * * jsdom bringt keine Zeichenflaeche mit. Gefragt ist hier ohnehin nicht das * Bild, sondern die Frage, OB die Vorschau der Fahrlinie ueberhaupt gezeichnet * wird - und das ist eine Folge von Anweisungen. */ class Aufzeichner { readonly striche: Strich[] = []; strokeStyle: string = ''; fillStyle: string = ''; lineWidth = 0; lineCap = 'butt'; lineJoin = 'miter'; globalAlpha = 1; font = ''; textAlign = 'left'; imageSmoothingQuality = 'low'; private strich = ''; save(): void {} restore(): void {} clearRect(): void {} fillRect(): void {} strokeRect(): void {} drawImage(): void {} beginPath(): void {} moveTo(): void {} lineTo(): void {} arc(): void {} fill(): void {} fillText(): void {} setTransform(): void {} measureText(): { width: number } { return { width: 24 }; } setLineDash(muster: readonly number[] = []): void { this.strich = muster.join(','); } stroke(): void { this.striche.push({ farbe: String(this.strokeStyle), breite: this.lineWidth, strich: this.strich, }); } leeren(): void { this.striche.length = 0; } } const stift = new Aufzeichner(); const urspruenglichesPath2D = (globalThis as { Path2D?: unknown }).Path2D; const urspruenglichesImage = (globalThis as { Image?: unknown }).Image; /** * Bildelement, das sofort geladen meldet. * * jsdom holt keine Daten-URL ab, `onload` bliebe also aus - und ohne geladenes * Bild zeichnet `zeichne` nur den Satz "Kein Lageplan geladen" und kehrt * zurueck. Geprueft werden soll aber genau das, was danach kaeme. */ class SofortBild { onload: (() => void) | null = null; onerror: (() => void) | null = null; naturalWidth = 800; naturalHeight = 600; set src(_wert: string) { this.onload?.(); } } beforeAll(() => { // jsdom bringt weder eine Zeichenflaeche noch Path2D mit. Gebraucht wird // hier nur der Behaelter, nicht seine Geometrie - gezaehlt werden Striche. (globalThis as { Path2D?: unknown }).Path2D = class { moveTo(): void {} lineTo(): void {} }; (globalThis as { Image?: unknown }).Image = SofortBild; vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( () => stift as unknown as CanvasRenderingContext2D, ); }); afterAll(() => { (globalThis as { Path2D?: unknown }).Path2D = urspruenglichesPath2D; (globalThis as { Image?: unknown }).Image = urspruenglichesImage; }); /** Plan mit Bild und Massstab: 100 Bildpunkte auf 20 m, noch keine Haltlinie. */ function planMitMassstab(): Lageplan { return { arbeitsbereiche: [], signalgeber: [], bild: { datenUrl: 'data:image/png;base64,iVBORw0KGgo=', breite: 800, hoehe: 600, herkunft: 'Prüfbild', geladenAm: FESTES_DATUM, }, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: 20, herkunft: 'gemessen', }, haltlinien: [], linien: [], }; } interface Ansicht { readonly store: ProjectStore; readonly wurzel: HTMLElement; readonly leinwand: HTMLCanvasElement; readonly aufraeumen: () => void; } function baueAnsicht(lageplan: Lageplan = planMitMassstab()): 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(() => { document.body.replaceChildren(); stift.leeren(); }); 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 })); } } function ansage(ansicht: Ansicht): string { return ansicht.wurzel.querySelector('.lageplan-rahmen [role="status"]')?.textContent ?? ''; } describe('Werkzeugwechsel mit halb gesetzter Haltlinie', () => { it('lässt den nächsten Haltlinienpunkt wieder der erste sein, statt zum verworfenen Anfang abzuschließen', () => { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Haltlinie'); ansicht.leinwand.focus(); tippe(' '); expect(ansage(ansicht)).toContain('Erster Punkt der Haltlinie gesetzt'); // Werkzeugwechsel hin und zurueck - ohne Escape, ohne Fenster. waehleWerkzeug(ansicht, 'Fahrlinie'); waehleWerkzeug(ansicht, 'Haltlinie'); ansicht.leinwand.focus(); tippe('ArrowDown', 12); 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('zeichnet nach dem Wechsel wieder die Vorschau der Fahrlinie statt des alten Haltlinienbandes', () => { const ansicht = baueAnsicht(); try { waehleWerkzeug(ansicht, 'Haltlinie'); ansicht.leinwand.focus(); tippe(' '); waehleWerkzeug(ansicht, 'Fahrlinie'); ansicht.leinwand.focus(); tippe(' '); stift.leeren(); tippe('ArrowRight', 4); const gestricheltesBand = stift.striche.some( (s) => s.farbe === HALTLINIE && s.breite === 4 && s.strich === '8,5', ); const fahrlinienEntwurf = stift.striche.some((s) => s.farbe === ENTWURF && s.breite === 2); expect(gestricheltesBand, 'das Band der verworfenen Haltlinie wird noch gezeichnet').toBe( false, ); expect(fahrlinienEntwurf, 'die Fahrlinie wird ohne Vorschau gezogen').toBe(true); } finally { ansicht.aufraeumen(); } }); });