import { afterEach, describe, expect, it, vi } from 'vitest'; import { ProjectStore } from '@/app/store'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { validateProject } from '@/domain/validation'; import type { FileOperationResult } from '@/services/storage'; import type { ViewContext } from '@/ui/shell'; import { exportView } from '@/ui/views/exportView'; /** * Befund 49: Die Ansicht "Ausgabe" zeichnete sich selbst neu, ohne den Fokus * zu bewahren. * * WAS DER ANWENDER SONST ERLEBT * * Weg 1 - Tastatur und Kontrollkaestchen: Er stellt in der Ausgabe mit dem * Tabulator zusammen, was in die Planunterlage soll, und drueckt zwischendurch * Strg+S. Bei gesetztem Dateipfad speichert das ohne Dialog, `markSaved()` * benachrichtigt die Empfaenger, die Ansicht baut ihren ganzen Bedienteil neu * auf - und der Fokus faellt auf den Seitenkoerper. Der naechste Tabulator * beginnt wieder bei der Sprungmarke am Dokumentanfang, also acht Kaestchen, * sechs Tabellenknoepfe und die ganze Navigation weiter vorn. * * Weg 2 - PDF: Er geht mit dem Tabulator auf "PDF erzeugen" und drueckt die * Eingabetaste. Der Knopf wird fuer die Dauer des Laufs gesperrt, die Ansicht * zeichnet neu, der gedrueckte Knopf ist danach nicht mehr im Baum. Nach dem * Speichern zeichnet sie ein zweites Mal neu. Beide Male bleibt der Fokus auf * dem Seitenkoerper liegen; wer nur die Tastatur hat, muss sich zum eben * betaetigten Knopf zurueckarbeiten. */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); /** * Ersetzt wird nur die Aussenwelt: der Speicherdialog und das Erzeugen der * PDF-Bytes. Alles andere an der Ansicht bleibt das echte Modul - sonst * pruefte dieser Fall eine Nachbildung statt der Anwendung. */ const stub = vi.hoisted(() => ({ gespeichert: 0 })); vi.mock('@/services/storage', async (importOriginal) => { const echt = await importOriginal(); return { ...echt, saveDataToFile: (): Promise => { stub.gespeichert += 1; return Promise.resolve({ ok: true, canceled: false, filePath: 'C:/Plan.pdf', message: 'Gespeichert: C:/Plan.pdf', }); }, }; }); vi.mock('@/services/export/pdf', async (importOriginal) => { const echt = await importOriginal(); return { ...echt, buildProjectPdf: (): Uint8Array => new Uint8Array([37, 80, 68, 70]) }; }); function knopf(wurzel: ParentNode, beschriftung: string): HTMLButtonElement { const gefunden = [...wurzel.querySelectorAll('button')].find( (b) => b.textContent?.trim() === beschriftung, ); if (!gefunden) throw new Error(`Kein Knopf "${beschriftung}"`); return gefunden; } function baueAnsicht(): { wurzel: HTMLElement; store: ProjectStore; aufraeumen: () => void } { const projekt = createStandardIntersectionProject('Fokus Ausgabe', FESTES_DATUM); // Die Ansicht ist nur bei freiem Export vollstaendig bedienbar. expect( validateProject(projekt, buildSignalPlan(projekt), FESTES_DATUM).exportBlocked, 'Vorbedingung: Export nicht gesperrt', ).toBe(false); const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(projekt); const kontext: ViewContext = { store, refresh: () => {}, navigate: () => {}, target: null, }; const schliesse = exportView.render(wurzel, kontext); return { wurzel, store, aufraeumen: () => { schliesse(); wurzel.remove(); }, }; } /** Einen Durchlauf der Aufgabenschlange abwarten. */ const abwarten = (): Promise => new Promise((fertig) => { setTimeout(fertig, 0); }); afterEach(() => { stub.gespeichert = 0; document.body.replaceChildren(); }); describe('Ausgabe: Der Fokus ueberlebt den Neuaufbau der Ansicht (Befund 49)', () => { it('bleibt beim Speichern im Kontrollkaestchen stehen, statt auf den Seitenkoerper zu fallen', () => { const { wurzel, store, aufraeumen } = baueAnsicht(); try { const kaestchen = [...wurzel.querySelectorAll('input[type="checkbox"]')]; // Neun seit dem 11.09.2026: Die Begruendung der Umlaufzeit ist ein // eigener, abwaehlbarer Abschnitt der Unterlage. expect(kaestchen.length, 'neun Schalter fuer den Inhalt der Planunterlagen').toBe(9); const viertes = kaestchen[3]!; viertes.focus(); expect(document.activeElement).toBe(viertes); // Strg+S bei gesetztem Dateipfad: speichert ohne Dialog und // benachrichtigt die Empfaenger. store.markSaved('C:/Projekt.lsap'); // Der Neuaufbau ist wirklich gelaufen - sonst prueft der Fall nichts. expect(viertes.isConnected, 'die Ansicht muss neu gezeichnet worden sein').toBe(false); const jetzt = document.activeElement as HTMLElement; expect(jetzt.tagName, 'der Fokus darf nicht auf den Seitenkoerper fallen').toBe('INPUT'); const nachher = [...wurzel.querySelectorAll('input[type="checkbox"]')]; expect(nachher.indexOf(jetzt as HTMLInputElement), 'dasselbe Kaestchen wie vorher').toBe(3); } finally { aufraeumen(); } }); it('kehrt nach dem PDF-Lauf auf den wieder freigegebenen Knopf "PDF erzeugen" zurueck', async () => { const { wurzel, aufraeumen } = baueAnsicht(); try { const vorher = knopf(wurzel, 'PDF erzeugen'); expect(vorher.disabled).toBe(false); vorher.focus(); expect(document.activeElement).toBe(vorher); vorher.click(); // Der Standardknotenpunkt traegt Warnungen; die Rueckfrage ist zu // bestaetigen, bevor der Lauf beginnt. const dialog = document.querySelector('[role="dialog"]'); expect(dialog, 'die Rueckfrage zu den Warnungen muss erscheinen').not.toBeNull(); knopf(dialog!, 'PDF erzeugen').click(); await abwarten(); await abwarten(); await abwarten(); expect(stub.gespeichert, 'der Speicherweg muss durchlaufen sein').toBe(1); // Beide Neuaufbauten sind gelaufen - der alte Knopf haengt nicht mehr. expect(vorher.isConnected, 'die Ansicht muss neu gezeichnet worden sein').toBe(false); const jetzt = document.activeElement as HTMLElement; expect(jetzt.tagName, 'der Fokus darf nicht auf den Seitenkoerper fallen').toBe('BUTTON'); expect((jetzt.textContent ?? '').trim()).toBe('PDF erzeugen'); expect(wurzel.contains(jetzt), 'und zwar auf den Knopf der Ansicht').toBe(true); expect((jetzt as HTMLButtonElement).disabled, 'wieder freigegeben').toBe(false); } finally { aufraeumen(); } }); });