import { afterEach, describe, expect, it } from 'vitest'; import { beschrifteTabelle, datenTabelle, el } from '@/ui/dom'; import { mitBewahrtemFokus } from '@/ui/fokus'; /* * Der Fokusschluessel aus `benenneBedienelemente()` ist nicht eindeutig. * * Er lautete `${spalte}|${zeile}|${tagName}`. Zwei Faelle brechen ihn: * * - Eine Zelle enthaelt mehrere gleichartige Bedienelemente (drei Knoepfe am * Zeilenende, Spalte ohne Kopftext, alle BUTTON: dreimal `|K1|BUTTON`). * `mitBewahrtemFokus` nimmt den ersten Treffer in Dokumentreihenfolge - nach * "nach unten" sass der Fokus auf "nach oben", der Schaltflaeche mit der * Gegenwirkung. * - Zwei Zeilen tragen denselben Namen. Bezeichnungen sind freier Text; dann * stimmen bei JEDEM Feld beider Zeilen alle drei Teile ueberein, und der * Fokus landet in der falschen Zeile. * * Die Behebung von Befund 50 zur Fassung 5.8.0 hat nur die drei Knoepfe der * Signalgruppentabelle mit eigenen Schluesseln versorgt. Hier steht die * allgemeine Wache: an `datenTabelle()` und `beschrifteTabelle()` selbst, also * fuer jede Tabelle. */ let aufraeumen: (() => void) | null = null; afterEach(() => { aufraeumen?.(); aufraeumen = null; document.body.replaceChildren(); }); /** Alle vergebenen Fokusschluessel in Dokumentreihenfolge. */ const schluessel = (wurzel: HTMLElement): string[] => [...wurzel.querySelectorAll('[data-fokus]')].map( (feld) => feld.getAttribute('data-fokus') ?? '', ); /** Schaltflaeche mit eigenem Text - sie bringt ihren Namen selbst mit. */ function knopf(text: string, beiKlick?: () => void): HTMLButtonElement { const node = el('button', { type: 'button', text }); if (beiKlick !== undefined) node.addEventListener('click', beiKlick); return node; } /** Text der ersten Zelle der Zeile, in der das Element steht. */ const zeileVon = (element: Element | null): string => (element?.closest('tr')?.firstElementChild?.textContent ?? '').trim(); describe('Fokusschluessel einer Datentabelle', () => { it('trennt mehrere gleichartige Bedienelemente derselben Zelle', () => { const tabelle = datenTabelle({ beschriftung: 'Signalgruppen', spalten: [ { kopf: 'Bezeichnung', zelle: (name) => name }, // Die Spalte mit den Zeilenknoepfen hat keinen Kopftext - genau wie in // der Signalgruppentabelle. { kopf: '', zelle: () => el('div', {}, knopf('nach oben'), knopf('nach unten'), knopf('löschen')), }, ], zeilen: ['K1', 'K2'], zeilenname: (name) => name, }); const vergeben = schluessel(tabelle); expect(vergeben.length, 'sechs Knoepfe erwartet').toBe(6); expect(new Set(vergeben).size, `Doppelte Schluessel: ${vergeben.join(' / ')}`).toBe( vergeben.length, ); }); it('trennt zwei gleich benannte Zeilen', () => { // Bezeichnungen sind freier Text; zwei Zeilen duerfen denselben Namen // tragen. Dann heissen auch die Felder gleich - der zugaengliche Name // allein trennt sie nicht. const tabelle = datenTabelle<{ kennung: string }>({ beschriftung: 'Signalgruppen', spalten: [ { kopf: 'Kennung', zelle: (zeile) => zeile.kennung }, { kopf: 'Wert', zelle: () => el('input', { type: 'number' }) }, ], zeilen: [{ kennung: 'A' }, { kennung: 'B' }], zeilenname: () => 'K1', }); const vergeben = schluessel(tabelle); expect(vergeben.length, 'zwei Eingabefelder erwartet').toBe(2); expect(new Set(vergeben).size, `Doppelte Schluessel: ${vergeben.join(' / ')}`).toBe(2); }); }); describe('Fokusschluessel einer nachtraeglich beschrifteten Tabelle', () => { /** Von Hand gebaute Tabelle, wie sie die bestehenden Ansichten liefern. */ function baueTabelle(zeilennamen: readonly string[]): HTMLElement { const koerper = el('tbody', {}); for (const name of zeilennamen) { koerper.append( el( 'tr', {}, el('td', { text: name }), el('td', {}, el('input', { type: 'number' })), el('td', {}, knopf('nach oben'), knopf('nach unten')), ), ); } return el( 'table', {}, el('thead', {}, el('tr', {}, el('th', { text: 'Bezeichnung' }), el('th', { text: 'Wert' }))), koerper, ); } it('trennt mehrere gleichartige Bedienelemente derselben Zelle', () => { const tabelle = beschrifteTabelle(baueTabelle(['K1', 'K2']), { beschriftung: 'Signalgruppen', }); const vergeben = schluessel(tabelle); expect(vergeben.length, 'zwei Felder und vier Knoepfe erwartet').toBe(6); expect(new Set(vergeben).size, `Doppelte Schluessel: ${vergeben.join(' / ')}`).toBe(6); }); it('trennt zwei gleich benannte Zeilen', () => { const tabelle = beschrifteTabelle(baueTabelle(['K1', 'K1']), { beschriftung: 'Signalgruppen', }); const vergeben = schluessel(tabelle); expect(vergeben.length, 'zwei Felder und vier Knoepfe erwartet').toBe(6); expect(new Set(vergeben).size, `Doppelte Schluessel: ${vergeben.join(' / ')}`).toBe(6); }); }); describe('Fokusschluessel ueber zwei Beschriftungsdurchgaenge', () => { /* * Die Ueberschrift von `fokusschluessel` sagt "eindeutig innerhalb seiner * Tabelle" zu - nicht "innerhalb eines Durchgangs". `beschrifteTabelle()` * darf also ein zweites Mal ueber dieselbe, inzwischen gewachsene Tabelle * laufen, ohne dass ein Schluessel zweimal vergeben wird. Ohne die Vorbelegung * aus den bereits gesetzten `data-fokus` beginnt die Zaehlung wieder bei null, * und die neue Zeile bekommt die Grundform, die eine aeltere schon traegt. */ /** Zeile mit Bezeichnung und einem Eingabefeld. */ const zeileMitFeld = (name: string): HTMLElement => el('tr', {}, el('td', { text: name }), el('td', {}, el('input', { type: 'number' }))); const beschrifte = (tabelle: HTMLElement): HTMLElement => beschrifteTabelle(tabelle, { beschriftung: 'Signalgruppen', zeilenname: (tr) => (tr.children[0]?.textContent ?? '').trim(), }); it('teilt einen Schluessel nicht erneut aus, wenn die Tabelle zwischen zwei Durchgaengen waechst', () => { const koerper = el('tbody', {}, zeileMitFeld('K1')); const tabelle = el( 'table', {}, el('thead', {}, el('tr', {}, el('th', { text: 'Bezeichnung' }), el('th', { text: 'Wert' }))), koerper, ); beschrifte(tabelle); // Eine gleichnamige Zeile kommt hinzu; die Tabelle wird nicht neu gebaut, // sondern erneut beschriftet. koerper.append(zeileMitFeld('K1')); beschrifte(tabelle); const vergeben = schluessel(tabelle); expect(vergeben.length, 'zwei Eingabefelder erwartet').toBe(2); expect(new Set(vergeben).size, `Doppelte Schluessel: ${vergeben.join(' / ')}`).toBe(2); }); }); describe('Fokus nach dem Neuaufbau einer Datentabelle', () => { /** * Tabelle mit zwei Knoepfen je Zeile, die die Reihenfolge aendern - dieselbe * Bedienung wie in der Signalgruppentabelle, aber an `datenTabelle()` selbst. */ function bauePlatz(anfang: readonly string[]): { wurzel: HTMLElement; reihenfolge: () => readonly string[]; } { const wurzel = document.createElement('div'); document.body.append(wurzel); aufraeumen = () => wurzel.remove(); let namen: readonly string[] = [...anfang]; const verschiebe = (index: number, richtung: number): void => { const ziel = index + richtung; if (ziel < 0 || ziel >= namen.length) return; const kopie = [...namen]; const gezogen = kopie.splice(index, 1)[0] ?? ''; kopie.splice(ziel, 0, gezogen); namen = kopie; mitBewahrtemFokus(wurzel, zeichne); }; function zeichne(): void { wurzel.replaceChildren( datenTabelle({ beschriftung: 'Signalgruppen', spalten: [ { kopf: 'Bezeichnung', zelle: (name) => name }, { kopf: '', zelle: (_name, index) => el( 'div', {}, knopf('nach oben', () => { verschiebe(index, -1); }), knopf('nach unten', () => { verschiebe(index, 1); }), ), }, ], zeilen: [...namen], zeilenname: (name) => name, }), ); } zeichne(); return { wurzel, reihenfolge: () => namen }; } const knopfDerZeile = (wurzel: HTMLElement, zeile: string, text: string): HTMLButtonElement => { const treffer = [...wurzel.querySelectorAll('button')].filter( (b) => (b.textContent ?? '').trim() === text && zeileVon(b) === zeile, ); expect(treffer.length, `Kein Knopf "${text}" in Zeile "${zeile}"`).toBe(1); return treffer[0] as HTMLButtonElement; }; it('bleibt auf "nach unten" und nicht auf der Schaltflaeche mit der Gegenwirkung', () => { const { wurzel, reihenfolge } = bauePlatz(['K1', 'K2', 'K3']); const runter = knopfDerZeile(wurzel, 'K1', 'nach unten'); runter.focus(); expect(document.activeElement).toBe(runter); runter.click(); expect(reihenfolge()).toEqual(['K2', 'K1', 'K3']); const jetzt = document.activeElement as HTMLElement | null; expect((jetzt?.textContent ?? '').trim()).toBe('nach unten'); expect(zeileVon(jetzt)).toBe('K1'); }); it('schiebt beim zweiten Tastendruck weiter, statt zwischen zwei Plaetzen zu pendeln', () => { const { wurzel, reihenfolge } = bauePlatz(['K1', 'K2', 'K3']); knopfDerZeile(wurzel, 'K1', 'nach unten').focus(); (document.activeElement as HTMLButtonElement | null)?.click(); (document.activeElement as HTMLButtonElement | null)?.click(); expect(reihenfolge()).toEqual(['K2', 'K3', 'K1']); }); it('bleibt bei zwei gleich benannten Zeilen in der richtigen Zeile', () => { const wurzel = document.createElement('div'); document.body.append(wurzel); aufraeumen = () => wurzel.remove(); const werte = new Map([ ['A', '10'], ['B', '20'], ]); const zeichne = (): void => { wurzel.replaceChildren( datenTabelle({ beschriftung: 'Signalgruppen', spalten: [ { kopf: 'Kennung', zelle: (kennung) => kennung }, { kopf: 'Wert', zelle: (kennung) => { const feld = el('input', { type: 'number', value: werte.get(kennung) ?? '' }); feld.addEventListener('change', () => { werte.set(kennung, feld.value); mitBewahrtemFokus(wurzel, zeichne); }); return feld; }, }, ], zeilen: ['A', 'B'], // Beide Zeilen heissen gleich - das laesst das Programm zu. zeilenname: () => 'K1', }), ); }; zeichne(); const felder = [...wurzel.querySelectorAll('input')]; expect(felder.length).toBe(2); const zweites = felder[1] as HTMLInputElement; expect(zeileVon(zweites)).toBe('B'); zweites.focus(); zweites.value = '25'; zweites.dispatchEvent(new Event('change')); expect(werte.get('B')).toBe('25'); const jetzt = document.activeElement as HTMLElement | null; expect(zeileVon(jetzt), 'der Fokus muss in der bearbeiteten Zeile bleiben').toBe('B'); }); });