import { describe, expect, it } from 'vitest'; import * as actions from '@/app/actions'; import { ProjectStore } from '@/app/store'; import { START_SICHT, zeichne } from '@/ui/lageplan/leinwand'; import { lageplanView } from '@/ui/views/lageplanView'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import type { Lageplan } from '@/domain/geometrie/lageplan'; /** * Das Signalgeberwerkzeug am Bildschirm. * * ZWEI DINGE STEHEN HIER AUF DEM PRUEFSTAND, und beide sind Zusagen, die man * der Oberflaeche nicht ansieht: * * - DASS ES DAS WERKZEUG GIBT und dass es gesperrt ist, solange kein Massstab * vorliegt. Ein Signalgeber ohne Massstab liesse sich setzen, aber nicht * verorten - und auf dem Blatt stuende er dann irgendwo. * - DASS DIE ZEICHENFLAECHE IHN ZEICHNET, samt Pfeil und Kennung. Die Marke * ist die einzige Stelle, an der die Blickrichtung ueberhaupt sichtbar * wird; ohne sie waere die Ausrichtung eine Zahl, die niemand prueft. */ const DATUM = new Date('2026-09-12T12:00:00Z'); /** Zeichenkontext, der Kreise, Vielecke und Beschriftungen mitschreibt. */ class Aufzeichner { readonly boegen: { x: number; y: number; r: number }[] = []; readonly texte: string[] = []; fuellungen = 0; striche = 0; strokeStyle = ''; fillStyle = ''; lineWidth = 0; lineCap = 'butt'; lineJoin = 'miter'; globalAlpha = 1; font = ''; textAlign = 'left'; textBaseline = 'alphabetic'; imageSmoothingQuality = 'low'; save(): void {} restore(): void {} clearRect(): void {} fillRect(): void {} strokeRect(): void {} drawImage(): void {} beginPath(): void {} moveTo(): void {} lineTo(): void {} closePath(): void {} setLineDash(): void {} fillText(value: string): void { this.texte.push(value); } strokeText(): void {} measureText(): { width: number } { return { width: 24 }; } arc(x: number, y: number, r: number): void { this.boegen.push({ x, y, r }); } fill(): void { this.fuellungen += 1; } stroke(): void { this.striche += 1; } } function planMitGeber(): Lageplan { return { 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: 160, y: 200 }, }, ], linien: [], arbeitsbereiche: [], signalgeber: [ { id: 'sg1', name: 'K1', punkt: { x: 130, y: 180 }, ausrichtung: 45, signalGroupId: null }, ], }; } function zeichneAuf(lageplan: Lageplan): Aufzeichner { const ctx = new Aufzeichner(); zeichne(ctx as unknown as CanvasRenderingContext2D, 800, 500, { lageplan, sicht: START_SICHT, bild: { naturalWidth: 400, naturalHeight: 300 } as unknown as HTMLImageElement, aktiveLinieId: null, aktiveHaltlinieId: null, haltlinienStart: null, entwurf: [], kalibrierStart: null, zeiger: null, dunkel: false, }); return ctx; } describe('Der Signalgeber auf der Zeichenfläche', () => { /* * GEZAEHLT WIRD DIE DIFFERENZ. Die Zeichenflaeche setzt auch anderswo Boegen * - Greifpunkte, Schnittpunkte -, und eine Zaehlung ueber alle pruefte nicht, * was sie zu pruefen vorgibt. */ function mehrBoegen(mit: Lageplan, ohne: Lageplan): number { return zeichneAuf(mit).boegen.length - zeichneAuf(ohne).boegen.length; } it('zeichnet Kreis, Pfeilspitze und Kennung', () => { const plan = planMitGeber(); expect(mehrBoegen(plan, { ...plan, signalgeber: [] }), 'kein Kreis').toBeGreaterThanOrEqual(1); expect(zeichneAuf(plan).texte, 'die Kennung fehlt').toContain('K1'); }); it('zeichnet ohne Signalgeber keinen zusätzlichen Kreis und keine Kennung', () => { const ohne = zeichneAuf({ ...planMitGeber(), signalgeber: [] }); expect(ohne.texte).not.toContain('K1'); }); it('lässt die Kennung weg, wenn keine vergeben ist', () => { // Ein leerer Ruecken unter einem leeren Text waere ein weisser Fleck auf // der Zeichnung - sichtbar, ohne etwas zu sagen. const plan = planMitGeber(); const ohneName: Lageplan = { ...plan, signalgeber: [{ ...plan.signalgeber[0]!, name: ' ' }], }; expect( mehrBoegen(ohneName, { ...plan, signalgeber: [] }), 'der Kreis fehlt', ).toBeGreaterThanOrEqual(1); expect(zeichneAuf(ohneName).texte.filter((t) => t.trim() === '')).toHaveLength(0); }); }); describe('Die Länge der Haltlinie steht in der Ansicht und nicht im Ausdruck', () => { /* * AUS DEM BETRIEB GEMELDET (Fassung 5.42.1). Die Laenge einer Haltlinie * stand als Masskette auf dem Lageplanblatt der Planunterlage und sonst * NIRGENDS im Programm - keine Spalte, kein Fenster, keine Ansage. Der * Bearbeiter zeichnete nach Augenmass und erfuhr die Zahl erst im Ausdruck, * wo sie wie eine Messung aussah. * * Sie ist jetzt umgekehrt: Auskunft beim Zeichnen, keine Zusicherung in der * Unterlage. Diese Faelle halten beide Haelften fest - die Masskette prueft * tests/domain/massketten.test.ts. */ function ansichtMitHaltlinie() { const basis = projektAusAngaben( { ...ASSISTENT_VORGABE, form: 'einstreifig' }, 'Arbeitsstelle', DATUM, ); const projekt = actions.setLageplan(basis, planMitGeber()); const store = new ProjectStore(projekt); const wurzel = document.createElement('div'); document.body.appendChild(wurzel); const aufraeumen = lageplanView.render(wurzel, { store, refresh: () => {}, navigate: () => {}, } as unknown as Parameters[1]); return { wurzel, aufraeumen: () => { if (typeof aufraeumen === 'function') aufraeumen(); wurzel.remove(); }, }; } it('führt die gemessene Länge in der Haltlinientabelle', () => { /* * Die Haltlinie des Prüfstücks läuft von y=200 bis y=200 über 60 * Bildpunkte bei 20 m auf 100 Bildpunkten - das sind 12 m. */ const { wurzel, aufraeumen } = ansichtMitHaltlinie(); try { const text = wurzel.textContent ?? ''; expect(text, 'die Spalte Länge fehlt').toContain('Länge'); expect(text, 'die gemessene Länge fehlt').toMatch(/12(,0)?\s*m/); } finally { aufraeumen(); } }); }); describe('Das Signalgeberwerkzeug in der Ansicht Lageplan', () => { function baueAnsicht(mitMassstab: boolean) { const basis = projektAusAngaben( { ...ASSISTENT_VORGABE, form: 'einstreifig' }, 'Arbeitsstelle', DATUM, ); const projekt = mitMassstab ? actions.setLageplan(basis, planMitGeber()) : actions.setLageplan(basis, { ...planMitGeber(), kalibrierung: null, signalgeber: [] }); const store = new ProjectStore(projekt); const wurzel = document.createElement('div'); document.body.appendChild(wurzel); const aufraeumen = lageplanView.render(wurzel, { store, refresh: () => {}, navigate: () => {}, } as unknown as Parameters[1]); return { wurzel, store, aufraeumen: () => { if (typeof aufraeumen === 'function') aufraeumen(); wurzel.remove(); }, }; } function knopf(wurzel: HTMLElement, beschriftung: string): HTMLButtonElement | undefined { return [...wurzel.querySelectorAll('button')].find( (b) => (b.textContent ?? '').trim() === beschriftung, ); } it('bietet das Werkzeug an, sobald ein Maßstab vorliegt', () => { const { wurzel, aufraeumen } = baueAnsicht(true); try { const werkzeug = knopf(wurzel, 'Signalgeber'); expect(werkzeug, 'der Werkzeugknopf fehlt').toBeDefined(); expect(werkzeug!.disabled, 'das Werkzeug ist trotz Maßstab gesperrt').toBe(false); } finally { aufraeumen(); } }); it('sperrt es ohne Maßstab', () => { /* * Ein Signalgeber ohne Massstab liesse sich setzen und nicht verorten. Er * stuende auf dem Blatt an einer Stelle, die niemand nachmessen kann - und * das Blatt sagt nicht, dass es so ist. */ const { wurzel, aufraeumen } = baueAnsicht(false); try { expect(knopf(wurzel, 'Signalgeber')!.disabled).toBe(true); } finally { aufraeumen(); } }); it('führt die erfassten Signalgeber in der Seitenleiste auf', () => { const { wurzel, aufraeumen } = baueAnsicht(true); try { const text = wurzel.textContent ?? ''; expect(text).toContain('Signalgeber'); expect(text, 'die Kennung fehlt in der Liste').toContain('K1'); expect(text, 'die Blickrichtung fehlt').toContain('45°'); expect(text, 'die fehlende Zuordnung wird verschwiegen').toContain('ohne Zuordnung'); } finally { aufraeumen(); } }); it('sagt beim gewählten Werkzeug, dass der Signalgeber nicht mitrechnet', () => { /* * Der Satz ist die Bedingung, unter der es das Objekt gibt. Steht er nicht * dort, wo gezeichnet wird, liest ihn niemand - und der naechste Bearbeiter * haelt den Standort fuer eine Rechengroesse. */ const { wurzel, store, aufraeumen } = baueAnsicht(true); try { knopf(wurzel, 'Signalgeber')!.click(); const text = wurzel.textContent ?? ''; expect(text).toContain('keine Rechnung'); expect(store.getProject().lageplan.signalgeber).toHaveLength(1); } finally { aufraeumen(); } }); it('legt über die Tastatur einen Signalgeber an', () => { /* * DER GANZE WEG, so wie ein Anwender ihn geht: Werkzeug waehlen, Standort * setzen, Richtungspunkt setzen, im Fenster bestaetigen. Beide Punkte * gehen durch dieselbe Stelle wie jedes andere Zeichnen - deshalb genuegt * die Tastatur, und deshalb prueft dieser Fall sie und nicht die Maus. */ const { wurzel, store, aufraeumen } = baueAnsicht(true); try { const leinwand = wurzel.querySelector('canvas'); expect(leinwand, 'die Ansicht hat keine Zeichenfläche').not.toBeNull(); knopf(wurzel, 'Signalgeber')!.click(); const druecke = (key: string): void => { leinwand!.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); }; druecke(' '); expect( wurzel.querySelector('[role="status"]')?.textContent ?? '', 'nach dem ersten Punkt fehlt die Ansage', ).toContain('Blickrichtung'); druecke('ArrowRight'); druecke(' '); const anlegen = [...document.querySelectorAll('button')].find( (b) => (b.textContent ?? '').trim() === 'Signalgeber anlegen', ); expect(anlegen, 'das Fenster zum Anlegen erscheint nicht').toBeDefined(); anlegen!.click(); const geber = store.getProject().lageplan.signalgeber; expect(geber, 'kein Signalgeber angelegt').toHaveLength(2); expect(store.getState().undoLabel).toBe('Signalgeber gezeichnet'); } finally { aufraeumen(); } }); it('nimmt den halb gesetzten Signalgeber mit der Rückschritttaste zurück', () => { // Sonst bliebe der Standort stehen, und der naechste Tastendruck setzte // die Richtung gegen einen Punkt, den der Anwender verwerfen wollte. const { wurzel, store, aufraeumen } = baueAnsicht(true); try { const leinwand = wurzel.querySelector('canvas')!; knopf(wurzel, 'Signalgeber')!.click(); leinwand.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); leinwand.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true })); expect(wurzel.querySelector('[role="status"]')?.textContent ?? '').toContain( 'zurückgenommen', ); expect(store.getProject().lageplan.signalgeber).toHaveLength(1); } finally { aufraeumen(); } }); it('löscht einen Signalgeber über die Liste', () => { const { wurzel, store, aufraeumen } = baueAnsicht(true); try { const loeschen = [...wurzel.querySelectorAll('button')].find((b) => (b.getAttribute('aria-label') ?? '').includes('Signalgeber "K1" löschen'), ); expect(loeschen, 'kein Löschknopf').toBeDefined(); loeschen!.click(); expect(store.getProject().lageplan.signalgeber).toHaveLength(0); } finally { aufraeumen(); } }); });