lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui arbeitsbereichZeichnen.test.ts
| 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 | }); |