lsa-planer

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

/ tests ui phaseneinteilungAnsicht.test.ts

8,4 KB Rohdatei
tests/ui/phaseneinteilungAnsicht.test.ts — 253 Zeilen
1 import { afterEach, describe, expect, it } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import {
4 leiteAb,
5 uebernimmSignalgruppen,
6 uebernimmWege,
7 wegSchluessel,
8 } from '@/domain/geometrie/ableitung';
9 import { REGELBREITE } from '@/domain/geometrie/vermessung';
10 import { createEmptyProject, createStandardIntersectionProject } from '@/domain/model/factory';
11 import { phasesView, vergessEinteilungssuche } from '@/ui/views/phasesView';
12 import type { Lageplan, Planlinie } from '@/domain/geometrie/lageplan';
13 import type { Project } from '@/domain/model/project';
14
15 /**
16 * Die Karte "Zulaessige Phaseneinteilungen".
17 *
18 * SIE ZEIGT ENTWEDER VORSCHLAEGE ODER DEN GRUND, WARUM ES KEINE GIBT. Ein
19 * leeres Feld waere hier das Schlimmste: Der Bearbeiter hielte es fuer "es gibt
20 * keine zulaessige Einteilung", und die Wahrheit ist meistens "die
21 * Vertraeglichkeit ist nicht belegt".
22 */
23
24 const STICHTAG = new Date('2026-01-01T00:00:00Z');
25
26 function linie(id: string, name: string, punkte: readonly { x: number; y: number }[]): Planlinie {
27 return {
28 id,
29 name,
30 mode: 'kfz',
31 movement: 'geradeaus',
32 breiteMeter: REGELBREITE.kfz,
33 punkte,
34 haltlinieId: null,
35 startT: 0.5,
36 signalGroupId: null,
37 };
38 }
39
40 function lageplan(): Lageplan {
41 return {
42 arbeitsbereiche: [],
43 signalgeber: [],
44 haltlinien: [],
45 bild: {
46 datenUrl: 'data:image/png;base64,AAAA',
47 breite: 400,
48 hoehe: 400,
49 herkunft: 'Prüfstück',
50 geladenAm: STICHTAG.toISOString(),
51 },
52 kalibrierung: {
53 von: { x: 0, y: 0 },
54 bis: { x: 100, y: 0 },
55 laengeMeter: 50,
56 herkunft: 'gemessen',
57 },
58 linien: [
59 linie('l-w', 'K1', [
60 { x: 0, y: 100 },
61 { x: 200, y: 100 },
62 ]),
63 linie('l-s', 'K2', [
64 { x: 100, y: 0 },
65 { x: 100, y: 200 },
66 ]),
67 linie('l-p', 'K3', [
68 { x: 0, y: 160 },
69 { x: 200, y: 160 },
70 ]),
71 ],
72 };
73 }
74
75 /** Ein Projekt, dessen Verträglichkeit vollständig aus dem Lageplan stammt. */
76 function belegtesProjekt(): Project {
77 const leer: Project = { ...createEmptyProject('Kreuzung', STICHTAG), lageplan: lageplan() };
78 const gruppen = uebernimmSignalgruppen(
79 leer,
80 leer.lageplan,
81 leer.lageplan.linien.map((l) => l.id),
82 );
83 const mitPlan: Project = { ...gruppen.project, lageplan: gruppen.lageplan };
84 const ableitung = leiteAb(mitPlan, mitPlan.lageplan);
85 return uebernimmWege(
86 mitPlan,
87 mitPlan.lageplan,
88 ableitung,
89 ableitung.wege.map((w) => wegSchluessel(w)),
90 STICHTAG,
91 ).project;
92 }
93
94 function zeichne(project: Project): {
95 wurzel: HTMLElement;
96 store: ProjectStore;
97 aufraeumen: () => void;
98 } {
99 const wurzel = document.createElement('div');
100 document.body.append(wurzel);
101 const store = new ProjectStore(project);
102 const ende = phasesView.render(wurzel, {
103 store,
104 refresh: () => {},
105 navigate: () => {},
106 target: null,
107 });
108 return {
109 wurzel,
110 store,
111 aufraeumen: () => {
112 ende();
113 wurzel.remove();
114 },
115 };
116 }
117
118 function knopf(wurzel: ParentNode, text: string): HTMLButtonElement {
119 const treffer = [...wurzel.querySelectorAll('button')].find(
120 (b) => b.textContent?.trim() === text,
121 );
122 if (!treffer) throw new Error(`Schaltfläche "${text}" nicht gefunden.`);
123 return treffer;
124 }
125
126 afterEach(() => {
127 vergessEinteilungssuche();
128 });
129
130 describe('Phasenansicht - zulässige Einteilungen', () => {
131 it('sucht erst auf Knopfdruck', () => {
132 /*
133 * Die Suche baut je Vorschlag einen ganzen Signalzeitenplan. In einer
134 * Zeichnung, die bei jeder Tastatureingabe laeuft, hat das nichts zu
135 * suchen - eine traege Oberflaeche ist das erste, was der Bearbeiter
136 * merkt.
137 */
138 const { wurzel, aufraeumen } = zeichne(belegtesProjekt());
139 try {
140 expect(wurzel.textContent).toContain('Die Suche läuft auf Knopfdruck');
141 expect(wurzel.textContent).not.toContain('gereiht nach der Umlaufzeit');
142 knopf(wurzel, 'Einteilungen suchen').click();
143 expect(wurzel.textContent).toContain('gereiht nach der Umlaufzeit');
144 } finally {
145 aufraeumen();
146 }
147 });
148
149 it('macht ohne Beleg keinen Vorschlag und sagt warum', () => {
150 /*
151 * DIE BEDINGUNG DES BEFUNDS, an der Oberfläche. Das Standardprojekt hat
152 * keinen Lageplan: Seine Verträglichkeit ist nirgends belegt, sie ist nur
153 * nicht widerlegt.
154 */
155 const { wurzel, aufraeumen } = zeichne(
156 createStandardIntersectionProject('Hauptstraße', STICHTAG),
157 );
158 try {
159 knopf(wurzel, 'Einteilungen suchen').click();
160 expect(wurzel.textContent).toContain('Keine Vorschläge');
161 expect(wurzel.textContent).toContain('nicht belegt');
162 expect(wurzel.textContent).toContain('Ohne Beleg sind');
163 // Und kein einziger Vorschlag - auch kein gekennzeichneter.
164 expect(wurzel.textContent).not.toContain('Übernehmen');
165 } finally {
166 aufraeumen();
167 }
168 });
169
170 it('übernimmt einen Vorschlag erst nach Rückfrage', async () => {
171 const { wurzel, store, aufraeumen } = zeichne(belegtesProjekt());
172 try {
173 knopf(wurzel, 'Einteilungen suchen').click();
174 const vorher = store.getProject().phases.length;
175 knopf(wurzel, 'Übernehmen').click();
176 const dialog = document.querySelector<HTMLElement>('[role="dialog"]');
177 expect(dialog, 'Ohne Rückfrage wäre die Einteilung mit einem Klick ersetzt').not.toBeNull();
178 expect(dialog!.textContent).toContain('in jedem Tagesprogramm');
179 [...dialog!.querySelectorAll('button')]
180 .find((b) => b.textContent?.trim() === 'Übernehmen')!
181 .click();
182 await new Promise((fertig) => setTimeout(fertig, 0));
183
184 const nachher = store.getProject();
185 expect(nachher.phases.length).toBeGreaterThan(vorher);
186 // Jede Gruppe hat eine Phase, und die Phasenfolge kennt jede Phase.
187 const inPhasen = new Set(nachher.phases.flatMap((p) => p.signalGroupIds));
188 expect(inPhasen.size).toBe(nachher.signalGroups.length);
189 expect(nachher.program.phaseOrder).toEqual(nachher.phases.map((p) => p.id));
190 } finally {
191 aufraeumen();
192 }
193 });
194
195 it('ändert nichts, wenn die Rückfrage abgebrochen wird', async () => {
196 // Die Rueckfrage ist keine Zierde: Sie ersetzt die ganze Phaseneinteilung
197 // samt den festen Freigabezeiten jedes Tagesprogramms.
198 const { wurzel, store, aufraeumen } = zeichne(belegtesProjekt());
199 try {
200 knopf(wurzel, 'Einteilungen suchen').click();
201 const vorher = store.getProject();
202 knopf(wurzel, 'Übernehmen').click();
203 const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!;
204 [...dialog.querySelectorAll('button')]
205 .find((b) => b.textContent?.trim() === 'Abbrechen')!
206 .click();
207 await new Promise((fertig) => setTimeout(fertig, 0));
208 expect(store.getProject()).toBe(vorher);
209 } finally {
210 aufraeumen();
211 }
212 });
213
214 it('kennzeichnet die heutige Einteilung und bietet sie nicht zum Übernehmen an', async () => {
215 // Ein Knopf, der nichts aendert, ist eine Falle: Der Bearbeiter druecke
216 // ihn, bekaeme einen Rueckgaengig-Schritt und dieselbe Einteilung.
217 const erste = zeichne(belegtesProjekt());
218 let uebernommen: Project;
219 try {
220 knopf(erste.wurzel, 'Einteilungen suchen').click();
221 knopf(erste.wurzel, 'Übernehmen').click();
222 const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!;
223 [...dialog.querySelectorAll('button')]
224 .find((b) => b.textContent?.trim() === 'Übernehmen')!
225 .click();
226 await new Promise((fertig) => setTimeout(fertig, 0));
227 uebernommen = erste.store.getProject();
228 } finally {
229 erste.aufraeumen();
230 vergessEinteilungssuche();
231 }
232
233 const zweite = zeichne(uebernommen);
234 try {
235 knopf(zweite.wurzel, 'Einteilungen suchen').click();
236 expect(zweite.wurzel.textContent).toContain('die heutige');
237 const uebernehmen = [...zweite.wurzel.querySelectorAll('button')].filter(
238 (b) => b.textContent?.trim() === 'Übernehmen',
239 );
240 const zeilen = [...zweite.wurzel.querySelectorAll('tbody tr')].filter((tr) =>
241 tr.textContent?.includes('die heutige'),
242 );
243 expect(zeilen.length).toBe(1);
244 expect(zeilen[0]!.querySelectorAll('button')).toHaveLength(0);
245 // Die uebrigen Vorschlaege behalten ihren Knopf.
246 expect(uebernehmen.length).toBeLessThan(
247 [...zweite.wurzel.querySelectorAll('tbody tr')].length,
248 );
249 } finally {
250 zweite.aufraeumen();
251 }
252 });
253 });