import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import { mitBewahrtemFokus } from '@/ui/fokus'; import type { Project } from '@/domain/model/project'; import type { ViewContext } from '@/ui/shell'; import { signalGroupsView } from '@/ui/views/signalGroupsView'; /* * Befund 50 zur Fassung 5.8.0. * * `benenneBedienelemente()` vergibt den Fokusschluessel aus Spaltenkopf, * Zeilenname und Elementart. In der letzten Spalte der Signalgruppentabelle * stehen drei Knoepfe in EINER Zelle, die Spalte hat keinen Kopftext, und alle * drei sind BUTTON - sie trugen deshalb denselben Schluessel "|K1|BUTTON". * `mitBewahrtemFokus` sucht ueber `.find()` und traf damit immer den ersten * Treffer in Dokumentreihenfolge: Nach "nach unten" sass der Fokus auf "nach * oben", also auf der Schaltflaeche mit der Gegenwirkung. Wer eine Signalgruppe * mit der Tastatur um drei Plaetze verschieben wollte, pendelte zwischen zwei * Plaetzen. * * Zusatzfall: Zwei Signalgruppen mit derselben Bezeichnung - die Bezeichnung * ist freier Text - ergaben denselben Schluessel in verschiedenen Zeilen; der * Fokus sprang dann in die falsche Zeile. Das trifft nicht nur die drei * Knoepfe, sondern JEDES Bedienelement beider Zeilen: gleicher Spaltenkopf, * gleicher Zeilenname, gleiche Elementart. */ let aufraeumen: (() => void) | null = null; afterEach(() => { aufraeumen?.(); aufraeumen = null; document.body.replaceChildren(); }); /** Ansicht mit demselben Neuaufbau, den die Shell fuer `refresh` vorsieht. */ function zeichne(project: Project): { wurzel: HTMLElement; store: ProjectStore } { 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 = signalGroupsView.render(wurzel, context); }); }, navigate: () => {}, target: null, }; dispose = signalGroupsView.render(wurzel, context); aufraeumen = () => { dispose(); wurzel.remove(); }; return { wurzel, store }; } /* * Ueber die Werte vergleichen und nicht ueber einen Attributselektor: Der Name * enthaelt die Bezeichnung der Signalgruppe in Anfuehrungszeichen, und die * bricht jeden Selektor. */ const knoepfeMitNamen = (wurzel: HTMLElement, name: string): HTMLButtonElement[] => [...wurzel.querySelectorAll('button')].filter( (b) => b.getAttribute('aria-label') === name, ); const knopf = (wurzel: HTMLElement, name: string): HTMLButtonElement => { const treffer = knoepfeMitNamen(wurzel, name); expect(treffer.length, `Keine Schaltflaeche "${name}"`).toBe(1); return treffer[0] as HTMLButtonElement; }; const namen = (store: ProjectStore): string[] => store.getProject().signalGroups.map((gruppe) => gruppe.name); /** * Projekt mit zwei gleich benannten Signalgruppen: K2 heisst ebenfalls "K1". * Die Bezeichnung ist freier Text, das Programm laesst das zu. */ function projektMitZweiMalK1(): Project { const basis = createStandardIntersectionProject(); return { ...basis, signalGroups: basis.signalGroups.map((g) => (g.name === 'K2' ? { ...g, name: 'K1' } : g)), }; } /** Zeile einer Signalgruppe - ueber die Kennung, nicht ueber die Bezeichnung. */ const zeileDerGruppe = (wurzel: HTMLElement, groupId: string): HTMLTableRowElement => { const treffer = [...wurzel.querySelectorAll('tbody tr')].find( (tr) => tr.id === `sg-${groupId}`, ); expect(treffer, `Keine Zeile zur Signalgruppe ${groupId}`).toBeDefined(); return treffer as HTMLTableRowElement; }; describe('Fokus nach dem Verschieben einer Signalgruppe (Befund 50)', () => { it('bleibt auf "nach unten" und nicht auf der Schaltflaeche mit der Gegenwirkung', () => { const { wurzel, store } = zeichne(createStandardIntersectionProject()); const runter = knopf(wurzel, 'Signalgruppe "K1" nach unten'); runter.focus(); expect(document.activeElement).toBe(runter); runter.click(); expect(namen(store).slice(0, 2)).toEqual(['K2', 'K1']); const jetzt = document.activeElement as HTMLElement | null; expect(jetzt?.getAttribute('aria-label')).toBe('Signalgruppe "K1" nach unten'); }); it('schiebt beim zweiten Tastendruck weiter, statt zwischen zwei Plaetzen zu pendeln', () => { const { wurzel, store } = zeichne(createStandardIntersectionProject()); const runter = knopf(wurzel, 'Signalgruppe "K1" nach unten'); runter.focus(); // Zweimal die Eingabetaste auf derselben Schaltflaeche - der Fokus wandert // dazwischen nur, weil die Ansicht sich neu aufbaut. runter.click(); (document.activeElement as HTMLButtonElement | null)?.click(); expect(namen(store).slice(0, 3)).toEqual(['K2', 'K3', 'K1']); }); it('bleibt bei zwei gleich benannten Signalgruppen in der richtigen Zeile', () => { // Die Bezeichnung ist freier Text; zwei Zeilen duerfen denselben Namen // tragen. Ueber den Zeilennamen allein ist die Zeile dann nicht zu finden. const projekt = projektMitZweiMalK1(); const erste = projekt.signalGroups[0]; expect(erste?.name).toBe('K1'); const { wurzel } = zeichne(projekt); const knoepfe = knoepfeMitNamen(wurzel, 'Signalgruppe "K1" nach unten'); expect(knoepfe.length, 'Zwei gleich benannte Signalgruppen erwartet').toBe(2); knoepfe[0]?.focus(); knoepfe[0]?.click(); const zeile = (document.activeElement as HTMLElement | null)?.closest('tr'); expect(zeile?.id).toBe(`sg-${erste?.id ?? ''}`); }); /* * Derselbe Zusatzfall an einem Feld, das kein Knopf ist: Wer in der ZWEITEN * von zwei gleich benannten Zeilen die Fahrzeugart umstellt, findet den Fokus * danach in der ERSTEN Zeile wieder - und stellt dort mit der naechsten * Eingabe eine Signalgruppe um, die er gar nicht bearbeiten wollte. */ it('bleibt nach einer Aenderung im Auswahlfeld Fahrzeugart in der richtigen Zeile', () => { const projekt = projektMitZweiMalK1(); const zweite = projekt.signalGroups[1]; expect(zweite?.name).toBe('K1'); expect(zweite?.mode).toBe('kfz'); const zweiteId = zweite?.id ?? ''; const { wurzel, store } = zeichne(projekt); const feld = zeileDerGruppe(wurzel, zweiteId).querySelector( 'select[aria-label="Fahrzeugart, K1"]', ); expect(feld, 'Kein Auswahlfeld "Fahrzeugart" in der zweiten Zeile').not.toBeNull(); feld?.focus(); if (feld !== null && feld !== undefined) feld.value = 'lkw'; feld?.dispatchEvent(new Event('change')); // Die Aenderung greift an der zweiten Gruppe - der Fokus muss ihr folgen. expect(store.getProject().signalGroups[1]?.vehicleClass).toBe('lkw'); const jetzt = document.activeElement as HTMLElement | null; expect(jetzt?.closest('tr')?.id).toBe(`sg-${zweiteId}`); expect(jetzt?.getAttribute('aria-label')).toBe('Fahrzeugart, K1'); }); });