import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { createPhase, createStandardIntersectionProject } from '@/domain/model/factory'; import { mitBewahrtemFokus } from '@/ui/fokus'; import { orderedPhases, type Project } from '@/domain/model/project'; import type { ViewContext, ViewDefinition } from '@/ui/shell'; import { phasesView } from '@/ui/views/phasesView'; import { signalGroupsView } from '@/ui/views/signalGroupsView'; /** * Befund 47 zur Fassung 5.10.0 - der Fokus nach dem Umsortieren. * * Ein Druck auf "Signalgruppe \"K2\" nach oben" vertauscht zwei Zeilen und baut * die Ansicht neu auf. `mitBewahrtemFokus` findet den bestaendigen Schluessel * `signalgruppe||hoch` zwar wieder, verwirft ihn aber, weil die * Schaltflaeche in der nun ersten Zeile gesperrt ist (ui/fokus.ts - und diese * Regel ist richtig: ein Fokus auf etwas Unbedienbarem ist schlechter als * keiner). Der Rueckfall ist der Strukturweg, und der zeigt auf dieselbe * DOM-Stelle - dort steht jetzt die Zeile, die gerade heruntergerutscht ist. * Der Fokus sass danach auf der GLEICHNAMIGEN Schaltflaeche der Nachbarzeile: * Derselbe Tastendruck ein zweites Mal nahm die eben ausgefuehrte Verschiebung * wieder zurueck, ohne dass sich am Namen unter dem Finger etwas geaendert * haette. Ueber Platz 1 kam man mit der Tastatur nicht hinaus. * * Derselbe Schaden, den Befund 50 zur Fassung 5.8.0 fuer diese Tabelle bereits * als Fehler eingestuft hat ("nach unten" hinterliess den Fokus auf "nach * oben") - nur ueber den Rueckfallpfad. * * In der Phasenansicht wog es schwerer: Dort trugen die Schaltflaechen ↑, ↓ und * × ueberhaupt keinen `data-fokus`. Es griff von vornherein nur der * Strukturweg, und der Fokus landete auf der Nachbarkarte - nicht nur am Rand, * sondern bei JEDER Verschiebung. */ const STICHTAG = new Date('2026-01-01T00:00:00Z'); let aufraeumen: (() => void) | null = null; afterEach(() => { aufraeumen?.(); aufraeumen = null; document.body.replaceChildren(); }); /** Ansicht mit demselben Neuaufbau, den die Huelle fuer `refresh` vorsieht. */ function zeichne(view: ViewDefinition, project: Project): { wurzel: HTMLElement } { const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(project); let dispose: () => void = () => {}; const context: ViewContext = { store, refresh: () => { mitBewahrtemFokus(wurzel, () => { dispose(); wurzel.replaceChildren(); dispose = view.render(wurzel, context); }); }, navigate: () => {}, target: null, }; dispose = view.render(wurzel, context); aufraeumen = () => { dispose(); wurzel.remove(); }; return { wurzel }; } /* * Ueber die Werte vergleichen und nicht ueber einen Attributselektor: Der Name * enthaelt die Bezeichnung in Anfuehrungszeichen, und die bricht jeden * Selektor. */ function knopf(wurzel: HTMLElement, name: string): HTMLButtonElement { const treffer = [...wurzel.querySelectorAll('button')].filter( (b) => b.getAttribute('aria-label') === name, ); expect(treffer.length, `Keine Schaltflaeche "${name}"`).toBe(1); return treffer[0] as HTMLButtonElement; } /** Name des Elements, das den Fokus hat. */ const fokusname = (): string => (document.activeElement as HTMLElement | null)?.getAttribute('aria-label') ?? ''; /** Druckt die Schaltflaeche so, wie es die Tastatur tut: mit Fokus darauf. */ function druecke(taste: HTMLButtonElement): void { taste.focus(); expect(document.activeElement).toBe(taste); taste.click(); } describe('Befund 47 - Signalgruppen umsortieren', () => { it('haelt den Fokus bei der verschobenen Gruppe, wenn "nach oben" gesperrt wird', () => { const { wurzel } = zeichne(signalGroupsView, createStandardIntersectionProject('F', STICHTAG)); druecke(knopf(wurzel, 'Signalgruppe "K2" nach oben')); // Nicht die gleichnamige Schaltflaeche der Nachbarzeile - die machte den // Schritt beim naechsten Druck unbemerkt rueckgaengig. expect(fokusname()).not.toBe('Signalgruppe "K1" nach oben'); expect(fokusname()).toBe('Signalgruppe "K2" nach unten'); }); it('haelt ihn ebenso am unteren Ende', () => { const { wurzel } = zeichne(signalGroupsView, createStandardIntersectionProject('F', STICHTAG)); druecke(knopf(wurzel, 'Signalgruppe "F1" nach unten')); expect(fokusname()).not.toBe('Signalgruppe "F2" nach unten'); expect(fokusname()).toBe('Signalgruppe "F1" nach oben'); }); it('laesst den Fokus abseits der Raender auf derselben Schaltflaeche', () => { // Unveraendert - das sichert schon tests/ui/fokusReihenfolge; // hier steht es als Gegenprobe, dass die Ausweichregel nur am Rand greift. const { wurzel } = zeichne(signalGroupsView, createStandardIntersectionProject('F', STICHTAG)); druecke(knopf(wurzel, 'Signalgruppe "K1" nach unten')); expect(fokusname()).toBe('Signalgruppe "K1" nach unten'); }); it('nimmt einer Maus ohne Fokus den Fokus nicht weg', () => { // Ein Mausklick fokussiert in jsdom nichts. Dann darf die Ausweichregel // auch nichts fokussieren - sonst risse ein Klick den Fokus aus dem Feld, // in dem der Anwender gerade steht. const { wurzel } = zeichne(signalGroupsView, createStandardIntersectionProject('F', STICHTAG)); knopf(wurzel, 'Signalgruppe "K2" nach oben').click(); expect(document.activeElement).toBe(document.body); }); }); describe('Befund 47 - Phasen umsortieren', () => { /** Drei Phasen, damit es eine Verschiebung ohne Randlage gibt. */ function dreiPhasen(): Project { const roh = createStandardIntersectionProject('Drei Phasen', STICHTAG); const dritte = createPhase('Phase 3 - Linksabbieger', []); return { ...roh, phases: [...roh.phases, dritte], program: { ...roh.program, phaseOrder: [...roh.program.phaseOrder, dritte.id] }, }; } it('haelt den Fokus bei der verschobenen Phase, wenn "nach vorne" gesperrt wird', () => { const { wurzel } = zeichne(phasesView, dreiPhasen()); druecke(knopf(wurzel, '2. Phase "Phase 2 - Ost/West" nach vorne')); expect(fokusname()).not.toBe('1. Phase "Phase 1 - Nord/Süd" nach vorne'); expect(fokusname()).toBe('1. Phase "Phase 2 - Ost/West" nach hinten'); }); it('haelt ihn abseits der Raender auf derselben Schaltflaeche', () => { // Ohne `data-fokus` griff hier nur der Strukturweg - und der zeigte nach // dem Tausch auf die Nachbarkarte. const { wurzel } = zeichne(phasesView, dreiPhasen()); druecke(knopf(wurzel, '1. Phase "Phase 1 - Nord/Süd" nach hinten')); expect(fokusname()).toBe('2. Phase "Phase 1 - Nord/Süd" nach hinten'); }); it('schiebt beim zweiten Tastendruck weiter, statt zwischen zwei Plaetzen zu pendeln', () => { const { wurzel } = zeichne(phasesView, dreiPhasen()); druecke(knopf(wurzel, '1. Phase "Phase 1 - Nord/Süd" nach hinten')); (document.activeElement as HTMLButtonElement | null)?.click(); // Nur die Ueberschriften der Phasenkarten. Seit die Ansicht unter der // Auswahlliste die vier Rechenansaetze gegenueberstellt, steht dort eine // weitere h3, und die gehoert zu keiner Phase. const namen = [...wurzel.querySelectorAll('[id^="phase-"] h3')].map((h) => h.textContent); expect(namen).toEqual([ '1. Phase 2 - Ost/West', '2. Phase 3 - Linksabbieger', '3. Phase 1 - Nord/Süd', ]); }); }); describe('Befund 47 - die Reihenfolge im Speicher stimmt weiter', () => { it('vertauscht genau zwei Phasen je Druck', () => { const roh = createStandardIntersectionProject('Reihenfolge', STICHTAG); const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(roh); let dispose: () => void = () => {}; const context: ViewContext = { store, refresh: () => { mitBewahrtemFokus(wurzel, () => { dispose(); wurzel.replaceChildren(); dispose = phasesView.render(wurzel, context); }); }, navigate: () => {}, target: null, }; dispose = phasesView.render(wurzel, context); aufraeumen = () => { dispose(); wurzel.remove(); }; druecke(knopf(wurzel, '2. Phase "Phase 2 - Ost/West" nach vorne')); expect(orderedPhases(store.getProject()).map((p) => p.name)).toEqual([ 'Phase 2 - Ost/West', 'Phase 1 - Nord/Süd', ]); }); });