lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests ui lageplanZeichnung.test.ts

11,0 KB Rohdatei
tests/ui/lageplanZeichnung.test.ts — 338 Zeilen
1 import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2 import * as actions from '@/app/actions';
3 import { ProjectStore } from '@/app/store';
4 import type { Lageplan, LageplanBild } from '@/domain/geometrie/lageplan';
5 import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent';
6 import { START_SICHT, zeichne } from '@/ui/lageplan/leinwand';
7 import { lageplanView } from '@/ui/views/lageplanView';
8
9 /**
10 * Zeichnung des Lageplans: Reihenfolge der Lagen und Zustand der Einstiegskarte.
11 *
12 * Beides sind Zusagen, die man der Zeichnung nicht ansieht, ohne sie zu
13 * betrachten - und die deshalb still zerfallen. Geprueft wird gegen einen
14 * mitschreibenden Zeichenkontext, nicht gegen ein Bild: Was gezeichnet wurde,
15 * ist eine Folge von Anweisungen, und genau ihre Reihenfolge ist hier die
16 * Aussage.
17 */
18
19 const FESTES_DATUM = '2026-01-01T00:00:00.000Z';
20
21 /** Farbe des weissen Casings - der Kennwert, an dem es sich erkennen laesst. */
22 const CASING = 'rgba(255,255,255,0.92)';
23 /** Farbe der Haltlinien aus leinwand.ts. */
24 const HALTLINIE = '#111827';
25 /** Farbe der Kernlinie einer Kfz-Fahrlinie. */
26 const KFZ = '#2f6fd0';
27
28 interface Strich {
29 /** Wurde ein vorbereiteter Pfad gestrichen (Fahrlinie) oder der laufende? */
30 readonly ausPfad: boolean;
31 readonly farbe: string;
32 readonly breite: number;
33 readonly abschluss: string;
34 readonly deckkraft: number;
35 }
36
37 /**
38 * Zeichenkontext, der jeden Strich mitschreibt.
39 *
40 * jsdom bringt keine Zeichenflaeche mit; ein echter Kontext waere hier aber
41 * auch nicht hilfreich - aus Bildpunkten liesse sich die Reihenfolge nur
42 * erraten.
43 */
44 class Aufzeichner {
45 readonly striche: Strich[] = [];
46 strokeStyle: string = '';
47 fillStyle: string = '';
48 lineWidth = 0;
49 lineCap = 'butt';
50 lineJoin = 'miter';
51 globalAlpha = 1;
52 font = '';
53 textAlign = 'left';
54 imageSmoothingQuality = 'low';
55
56 save(): void {}
57 restore(): void {}
58 clearRect(): void {}
59 fillRect(): void {}
60 strokeRect(): void {}
61 drawImage(): void {}
62 beginPath(): void {}
63 moveTo(): void {}
64 lineTo(): void {}
65 arc(): void {}
66 fill(): void {}
67 setLineDash(): void {}
68 fillText(): void {}
69 measureText(): { width: number } {
70 return { width: 24 };
71 }
72
73 stroke(pfad?: unknown): void {
74 this.striche.push({
75 ausPfad: pfad !== undefined,
76 farbe: String(this.strokeStyle),
77 breite: this.lineWidth,
78 abschluss: this.lineCap,
79 deckkraft: this.globalAlpha,
80 });
81 }
82 }
83
84 /** Ein Lageplan mit einer Haltlinie und drei Stroemen darauf. */
85 function planMitZufahrt(): Lageplan {
86 const haltlinie = {
87 id: 'hl1',
88 name: 'Nord',
89 art: 'zufahrt' as const,
90 armId: null,
91 von: { x: 100, y: 200 },
92 bis: { x: 160, y: 200 },
93 };
94 const linie = (id: string, ziel: { x: number; y: number }, t: number) => ({
95 id,
96 name: id,
97 mode: 'kfz' as const,
98 movement: 'geradeaus' as const,
99 breiteMeter: 3.25,
100 punkte: [{ x: 100 + 60 * t, y: 200 }, ziel],
101 haltlinieId: 'hl1',
102 startT: t,
103 signalGroupId: null,
104 });
105 return {
106 arbeitsbereiche: [],
107 signalgeber: [],
108 bild: null,
109 kalibrierung: {
110 von: { x: 0, y: 0 },
111 bis: { x: 100, y: 0 },
112 laengeMeter: 20,
113 herkunft: 'gemessen',
114 },
115 haltlinien: [haltlinie],
116 linien: [
117 linie('pl1', { x: 130, y: 40 }, 0.2),
118 linie('pl2', { x: 40, y: 120 }, 0.5),
119 linie('pl3', { x: 240, y: 120 }, 0.8),
120 ],
121 };
122 }
123
124 function zeichneAuf(): Strich[] {
125 const ctx = new Aufzeichner();
126 zeichne(ctx as unknown as CanvasRenderingContext2D, 800, 500, {
127 lageplan: planMitZufahrt(),
128 sicht: START_SICHT,
129 bild: { naturalWidth: 400, naturalHeight: 300 } as unknown as HTMLImageElement,
130 aktiveLinieId: null,
131 aktiveHaltlinieId: null,
132 haltlinienStart: null,
133 entwurf: [],
134 kalibrierStart: null,
135 zeiger: null,
136 dunkel: false,
137 });
138 return ctx.striche;
139 }
140
141 /** Letzter Strich, auf den die Bedingung zutrifft; -1, wenn keiner. */
142 function letzterIndex(striche: readonly Strich[], trifftZu: (s: Strich) => boolean): number {
143 for (let i = striche.length - 1; i >= 0; i -= 1) {
144 if (trifftZu(striche[i]!)) return i;
145 }
146 return -1;
147 }
148
149 describe('Reihenfolge der Lagen in der Zeichnung', () => {
150 const urspruenglich = (globalThis as { Path2D?: unknown }).Path2D;
151
152 beforeAll(() => {
153 // jsdom kennt Path2D nicht; gebraucht wird hier nur der Behaelter, nicht
154 // seine Geometrie - die Zeichnung wird nicht dargestellt, sondern gezaehlt.
155 (globalThis as { Path2D?: unknown }).Path2D = class {
156 moveTo(): void {}
157 lineTo(): void {}
158 };
159 });
160
161 afterAll(() => {
162 (globalThis as { Path2D?: unknown }).Path2D = urspruenglich;
163 });
164
165 it('streicht jedes Casing vor jeder Haltlinie', () => {
166 // Das Casing ist deckendes Weiss. Wird es nach der Haltlinie gestrichen,
167 // loescht es sie am Anschlusspunkt - genau dort, wo es den Anschluss
168 // sichtbar halten soll.
169 const striche = zeichneAuf();
170 const letztesCasing = letzterIndex(striche, (s) => s.farbe === CASING);
171 const ersteHaltlinie = striche.findIndex((s) => s.farbe === HALTLINIE && !s.ausPfad);
172
173 expect(letztesCasing).toBeGreaterThanOrEqual(0);
174 expect(ersteHaltlinie).toBeGreaterThanOrEqual(0);
175 expect(letztesCasing).toBeLessThan(ersteHaltlinie);
176 });
177
178 it('streicht jede Kernlinie nach jeder Haltlinie', () => {
179 // Die Haltlinie ist die Grundlage, die Fahrlinie das Ergebnis: Der Anschluss
180 // gehoert obenauf, sonst deckt der Balken den Anfang der Linie zu.
181 const striche = zeichneAuf();
182 const letzteHaltlinie = letzterIndex(striche, (s) => s.farbe === HALTLINIE && !s.ausPfad);
183 const ersteKernlinie = striche.findIndex(
184 (s) => s.farbe === KFZ && s.ausPfad && s.deckkraft === 1,
185 );
186
187 expect(ersteKernlinie).toBeGreaterThan(letzteHaltlinie);
188 });
189
190 it('schliesst das Casing stumpf ab', () => {
191 // Ein runder Abschluss ragt um die halbe Strichstaerke ueber den ersten
192 // Punkt hinaus, also quer ueber die Haltlinie, auf der dieser Punkt liegt.
193 const casings = zeichneAuf().filter((s) => s.farbe === CASING);
194 expect(casings).toHaveLength(3);
195 expect(casings.every((s) => s.abschluss === 'butt')).toBe(true);
196 });
197
198 it('streicht je Fahrlinie genau ein Casing und einen Kern', () => {
199 const striche = zeichneAuf();
200 expect(striche.filter((s) => s.farbe === CASING)).toHaveLength(3);
201 expect(striche.filter((s) => s.farbe === KFZ && s.ausPfad && s.deckkraft === 1)).toHaveLength(
202 3,
203 );
204 });
205 });
206
207 describe('Einstiegskarte des Lageplans', () => {
208 const BILD: LageplanBild = {
209 datenUrl: 'data:image/png;base64,iVBORw0KGgo=',
210 breite: 400,
211 hoehe: 300,
212 herkunft: 'Pruefbild',
213 geladenAm: FESTES_DATUM,
214 };
215
216 const NAME = 'Lageplan – noch kein Bild hinterlegt';
217
218 it('verschwindet, sobald ein Bild hinterlegt ist', () => {
219 // Die Karte wurde einmal beim Aufbau berechnet. Danach meldete eine
220 // Sprachausgabe den Bereich weiter als leer, obwohl das Luftbild dastand.
221 const store = new ProjectStore(projektAusAngaben({ ...ASSISTENT_VORGABE }, FESTES_DATUM));
222 const wurzel = document.createElement('div');
223 document.body.append(wurzel);
224 const aufraeumen = lageplanView.render(wurzel, {
225 store,
226 refresh: () => {},
227 navigate: () => {},
228 target: null,
229 });
230
231 try {
232 expect(wurzel.querySelector(`[aria-label="${NAME}"]`)).not.toBeNull();
233
234 store.update((p) => actions.setLageplan(p, { ...p.lageplan, bild: BILD }), {
235 label: 'Bild hinterlegt',
236 });
237 // Irgendein Neuzeichnen der Ansicht - hier der Klick auf die
238 // Zeichenflaeche im Werkzeug "Ansehen".
239 wurzel
240 .querySelector('canvas')
241 ?.dispatchEvent(new MouseEvent('click', { button: 0, detail: 1 }));
242
243 expect(wurzel.querySelector(`[aria-label="${NAME}"]`)).toBeNull();
244 } finally {
245 aufraeumen();
246 wurzel.remove();
247 }
248 });
249 });
250
251 /**
252 * Die Beschriftung folgt der Schriftgroesse der Oberflaeche
253 * (EN 301 549 Kap. 11.7, Befund L13).
254 *
255 * Eine Leinwand erbt die Schriftgroesse nicht - sie muss sie erfragen. Hier
256 * stand sie fest verdrahtet: 14 px fuer den Hinweis "Kein Lageplan geladen",
257 * 12 px fuer Massstabsbalken und Linienbeschriftungen. Wer die Grundschrift
258 * hochstellt - der uebliche Weg bei Sehbehinderung -, bekam eine Oberflaeche,
259 * in der jeder Text mitwuchs, nur die Namen der Fahrlinien nicht; sie waren
260 * danach die kleinste Schrift der Seite.
261 */
262 describe('Lageplan: die Beschriftung folgt der eingestellten Schriftgroesse (Befund L13)', () => {
263 const urspruenglich = (globalThis as { Path2D?: unknown }).Path2D;
264
265 beforeAll(() => {
266 (globalThis as { Path2D?: unknown }).Path2D = class {
267 moveTo(): void {}
268 lineTo(): void {}
269 };
270 });
271
272 afterAll(() => {
273 (globalThis as { Path2D?: unknown }).Path2D = urspruenglich;
274 });
275
276 /** Alle Schriftangaben, die beim Zeichnen gesetzt wurden. */
277 function schriften(schriftgroesse?: number, mitBild = true): number[] {
278 const gesetzt: number[] = [];
279 const ctx = new Aufzeichner();
280 Object.defineProperty(ctx, 'font', {
281 get: () => '',
282 set: (wert: string) => {
283 const treffer = /(\d+)px/.exec(wert);
284 if (treffer !== null) gesetzt.push(Number(treffer[1]));
285 },
286 });
287 zeichne(ctx as unknown as CanvasRenderingContext2D, 800, 500, {
288 lageplan: planMitZufahrt(),
289 sicht: START_SICHT,
290 bild: mitBild
291 ? ({ naturalWidth: 400, naturalHeight: 300 } as unknown as HTMLImageElement)
292 : null,
293 aktiveLinieId: null,
294 aktiveHaltlinieId: null,
295 haltlinienStart: null,
296 entwurf: [],
297 kalibrierStart: null,
298 zeiger: null,
299 ...(schriftgroesse === undefined ? {} : { schriftgroesse }),
300 dunkel: false,
301 });
302 return gesetzt;
303 }
304
305 it('behaelt die bisherigen Groessen, solange die Grundschrift steht', () => {
306 const ohne = schriften();
307 const bei16 = schriften(16);
308 expect(ohne.length, 'es wurde gar nichts beschriftet').toBeGreaterThan(0);
309 expect(bei16).toEqual(ohne);
310 expect(new Set(ohne)).toEqual(new Set([12]));
311 });
312
313 it('waechst mit der Grundschrift - Beschriftung, Massstabsbalken und Hinweis', () => {
314 const gross = schriften(24);
315 expect(gross.length).toBe(schriften(16).length);
316 for (const [i, wert] of gross.entries()) {
317 expect(wert, `Schrift ${i}`).toBeGreaterThan(12);
318 }
319
320 // Auch der Hinweis auf der leeren Flaeche, der mit 14 px groesser ansetzt.
321 expect(schriften(24, false)[0]).toBeGreaterThan(schriften(16, false)[0]!);
322 });
323
324 it('verkleinert nicht und waechst nicht ins Uferlose', () => {
325 // Nach unten nicht: Die Beschriftung ist schon am unteren Rand des
326 // Lesbaren. Nach oben begrenzt: Die Namen stehen auf Platten ueber dem
327 // Luftbild und verdecken ab etwa dem Doppelten die Geometrie, die sie
328 // beschriften.
329 expect(schriften(9)).toEqual(schriften(16));
330 const sehrGross = schriften(64);
331 for (const wert of sehrGross) expect(wert).toBeLessThanOrEqual(21);
332 });
333
334 it('haelt eine unbrauchbare Angabe von der Zeichnung fern', () => {
335 expect(schriften(Number.NaN)).toEqual(schriften(16));
336 expect(schriften(0)).toEqual(schriften(16));
337 });
338 });