import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { mitBewahrtemFokus } from '@/ui/fokus'; import { conflictsView } from '@/ui/views/conflictsView'; import { lageplanView } from '@/ui/views/lageplanView'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; import type { Lageplan } from '@/domain/geometrie/lageplan'; /* * Ein Neuaufbau ist kein Ortswechsel: Der Fokus bleibt, wo er war. * * Die Shell konnte das laengst, Ansichten mit eigenem Neuaufbau nicht - der * Lageplan ruft seinen Aufbau sechsundzwanzigmal auf und context.refresh() kein * einziges Mal. Im Konfliktfenster warf die Fokusfalle den naechsten Tabulator * auf "Schliessen", genau dort, wo Raeumweg, Einfahrweg und beide * Geschwindigkeiten nacheinander einzutragen sind. * * Im gesamten Testbestand gab es dafuer bis hierher keine Zusicherung: kein * Treffer fuer .focus(), activeElement oder tabindex. */ const STICHTAG = new Date('2026-01-01T00:00:00Z'); let aufraeumen: (() => void) | null = null; afterEach(() => { aufraeumen?.(); aufraeumen = null; document.body.replaceChildren(); }); /** Baut eine kleine Ansicht mit zwei Feldern in einer Tabelle. */ function bauePlatz(mitFokusschluessel: boolean): { wurzel: HTMLElement; neu: () => void } { const wurzel = document.createElement('div'); document.body.append(wurzel); const neu = (): void => { wurzel.replaceChildren(); const tabelle = document.createElement('table'); const koerper = document.createElement('tbody'); for (const name of ['K1 nach K3', 'K2 nach K4']) { const zeile = document.createElement('tr'); for (const spalte of ['Räumweg', 'Einfahrweg']) { const zelle = document.createElement('td'); const feld = document.createElement('input'); feld.type = 'text'; // Die Kennung wechselt bei jedem Aufbau - genau wie im Programm, wo // field() fortlaufend hochzaehlt. Ein Wiederfinden ueber die Kennung // kann deshalb nicht funktionieren. feld.id = `feld-${Math.floor(performance.now() * 1000) % 100000}-${name}-${spalte}`; feld.value = '22'; if (mitFokusschluessel) feld.setAttribute('data-fokus', `${name}|${spalte}`); zelle.append(feld); zeile.append(zelle); } koerper.append(zeile); } tabelle.append(koerper); wurzel.append(tabelle); }; neu(); aufraeumen = () => wurzel.remove(); return { wurzel, neu }; } const feldMit = (wurzel: HTMLElement, schluessel: string): HTMLInputElement => wurzel.querySelector(`[data-fokus="${schluessel}"]`)!; describe('Fokus ueber einen Neuaufbau', () => { it('findet dieselbe Tabellenzelle ueber ihren fachlichen Schluessel wieder', () => { const { wurzel, neu } = bauePlatz(true); const vorher = feldMit(wurzel, 'K2 nach K4|Einfahrweg'); vorher.focus(); expect(document.activeElement).toBe(vorher); mitBewahrtemFokus(wurzel, neu); const nachher = document.activeElement as HTMLElement; expect(nachher).not.toBe(vorher); expect(nachher.getAttribute('data-fokus')).toBe('K2 nach K4|Einfahrweg'); }); it('traegt die Schreibmarke mit, statt ans Ende zu springen', () => { const { wurzel, neu } = bauePlatz(true); const vorher = feldMit(wurzel, 'K1 nach K3|Räumweg'); vorher.value = '1234'; vorher.focus(); vorher.setSelectionRange(2, 2); mitBewahrtemFokus(wurzel, neu); const nachher = document.activeElement as HTMLInputElement; expect(nachher.getAttribute('data-fokus')).toBe('K1 nach K3|Räumweg'); expect(nachher.selectionStart, 'Schreibmarke mitten in der Zahl').toBe(2); }); it('findet das Feld auch ohne Schluessel ueber seinen Platz im Aufbau', () => { const { wurzel, neu } = bauePlatz(false); const felder = [...wurzel.querySelectorAll('input')]; const vorher = felder[2]!; vorher.focus(); mitBewahrtemFokus(wurzel, neu); const nachher = document.activeElement as HTMLElement; expect([...wurzel.querySelectorAll('input')].indexOf(nachher as HTMLInputElement)).toBe(2); }); it('laesst einen Fokus ausserhalb des neu gezeichneten Bereichs in Ruhe', () => { const { wurzel, neu } = bauePlatz(true); const draussen = document.createElement('button'); document.body.append(draussen); draussen.focus(); mitBewahrtemFokus(wurzel, neu); expect(document.activeElement).toBe(draussen); draussen.remove(); }); it('springt nicht auf etwas, das sich nicht bedienen laesst', () => { // Aendert sich der Aufbau, kann der Strukturweg auf einen Absatz zeigen. // Dort zu landen waere schlechter als gar nichts. const wurzel = document.createElement('div'); document.body.append(wurzel); aufraeumen = () => wurzel.remove(); const feld = document.createElement('input'); wurzel.append(feld); feld.focus(); mitBewahrtemFokus(wurzel, () => { wurzel.replaceChildren(document.createElement('p')); }); expect(document.activeElement).not.toBe(wurzel.firstElementChild); expect((document.activeElement as HTMLElement).tagName).not.toBe('P'); }); }); describe('Konfliktfenster behaelt den Fokus beim Eintragen', () => { function oeffneFenster(): { wurzel: HTMLElement; store: ProjectStore } { const projekt: Project = createStandardIntersectionProject('Fokus', STICHTAG); const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(projekt); const schliesse = conflictsView.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); aufraeumen = () => { schliesse(); wurzel.remove(); }; // Die erste besetzte Zelle der Matrix anklicken. const zelle = [...document.querySelectorAll('.matrix-zelle')].find( (b) => !b.classList.contains('matrix-zelle-leer'), ); expect(zelle, 'die Matrix muss eine besetzte Zelle haben').toBeDefined(); zelle!.click(); return { wurzel, store }; } it('bleibt nach einer Eingabe im selben Feld stehen', () => { oeffneFenster(); const felder = [...document.querySelectorAll('[role="dialog"] input')]; expect(felder.length, 'das Fenster muss Eingabefelder haben').toBeGreaterThan(1); const feld = felder[0]!; feld.focus(); const platz = felder.indexOf(feld); feld.value = '31'; feld.dispatchEvent(new Event('change', { bubbles: true })); const jetzt = document.activeElement as HTMLElement; // Nicht auf den Fensterrumpf und nicht auf "Schliessen". expect(jetzt.tagName).toBe('INPUT'); const nachher = [...document.querySelectorAll('[role="dialog"] input')]; expect(nachher.indexOf(jetzt as HTMLInputElement)).toBe(platz); }); }); describe('Lageplan behaelt den Fokus beim Eintragen', () => { it('bleibt nach einer Eingabe im selben Feld stehen', () => { // Ohne Lageplan zeigt die Ansicht nur einen Leerzustand; die Eingabefelder // haengen an der Vermassung. const lageplan: Lageplan = { arbeitsbereiche: [], signalgeber: [], bild: null, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 1000, y: 0 }, laengeMeter: 60, herkunft: 'georeferenziert', }, haltlinien: [], linien: [], }; const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore({ ...createStandardIntersectionProject('Lageplan', STICHTAG), lageplan, }); const schliesse = lageplanView.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); aufraeumen = () => { schliesse(); wurzel.remove(); }; // Das Werkzeug "Ansehen" ist ohne Bild als einziges bedienbar - die // uebrigen Werkzeuge sind gesperrt, solange weder Bild noch Vermassung // vorliegen, und eine gesperrte Schaltflaeche nimmt ohnehin keinen Fokus an. const knoepfe = [...wurzel.querySelectorAll('button')]; const werkzeug = knoepfe.find((b) => (b.textContent ?? '').trim() === 'Ansehen'); expect(werkzeug, 'das Werkzeug "Ansehen" muss es geben').toBeDefined(); const platz = knoepfe.indexOf(werkzeug!); werkzeug!.focus(); expect(document.activeElement).toBe(werkzeug); werkzeug!.click(); // Der Aufbau ist wirklich durchgelaufen - sonst prueft der Test nichts. expect(werkzeug!.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'); const nachher = [...wurzel.querySelectorAll('button')]; expect(nachher.indexOf(jetzt as HTMLButtonElement)).toBe(platz); expect((jetzt.textContent ?? '').trim()).toBe('Ansehen'); }); });