lsa-planer

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

/ tests ui laufmarke.test.ts

7,3 KB Rohdatei
tests/ui/laufmarke.test.ts — 219 Zeilen
1 import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
2 import { ProjectStore } from '@/app/store';
3 import { buildSignalPlan } from '@/domain/plan/signalPlan';
4 import { createStandardIntersectionProject } from '@/domain/model/factory';
5 import type { Project } from '@/domain/model/project';
6 import { simulationView } from '@/ui/views/simulationView';
7
8 /**
9 * Die Laufmarke muss gleichmaessig wandern - und im stehenden Bild genau dort
10 * stehen, wo "Position im Umlauf" steht (Nachtrag zu Befund 27).
11 *
12 * Was der Anwender sonst erlebt: Seit die Simulation nur noch in ganzen
13 * Schritten von 0,5 s rechnet, wandert die Laufmarke nicht mehr mit der
14 * Bildwiederholung, sondern springt in Halbsekundenstufen - bei "Echtzeit"
15 * zweimal je Sekunde, bei der Voreinstellung 4-fach achtmal, waehrend die
16 * Leinwand weiter sechzigmal je Sekunde neu gezeichnet wird. Statt einer
17 * laufenden Marke sieht er ein Ruckeln, und die Zusage an der Zeichenstelle
18 * ("die Laufmarke soll gleichmaessig wandern") gilt nicht mehr.
19 *
20 * Umgekehrt darf die Marke im stehenden Bild nicht vor der Rechnung stehen:
21 * Nach "Anhalten" wird abgelesen, und dann muss sie genau die Position zeigen,
22 * die auch als Zahl daneben steht.
23 */
24
25 const FESTES_DATUM = new Date('2026-01-01T00:00:00Z');
26
27 /** Bildabstand bei 60 Bildern je Sekunde. */
28 const BILDABSTAND_MS = 1000 / 60;
29
30 /**
31 * Bildabstand, bei dem die Voreinstellung "4-fach" genau einen Zeitschritt
32 * (0,5 s) je Bild ergibt - das Restzeitkonto ist danach jedes Mal leer.
33 */
34 const SCHRITTGENAUER_BILDABSTAND_MS = 125;
35
36 /**
37 * Zeichenkontext, der die Laufmarke mitschreibt.
38 *
39 * jsdom bringt keine Zeichenflaeche mit. Gefragt ist hier ohnehin nur eine
40 * einzige Linie: Der Saum der Laufmarke ist der einzige Strich des
41 * Signalzeitenplans, der 4 Bildpunkte breit gezogen wird.
42 */
43 class Aufzeichner {
44 readonly marken: number[] = [];
45 strokeStyle = '';
46 fillStyle = '';
47 lineWidth = 0;
48 font = '';
49 textAlign = 'left';
50 textBaseline = 'top';
51 private x = 0;
52
53 save(): void {}
54 restore(): void {}
55 clearRect(): void {}
56 fillRect(): void {}
57 strokeRect(): void {}
58 setTransform(): void {}
59 setLineDash(): void {}
60 beginPath(): void {}
61 lineTo(): void {}
62 fillText(): void {}
63
64 moveTo(x: number): void {
65 this.x = x;
66 }
67
68 measureText(): { width: number } {
69 return { width: 24 };
70 }
71
72 stroke(): void {
73 if (this.lineWidth === 4) this.marken.push(this.x);
74 }
75
76 leeren(): void {
77 this.marken.length = 0;
78 }
79 }
80
81 const stift = new Aufzeichner();
82
83 /** Das jeweils naechste angeforderte Einzelbild. */
84 let naechstesBild: FrameRequestCallback | null = null;
85
86 beforeAll(() => {
87 vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
88 () => stift as unknown as CanvasRenderingContext2D,
89 );
90 // Die Bildschleife wird von Hand getaktet: Echte Einzelbilder kaemen in
91 // unvorhersehbaren Abstaenden, und genau der Abstand ist hier die Groesse,
92 // um die es geht.
93 vi.stubGlobal('requestAnimationFrame', (rueckruf: FrameRequestCallback): number => {
94 naechstesBild = rueckruf;
95 return 1;
96 });
97 vi.stubGlobal('cancelAnimationFrame', (): void => {
98 naechstesBild = null;
99 });
100 });
101
102 afterAll(() => {
103 vi.restoreAllMocks();
104 vi.unstubAllGlobals();
105 });
106
107 afterEach(() => {
108 naechstesBild = null;
109 stift.leeren();
110 document.body.replaceChildren();
111 });
112
113 function zeichne(project: Project): { wurzel: HTMLElement; aufraeumen: () => void } {
114 const wurzel = document.createElement('div');
115 document.body.append(wurzel);
116 const store = new ProjectStore(project);
117 const abmelden = simulationView.render(wurzel, {
118 store,
119 refresh: () => {},
120 navigate: () => {},
121 target: null,
122 });
123 return {
124 wurzel,
125 aufraeumen: () => {
126 abmelden();
127 wurzel.remove();
128 },
129 };
130 }
131
132 function knopf(wurzel: ParentNode, beschriftung: string): HTMLButtonElement {
133 const gefunden = [...wurzel.querySelectorAll('button')].find(
134 (b) => b.textContent?.trim() === beschriftung,
135 );
136 if (!gefunden) throw new Error(`Keine Schaltfläche "${beschriftung}"`);
137 return gefunden;
138 }
139
140 function kennzahl(wurzel: ParentNode, beschriftung: string): string {
141 const feld = [...wurzel.querySelectorAll('.kennzahl')].find(
142 (k) => k.querySelector('dt')?.textContent === beschriftung,
143 );
144 if (!feld) throw new Error(`Keine Kennzahl "${beschriftung}"`);
145 return feld.querySelector('dd')?.textContent ?? '';
146 }
147
148 /** "1,5 s" -> 1.5 */
149 function sekunden(anzeige: string): number {
150 return Number(anzeige.replace(' s', '').replace('.', '').replace(',', '.'));
151 }
152
153 /** Loest ein Einzelbild zum angegebenen Zeitpunkt aus. */
154 function einzelbild(zeitpunktMs: number): void {
155 const rueckruf = naechstesBild;
156 naechstesBild = null;
157 if (!rueckruf) throw new Error('Kein Einzelbild angefordert');
158 rueckruf(zeitpunktMs);
159 }
160
161 describe('Laufmarke der Simulation', () => {
162 it('wandert in jedem Einzelbild weiter, nicht nur in Halbsekundenstufen', () => {
163 const project = createStandardIntersectionProject('Laufmarke', FESTES_DATUM);
164 expect(buildSignalPlan(project).cycleTime).toBe(90);
165 const { wurzel, aufraeumen } = zeichne(project);
166 try {
167 knopf(wurzel, 'Starten').click();
168 stift.leeren();
169 for (let i = 1; i <= 60; i += 1) einzelbild(i * BILDABSTAND_MS);
170 // Eine Sekunde Echtzeit bei 60 Bildern je Sekunde: sechzig Bilder, und in
171 // jedem steht die Marke woanders. Rechnete die Ansicht die angefangene
172 // Zeit nicht mit, waeren es bei 4-fach nur acht Stellungen.
173 expect(stift.marken).toHaveLength(60);
174 expect(new Set(stift.marken).size).toBe(60);
175 } finally {
176 aufraeumen();
177 }
178 });
179
180 it('steht nach dem Anhalten genau auf der angezeigten Position im Umlauf', () => {
181 const project = createStandardIntersectionProject('Laufmarke', FESTES_DATUM);
182 const { wurzel, aufraeumen } = zeichne(project);
183 try {
184 knopf(wurzel, 'Starten').click();
185 stift.leeren();
186
187 // Zwei schrittgenaue Bilder als Massstab: Die Zeichnung bildet die
188 // Umlaufzeit linear auf die Breite ab, das Restzeitkonto ist leer, die
189 // Marke steht also auf 0,0 s und danach auf 0,5 s.
190 einzelbild(SCHRITTGENAUER_BILDABSTAND_MS);
191 einzelbild(2 * SCHRITTGENAUER_BILDABSTAND_MS);
192 const [beiNull, beiEinemSchritt] = stift.marken;
193 if (beiNull === undefined || beiEinemSchritt === undefined) {
194 throw new Error('Keine Laufmarke gezeichnet');
195 }
196 const jeSekunde = (beiEinemSchritt - beiNull) / 0.5;
197 expect(jeSekunde).toBeGreaterThan(0);
198
199 // Dann zwanzig gewoehnliche Einzelbilder - danach steht im Restzeitkonto
200 // ein angefangener Schritt.
201 const beginn = 2 * SCHRITTGENAUER_BILDABSTAND_MS;
202 for (let i = 1; i <= 20; i += 1) einzelbild(beginn + i * BILDABSTAND_MS);
203 const imLauf = stift.marken[stift.marken.length - 1];
204
205 stift.leeren();
206 knopf(wurzel, 'Anhalten').click();
207 const [imStand] = stift.marken;
208 if (imStand === undefined) throw new Error('Beim Anhalten nichts gezeichnet');
209
210 const position = sekunden(kennzahl(wurzel, 'Position im Umlauf'));
211 expect(imStand).toBeCloseTo(beiNull + position * jeSekunde, 6);
212 // Waehrend des Laufs lief die Marke der Rechnung voraus - genau darum
213 // wird sie beim Anhalten neu gezeichnet.
214 expect(imLauf).toBeGreaterThan(imStand);
215 } finally {
216 aufraeumen();
217 }
218 });
219 });