lsa-planer

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

/ tests ui konfliktmatrix.test.ts

11,2 KB Rohdatei
tests/ui/konfliktmatrix.test.ts — 293 Zeilen
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 });