import { describe, expect, it } from 'vitest'; 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'; /** * Genauigkeitsfuehrung und Fangradien im Lageplan. * * Beides haengt an der Bodenaufloesung der Unterlage, und beides wurde erst * gefaehrlich, als sich Ausschnitte bis 2000 m holen lassen: * * - Ein Uebersichtsbild sieht aus wie ein Knotenpunktbild, traegt aber * 28,6 statt 12,5 cm je Bildpunkt. Wer darauf eine Fussgaengerfurt * abgreift, erhaelt eine zu KURZE Zwischenzeit. * - Die Fangtoleranzen sind in Bildschirmpunkten festgelegt. Ohne Deckelung * am Boden waechst der Fangradius mit dem Ausschnitt mit und rastet den * Anfangspunkt einer Fahrlinie auf die Haltlinie des Nachbarknotenpunkts. * * Geprueft wird gegen die aufgebaute Ansicht, nicht gegen die Rechnung: Die * Einstufung selbst ist in der Domaene geprueft. Hier steht die Frage, ob sie * beim Anwender ankommt und ob der Fang sie beachtet. */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; /** Waagerechte Haltlinie bei y = 200, von x = 100 bis x = 160. */ const HALTLINIE = { id: 'hl1', name: 'Nord', art: 'zufahrt' as const, armId: null, von: { x: 100, y: 200 }, bis: { x: 160, y: 200 }, }; /** * Lageplan mit georeferenziertem Massstab. * * `kantenlaenge / bildbreite` ist die Bodenaufloesung - genau die Groesse, an * der Einstufung und Fangradius haengen. Ein Hintergrundbild wird nicht * gebraucht: Ohne Bild bleibt die Sicht unveraendert (Zoom 1), und damit ist * ein Bildschirmpunkt ein Bildpunkt. */ function planMit(kantenlaengeMeter: number, bildbreite: number): Lageplan { return { arbeitsbereiche: [], signalgeber: [], bild: null, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: bildbreite, y: 0 }, laengeMeter: kantenlaengeMeter, herkunft: 'georeferenziert', }, haltlinien: [HALTLINIE], linien: [], }; } interface Ansicht { readonly wurzel: HTMLElement; readonly aufraeumen: () => void; } function baueAnsicht(lageplan: Lageplan | null): Ansicht { const grundlage = projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM); const store = new ProjectStore(lageplan === null ? grundlage : { ...grundlage, lageplan }); const wurzel = document.createElement('div'); document.body.append(wurzel); const aufraeumen = lageplanView.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); return { wurzel, aufraeumen: () => { aufraeumen(); wurzel.remove(); }, }; } /** Text des Hinweisbalkens mit dieser Ueberschrift; null, wenn es ihn nicht gibt. */ function balkenText(wurzel: HTMLElement, titel: string): string | null { for (const balken of wurzel.querySelectorAll('.hinweisbalken')) { if (balken.querySelector('strong')?.textContent === titel) return balken.textContent ?? ''; } return null; } /** Alle Ueberschriften der Hinweisbalken - zum Nachweis, dass keiner dasteht. */ function balkenTitel(wurzel: HTMLElement): string[] { return [...wurzel.querySelectorAll('.hinweisbalken strong')].map((k) => k.textContent ?? ''); } function knopf(wurzel: HTMLElement, beschriftung: string): HTMLButtonElement { const treffer = [...wurzel.querySelectorAll('button')].find( (b) => b.textContent === beschriftung, ); if (treffer === undefined) throw new Error(`Knopf "${beschriftung}" nicht gefunden`); return treffer; } /** Setzt den ersten Punkt einer Fahrlinie an dieser Stelle der Zeichenflaeche. */ function zeichnePunkt(wurzel: HTMLElement, x: number, y: number): void { knopf(wurzel, 'Fahrlinie').click(); wurzel .querySelector('canvas') ?.dispatchEvent(new MouseEvent('click', { button: 0, detail: 1, clientX: x, clientY: y })); } describe('Genauigkeit der Unterlage in der Seitenleiste', () => { it('nennt bei feiner Unterlage die Eignung samt Fehler in Sekunden', () => { // 100 m auf 2000 Bildpunkte = 5 cm je Bildpunkt. const { wurzel, aufraeumen } = baueAnsicht(planMit(100, 2000)); try { const text = balkenText(wurzel, 'Zum Vermessen geeignet'); expect(text).not.toBeNull(); expect(text).toContain('5 cm'); // Zwei Bildpunkte Zeichenfehler auf Raeum- UND Einfahrweg einer Furt: // 2 * 0,05 m * (1/1,0 + 1/11,1) = 0,109009 s, ausgegeben als 0,11 s. expect(text).toContain('0,11 s'); expect(text).toContain('0,25 s'); } finally { aufraeumen(); } }); it('warnt bei einem 2000-m-Ausschnitt vor Furten und nennt beide Fehler', () => { // Der Regelfall der neuen Mosaike: 2000 m auf 7000 Bildpunkte. const { wurzel, aufraeumen } = baueAnsicht(planMit(2000, 7000)); try { const text = balkenText(wurzel, 'Nur für Kfz-Beziehungen genau genug'); expect(text).not.toBeNull(); expect(text).toContain('28,6 cm'); // Kein Text ohne Zahl: der Fehler an der Furt und der beim Kfz. // Furt: 2 * 0,285714 m * (1/1,0 + 1/11,1) = 0,622909 s. // Kfz: 2 * 0,285714 m * (1/7,0 + 1/11,1) = 0,133113 s. expect(text).toContain('0,62 s'); expect(text).toContain('0,13 s'); expect(text).toContain('Radquerungen'); } finally { aufraeumen(); } }); it('meldet eine unbrauchbare Unterlage als Fehler, nicht als Warnung', () => { // 1000 m auf 1000 Bildpunkte = 1 m je Bildpunkt. const { wurzel, aufraeumen } = baueAnsicht(planMit(1000, 1000)); try { const text = balkenText(wurzel, 'Als Messgrundlage nicht geeignet'); expect(text).not.toBeNull(); // Furt: 2 * 1,0 m * (1/1,0 + 1/11,1) = 2,180180 s. // Kfz: 2 * 1,0 m * (1/7,0 + 1/11,1) = 0,465894 s - auch der reisst die // Schranke, deshalb Fehler statt Warnung. expect(text).toContain('2,18 s'); expect(text).toContain('0,47 s'); // Die Einstufung darf nicht allein in der Farbe stehen - aber die Farbe // gehoert dazu: Ein Fehler sieht anders aus als eine Warnung. const balken = [...wurzel.querySelectorAll('.hinweisbalken')].find( (b) => b.querySelector('strong')?.textContent === 'Als Messgrundlage nicht geeignet', ); expect(balken?.classList.contains('hinweisbalken-fehler')).toBe(true); } finally { aufraeumen(); } }); it('behauptet ohne Massstab keine Genauigkeit', () => { const { wurzel, aufraeumen } = baueAnsicht(null); try { const titel = balkenTitel(wurzel); expect(titel).toContain('Maßstab fehlt'); expect(titel).not.toContain('Zum Vermessen geeignet'); expect(titel).not.toContain('Nur für Kfz-Beziehungen genau genug'); expect(titel).not.toContain('Als Messgrundlage nicht geeignet'); } finally { aufraeumen(); } }); it('warnt beim Zeichnen einer Furt noch einmal ausdruecklich', () => { const { wurzel, aufraeumen } = baueAnsicht(planMit(2000, 7000)); try { knopf(wurzel, 'Fahrlinie').click(); expect(balkenTitel(wurzel)).not.toContain('Fußgängerfurt auf dieser Unterlage vermessen?'); const auswahl = [...wurzel.querySelectorAll('select')].find((s) => [...s.options].some((o) => o.value === 'fuss'), ); expect(auswahl).not.toBeUndefined(); auswahl!.value = 'fuss'; auswahl!.dispatchEvent(new Event('change')); const text = balkenText(wurzel, 'Fußgängerfurt auf dieser Unterlage vermessen?'); expect(text).not.toBeNull(); // Derselbe Furtfehler wie im Balken der Seitenleiste: 0,622909 s. expect(text).toContain('0,62 s'); } finally { aufraeumen(); } }); }); describe('Fangradius am Boden gedeckelt', () => { it('faengt bei grobem Ausschnitt nicht mehr aus 3,4 m Entfernung', () => { // 2000 m auf 7000 Bildpunkte: 15 Bildschirmpunkte waeren 4,3 m am Boden. // Der Klick liegt 12 Bildpunkte = 3,4 m neben der Haltlinie - ohne // Deckelung raste der Anfangspunkt darauf ein, und mit ihm der Nullpunkt // von Raeum- und Einfahrweg. const { wurzel, aufraeumen } = baueAnsicht(planMit(2000, 7000)); try { zeichnePunkt(wurzel, 130, 212); expect(wurzel.textContent).toContain('keiner Zufahrt zugeordnet'); } finally { aufraeumen(); } }); it('faengt bei grobem Ausschnitt weiterhin aus 1,4 m Entfernung', () => { // 5 Bildpunkte = 1,4 m: innerhalb der 2,5 m, das Einrasten bleibt bequem. const { wurzel, aufraeumen } = baueAnsicht(planMit(2000, 7000)); try { zeichnePunkt(wurzel, 130, 205); expect(wurzel.textContent).toContain('Beginnt an der Haltlinie'); } finally { aufraeumen(); } }); it('nimmt bei feinem Ausschnitt nichts vom bisherigen Fang weg', () => { // 200 m auf 2000 Bildpunkte = 10 cm je Bildpunkt: 2,5 m waeren 25 // Bildpunkte, es bleibt bei den 15 Bildschirmpunkten. Der Deckel wirkt nur // nach oben - sonst haette die Deckelung die Bedienung verschlechtert. const { wurzel, aufraeumen } = baueAnsicht(planMit(200, 2000)); try { zeichnePunkt(wurzel, 130, 212); expect(wurzel.textContent).toContain('Beginnt an der Haltlinie'); } finally { aufraeumen(); } }); });