lsa-planer

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

/ tests ui konfliktAufheben.test.ts

8,6 KB Rohdatei
tests/ui/konfliktAufheben.test.ts — 244 Zeilen
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 { mitBewahrtemFokus } from '@/ui/fokus';
6 import type { ViewContext } from '@/ui/shell';
7 import { conflictsView } from '@/ui/views/conflictsView';
8
9 /**
10 * Befund 35 zur Fassung 5.9.0, die zweite ungeschuetzte Stelle.
11 *
12 * WAS DER ANWENDER SONST ERLEBT
13 *
14 * Er hat eine Konfliktbeziehung vermassen - Raeumweg, Einfahrweg, "enger
15 * Innenradius", eine von Hand abgestimmte Zwischenzeit - und drueckt im
16 * Bearbeitungsfenster auf "Konflikt aufheben". Der Datensatz ist fort, ohne
17 * Rueckfrage und ohne Meldung. Ein zweiter Klick in der Matrix legt eine
18 * frische Beziehung an: Wege 0, alle Ueberschreibungen null, `engerRadius` und
19 * `haltVorKnoten` weggelassen. Die fehlenden Wege meldet der Pruefbericht
20 * spaeter als Fehler; den fehlenden engen Innenradius meldet niemand - der
21 * Abbieger raeumt dann mit 7,0 statt 5,0 m/s, also mit kuerzerer Zwischenzeit,
22 * und ein Merkmal ueber die Oertlichkeit laesst sich aus den Daten nicht
23 * zurueckgewinnen.
24 *
25 * Die Behebung von Befund 35 hat allein die Matrixzelle der Ansicht
26 * "Verträglichkeit" gesichert. Dieselbe Rueckfrage gehoert an diese
27 * Schaltflaeche - mit demselben Massstab: gefragt wird nur, wenn wirklich
28 * etwas daran haengt.
29 *
30 * NACHGEZOGEN (Befund 10 zur Fassung 5.10.0): Die Schaltflaeche
31 * hebt das PAAR auf, nicht die angeklickte Richtung - eine einzelne Richtung
32 * zu entfernen hinterliess ein halbes Paar, das der Pruefbericht als Fehler
33 * meldet und das die Ausgabe sperrt. Dieser Test begruendet allein die
34 * Rueckfrage; die Zahl der entfernten Richtungen war blosses Mitschreiben des
35 * vorgefundenen Verhaltens. Sie steht jetzt auf `vorher - 2`, und der Fall
36 * "nichts erfasst" leert beide Richtungen - sonst traefe seine Voraussetzung
37 * nicht mehr zu, denn gefragt wird ueber beide.
38 */
39
40 const FESTES_DATUM = new Date('2026-01-01T00:00:00Z');
41
42 let aufraeumen: (() => void) | null = null;
43 afterEach(() => {
44 aufraeumen?.();
45 aufraeumen = null;
46 document.body.replaceChildren();
47 });
48
49 interface Ansicht {
50 readonly wurzel: HTMLElement;
51 readonly store: ProjectStore;
52 }
53
54 function zeichne(project: Project): Ansicht {
55 const wurzel = document.createElement('div');
56 document.body.append(wurzel);
57 const store = new ProjectStore(project);
58 let abmelden: () => void = () => {};
59 const context: ViewContext = {
60 store,
61 refresh: () => {
62 mitBewahrtemFokus(wurzel, () => {
63 abmelden();
64 wurzel.replaceChildren();
65 abmelden = conflictsView.render(wurzel, context);
66 });
67 },
68 navigate: () => {},
69 target: null,
70 };
71 abmelden = conflictsView.render(wurzel, context);
72 aufraeumen = () => {
73 abmelden();
74 wurzel.remove();
75 };
76 return { wurzel, store };
77 }
78
79 async function warte(ms = 0): Promise<void> {
80 await new Promise((fertig) => setTimeout(fertig, ms));
81 }
82
83 function offeneFenster(): string[] {
84 return [...document.querySelectorAll('.dialog-hintergrund h2')].map(
85 (h) => h.textContent?.trim() ?? '',
86 );
87 }
88
89 /** Text der zuletzt geoeffneten Rueckfrage. */
90 function rueckfrageText(): string {
91 const dialoge = [...document.querySelectorAll('.dialog-hintergrund .dialog-text')];
92 return dialoge[dialoge.length - 1]?.textContent ?? '';
93 }
94
95 /** Schaltflaeche mit dieser Beschriftung - im zuletzt geoeffneten Fenster. */
96 function knopf(beschriftung: string): HTMLButtonElement {
97 const fenster = [...document.querySelectorAll('.dialog-hintergrund')];
98 const oben = fenster[fenster.length - 1];
99 const treffer = [...(oben?.querySelectorAll('button') ?? [])].find(
100 (b) => b.textContent?.trim() === beschriftung,
101 );
102 if (treffer === undefined) throw new Error(`Keine Schaltfläche "${beschriftung}"`);
103 return treffer;
104 }
105
106 /** Oeffnet das Bearbeitungsfenster ueber die Matrixzelle. */
107 function oeffneFenster(ansicht: Ansicht, von: string, nach: string): void {
108 const zelle = [...ansicht.wurzel.querySelectorAll('button')].find((b) =>
109 (b.getAttribute('aria-label') ?? '').startsWith(`${von} räumt, ${nach} fährt ein`),
110 );
111 if (zelle === undefined) throw new Error(`Keine Matrixzelle ${von} → ${nach}`);
112 zelle.click();
113 }
114
115 /** Ein Regelknotenpunkt, dessen erste Beziehung vollstaendig erfasst ist. */
116 function projektMitAngaben(): {
117 readonly projekt: Project;
118 readonly von: string;
119 readonly nach: string;
120 } {
121 const basis = createStandardIntersectionProject('Vermaßen', FESTES_DATUM);
122 const erste = basis.conflicts[0];
123 if (erste === undefined) throw new Error('Der Regelknotenpunkt führt keine Konflikte.');
124 const name = (id: string): string =>
125 basis.signalGroups.find((g) => g.id === id)?.name ?? '(ohne)';
126 const angereichert: Conflict = {
127 ...erste,
128 clearingDistance: 22,
129 enteringDistance: 8,
130 engerRadius: true,
131 manualIntergreen: 7,
132 };
133 return {
134 projekt: {
135 ...basis,
136 conflicts: basis.conflicts.map((c) => (c.id === erste.id ? angereichert : c)),
137 },
138 von: name(erste.fromId),
139 nach: name(erste.toId),
140 };
141 }
142
143 /**
144 * Ein Regelknotenpunkt, dessen erstes PAAR noch nichts traegt.
145 *
146 * Beide Richtungen, seit die Schaltflaeche beide aufhebt und die Rueckfrage
147 * ueber beide fragt (Befund 10): Mit einer noch vermassten Gegenrichtung waere
148 * dies kein Fall von "nichts erfasst" mehr.
149 */
150 function projektOhneAngaben(): {
151 readonly projekt: Project;
152 readonly von: string;
153 readonly nach: string;
154 } {
155 const basis = createStandardIntersectionProject('Frisch', FESTES_DATUM);
156 const erste = basis.conflicts[0];
157 if (erste === undefined) throw new Error('Der Regelknotenpunkt führt keine Konflikte.');
158 const name = (id: string): string =>
159 basis.signalGroups.find((g) => g.id === id)?.name ?? '(ohne)';
160 const leere = (c: Conflict): Conflict => ({
161 ...c,
162 clearingDistance: 0,
163 enteringDistance: 0,
164 clearingSpeedOverride: null,
165 enteringSpeedOverride: null,
166 crossingTimeOverride: null,
167 manualIntergreen: null,
168 note: '',
169 });
170 const gehoertZumPaar = (c: Conflict): boolean =>
171 (c.fromId === erste.fromId && c.toId === erste.toId) ||
172 (c.fromId === erste.toId && c.toId === erste.fromId);
173 return {
174 projekt: {
175 ...basis,
176 conflicts: basis.conflicts.map((c) => (gehoertZumPaar(c) ? leere(c) : c)),
177 },
178 von: name(erste.fromId),
179 nach: name(erste.toId),
180 };
181 }
182
183 function anzahlKonflikte(ansicht: Ansicht): number {
184 return ansicht.store.getProject().conflicts.length;
185 }
186
187 describe('Zwischenzeiten: "Konflikt aufheben" fragt vor dem Verwerfen', () => {
188 it('nennt die erfassten Angaben und verwirft erst nach der Bestätigung', async () => {
189 const { projekt, von, nach } = projektMitAngaben();
190 const ansicht = zeichne(projekt);
191 const vorher = anzahlKonflikte(ansicht);
192 oeffneFenster(ansicht, von, nach);
193 await warte(0);
194
195 knopf('Konflikt aufheben').click();
196 await warte(10);
197
198 expect(offeneFenster(), 'Es steht keine Rückfrage über dem Bearbeitungsfenster').toContain(
199 'Konflikt aufheben',
200 );
201 expect(rueckfrageText()).toContain('Räumweg');
202 expect(rueckfrageText()).toContain('enger Innenradius');
203 expect(rueckfrageText()).toContain('von Hand abgestimmte Zwischenzeit');
204 expect(anzahlKonflikte(ansicht), 'Die Beziehung ist schon vor der Antwort fort').toBe(vorher);
205
206 knopf('Aufheben').click();
207 await warte(10);
208
209 // Beide Richtungen: Ein halbes Paar ist ein Fehler mit Exportsperre.
210 expect(anzahlKonflikte(ansicht)).toBe(vorher - 2);
211 expect(offeneFenster(), 'Nach dem Aufheben bleibt kein Fenster stehen').toEqual([]);
212 });
213
214 it('behält die Beziehung, wenn die Rückfrage abgebrochen wird', async () => {
215 const { projekt, von, nach } = projektMitAngaben();
216 const ansicht = zeichne(projekt);
217 const vorher = anzahlKonflikte(ansicht);
218 oeffneFenster(ansicht, von, nach);
219 await warte(0);
220
221 knopf('Konflikt aufheben').click();
222 await warte(10);
223 knopf('Abbrechen').click();
224 await warte(10);
225
226 expect(anzahlKonflikte(ansicht)).toBe(vorher);
227 });
228
229 it('fragt nicht, wenn an der Beziehung nichts erfasst ist', async () => {
230 // Derselbe Massstab wie an der Matrixzelle: Wer an frischen Beziehungen
231 // arbeitet, soll nicht bei jedem Klick aufgehalten werden.
232 const { projekt, von, nach } = projektOhneAngaben();
233 const ansicht = zeichne(projekt);
234 const vorher = anzahlKonflikte(ansicht);
235 oeffneFenster(ansicht, von, nach);
236 await warte(0);
237
238 knopf('Konflikt aufheben').click();
239 await warte(10);
240
241 expect(anzahlKonflikte(ansicht)).toBe(vorher - 2);
242 expect(offeneFenster()).toEqual([]);
243 });
244 });