lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui konfliktmatrix.test.ts
| 1 | import { afterEach, describe, expect, it } from 'vitest'; |
| 2 | import { ProjectStore } from '@/app/store'; |
| 3 | import * as actions from '@/app/actions'; |
| 4 | import { createEmptyProject, createStandardIntersectionProject } from '@/domain/model/factory'; |
| 5 | import type { Conflict, Project } from '@/domain/model/project'; |
| 6 | import { validateProject } from '@/domain/validation'; |
| 7 | import { buildSignalPlan } from '@/domain/plan/signalPlan'; |
| 8 | import { mitBewahrtemFokus } from '@/ui/fokus'; |
| 9 | import type { ViewContext } from '@/ui/shell'; |
| 10 | import { conflictsView } from '@/ui/views/conflictsView'; |
| 11 | |
| 12 | /** |
| 13 | * Fassung 5.10.0 - Zwischenzeitenmatrix. |
| 14 | * |
| 15 | * Drei Befunde an derselben Ansicht: |
| 16 | * |
| 17 | * - Befund 3 (kritisch): Der Klick auf eine LEERE Zelle rief |
| 18 | * `toggleConflictPair` - einen Umschalter ueber das ungerichtete Paar. Stand |
| 19 | * das Paar einseitig, verwarf die Zelle die noch bestehende Gegenrichtung |
| 20 | * samt Raeumweg, engem Innenradius und Bemerkung, ohne Rueckfrage und unter |
| 21 | * der Rueckgaengig-Beschriftung "Konfliktbeziehung angelegt". Danach gilt |
| 22 | * das Paar als vollstaendig vertraeglich: `feindlichePartnerInPhase` findet |
| 23 | * nichts mehr, beide Signalgruppen duerfen in dieselbe Phase - die |
| 24 | * gefaehrliche Richtung. |
| 25 | * |
| 26 | * - Befund 10 (hoch): "Konflikt aufheben" entfernte ueber `removeConflict` |
| 27 | * genau eine Richtung. Zurueck blieb ein halbes Paar: Der Pruefbericht |
| 28 | * meldete `konflikte.gegenrichtung-fehlt` als Fehler und sperrte die |
| 29 | * Ausgabe, und dieselbe Beziehung hiess in der Zwischenzeitenmatrix |
| 30 | * "vertraeglich" und in der Vertraeglichkeitsansicht "feindlich". |
| 31 | * |
| 32 | * - Befund 55 (gering): Die Abschnittsueberschrift setzte die Zahl unbesehen |
| 33 | * vor die Mehrzahl - bei genau einer Beziehung stand dort "1 Beziehungen zu |
| 34 | * vermaßen". Die Ueberschrift ist zugleich der Name der Abschnittskarte und |
| 35 | * wird so vorgelesen. |
| 36 | */ |
| 37 | |
| 38 | const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); |
| 39 | |
| 40 | let aufraeumen: (() => void) | null = null; |
| 41 | afterEach(() => { |
| 42 | aufraeumen?.(); |
| 43 | aufraeumen = null; |
| 44 | document.body.replaceChildren(); |
| 45 | }); |
| 46 | |
| 47 | interface Ansicht { |
| 48 | readonly wurzel: HTMLElement; |
| 49 | readonly store: ProjectStore; |
| 50 | } |
| 51 | |
| 52 | function zeichne(project: Project): Ansicht { |
| 53 | const wurzel = document.createElement('div'); |
| 54 | document.body.append(wurzel); |
| 55 | const store = new ProjectStore(project); |
| 56 | let abmelden: () => void = () => {}; |
| 57 | const context: ViewContext = { |
| 58 | store, |
| 59 | refresh: () => { |
| 60 | mitBewahrtemFokus(wurzel, () => { |
| 61 | abmelden(); |
| 62 | wurzel.replaceChildren(); |
| 63 | abmelden = conflictsView.render(wurzel, context); |
| 64 | }); |
| 65 | }, |
| 66 | navigate: () => {}, |
| 67 | target: null, |
| 68 | }; |
| 69 | abmelden = conflictsView.render(wurzel, context); |
| 70 | aufraeumen = () => { |
| 71 | abmelden(); |
| 72 | wurzel.remove(); |
| 73 | }; |
| 74 | return { wurzel, store }; |
| 75 | } |
| 76 | |
| 77 | async function warte(ms = 0): Promise<void> { |
| 78 | await new Promise((fertig) => setTimeout(fertig, ms)); |
| 79 | } |
| 80 | |
| 81 | /** Zelle der gerichteten Matrix - ueber ihren Namen, nicht ueber die Lage. */ |
| 82 | function zelle(ansicht: Ansicht, von: string, nach: string): HTMLButtonElement { |
| 83 | const treffer = [...ansicht.wurzel.querySelectorAll('button')].find((b) => { |
| 84 | const name = b.getAttribute('aria-label') ?? ''; |
| 85 | return ( |
| 86 | name.startsWith(`${von} räumt, ${nach} fährt ein`) || name.startsWith(`${von} und ${nach}:`) |
| 87 | ); |
| 88 | }); |
| 89 | if (treffer === undefined) throw new Error(`Keine Matrixzelle ${von} → ${nach}`); |
| 90 | return treffer; |
| 91 | } |
| 92 | |
| 93 | /** Schaltflaeche mit dieser Beschriftung - im zuletzt geoeffneten Fenster. */ |
| 94 | function knopf(beschriftung: string): HTMLButtonElement { |
| 95 | const fenster = [...document.querySelectorAll('.dialog-hintergrund')]; |
| 96 | const oben = fenster[fenster.length - 1]; |
| 97 | const treffer = [...(oben?.querySelectorAll('button') ?? [])].find( |
| 98 | (b) => b.textContent?.trim() === beschriftung, |
| 99 | ); |
| 100 | if (treffer === undefined) throw new Error(`Keine Schaltfläche "${beschriftung}"`); |
| 101 | return treffer; |
| 102 | } |
| 103 | |
| 104 | function rueckfrageText(): string { |
| 105 | const dialoge = [...document.querySelectorAll('.dialog-hintergrund .dialog-text')]; |
| 106 | return dialoge[dialoge.length - 1]?.textContent ?? ''; |
| 107 | } |
| 108 | |
| 109 | function richtungen(store: ProjectStore, aId: string, bId: string): readonly Conflict[] { |
| 110 | return store |
| 111 | .getProject() |
| 112 | .conflicts.filter( |
| 113 | (c) => (c.fromId === aId && c.toId === bId) || (c.fromId === bId && c.toId === aId), |
| 114 | ); |
| 115 | } |
| 116 | |
| 117 | function gruppe(project: Project, name: string): string { |
| 118 | const id = project.signalGroups.find((g) => g.name === name)?.id; |
| 119 | if (id === undefined) throw new Error(`Keine Signalgruppe "${name}"`); |
| 120 | return id; |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * Regelknotenpunkt, an dem nur noch K3 → K1 steht - der Zustand, den |
| 125 | * `engine.ts` als 'konflikte.gegenrichtung-fehlt' meldet und dessen Behebung |
| 126 | * der Pruefbericht ausdruecklich verlangt ("Erfassen Sie auch die Beziehung"). |
| 127 | * Die verbliebene Richtung traegt Angaben, die aus den Daten nicht |
| 128 | * zurueckzugewinnen sind. |
| 129 | */ |
| 130 | function einseitigesPaar(): { |
| 131 | readonly projekt: Project; |
| 132 | readonly k1: string; |
| 133 | readonly k3: string; |
| 134 | } { |
| 135 | const basis = createStandardIntersectionProject('Einseitig', FESTES_DATUM); |
| 136 | const k1 = gruppe(basis, 'K1'); |
| 137 | const k3 = gruppe(basis, 'K3'); |
| 138 | const conflicts = basis.conflicts |
| 139 | .filter((c) => !(c.fromId === k1 && c.toId === k3)) |
| 140 | .map((c) => |
| 141 | c.fromId === k3 && c.toId === k1 |
| 142 | ? { ...c, clearingDistance: 42, engerRadius: true, note: 'am Lageplan geprüft' } |
| 143 | : c, |
| 144 | ); |
| 145 | return { projekt: { ...basis, conflicts }, k1, k3 }; |
| 146 | } |
| 147 | |
| 148 | describe('Befund 3 - Klick auf eine leere Matrixzelle legt an, statt zu loeschen', () => { |
| 149 | it('legt die fehlende Richtung an und laesst die Gegenrichtung unberuehrt', () => { |
| 150 | const { projekt, k1, k3 } = einseitigesPaar(); |
| 151 | const ansicht = zeichne(projekt); |
| 152 | |
| 153 | // Die Zelle sagt "Zum Erfassen eines Konflikts anklicken" - genau das muss |
| 154 | // sie auch tun. |
| 155 | expect(zelle(ansicht, 'K1', 'K3').getAttribute('aria-label')).toContain( |
| 156 | 'Zum Erfassen eines Konflikts anklicken', |
| 157 | ); |
| 158 | zelle(ansicht, 'K1', 'K3').click(); |
| 159 | |
| 160 | const danach = richtungen(ansicht.store, k1, k3); |
| 161 | expect( |
| 162 | danach.map((c) => `${c.fromId === k1 ? 'K1' : 'K3'}→${c.toId === k1 ? 'K1' : 'K3'}`).sort(), |
| 163 | 'Der Klick hat die Gegenrichtung verworfen, statt die fehlende anzulegen', |
| 164 | ).toEqual(['K1→K3', 'K3→K1']); |
| 165 | |
| 166 | const gegen = danach.find((c) => c.fromId === k3 && c.toId === k1); |
| 167 | expect(gegen?.clearingDistance, 'Der Räumweg der Gegenrichtung ist fort').toBe(42); |
| 168 | expect(gegen?.engerRadius, 'Der enge Innenradius der Gegenrichtung ist fort').toBe(true); |
| 169 | expect(gegen?.note).toBe('am Lageplan geprüft'); |
| 170 | }); |
| 171 | |
| 172 | it('haelt die beiden Signalgruppen danach aus derselben Phase heraus', () => { |
| 173 | const { projekt, k3 } = einseitigesPaar(); |
| 174 | const ansicht = zeichne(projekt); |
| 175 | zelle(ansicht, 'K1', 'K3').click(); |
| 176 | |
| 177 | const nachher = ansicht.store.getProject(); |
| 178 | const phase1 = nachher.phases[0]; |
| 179 | expect(phase1).toBeDefined(); |
| 180 | expect( |
| 181 | actions.feindlichePartnerInPhase(nachher, phase1!.id, k3).map((g) => g.name), |
| 182 | 'K1 und K3 gelten nach dem Klick als verträglich', |
| 183 | ).toContain('K1'); |
| 184 | }); |
| 185 | |
| 186 | it('legt bei einem noch gar nicht erfassten Paar weiterhin beide Richtungen an', () => { |
| 187 | const basis = createStandardIntersectionProject('Frisch', FESTES_DATUM); |
| 188 | const k1 = gruppe(basis, 'K1'); |
| 189 | const k2 = gruppe(basis, 'K2'); |
| 190 | // K1 und K2 sind in der Vorlage vertraeglich - beide Richtungen fehlen. |
| 191 | const ansicht = zeichne(basis); |
| 192 | expect(richtungen(ansicht.store, k1, k2)).toHaveLength(0); |
| 193 | |
| 194 | zelle(ansicht, 'K1', 'K2').click(); |
| 195 | |
| 196 | expect( |
| 197 | richtungen(ansicht.store, k1, k2), |
| 198 | 'Ein Klick muss das Paar vollständig anlegen, sonst sperrt die fehlende Gegenrichtung die Ausgabe', |
| 199 | ).toHaveLength(2); |
| 200 | }); |
| 201 | }); |
| 202 | |
| 203 | describe('Befund 10 - "Konflikt aufheben" hebt das Paar auf', () => { |
| 204 | it('entfernt beide Richtungen und laesst kein halbes Paar zurueck', async () => { |
| 205 | const basis = createStandardIntersectionProject('Aufheben', FESTES_DATUM); |
| 206 | const k1 = gruppe(basis, 'K1'); |
| 207 | const k3 = gruppe(basis, 'K3'); |
| 208 | const ansicht = zeichne(basis); |
| 209 | const vorher = ansicht.store.getProject().conflicts.length; |
| 210 | |
| 211 | zelle(ansicht, 'K1', 'K3').click(); |
| 212 | await warte(0); |
| 213 | knopf('Konflikt aufheben').click(); |
| 214 | await warte(10); |
| 215 | knopf('Aufheben').click(); |
| 216 | await warte(10); |
| 217 | |
| 218 | expect(richtungen(ansicht.store, k1, k3), 'Es bleibt ein halbes Paar stehen').toHaveLength(0); |
| 219 | expect(ansicht.store.getProject().conflicts).toHaveLength(vorher - 2); |
| 220 | |
| 221 | const projekt = ansicht.store.getProject(); |
| 222 | const bericht = validateProject(projekt, buildSignalPlan(projekt), FESTES_DATUM); |
| 223 | expect( |
| 224 | bericht.findings.filter((f) => f.rule === 'konflikte.gegenrichtung-fehlt'), |
| 225 | 'Das Aufheben erzeugt selbst den Fehler, den der Prüfbericht meldet', |
| 226 | ).toHaveLength(0); |
| 227 | expect(bericht.exportBlocked, 'Das Aufheben sperrt die Ausgabe').toBe(false); |
| 228 | }); |
| 229 | |
| 230 | it('nennt in der Rueckfrage beide Richtungen und die Angaben der Gegenrichtung', async () => { |
| 231 | const basis = createStandardIntersectionProject('Rueckfrage', FESTES_DATUM); |
| 232 | const k1 = gruppe(basis, 'K1'); |
| 233 | const k3 = gruppe(basis, 'K3'); |
| 234 | // Die angeklickte Richtung traegt nichts, die Gegenrichtung alles: Genau |
| 235 | // dann verschwiegen Rueckfrage und Beschriftung, was verworfen wird. |
| 236 | const conflicts = basis.conflicts.map((c) => { |
| 237 | if (c.fromId === k1 && c.toId === k3) { |
| 238 | return { ...c, clearingDistance: 0, enteringDistance: 0 }; |
| 239 | } |
| 240 | if (c.fromId === k3 && c.toId === k1) return { ...c, engerRadius: true }; |
| 241 | return c; |
| 242 | }); |
| 243 | const ansicht = zeichne({ ...basis, conflicts }); |
| 244 | |
| 245 | zelle(ansicht, 'K1', 'K3').click(); |
| 246 | await warte(0); |
| 247 | knopf('Konflikt aufheben').click(); |
| 248 | await warte(10); |
| 249 | |
| 250 | const text = rueckfrageText(); |
| 251 | expect(text, 'Die Rückfrage bleibt aus, obwohl die Gegenrichtung Angaben trägt').not.toBe(''); |
| 252 | expect(text).toContain('beiden Richtungen'); |
| 253 | expect(text, 'Der enge Innenradius der Gegenrichtung wird verschwiegen').toContain( |
| 254 | 'enger Innenradius', |
| 255 | ); |
| 256 | |
| 257 | knopf('Aufheben').click(); |
| 258 | await warte(10); |
| 259 | expect(richtungen(ansicht.store, k1, k3)).toHaveLength(0); |
| 260 | }); |
| 261 | }); |
| 262 | |
| 263 | describe('Befund 55 - Ueberschrift der Matrix in Ein- und Mehrzahl', () => { |
| 264 | function projektMitBeziehungen(anzahl: 1 | 2): Project { |
| 265 | let projekt = createEmptyProject('Zählung', FESTES_DATUM); |
| 266 | projekt = actions.addSignalGroup(actions.addSignalGroup(projekt, 'kfz'), 'kfz'); |
| 267 | const [a, b] = projekt.signalGroups; |
| 268 | projekt = actions.toggleConflictPair(projekt, a!.id, b!.id); |
| 269 | return anzahl === 2 |
| 270 | ? projekt |
| 271 | : { ...projekt, conflicts: projekt.conflicts.filter((c) => c.fromId === a!.id) }; |
| 272 | } |
| 273 | |
| 274 | function ueberschrift(ansicht: Ansicht): string { |
| 275 | const titel = [...ansicht.wurzel.querySelectorAll('.abschnitt-titel')].map( |
| 276 | (t) => t.textContent ?? '', |
| 277 | ); |
| 278 | const treffer = titel.find((t) => t.includes('vermaßen')); |
| 279 | if (treffer === undefined) |
| 280 | throw new Error(`Keine Überschrift zur Matrix: ${titel.join(' | ')}`); |
| 281 | return treffer; |
| 282 | } |
| 283 | |
| 284 | it('nennt eine einzelne Beziehung in der Einzahl', () => { |
| 285 | const ansicht = zeichne(projektMitBeziehungen(1)); |
| 286 | expect(ueberschrift(ansicht)).toBe('1 Beziehung zu vermaßen'); |
| 287 | }); |
| 288 | |
| 289 | it('bleibt bei mehreren Beziehungen in der Mehrzahl', () => { |
| 290 | const ansicht = zeichne(projektMitBeziehungen(2)); |
| 291 | expect(ueberschrift(ansicht)).toBe('2 Beziehungen zu vermaßen'); |
| 292 | }); |
| 293 | }); |