import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as actions from '@/app/actions'; import { ProjectStore } from '@/app/store'; import type { Lageplan, LageplanBild } from '@/domain/geometrie/lageplan'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import { START_SICHT, zeichne } from '@/ui/lageplan/leinwand'; import { lageplanView } from '@/ui/views/lageplanView'; /** * Zeichnung des Lageplans: Reihenfolge der Lagen und Zustand der Einstiegskarte. * * Beides sind Zusagen, die man der Zeichnung nicht ansieht, ohne sie zu * betrachten - und die deshalb still zerfallen. Geprueft wird gegen einen * mitschreibenden Zeichenkontext, nicht gegen ein Bild: Was gezeichnet wurde, * ist eine Folge von Anweisungen, und genau ihre Reihenfolge ist hier die * Aussage. */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; /** Farbe des weissen Casings - der Kennwert, an dem es sich erkennen laesst. */ const CASING = 'rgba(255,255,255,0.92)'; /** Farbe der Haltlinien aus leinwand.ts. */ const HALTLINIE = '#111827'; /** Farbe der Kernlinie einer Kfz-Fahrlinie. */ const KFZ = '#2f6fd0'; interface Strich { /** Wurde ein vorbereiteter Pfad gestrichen (Fahrlinie) oder der laufende? */ readonly ausPfad: boolean; readonly farbe: string; readonly breite: number; readonly abschluss: string; readonly deckkraft: number; } /** * Zeichenkontext, der jeden Strich mitschreibt. * * jsdom bringt keine Zeichenflaeche mit; ein echter Kontext waere hier aber * auch nicht hilfreich - aus Bildpunkten liesse sich die Reihenfolge nur * erraten. */ class Aufzeichner { readonly striche: Strich[] = []; strokeStyle: string = ''; fillStyle: string = ''; lineWidth = 0; lineCap = 'butt'; lineJoin = 'miter'; globalAlpha = 1; font = ''; textAlign = 'left'; imageSmoothingQuality = 'low'; save(): void {} restore(): void {} clearRect(): void {} fillRect(): void {} strokeRect(): void {} drawImage(): void {} beginPath(): void {} moveTo(): void {} lineTo(): void {} arc(): void {} fill(): void {} setLineDash(): void {} fillText(): void {} measureText(): { width: number } { return { width: 24 }; } stroke(pfad?: unknown): void { this.striche.push({ ausPfad: pfad !== undefined, farbe: String(this.strokeStyle), breite: this.lineWidth, abschluss: this.lineCap, deckkraft: this.globalAlpha, }); } } /** Ein Lageplan mit einer Haltlinie und drei Stroemen darauf. */ function planMitZufahrt(): Lageplan { const haltlinie = { id: 'hl1', name: 'Nord', art: 'zufahrt' as const, armId: null, von: { x: 100, y: 200 }, bis: { x: 160, y: 200 }, }; const linie = (id: string, ziel: { x: number; y: number }, t: number) => ({ id, name: id, mode: 'kfz' as const, movement: 'geradeaus' as const, breiteMeter: 3.25, punkte: [{ x: 100 + 60 * t, y: 200 }, ziel], haltlinieId: 'hl1', startT: t, signalGroupId: null, }); return { arbeitsbereiche: [], signalgeber: [], bild: null, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: 20, herkunft: 'gemessen', }, haltlinien: [haltlinie], linien: [ linie('pl1', { x: 130, y: 40 }, 0.2), linie('pl2', { x: 40, y: 120 }, 0.5), linie('pl3', { x: 240, y: 120 }, 0.8), ], }; } function zeichneAuf(): Strich[] { const ctx = new Aufzeichner(); zeichne(ctx as unknown as CanvasRenderingContext2D, 800, 500, { lageplan: planMitZufahrt(), 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.striche; } /** Letzter Strich, auf den die Bedingung zutrifft; -1, wenn keiner. */ function letzterIndex(striche: readonly Strich[], trifftZu: (s: Strich) => boolean): number { for (let i = striche.length - 1; i >= 0; i -= 1) { if (trifftZu(striche[i]!)) return i; } return -1; } describe('Reihenfolge der Lagen in der Zeichnung', () => { const urspruenglich = (globalThis as { Path2D?: unknown }).Path2D; beforeAll(() => { // jsdom kennt Path2D nicht; gebraucht wird hier nur der Behaelter, nicht // seine Geometrie - die Zeichnung wird nicht dargestellt, sondern gezaehlt. (globalThis as { Path2D?: unknown }).Path2D = class { moveTo(): void {} lineTo(): void {} }; }); afterAll(() => { (globalThis as { Path2D?: unknown }).Path2D = urspruenglich; }); it('streicht jedes Casing vor jeder Haltlinie', () => { // Das Casing ist deckendes Weiss. Wird es nach der Haltlinie gestrichen, // loescht es sie am Anschlusspunkt - genau dort, wo es den Anschluss // sichtbar halten soll. const striche = zeichneAuf(); const letztesCasing = letzterIndex(striche, (s) => s.farbe === CASING); const ersteHaltlinie = striche.findIndex((s) => s.farbe === HALTLINIE && !s.ausPfad); expect(letztesCasing).toBeGreaterThanOrEqual(0); expect(ersteHaltlinie).toBeGreaterThanOrEqual(0); expect(letztesCasing).toBeLessThan(ersteHaltlinie); }); it('streicht jede Kernlinie nach jeder Haltlinie', () => { // Die Haltlinie ist die Grundlage, die Fahrlinie das Ergebnis: Der Anschluss // gehoert obenauf, sonst deckt der Balken den Anfang der Linie zu. const striche = zeichneAuf(); const letzteHaltlinie = letzterIndex(striche, (s) => s.farbe === HALTLINIE && !s.ausPfad); const ersteKernlinie = striche.findIndex( (s) => s.farbe === KFZ && s.ausPfad && s.deckkraft === 1, ); expect(ersteKernlinie).toBeGreaterThan(letzteHaltlinie); }); it('schliesst das Casing stumpf ab', () => { // Ein runder Abschluss ragt um die halbe Strichstaerke ueber den ersten // Punkt hinaus, also quer ueber die Haltlinie, auf der dieser Punkt liegt. const casings = zeichneAuf().filter((s) => s.farbe === CASING); expect(casings).toHaveLength(3); expect(casings.every((s) => s.abschluss === 'butt')).toBe(true); }); it('streicht je Fahrlinie genau ein Casing und einen Kern', () => { const striche = zeichneAuf(); expect(striche.filter((s) => s.farbe === CASING)).toHaveLength(3); expect(striche.filter((s) => s.farbe === KFZ && s.ausPfad && s.deckkraft === 1)).toHaveLength( 3, ); }); }); describe('Einstiegskarte des Lageplans', () => { const BILD: LageplanBild = { datenUrl: 'data:image/png;base64,iVBORw0KGgo=', breite: 400, hoehe: 300, herkunft: 'Pruefbild', geladenAm: FESTES_DATUM, }; const NAME = 'Lageplan – noch kein Bild hinterlegt'; it('verschwindet, sobald ein Bild hinterlegt ist', () => { // Die Karte wurde einmal beim Aufbau berechnet. Danach meldete eine // Sprachausgabe den Bereich weiter als leer, obwohl das Luftbild dastand. const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM)); const wurzel = document.createElement('div'); document.body.append(wurzel); const aufraeumen = lageplanView.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); try { expect(wurzel.querySelector(`[aria-label="${NAME}"]`)).not.toBeNull(); store.update((p) => actions.setLageplan(p, { ...p.lageplan, bild: BILD }), { label: 'Bild hinterlegt', }); // Irgendein Neuzeichnen der Ansicht - hier der Klick auf die // Zeichenflaeche im Werkzeug "Ansehen". wurzel .querySelector('canvas') ?.dispatchEvent(new MouseEvent('click', { button: 0, detail: 1 })); expect(wurzel.querySelector(`[aria-label="${NAME}"]`)).toBeNull(); } finally { aufraeumen(); wurzel.remove(); } }); }); /** * Die Beschriftung folgt der Schriftgroesse der Oberflaeche * (EN 301 549 Kap. 11.7, Befund L13). * * Eine Leinwand erbt die Schriftgroesse nicht - sie muss sie erfragen. Hier * stand sie fest verdrahtet: 14 px fuer den Hinweis "Kein Lageplan geladen", * 12 px fuer Massstabsbalken und Linienbeschriftungen. Wer die Grundschrift * hochstellt - der uebliche Weg bei Sehbehinderung -, bekam eine Oberflaeche, * in der jeder Text mitwuchs, nur die Namen der Fahrlinien nicht; sie waren * danach die kleinste Schrift der Seite. */ describe('Lageplan: die Beschriftung folgt der eingestellten Schriftgroesse (Befund L13)', () => { const urspruenglich = (globalThis as { Path2D?: unknown }).Path2D; beforeAll(() => { (globalThis as { Path2D?: unknown }).Path2D = class { moveTo(): void {} lineTo(): void {} }; }); afterAll(() => { (globalThis as { Path2D?: unknown }).Path2D = urspruenglich; }); /** Alle Schriftangaben, die beim Zeichnen gesetzt wurden. */ function schriften(schriftgroesse?: number, mitBild = true): number[] { const gesetzt: number[] = []; const ctx = new Aufzeichner(); Object.defineProperty(ctx, 'font', { get: () => '', set: (wert: string) => { const treffer = /(\d+)px/.exec(wert); if (treffer !== null) gesetzt.push(Number(treffer[1])); }, }); zeichne(ctx as unknown as CanvasRenderingContext2D, 800, 500, { lageplan: planMitZufahrt(), sicht: START_SICHT, bild: mitBild ? ({ naturalWidth: 400, naturalHeight: 300 } as unknown as HTMLImageElement) : null, aktiveLinieId: null, aktiveHaltlinieId: null, haltlinienStart: null, entwurf: [], kalibrierStart: null, zeiger: null, ...(schriftgroesse === undefined ? {} : { schriftgroesse }), dunkel: false, }); return gesetzt; } it('behaelt die bisherigen Groessen, solange die Grundschrift steht', () => { const ohne = schriften(); const bei16 = schriften(16); expect(ohne.length, 'es wurde gar nichts beschriftet').toBeGreaterThan(0); expect(bei16).toEqual(ohne); expect(new Set(ohne)).toEqual(new Set([12])); }); it('waechst mit der Grundschrift - Beschriftung, Massstabsbalken und Hinweis', () => { const gross = schriften(24); expect(gross.length).toBe(schriften(16).length); for (const [i, wert] of gross.entries()) { expect(wert, `Schrift ${i}`).toBeGreaterThan(12); } // Auch der Hinweis auf der leeren Flaeche, der mit 14 px groesser ansetzt. expect(schriften(24, false)[0]).toBeGreaterThan(schriften(16, false)[0]!); }); it('verkleinert nicht und waechst nicht ins Uferlose', () => { // Nach unten nicht: Die Beschriftung ist schon am unteren Rand des // Lesbaren. Nach oben begrenzt: Die Namen stehen auf Platten ueber dem // Luftbild und verdecken ab etwa dem Doppelten die Geometrie, die sie // beschriften. expect(schriften(9)).toEqual(schriften(16)); const sehrGross = schriften(64); for (const wert of sehrGross) expect(wert).toBeLessThanOrEqual(21); }); it('haelt eine unbrauchbare Angabe von der Zeichnung fern', () => { expect(schriften(Number.NaN)).toEqual(schriften(16)); expect(schriften(0)).toEqual(schriften(16)); }); });