import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { ProjectStore } from '@/app/store'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; import { simulationView } from '@/ui/views/simulationView'; /** * Die Laufmarke muss gleichmaessig wandern - und im stehenden Bild genau dort * stehen, wo "Position im Umlauf" steht (Nachtrag zu Befund 27). * * Was der Anwender sonst erlebt: Seit die Simulation nur noch in ganzen * Schritten von 0,5 s rechnet, wandert die Laufmarke nicht mehr mit der * Bildwiederholung, sondern springt in Halbsekundenstufen - bei "Echtzeit" * zweimal je Sekunde, bei der Voreinstellung 4-fach achtmal, waehrend die * Leinwand weiter sechzigmal je Sekunde neu gezeichnet wird. Statt einer * laufenden Marke sieht er ein Ruckeln, und die Zusage an der Zeichenstelle * ("die Laufmarke soll gleichmaessig wandern") gilt nicht mehr. * * Umgekehrt darf die Marke im stehenden Bild nicht vor der Rechnung stehen: * Nach "Anhalten" wird abgelesen, und dann muss sie genau die Position zeigen, * die auch als Zahl daneben steht. */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); /** Bildabstand bei 60 Bildern je Sekunde. */ const BILDABSTAND_MS = 1000 / 60; /** * Bildabstand, bei dem die Voreinstellung "4-fach" genau einen Zeitschritt * (0,5 s) je Bild ergibt - das Restzeitkonto ist danach jedes Mal leer. */ const SCHRITTGENAUER_BILDABSTAND_MS = 125; /** * Zeichenkontext, der die Laufmarke mitschreibt. * * jsdom bringt keine Zeichenflaeche mit. Gefragt ist hier ohnehin nur eine * einzige Linie: Der Saum der Laufmarke ist der einzige Strich des * Signalzeitenplans, der 4 Bildpunkte breit gezogen wird. */ class Aufzeichner { readonly marken: number[] = []; strokeStyle = ''; fillStyle = ''; lineWidth = 0; font = ''; textAlign = 'left'; textBaseline = 'top'; private x = 0; save(): void {} restore(): void {} clearRect(): void {} fillRect(): void {} strokeRect(): void {} setTransform(): void {} setLineDash(): void {} beginPath(): void {} lineTo(): void {} fillText(): void {} moveTo(x: number): void { this.x = x; } measureText(): { width: number } { return { width: 24 }; } stroke(): void { if (this.lineWidth === 4) this.marken.push(this.x); } leeren(): void { this.marken.length = 0; } } const stift = new Aufzeichner(); /** Das jeweils naechste angeforderte Einzelbild. */ let naechstesBild: FrameRequestCallback | null = null; beforeAll(() => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( () => stift as unknown as CanvasRenderingContext2D, ); // Die Bildschleife wird von Hand getaktet: Echte Einzelbilder kaemen in // unvorhersehbaren Abstaenden, und genau der Abstand ist hier die Groesse, // um die es geht. vi.stubGlobal('requestAnimationFrame', (rueckruf: FrameRequestCallback): number => { naechstesBild = rueckruf; return 1; }); vi.stubGlobal('cancelAnimationFrame', (): void => { naechstesBild = null; }); }); afterAll(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); afterEach(() => { naechstesBild = null; stift.leeren(); document.body.replaceChildren(); }); function zeichne(project: Project): { wurzel: HTMLElement; aufraeumen: () => void } { const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(project); const abmelden = simulationView.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); return { wurzel, aufraeumen: () => { abmelden(); wurzel.remove(); }, }; } function knopf(wurzel: ParentNode, beschriftung: string): HTMLButtonElement { const gefunden = [...wurzel.querySelectorAll('button')].find( (b) => b.textContent?.trim() === beschriftung, ); if (!gefunden) throw new Error(`Keine Schaltfläche "${beschriftung}"`); return gefunden; } function kennzahl(wurzel: ParentNode, beschriftung: string): string { const feld = [...wurzel.querySelectorAll('.kennzahl')].find( (k) => k.querySelector('dt')?.textContent === beschriftung, ); if (!feld) throw new Error(`Keine Kennzahl "${beschriftung}"`); return feld.querySelector('dd')?.textContent ?? ''; } /** "1,5 s" -> 1.5 */ function sekunden(anzeige: string): number { return Number(anzeige.replace(' s', '').replace('.', '').replace(',', '.')); } /** Loest ein Einzelbild zum angegebenen Zeitpunkt aus. */ function einzelbild(zeitpunktMs: number): void { const rueckruf = naechstesBild; naechstesBild = null; if (!rueckruf) throw new Error('Kein Einzelbild angefordert'); rueckruf(zeitpunktMs); } describe('Laufmarke der Simulation', () => { it('wandert in jedem Einzelbild weiter, nicht nur in Halbsekundenstufen', () => { const project = createStandardIntersectionProject('Laufmarke', FESTES_DATUM); expect(buildSignalPlan(project).cycleTime).toBe(90); const { wurzel, aufraeumen } = zeichne(project); try { knopf(wurzel, 'Starten').click(); stift.leeren(); for (let i = 1; i <= 60; i += 1) einzelbild(i * BILDABSTAND_MS); // Eine Sekunde Echtzeit bei 60 Bildern je Sekunde: sechzig Bilder, und in // jedem steht die Marke woanders. Rechnete die Ansicht die angefangene // Zeit nicht mit, waeren es bei 4-fach nur acht Stellungen. expect(stift.marken).toHaveLength(60); expect(new Set(stift.marken).size).toBe(60); } finally { aufraeumen(); } }); it('steht nach dem Anhalten genau auf der angezeigten Position im Umlauf', () => { const project = createStandardIntersectionProject('Laufmarke', FESTES_DATUM); const { wurzel, aufraeumen } = zeichne(project); try { knopf(wurzel, 'Starten').click(); stift.leeren(); // Zwei schrittgenaue Bilder als Massstab: Die Zeichnung bildet die // Umlaufzeit linear auf die Breite ab, das Restzeitkonto ist leer, die // Marke steht also auf 0,0 s und danach auf 0,5 s. einzelbild(SCHRITTGENAUER_BILDABSTAND_MS); einzelbild(2 * SCHRITTGENAUER_BILDABSTAND_MS); const [beiNull, beiEinemSchritt] = stift.marken; if (beiNull === undefined || beiEinemSchritt === undefined) { throw new Error('Keine Laufmarke gezeichnet'); } const jeSekunde = (beiEinemSchritt - beiNull) / 0.5; expect(jeSekunde).toBeGreaterThan(0); // Dann zwanzig gewoehnliche Einzelbilder - danach steht im Restzeitkonto // ein angefangener Schritt. const beginn = 2 * SCHRITTGENAUER_BILDABSTAND_MS; for (let i = 1; i <= 20; i += 1) einzelbild(beginn + i * BILDABSTAND_MS); const imLauf = stift.marken[stift.marken.length - 1]; stift.leeren(); knopf(wurzel, 'Anhalten').click(); const [imStand] = stift.marken; if (imStand === undefined) throw new Error('Beim Anhalten nichts gezeichnet'); const position = sekunden(kennzahl(wurzel, 'Position im Umlauf')); expect(imStand).toBeCloseTo(beiNull + position * jeSekunde, 6); // Waehrend des Laufs lief die Marke der Rechnung voraus - genau darum // wird sie beim Anhalten neu gezeichnet. expect(imLauf).toBeGreaterThan(imStand); } finally { aufraeumen(); } }); });