lsa-planer

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

/ tests ui arbeitsbereichZeichnen.test.ts

9,0 KB Rohdatei
tests/ui/arbeitsbereichZeichnen.test.ts — 265 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { createEmptyProject } from '@/domain/model/factory';
4 import { LEERER_LAGEPLAN, type Lageplan } from '@/domain/geometrie/lageplan';
5 import type { Project } from '@/domain/model/project';
6 import { lageplanView } from '@/ui/views/lageplanView';
7
8 /**
9 * Der Arbeitsbereich in der Lageplanansicht.
10 *
11 * Geprüft wird das Werkzeug, die Tabelle und die Messkarte - nicht die
12 * Zeichnung selbst; die steht in tests/render/arbeitsbereichBlatt.test.ts und
13 * in der Leinwand, die unter jsdom keinen Zeichenkontext hat.
14 */
15
16 const JETZT = new Date('2026-09-11T00:00:00Z');
17
18 const KALIBRIERUNG = {
19 von: { x: 0, y: 0 },
20 bis: { x: 100, y: 0 },
21 laengeMeter: 100,
22 herkunft: 'gemessen' as const,
23 };
24
25 const BAND = {
26 id: 'ab1',
27 name: 'Sperrung Nord',
28 breiteMeter: 4,
29 punkte: [
30 { x: 0, y: 0 },
31 { x: 60, y: 0 },
32 ],
33 };
34
35 const HALTLINIE = {
36 id: 'h1',
37 name: 'Ost',
38 art: 'zufahrt' as const,
39 armId: null,
40 von: { x: 70, y: -3 },
41 bis: { x: 70, y: 3 },
42 };
43
44 function projekt(lageplan: Partial<Lageplan> = {}): Project {
45 return {
46 ...createEmptyProject('Baustelle', JETZT, 'einstreifig'),
47 lageplan: { ...LEERER_LAGEPLAN, kalibrierung: KALIBRIERUNG, ...lageplan },
48 };
49 }
50
51 let aufraeumen: () => void = () => {};
52
53 function zeichne(project: Project) {
54 const wurzel = document.createElement('div');
55 document.body.append(wurzel);
56 const store = new ProjectStore(project);
57 const abmelden = lageplanView.render(wurzel, {
58 store,
59 refresh: () => {},
60 navigate: () => {},
61 target: null,
62 });
63 aufraeumen = () => {
64 abmelden();
65 wurzel.remove();
66 };
67 return { wurzel, store };
68 }
69
70 function knopf(wurzel: ParentNode, beschriftung: string): HTMLButtonElement | null {
71 return (
72 [...wurzel.querySelectorAll('button')].find((b) => b.textContent?.trim() === beschriftung) ??
73 null
74 );
75 }
76
77 afterEach(() => {
78 aufraeumen();
79 document.body.replaceChildren();
80 });
81
82 describe('Das fünfte Werkzeug', () => {
83 it('gibt es einen Knopf "Arbeitsbereich"', () => {
84 const { wurzel } = zeichne(projekt());
85 expect(knopf(wurzel, 'Arbeitsbereich')).not.toBeNull();
86 });
87
88 it('ist er gesperrt, solange kein Maßstab festgelegt ist', () => {
89 // Ohne Maßstab hat ein Band in Metern keine Breite.
90 const { wurzel } = zeichne({
91 ...createEmptyProject('Ohne Maßstab', JETZT),
92 lageplan: LEERER_LAGEPLAN,
93 });
94 expect(knopf(wurzel, 'Arbeitsbereich')?.disabled).toBe(true);
95 });
96
97 it('ist er frei, sobald der Maßstab steht', () => {
98 const { wurzel } = zeichne(projekt());
99 expect(knopf(wurzel, 'Arbeitsbereich')?.disabled).toBe(false);
100 });
101
102 it('sagt der Hinweis, dass er nicht mitrechnet', () => {
103 const { wurzel } = zeichne(projekt());
104 knopf(wurzel, 'Arbeitsbereich')?.click();
105 const text = wurzel.textContent ?? '';
106 expect(text).toContain('geht in keine Zwischenzeit');
107 });
108 });
109
110 describe('Die Tabelle', () => {
111 it('sagt sie, wenn noch nichts gezeichnet ist', () => {
112 const { wurzel } = zeichne(projekt());
113 expect(wurzel.textContent).toContain('Arbeitsbereiche (0)');
114 expect(wurzel.textContent).toContain('Noch kein Arbeitsbereich');
115 });
116
117 it('führt sie ein gezeichnetes Band mit Name und Länge', () => {
118 const { wurzel } = zeichne(projekt({ arbeitsbereiche: [BAND] }));
119 expect(wurzel.textContent).toContain('Arbeitsbereiche (1)');
120 // Der Name steht in einem Eingabefeld und nicht im Text der Seite.
121 const namen = [...wurzel.querySelectorAll('input')].map((i) => i.value);
122 expect(namen).toContain('Sperrung Nord');
123 // 60 m bei einem Meter je Bildpunkt.
124 expect(wurzel.textContent).toContain('60,0 m');
125 });
126
127 it('löscht ein Band auf Knopfdruck', () => {
128 const { wurzel, store } = zeichne(projekt({ arbeitsbereiche: [BAND] }));
129 const loeschen = [...wurzel.querySelectorAll('button')].find(
130 (b) => b.getAttribute('aria-label') === 'Arbeitsbereich "Sperrung Nord" löschen',
131 );
132 expect(loeschen, 'Kein Löschknopf').not.toBeUndefined();
133 loeschen?.click();
134 expect(store.getProject().lageplan.arbeitsbereiche).toEqual([]);
135 });
136 });
137
138 describe('Die Messung unter der Tabelle', () => {
139 it('nennt sie den Abstand jeder Haltlinie', () => {
140 // Haltlinie bei x = 70, Band endet bei x = 60: 10 m.
141 const { wurzel } = zeichne(projekt({ arbeitsbereiche: [BAND], haltlinien: [HALTLINIE] }));
142 const text = wurzel.textContent ?? '';
143 expect(text).toContain('Abstand der Haltlinien zum nächsten Ende');
144 expect(text).toContain('10,0 m');
145 });
146
147 it('sagt sie, wenn eine Haltlinie im Arbeitsbereich liegt', () => {
148 const drin = { ...HALTLINIE, von: { x: 30, y: -3 }, bis: { x: 30, y: 3 } };
149 const { wurzel } = zeichne(projekt({ arbeitsbereiche: [BAND], haltlinien: [drin] }));
150 expect(wurzel.textContent).toContain('liegt im Arbeitsbereich');
151 });
152
153 it('schweigt sie ohne Haltlinien', () => {
154 const { wurzel } = zeichne(projekt({ arbeitsbereiche: [BAND] }));
155 expect(wurzel.textContent).not.toContain('Abstand der Haltlinien');
156 });
157 });
158
159 describe('Zeichnen mit der Tastatur', () => {
160 /** Ein Bild, damit die Zeichenfläche eine Größe hat. */
161 const BILD = {
162 datenUrl: 'data:image/png;base64,AAAA',
163 breite: 320,
164 hoehe: 240,
165 herkunft: 'datei' as const,
166 geladenAm: '2026-09-11T00:00:00.000Z',
167 };
168
169 function taste(leinwand: Element, key: string, male = 1): void {
170 for (let i = 0; i < male; i += 1) {
171 leinwand.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
172 }
173 }
174
175 function baue() {
176 const { wurzel, store } = zeichne(projekt({ bild: BILD }));
177 const leinwand = wurzel.querySelector('canvas');
178 if (leinwand === null) throw new Error('Keine Zeichenfläche');
179 return { wurzel, store, leinwand };
180 }
181
182 it('legt ein Band aus zwei Punkten an', () => {
183 const { wurzel, store, leinwand } = baue();
184 knopf(wurzel, 'Arbeitsbereich')?.click();
185 taste(leinwand, 'ArrowRight', 4);
186 taste(leinwand, ' ');
187 taste(leinwand, 'ArrowRight', 8);
188 taste(leinwand, ' ');
189 taste(leinwand, 'Enter');
190
191 const bereiche = store.getProject().lageplan.arbeitsbereiche;
192 expect(bereiche).toHaveLength(1);
193 expect(bereiche[0]?.punkte).toHaveLength(2);
194 expect(bereiche[0]?.name).toBe('Arbeitsbereich 1');
195 });
196
197 it('rastet der erste Punkt nicht auf eine Haltlinie ein', () => {
198 // Die Arbeitsstelle beginnt an der Absperrung, nicht an einem Querschnitt
199 // der Fahrbahn. Rastete sie ein, richtete sich das Band an der Haltlinie
200 // aus - und der Bearbeiter danach die Haltlinie am Band.
201 const { wurzel } = zeichne(projekt({ bild: BILD, haltlinien: [HALTLINIE] }));
202 const leinwand = wurzel.querySelector('canvas')!;
203 knopf(wurzel, 'Arbeitsbereich')?.click();
204 taste(leinwand, ' ');
205 const ansage = wurzel.textContent ?? '';
206 expect(ansage).toContain('Erster Punkt des Arbeitsbereichs gesetzt');
207 expect(ansage).not.toContain('eingerastet');
208 });
209
210 it('nimmt die Rückschritttaste den letzten Punkt zurück', () => {
211 const { wurzel, store, leinwand } = baue();
212 knopf(wurzel, 'Arbeitsbereich')?.click();
213 taste(leinwand, ' ');
214 taste(leinwand, 'ArrowRight', 4);
215 taste(leinwand, ' ');
216 taste(leinwand, 'Backspace');
217 taste(leinwand, 'Enter');
218 // Ein Punkt reicht nicht - es entsteht nichts.
219 expect(store.getProject().lageplan.arbeitsbereiche).toEqual([]);
220 });
221
222 it('verwirft der Werkzeugwechsel den halben Zug', () => {
223 const { wurzel, store, leinwand } = baue();
224 knopf(wurzel, 'Arbeitsbereich')?.click();
225 taste(leinwand, ' ');
226 knopf(wurzel, 'Ansehen')?.click();
227 knopf(wurzel, 'Arbeitsbereich')?.click();
228 taste(leinwand, 'ArrowRight', 4);
229 taste(leinwand, ' ');
230 taste(leinwand, 'Enter');
231 // Der Punkt von vorhin ist fort, also fehlt der zweite.
232 expect(store.getProject().lageplan.arbeitsbereiche).toEqual([]);
233 });
234
235 it('beendet der Doppelklick das Band ebenfalls', () => {
236 const { wurzel, store, leinwand } = baue();
237 knopf(wurzel, 'Arbeitsbereich')?.click();
238 taste(leinwand, ' ');
239 taste(leinwand, 'ArrowRight', 4);
240 taste(leinwand, ' ');
241 leinwand.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
242 expect(store.getProject().lageplan.arbeitsbereiche).toHaveLength(1);
243 });
244
245 it('bricht Escape ab und schaltet auf Ansehen', () => {
246 const { wurzel, store, leinwand } = baue();
247 knopf(wurzel, 'Arbeitsbereich')?.click();
248 taste(leinwand, ' ');
249 taste(leinwand, 'Escape');
250 taste(leinwand, 'Enter');
251 expect(store.getProject().lageplan.arbeitsbereiche).toEqual([]);
252 });
253 });
254
255 describe('Die übrigen Tabellen bleiben stehen', () => {
256 it('stehen Haltlinien und Fahrlinien weiterhin daneben', () => {
257 // Die drei Karten hängen an EINEM render-Aufruf; ein zweiter löschte die
258 // beiden ersten.
259 const { wurzel } = zeichne(projekt({ arbeitsbereiche: [BAND], haltlinien: [HALTLINIE] }));
260 const text = wurzel.textContent ?? '';
261 expect(text).toContain('Haltlinien (1)');
262 expect(text).toContain('Fahrlinien (0)');
263 expect(text).toContain('Arbeitsbereiche (1)');
264 });
265 });