import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { MINDEST_ZEICHENBREITE } from '@/render/signalPlanDrawing'; import type { Project } from '@/domain/model/project'; import type { ViewDefinition } from '@/ui/shell'; import { planView } from '@/ui/views/planView'; import { simulationView } from '@/ui/views/simulationView'; /** * Befunde 60 und 57 zur Fassung 5.10.0 - die Breite des Zeitdiagramms. * * BEFUND 60. Die Zeichenbreite war * `Math.max(MINDEST_ZEICHENBREITE, frame.clientWidth - 2, 640 * zoom)`. Der * Vergroesserungsfaktor wirkte erst, wenn `640 * zoom` die eingepasste Breite * uebersteigt. Beim Vorgabefenster 1440 x 920 bleibt der Rahmen rund 1125 * Bildpunkte breit; der erste Druck auf "+" (640 x 1,5 = 960) war damit ohne * jede Wirkung, maximiert auf 1920 sogar die ersten drei. Der Knopf blieb * bedienbar, das Bild Bildpunkt fuer Bildpunkt dasselbe, eine Rueckmeldung gab * es nicht. Die Zahl 640 ist an keiner Stelle des Projekts begruendet. * * BEFUND 57. Die Simulation zeichnete nur aus `tick`, `stop`, "Einen Umlauf", * "Zurücksetzen", dem Haken und einmal beim Aufbau - kein Beobachter der * Rahmengroesse. Da die Leinwand ihre Breite als feste Bildpunktzahl traegt * und `.plan-rahmen` waagerecht rollt, bekam der STEHENDE Plan beim * Verkleinern eine Rollleiste, obwohl er passte, und beim Vergroessern blieb * rechts Weissraum. Gerade der stehende Stand ist der, den man abliest; im * Lauf heilt es das naechste Einzelbild. Der Signalzeitenplan macht es seit * jeher richtig (ResizeObserver am Rahmen, abgemeldet im Rueckgabewert). */ const STICHTAG = new Date('2026-01-01T00:00:00Z'); /** Aufzeichnende Ersatzausfuehrung - jsdom bringt keinen ResizeObserver mit. */ class ProbeResizeObserver { static readonly angemeldet: ProbeResizeObserver[] = []; readonly ziele: Element[] = []; abgemeldet = false; constructor(private readonly rueckmeldung: () => void) { ProbeResizeObserver.angemeldet.push(this); } observe(ziel: Element): void { this.ziele.push(ziel); } disconnect(): void { this.abgemeldet = true; } /** Meldet eine Groessenaenderung, wie es der Browser taete. */ melde(): void { this.rueckmeldung(); } } type MitBeobachter = { ResizeObserver?: unknown }; let aufraeumen: (() => void) | null = null; beforeEach(() => { ProbeResizeObserver.angemeldet.length = 0; (globalThis as MitBeobachter).ResizeObserver = ProbeResizeObserver; }); afterEach(() => { aufraeumen?.(); aufraeumen = null; delete (globalThis as MitBeobachter).ResizeObserver; document.body.replaceChildren(); }); function zeichne(view: ViewDefinition, project: Project): HTMLElement { const wurzel = document.createElement('div'); document.body.append(wurzel); const schliessen = view.render(wurzel, { store: new ProjectStore(project), refresh: () => {}, navigate: () => {}, target: null, }); aufraeumen = () => { schliessen(); wurzel.remove(); }; return wurzel; } /** * Setzt die Breite des Rahmens. jsdom rechnet keine Darstellung und meldet * durchweg 0 - ohne diese Vorgabe fiele jede Zeichnung auf die Mindestbreite * zurueck, und genau die Lage, um die es geht (Rahmen breiter als 640 x zoom), * kaeme nie zustande. */ function setzeRahmenbreite(wurzel: HTMLElement, breite: number): HTMLElement { const rahmen = wurzel.querySelector('.plan-rahmen'); expect(rahmen, 'Kein Rahmen .plan-rahmen').not.toBeNull(); Object.defineProperty(rahmen, 'clientWidth', { value: breite, configurable: true }); return rahmen as HTMLElement; } /** Zuletzt gezeichnete Breite der Leinwand in Bildpunkten. */ function leinwandbreite(wurzel: HTMLElement): number { const leinwand = wurzel.querySelector('canvas.plan-leinwand'); expect(leinwand, 'Keine Leinwand').not.toBeNull(); return Number.parseFloat((leinwand as HTMLCanvasElement).style.width); } function knopf(wurzel: HTMLElement, name: string): HTMLButtonElement { const treffer = [...wurzel.querySelectorAll('button')].find( (b) => b.getAttribute('aria-label') === name || b.textContent?.trim() === name, ); expect(treffer, `Keine Schaltflaeche "${name}"`).toBeDefined(); return treffer as HTMLButtonElement; } /** Wartet den naechsten Bilddurchlauf ab. */ const naechstesBild = (): Promise => new Promise((fertig) => { requestAnimationFrame(() => { fertig(); }); }); describe('Befund 60 - jede Stufe von "Vergrößern" wirkt', () => { it('vergroessert vom eingepassten Bild aus, nicht von 640 Bildpunkten', () => { const wurzel = zeichne(planView, createStandardIntersectionProject('Zoom', STICHTAG)); // Rahmen des Vorgabefensters 1440 x 920: rund 1200 Bildpunkte. setzeRahmenbreite(wurzel, 1200); knopf(wurzel, 'Einpassen').click(); const eingepasst = leinwandbreite(wurzel); expect(eingepasst).toBe(1198); knopf(wurzel, 'Vergrößern').click(); expect(leinwandbreite(wurzel), 'Der erste Druck blieb ohne Wirkung').toBe(1198 * 1.5); knopf(wurzel, 'Vergrößern').click(); expect(leinwandbreite(wurzel)).toBe(1198 * 2); knopf(wurzel, 'Verkleinern').click(); expect(leinwandbreite(wurzel)).toBe(1198 * 1.5); }); it('laesst "Einpassen" die Ausgangslage bleiben', () => { const wurzel = zeichne(planView, createStandardIntersectionProject('Zoom', STICHTAG)); setzeRahmenbreite(wurzel, 1200); knopf(wurzel, 'Vergrößern').click(); knopf(wurzel, 'Einpassen').click(); expect(leinwandbreite(wurzel)).toBe(1198); }); it('haelt die Mindestbreite auch im schmalen Fenster ein', () => { const wurzel = zeichne(planView, createStandardIntersectionProject('Zoom', STICHTAG)); // Schmalster zulaessiger Rahmen: Die Untergrenze greift, und ab der ersten // Stufe wirkt jeder Druck - das war schon bisher so und bleibt es. setzeRahmenbreite(wurzel, 300); knopf(wurzel, 'Einpassen').click(); expect(leinwandbreite(wurzel)).toBe(MINDEST_ZEICHENBREITE); knopf(wurzel, 'Vergrößern').click(); expect(leinwandbreite(wurzel)).toBe(MINDEST_ZEICHENBREITE * 1.5); }); }); describe('Befund 57 - das Zeitdiagramm der Simulation folgt der Fenstergroesse', () => { it('zeichnet den stehenden Plan in der neuen Breite neu', async () => { const wurzel = zeichne(simulationView, createStandardIntersectionProject('Groesse', STICHTAG)); const rahmen = setzeRahmenbreite(wurzel, 1200); const beobachter = ProbeResizeObserver.angemeldet.at(-1); expect(beobachter, 'Kein ResizeObserver angemeldet').toBeDefined(); expect(beobachter?.ziele).toContain(rahmen); beobachter?.melde(); await naechstesBild(); expect(leinwandbreite(wurzel)).toBe(1198); setzeRahmenbreite(wurzel, 800); beobachter?.melde(); await naechstesBild(); expect(leinwandbreite(wurzel)).toBe(798); }); it('zeichnet nicht neu, wenn sich die Breite nicht geaendert hat', async () => { const wurzel = zeichne(simulationView, createStandardIntersectionProject('Groesse', STICHTAG)); setzeRahmenbreite(wurzel, 1200); const beobachter = ProbeResizeObserver.angemeldet.at(-1); expect(beobachter, 'Kein ResizeObserver angemeldet').toBeDefined(); beobachter?.melde(); await naechstesBild(); const leinwand = wurzel.querySelector('canvas.plan-leinwand'); leinwand?.setAttribute('data-probe', 'unveraendert'); leinwand!.style.width = '4711px'; beobachter?.melde(); await naechstesBild(); // Unveraendert: Ohne die Abfrage auf eine wirklich geaenderte Breite loeste // jeder Zeichenvorgang die naechste Groessenmeldung aus. expect(leinwandbreite(wurzel)).toBe(4711); }); it('meldet den Beobachter beim Verlassen der Ansicht ab', () => { zeichne(simulationView, createStandardIntersectionProject('Groesse', STICHTAG)); const beobachter = ProbeResizeObserver.angemeldet.at(-1); expect(beobachter?.abgemeldet).toBe(false); aufraeumen?.(); aufraeumen = null; // Sonst zeichnet die verlassene Ansicht bei jeder Fenstergroessenaenderung // weiter - dieselbe Zusage gibt der Signalzeitenplan. expect(beobachter?.abgemeldet).toBe(true); }); });