import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { DARK_THEME, LIGHT_THEME } from '@/render/signalPlanDrawing'; import { ladeSitzung, saveSession } from '@/services/storage'; /** * Wechselt das Erscheinungsbild im Betrieb, werden die Zeichenflaechen neu * gezeichnet (Befund 46). * * `applyTheme` setzt das Attribut `data-erscheinungsbild` am Wurzelelement. * Alle Farben der Oberflaeche haengen an CSS-Variablen und folgen sofort - die * Zeichenflaechen aber nicht: `planView`, `simulationView` und `lageplanView` * lesen das Attribut im Augenblick des Zeichnens und behalten sonst die alten * Farben, bis irgendetwas anderes ein Neuzeichnen ausloest. Der Anwender sieht * dann eine weisse Zeichnung mitten in einer dunklen Oberflaeche. * * Der Wechsel kommt aus zwei Richtungen: ueber die Kopfzeile und aus dem * Betriebssystem. Geprueft wird hier der zweite Weg, weil ihn niemand sonst * beantwortet - die Kopfzeile zeichnete bisher als einzige selbst nach. * * Geprueft wird am echten Einstiegspunkt: `src/main.ts` wird eingelesen und * startet die ganze Anwendung; ersetzt sind nur der Sitzungsspeicher, die * Medienabfragen und der Zeichenkontext. */ vi.mock('@/services/storage', async (importOriginal) => { const echt = await importOriginal(); return { ...echt, ladeSitzung: vi.fn(), saveSession: vi.fn(), addRecoveryPoint: vi.fn(), }; }); /** * Zeichenkontext, der nur die Fuellfarben mitschreibt. * * jsdom bringt keine Zeichenflaeche mit. Gefragt ist hier ohnehin nur eine * einzige Farbe: Der Grund des Signalzeitenplans wird als erstes Rechteck mit * `theme.surface` gefuellt. */ class Farbschreiber { readonly fuellungen: string[] = []; fillStyle = ''; strokeStyle = ''; lineWidth = 0; font = ''; textAlign = 'left'; textBaseline = 'top'; save(): void {} restore(): void {} clearRect(): void {} setTransform(): void {} setLineDash(): void {} beginPath(): void {} moveTo(): void {} lineTo(): void {} stroke(): void {} strokeRect(): void {} fillText(): void {} measureText(): { width: number } { return { width: 24 }; } fillRect(): void { this.fuellungen.push(this.fillStyle); } leeren(): void { this.fuellungen.length = 0; } } const stift = new Farbschreiber(); interface Abfrage { matches: boolean; empfaenger: (() => void)[]; } const abfragen = new Map(); function stelleMedienabfragen(): void { abfragen.clear(); abfragen.set('(forced-colors: active)', { matches: false, empfaenger: [] }); abfragen.set('(prefers-color-scheme: dark)', { matches: false, empfaenger: [] }); Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: (text: string) => { const zustand = abfragen.get(text) ?? { matches: false, empfaenger: [] }; abfragen.set(text, zustand); return { get matches() { return zustand.matches; }, media: text, addEventListener: (_art: string, empfaenger: () => void) => { zustand.empfaenger.push(empfaenger); }, removeEventListener: (_art: string, empfaenger: () => void) => { const stelle = zustand.empfaenger.indexOf(empfaenger); if (stelle >= 0) zustand.empfaenger.splice(stelle, 1); }, }; }, }); } /** Stellt das Betriebssystem um - so, wie es im Betrieb geschieht. */ function schalte(abfrage: string, matches: boolean): void { const zustand = abfragen.get(abfrage); if (zustand === undefined) throw new Error(`Abfrage ${abfrage} wurde nie gestellt.`); zustand.matches = matches; for (const empfaenger of [...zustand.empfaenger]) empfaenger(); } async function warte(ms = 0): Promise { await new Promise((fertig) => setTimeout(fertig, ms)); } /** Die Kopfzeilenschaltflaeche fuer das Erscheinungsbild. */ function erscheinungsbildKnopf(): HTMLButtonElement { const treffer = [...document.querySelectorAll('button')].find((b) => b.textContent?.startsWith('Ansicht:'), ); if (!treffer) throw new Error('Schaltfläche "Ansicht: ..." nicht gefunden.'); return treffer; } /** Wechselt ueber die Navigationsleiste in eine Ansicht. */ function gehZu(beschriftung: string): void { const treffer = [...document.querySelectorAll('.nav-knopf')].find( (b) => b.querySelector('.nav-beschriftung')?.textContent === beschriftung, ); if (!treffer) throw new Error(`Ansicht "${beschriftung}" nicht gefunden.`); treffer.click(); } beforeAll(async () => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( () => stift as unknown as CanvasRenderingContext2D, ); stelleMedienabfragen(); vi.mocked(ladeSitzung).mockResolvedValue({ art: 'geladen', stand: { project: createStandardIntersectionProject(), issues: [], repaired: false, migrated: false, sourceVersion: '13', intergreenComparison: [], }, }); vi.mocked(saveSession).mockResolvedValue({ ok: true, message: '' }); localStorage.clear(); document.body.replaceChildren(); const wurzel = document.createElement('div'); wurzel.id = 'anwendung'; document.body.append(wurzel); await import('@/main'); await warte(20); }); afterAll(() => { vi.restoreAllMocks(); Reflect.deleteProperty(window, 'matchMedia'); }); describe('Wechsel des Erscheinungsbilds im laufenden Betrieb', () => { it('zeichnet den Signalzeitenplan mit den neuen Farben neu', () => { gehZu('Signalzeitenplan'); // Ausgangslage: helles Erscheinungsbild, heller Grund. expect(document.documentElement.dataset['erscheinungsbild']).toBe('hell'); expect(stift.fuellungen).toContain(LIGHT_THEME.surface); stift.leeren(); schalte('(prefers-color-scheme: dark)', true); // Das Attribut folgt bereits - nur die Leinwand blieb stehen. expect(document.documentElement.dataset['erscheinungsbild']).toBe('dunkel'); expect(stift.fuellungen).toContain(DARK_THEME.surface); }); it('zeichnet auch über die Kopfzeile neu, ohne es ein zweites Mal zu regeln', () => { // Die Kopfzeile zeichnete den Wechsel bisher selbst nach. Seit beide Wege // an derselben Stelle beantwortet werden, muss dieser hier mitgeprueft // werden: Vom System steht das Erscheinungsbild auf "dunkel", die // Einstellung springt im Rundlauf von "System" auf "Hell". expect(document.documentElement.dataset['erscheinungsbild']).toBe('dunkel'); stift.leeren(); erscheinungsbildKnopf().click(); expect(document.documentElement.dataset['erscheinungsbild']).toBe('hell'); expect(stift.fuellungen).toContain(LIGHT_THEME.surface); }); }); /** * Der Neuaufbau nach einem Erscheinungsbildwechsel ist KEIN Ortswechsel. * * Der Wechsel kommt hier aus dem Betriebssystem - die Windows-Umschaltung bei * Sonnenuntergang oder das Einschalten des Kontrastdesigns. Der Anwender hat * nichts angefordert; er tippt gerade. Wird die Ansicht ueber den * Ortswechselweg neu gezeichnet, setzt `zeichneAnsicht` den Fokus auf die h1 * der Ansicht und den Inhaltsbereich an den Anfang zurueck. Genau die Folgen, * die der Kommentar an `Shell.navigate` als Grund dafuer aufzaehlt, dass ein * Neuaufbau den Fokus bewahrt: Wer ein Feld ausfuellt, verliert mitten in der * Eingabe die Schreibstelle - ausgeloest von etwas, das er nicht getan hat. */ describe('Erscheinungsbildwechsel aus dem Betriebssystem', () => { it('laesst den Fokus im Eingabefeld stehen, statt auf die Überschrift zu springen', () => { gehZu('Projekt'); const feld = document.querySelector('.ansicht-halter input[type="text"]'); if (feld === null) throw new Error('Kein Textfeld in der Ansicht "Projekt" gefunden.'); feld.focus(); // Die Schreibstelle steht mitten im Wort - sie gehoert mit bewahrt. feld.setSelectionRange(3, 3); expect(document.activeElement).toBe(feld); // Windows schaltet das Kontrastdesign ein. Das Erscheinungsbild richtet // sich dann nach der Systempalette (theme.ts, effektivesErscheinungsbild) // und wechselt von "hell" auf "dunkel". schalte('(forced-colors: active)', true); expect(document.documentElement.dataset['erscheinungsbild']).toBe('dunkel'); const danach = document.activeElement; expect(danach).toBeInstanceOf(HTMLInputElement); expect(danach?.tagName).toBe('INPUT'); expect((danach as HTMLInputElement).type).toBe('text'); }); });