lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
/ tests ui vertraeglichkeitVerwirft.test.ts
| 1 | import { afterEach, describe, expect, it } from 'vitest'; |
| 2 | import { ProjectStore } from '@/app/store'; |
| 3 | import { createStandardIntersectionProject } from '@/domain/model/factory'; |
| 4 | import type { Conflict, Project } from '@/domain/model/project'; |
| 5 | import { compatibilityView } from '@/ui/views/compatibilityView'; |
| 6 | |
| 7 | /** |
| 8 | * Befund 35 zur Fassung 5.9.0 (Datenverlust). |
| 9 | * |
| 10 | * WAS DER ANWENDER SONST ERLEBT |
| 11 | * |
| 12 | * Er hat das Paar K1/K3 vermasst - Raeumweg 25 m - und "enger Innenradius" |
| 13 | * angehakt. In der Ansicht "Verträglichkeit" klickt er die Zelle einmal |
| 14 | * (Zustand "verträglich") und, weil das ein Versehen war, gleich noch einmal. |
| 15 | * `toggleConflictPair` entfernt beim ersten Klick BEIDE gerichteten |
| 16 | * Datensaetze, der zweite legt ueber `createConflict` zwei frische an: |
| 17 | * Raeumweg 0, Einfahrweg 0, alle Ueberschreibungen null, die von Hand |
| 18 | * abgestimmte Zwischenzeit fort, Bemerkung leer, `engerRadius` und |
| 19 | * `haltVorKnoten` weggelassen. Nichts hat es angesagt. |
| 20 | * |
| 21 | * Die Wege faellt der Pruefbericht spaeter auf (`konflikte.raeumweg-fehlt`, |
| 22 | * Fehler mit Exportsperre). Den Verlust von `engerRadius` faengt niemand auf, |
| 23 | * und er geht in die gefaehrliche Richtung: Ohne das Merkmal raeumt der |
| 24 | * Abbieger mit 7,0 statt 5,0 m/s - bei s0 = 25 m rund 1,8 s kuerzere |
| 25 | * Zwischenzeit, nachdem der Bearbeiter den angemahnten Raeumweg wieder |
| 26 | * eingetragen hat. Ein Merkmal ueber die Oertlichkeit laesst sich aus den |
| 27 | * Daten nicht rekonstruieren. |
| 28 | * |
| 29 | * Die Nachbarschaltflaeche "Alle aufheben" fragt fuer denselben Vorgang ueber |
| 30 | * alle Paare und traegt `variant: 'gefahr'`; beim Loeschen einer Signalgruppe |
| 31 | * nennt das Programm sogar die Zahl der betroffenen Konfliktbeziehungen. Nur |
| 32 | * die Zelle verwarf ohne Rueckfrage. |
| 33 | */ |
| 34 | |
| 35 | const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); |
| 36 | |
| 37 | afterEach(() => { |
| 38 | document.body.replaceChildren(); |
| 39 | }); |
| 40 | |
| 41 | interface Ansicht { |
| 42 | readonly wurzel: HTMLElement; |
| 43 | readonly store: ProjectStore; |
| 44 | readonly aufraeumen: () => void; |
| 45 | } |
| 46 | |
| 47 | function zeichne(project: Project): Ansicht { |
| 48 | const wurzel = document.createElement('div'); |
| 49 | document.body.append(wurzel); |
| 50 | const store = new ProjectStore(project); |
| 51 | const abmelden = compatibilityView.render(wurzel, { |
| 52 | store, |
| 53 | refresh: () => {}, |
| 54 | navigate: () => {}, |
| 55 | target: null, |
| 56 | }); |
| 57 | return { |
| 58 | wurzel, |
| 59 | store, |
| 60 | aufraeumen: () => { |
| 61 | abmelden(); |
| 62 | wurzel.remove(); |
| 63 | }, |
| 64 | }; |
| 65 | } |
| 66 | |
| 67 | async function mikroschritt(): Promise<void> { |
| 68 | await new Promise<void>((fertig) => { |
| 69 | queueMicrotask(fertig); |
| 70 | }); |
| 71 | } |
| 72 | |
| 73 | /** Der Umschaltknopf der Zelle in Zeile `i`, Spalte `j` der oberen Dreiecksmatrix. */ |
| 74 | function zelle(wurzel: HTMLElement, i: number, j: number): HTMLButtonElement { |
| 75 | const zeile = wurzel.querySelectorAll<HTMLTableRowElement>('table.matrix tbody tr')[i]; |
| 76 | // Erste Zelle der Zeile ist der Zeilenkopf, danach je Signalgruppe eine. |
| 77 | const knopf = zeile?.children[1 + j]?.querySelector('button') ?? null; |
| 78 | if (knopf === null) throw new Error(`Kein Umschaltknopf in Zeile ${i}, Spalte ${j}`); |
| 79 | return knopf; |
| 80 | } |
| 81 | |
| 82 | function rueckfrage(): HTMLElement | null { |
| 83 | return document.querySelector<HTMLElement>('.dialog-hintergrund .dialog'); |
| 84 | } |
| 85 | |
| 86 | function dialogKnopf(beschriftung: string): HTMLButtonElement { |
| 87 | const knopf = [ |
| 88 | ...document.querySelectorAll<HTMLButtonElement>('.dialog-hintergrund .dialog-aktionen button'), |
| 89 | ].find((b) => (b.textContent ?? '').trim() === beschriftung); |
| 90 | if (knopf === undefined) throw new Error(`Keine Schaltflaeche "${beschriftung}"`); |
| 91 | return knopf; |
| 92 | } |
| 93 | |
| 94 | /** Beide gerichteten Beziehungen des Paares. */ |
| 95 | function beziehungen(project: Project, aId: string, bId: string): Conflict[] { |
| 96 | return project.conflicts.filter( |
| 97 | (c) => (c.fromId === aId && c.toId === bId) || (c.fromId === bId && c.toId === aId), |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | /** Regelknotenpunkt, an dessen Paar K1/K3 der enge Innenradius haengt. */ |
| 102 | function mitMerkmal(): { projekt: Project; k1: string; k3: string } { |
| 103 | const basis = createStandardIntersectionProject('Verträglichkeit', FESTES_DATUM); |
| 104 | const k1 = basis.signalGroups.find((g) => g.name === 'K1')!.id; |
| 105 | const k3 = basis.signalGroups.find((g) => g.name === 'K3')!.id; |
| 106 | return { |
| 107 | k1, |
| 108 | k3, |
| 109 | projekt: { |
| 110 | ...basis, |
| 111 | conflicts: basis.conflicts.map((c) => |
| 112 | c.fromId === k1 && c.toId === k3 |
| 113 | ? { ...c, clearingDistance: 25, engerRadius: true, manualIntergreen: 7 } |
| 114 | : c, |
| 115 | ), |
| 116 | }, |
| 117 | }; |
| 118 | } |
| 119 | |
| 120 | /** Dieselbe Beziehung ohne jede erfasste Angabe - so, wie `createConflict` sie anlegt. */ |
| 121 | function ohneAngaben(): { projekt: Project; k1: string; k3: string } { |
| 122 | const { projekt, k1, k3 } = mitMerkmal(); |
| 123 | const geleert = (c: Conflict): Conflict => { |
| 124 | const { engerRadius: _radius, haltVorKnoten: _halt, ...rest } = c; |
| 125 | return { ...rest, clearingDistance: 0, enteringDistance: 0, manualIntergreen: null, note: '' }; |
| 126 | }; |
| 127 | return { |
| 128 | k1, |
| 129 | k3, |
| 130 | projekt: { |
| 131 | ...projekt, |
| 132 | conflicts: projekt.conflicts.map((c) => |
| 133 | (c.fromId === k1 && c.toId === k3) || (c.fromId === k3 && c.toId === k1) ? geleert(c) : c, |
| 134 | ), |
| 135 | }, |
| 136 | }; |
| 137 | } |
| 138 | |
| 139 | describe('Verträglichkeitsmatrix: das Aufheben eines vermassten Paares fragt zurueck', () => { |
| 140 | it('nennt vor dem Verwerfen, was an dem Paar haengt, und laesst es bis dahin stehen', () => { |
| 141 | const { projekt, k1, k3 } = mitMerkmal(); |
| 142 | const ansicht = zeichne(projekt); |
| 143 | try { |
| 144 | // Zeile 0 ist K1, Spalte 2 ist K3. |
| 145 | zelle(ansicht.wurzel, 0, 2).click(); |
| 146 | |
| 147 | const dialog = rueckfrage(); |
| 148 | expect(dialog, 'Die Zelle verwirft ohne Rueckfrage').not.toBeNull(); |
| 149 | const text = dialog?.textContent ?? ''; |
| 150 | expect(text, 'die Rueckfrage nennt das Paar nicht').toContain('K1'); |
| 151 | expect(text).toContain('K3'); |
| 152 | expect(text, 'die Rueckfrage nennt das gefaehrlichste Merkmal nicht').toContain( |
| 153 | 'enger Innenradius', |
| 154 | ); |
| 155 | expect(text).toContain('Räumweg'); |
| 156 | expect(text).toContain('Zwischenzeit'); |
| 157 | |
| 158 | // Solange die Rueckfrage steht, ist nichts verworfen. |
| 159 | expect(beziehungen(ansicht.store.getProject(), k1, k3)).toHaveLength(2); |
| 160 | } finally { |
| 161 | ansicht.aufraeumen(); |
| 162 | } |
| 163 | }); |
| 164 | |
| 165 | it('laesst die Angaben unberuehrt, wenn die Rueckfrage abgebrochen wird', async () => { |
| 166 | const { projekt, k1, k3 } = mitMerkmal(); |
| 167 | const ansicht = zeichne(projekt); |
| 168 | try { |
| 169 | zelle(ansicht.wurzel, 0, 2).click(); |
| 170 | dialogKnopf('Abbrechen').click(); |
| 171 | await mikroschritt(); |
| 172 | await mikroschritt(); |
| 173 | |
| 174 | const paar = beziehungen(ansicht.store.getProject(), k1, k3); |
| 175 | expect(paar).toHaveLength(2); |
| 176 | expect( |
| 177 | paar.some((c) => c.engerRadius === true), |
| 178 | 'der enge Innenradius ist fort', |
| 179 | ).toBe(true); |
| 180 | } finally { |
| 181 | ansicht.aufraeumen(); |
| 182 | } |
| 183 | }); |
| 184 | |
| 185 | it('hebt das Paar auf, wenn der Bearbeiter zustimmt', async () => { |
| 186 | const { projekt, k1, k3 } = mitMerkmal(); |
| 187 | const ansicht = zeichne(projekt); |
| 188 | try { |
| 189 | zelle(ansicht.wurzel, 0, 2).click(); |
| 190 | dialogKnopf('Aufheben').click(); |
| 191 | await mikroschritt(); |
| 192 | await mikroschritt(); |
| 193 | |
| 194 | expect(beziehungen(ansicht.store.getProject(), k1, k3)).toHaveLength(0); |
| 195 | } finally { |
| 196 | ansicht.aufraeumen(); |
| 197 | } |
| 198 | }); |
| 199 | |
| 200 | it('fragt nicht, wenn an dem Paar nichts erfasst ist', () => { |
| 201 | const { projekt, k1, k3 } = ohneAngaben(); |
| 202 | const ansicht = zeichne(projekt); |
| 203 | try { |
| 204 | zelle(ansicht.wurzel, 0, 2).click(); |
| 205 | |
| 206 | expect(rueckfrage(), 'eine Rueckfrage ohne Anlass').toBeNull(); |
| 207 | expect(beziehungen(ansicht.store.getProject(), k1, k3)).toHaveLength(0); |
| 208 | } finally { |
| 209 | ansicht.aufraeumen(); |
| 210 | } |
| 211 | }); |
| 212 | |
| 213 | it('fragt nicht beim Erfassen eines Konflikts', () => { |
| 214 | const { projekt, k1 } = mitMerkmal(); |
| 215 | const k2 = projekt.signalGroups.find((g) => g.name === 'K2')!.id; |
| 216 | const ansicht = zeichne(projekt); |
| 217 | try { |
| 218 | // Zeile 0 ist K1, Spalte 1 ist K2 - dieses Paar ist verträglich. |
| 219 | zelle(ansicht.wurzel, 0, 1).click(); |
| 220 | |
| 221 | expect(rueckfrage()).toBeNull(); |
| 222 | expect(beziehungen(ansicht.store.getProject(), k1, k2)).toHaveLength(2); |
| 223 | } finally { |
| 224 | ansicht.aufraeumen(); |
| 225 | } |
| 226 | }); |
| 227 | }); |