import { afterEach, describe, expect, it } from 'vitest'; import { Shell } from '@/ui/shell'; import { ProjectStore } from '@/app/store'; import { createEmptyProject, createSignalGroup } from '@/domain/model/factory'; import { naechsterSchritt } from '@/ui/naechsterSchritt'; import type { AppState } from '@/app/store'; /** * Der Fuehrungshinweis: wo er steht und wohin er zeigt. * * DREI BEFUNDE AUS DER DURCHSICHT DER OBERFLAECHE, alle am selben Kasten: * * 1. ER STAND UEBER DER ANSICHT. Das Erste auf einer Seite sagt, WO MAN IST, * nicht, wo man hin soll - die Ueberschrift der Ansicht ist der Anker. * Wer mit Sprachausgabe arbeitet, begann jede Ansicht mit einem Satz ueber * eine ANDERE Ansicht. Er steht jetzt darunter, dort, wo die Arbeit dieser * Seite endet. * * 2. ER ZEIGTE AUF DIE SEITE, AUF DER MAN STAND. `naechsterSchritt` bekommt * nur den Zustand und kennt die offene Ansicht nicht; keine Bedingung * unterdrueckte den Sprungknopf auf seinem eigenen Ziel. Sieben der elf * Ansichten koennen sich selbst als Ziel nennen, und kein Test beruehrte * den Fall. Der Knopf war dabei nicht bloss wirkungslos: `navigate` * zeichnet die Ansicht vollstaendig neu, setzt den Fokus auf die * Ueberschrift, den Rollbalken auf null und verwirft den ansichtseigenen * Zustand - wer im Lageplan gezeichnet hatte, verlor seine Stelle. * * 3. DIE LEISTE TRUG DIE AUSKUNFT NICHT. Sie kannte "aktiv" und * "beanstandet", und ein Abzeichen bilden ueberhaupt nur 6 der 11 * Eintraege - gerade in den ersten beiden Schritten blieb sie stumm, * waehrend der Kasten dorthin zeigte. * * WAS HIER NICHT GEPRUEFT WIRD: ob der Kasten schoen aussieht. Geprueft wird * die Reihenfolge im Baum, das Vorhandensein des Knopfes und die Marke - alles * Dinge, die sich messen lassen. */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); let laufend: Shell | null = null; afterEach(() => { laufend?.stop(); laufend = null; document.body.replaceChildren(); }); /** * Eine Shell mit den beiden Ansichten, die der erste Schritt betrifft. * * Ein leeres Projekt hat keine Signalgruppe; `naechsterSchritt` zeigt dann auf * 'signalgruppen' (naechsterSchritt.ts, erster Zweig). Mit einer zweiten * Ansicht daneben laesst sich beides pruefen: der Fall "ich stehe auf dem Ziel" * und der Fall "ich stehe woanders". */ function baueShell(start: string): { wurzel: HTMLElement; shell: Shell; store: ProjectStore; } { const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(createEmptyProject('Musterkreuzung', FESTES_DATUM)); const shell = new Shell(wurzel, store, '5.17.0'); const ansicht = (id: string, label: string) => ({ id, label, group: 'Erfassung', description: `Nur zum Aufbau der Shell: ${label}`, // Dieselbe Zusage wie die echte Signalgruppenansicht - hier reicht sie fuer // alle drei, weil nur EINE davon sie je erfuellt: Ohne Signalgruppe ist sie // ueberall falsch, mit einer ueberall wahr, und der Fall unten prueft // beides. fertig: (zustand: AppState) => zustand.project.signalGroups.length > 0, render: () => () => {}, }); shell.addView(ansicht('signalgruppen', 'Signalgruppen')); shell.addView(ansicht('projekt', 'Projekt')); // Die dritte Ansicht ist das Ziel, sobald eine Signalgruppe da ist - ohne // sie liesse sich nur zeigen, dass die Marke verschwindet, nicht, dass sie // wandert. shell.addView(ansicht('phasen', 'Phasen')); shell.start(start); laufend = shell; return { wurzel, shell, store }; } /** Der Kasten selbst. */ function fuehrung(wurzel: HTMLElement): HTMLElement { const knoten = wurzel.querySelector('.fuehrung'); expect(knoten, 'der Fuehrungshinweis fehlt ganz').not.toBeNull(); return knoten as HTMLElement; } describe('Der Fuehrungshinweis steht unter der Ansicht', () => { it('zeigt ein leeres Projekt ueberhaupt auf die Signalgruppen', () => { /* * Sachstand zuerst. Zeigte der erste Zweig eines Tages woandershin, prueften * die Faelle darunter etwas anderes als das, was sie zu pruefen glauben - * und blieben womoeglich gruen, ohne es zu merken. */ const store = new ProjectStore(createEmptyProject('Musterkreuzung', FESTES_DATUM)); expect(naechsterSchritt(store.getState()).ansicht, 'Ziel bei leerem Projekt').toBe( 'signalgruppen', ); }); it('steht er im Baum HINTER dem Ansichtsbehaelter', () => { const { wurzel } = baueShell('projekt'); const halter = wurzel.querySelector('.fuehrung-halter'); const ansichten = wurzel.querySelector('.ansicht-halter'); expect(halter, 'der Halter des Hinweises fehlt').not.toBeNull(); expect(ansichten, 'der Ansichtsbehaelter fehlt').not.toBeNull(); /* * `compareDocumentPosition` statt eines Vergleichs der Kindindizes: Der * Vergleich gilt auch dann, wenn zwischen beiden eines Tages etwas * Drittes steht. DOCUMENT_POSITION_FOLLOWING (4) heisst: der Halter kommt * NACH dem Behaelter. */ const lage = (ansichten as Element).compareDocumentPosition(halter as Element); expect( lage & Node.DOCUMENT_POSITION_FOLLOWING, 'der Hinweis steht vor der Ansicht statt dahinter', ).toBeGreaterThan(0); }); it('ist er nicht das erste Element des Inhaltsbereichs', () => { // Die Gegenprobe zum Fall darueber, an derselben Stelle von der anderen // Seite: Wer den Inhalt liest, trifft zuerst auf die Ansicht. const { wurzel } = baueShell('projekt'); const inhalt = wurzel.querySelector('main.inhalt'); expect(inhalt, 'der Inhaltsbereich fehlt').not.toBeNull(); expect( (inhalt as HTMLElement).firstElementChild?.className, 'der Hinweis steht an erster Stelle im Inhalt', ).not.toContain('fuehrung'); }); }); describe('Der Fuehrungshinweis zeigt nie auf die eigene Seite', () => { it('traegt er auf einer anderen Ansicht einen Sprungknopf', () => { // Sachstand: Ohne diesen Fall koennte der Knopf ueberall fehlen, und der // Fall darunter waere gruen, ohne etwas zu bewachen. const { wurzel } = baueShell('projekt'); const knopf = fuehrung(wurzel).querySelector('button'); expect(knopf, 'auf einer fremden Ansicht fehlt der Sprungknopf').not.toBeNull(); expect(knopf?.textContent, 'Beschriftung des Sprungknopfes').toContain('Signalgruppen'); }); it('traegt er auf der Zielansicht KEINEN Sprungknopf', () => { const { wurzel } = baueShell('signalgruppen'); expect( fuehrung(wurzel).querySelector('button'), 'der Hinweis schickt den Anwender dorthin, wo er steht', ).toBeNull(); }); it('bleibt der Satz auf der Zielansicht stehen', () => { /* * Der Knopf faellt weg, die Anweisung nicht: Auf der Signalgruppenansicht * ist "Legen Sie die Signalgruppen an." genau richtig - nur der Weg dorthin * ist keiner. Ohne diesen Fall waere das Beheben durch blosses Ausblenden * des ganzen Kastens ununterscheidbar. */ const { wurzel } = baueShell('signalgruppen'); const text = fuehrung(wurzel).textContent ?? ''; expect(text, 'die Anweisung ist mit dem Knopf verschwunden').toContain('Signalgruppen'); expect(text, 'die Marke kuendigt einen Schritt an, der hier zu tun ist').toContain('Hier'); expect(text, 'die Marke behauptet weiter einen naechsten Schritt').not.toContain( 'Nächster Schritt', ); }); it('wechselt er mit der Ansicht', () => { // Derselbe Zustand, zwei Ansichten, zwei Ergebnisse - das ist der Kern: // Der Kasten haengt nicht mehr allein am Projekt, sondern auch am Standort. const { wurzel, shell } = baueShell('projekt'); expect(fuehrung(wurzel).querySelector('button'), 'Ausgangslage').not.toBeNull(); shell.navigate('signalgruppen'); expect(fuehrung(wurzel).querySelector('button'), 'nach dem Wechsel auf das Ziel').toBeNull(); shell.navigate('projekt'); expect(fuehrung(wurzel).querySelector('button'), 'nach dem Wechsel zurueck').not.toBeNull(); }); }); describe('Die Leiste markiert den naechsten Schritt', () => { /** Der Navigationsknopf zu einer Ansicht. */ function navKnopf(wurzel: HTMLElement, label: string): HTMLElement { const knoepfe = [...wurzel.querySelectorAll('.nav-knopf')]; const treffer = knoepfe.find( (k) => k.querySelector('.nav-beschriftung')?.textContent === label, ); expect(treffer, `Navigationseintrag "${label}"`).toBeDefined(); return treffer as HTMLElement; } it('markiert sie den Eintrag, auf den der Hinweis zeigt', () => { const { wurzel } = baueShell('projekt'); const marke = navKnopf(wurzel, 'Signalgruppen').querySelector('.nav-naechster'); expect(marke, 'die Marke fehlt im Eintrag').not.toBeNull(); expect(marke?.hidden, 'die Marke ist verborgen, obwohl der Eintrag dran ist').toBe(false); }); it('sagt sie es auch der Sprachausgabe', () => { /* * Der Punkt traegt `aria-hidden` - Farbe und Zeichen allein sind fuer eine * Sprachausgabe nichts. Wer nur den Punkt umschaltet, zeigt die Marke dem * Auge und verschweigt sie dem Ohr. */ const knoten = navKnopf(baueShell('projekt').wurzel, 'Signalgruppen'); const text = knoten.querySelector('.nav-naechster-text'); expect(text, 'der vorgelesene Zusatz fehlt').not.toBeNull(); expect(text?.hidden, 'der Zusatz bleibt verborgen, waehrend der Punkt sichtbar ist').toBe( false, ); expect(text?.textContent, 'Wortlaut des Zusatzes').toContain('als Nächstes dran'); }); it('markiert sie einen anderen Eintrag nicht', () => { const knoten = navKnopf(baueShell('projekt').wurzel, 'Projekt'); expect( knoten.querySelector('.nav-naechster')?.hidden, 'ein Eintrag ohne Anlass traegt die Marke', ).toBe(true); }); it('zieht sie mit, wenn sich der Zustand ohne Ansichtswechsel aendert', () => { /* * DIESE LUECKE HAT DER MUTATIONSNACHWEIS GEFUNDEN. Die Faelle darueber * pruefen nur nach `start()` und `navigate()` - beide laufen ueber * `zeichneAnsicht`, wo `markiereNaechsten` ein zweites Mal steht. Nimmt man * den Aufruf aus `updateChrome` heraus, blieben sie alle gruen. GEMESSEN * mit genau dieser Mutation. * * Der ungeprueft gebliebene Weg ist zugleich der haeufige: Wer eine * Signalgruppe anlegt, wechselt dabei die Ansicht nicht - der naechste * Schritt ist danach trotzdem ein anderer. */ const { wurzel, store } = baueShell('projekt'); expect( navKnopf(wurzel, 'Signalgruppen').querySelector('.nav-naechster')?.hidden, 'Ausgangslage: die Marke steht bei den Signalgruppen', ).toBe(false); store.update( (projekt) => ({ ...projekt, signalGroups: [createSignalGroup({ name: 'K1', mode: 'kfz' })], }), { label: 'Signalgruppe angelegt' }, ); // Mit einer Signalgruppe und ohne Phase zeigt die Kette auf die Phasen. expect(naechsterSchritt(store.getState()).ansicht, 'Ziel nach der Aenderung').toBe('phasen'); expect( navKnopf(wurzel, 'Signalgruppen').querySelector('.nav-naechster')?.hidden, 'die Marke steht noch bei den Signalgruppen', ).toBe(true); expect( navKnopf(wurzel, 'Phasen').querySelector('.nav-naechster')?.hidden, 'die Marke ist den Phasen nicht gefolgt', ).toBe(false); }); it('markiert sie nicht die Ansicht, auf der man steht', () => { /* * Dort sagt `aria-current` bereits, wo man ist. "Hier" und "als Naechstes * hierhin" nebeneinander waere dieselbe Verdopplung, die der Sprungknopf * auf der eigenen Seite war. */ const knoten = navKnopf(baueShell('signalgruppen').wurzel, 'Signalgruppen'); expect( knoten.querySelector('.nav-naechster')?.hidden, 'die Leiste kuendigt an, wohin man schon gegangen ist', ).toBe(true); expect( knoten.querySelector('.nav-naechster-text')?.hidden, 'der vorgelesene Zusatz bleibt stehen', ).toBe(true); }); }); describe('Der Haken "erledigt" in der Leiste', () => { /** Der Navigationsknopf zu einer Ansicht. */ function navKnopf(wurzel: HTMLElement, label: string): HTMLElement { const knoepfe = [...wurzel.querySelectorAll('.nav-knopf')]; const treffer = knoepfe.find( (k) => k.querySelector('.nav-beschriftung')?.textContent === label, ); expect(treffer, `Navigationseintrag "${label}"`).toBeDefined(); return treffer as HTMLElement; } it('steht am leeren Projekt an keinem Eintrag', () => { // Sachstand: Ohne Signalgruppe ist die Zusage der Testansichten ueberall // falsch. Stuende hier ein Haken, waere der Fall darunter wertlos. const { wurzel } = baueShell('projekt'); for (const label of ['Projekt', 'Signalgruppen', 'Phasen']) { expect( navKnopf(wurzel, label).querySelector('.nav-fertig')?.hidden, `Haken an "${label}" am leeren Projekt`, ).toBe(true); } }); it('erscheint er, sobald die Zusage erfuellt ist', () => { const { wurzel, store } = baueShell('projekt'); store.update( (projekt) => ({ ...projekt, signalGroups: [createSignalGroup({ name: 'K1', mode: 'kfz' })], }), { label: 'Signalgruppe angelegt' }, ); const knoten = navKnopf(wurzel, 'Signalgruppen'); expect(knoten.querySelector('.nav-fertig')?.hidden, 'Haken sichtbar').toBe(false); expect( knoten.querySelector('.nav-fertig-text')?.hidden, 'der vorgelesene Zusatz bleibt verborgen', ).toBe(false); expect( knoten.querySelector('.nav-fertig-text')?.textContent, 'Wortlaut des Zusatzes', ).toContain('erledigt'); }); it('schweigt er an dem Eintrag, der als Naechstes dran ist', () => { /* * "Erledigt" und "als Nächstes dran" am selben Eintrag waeren ein * Widerspruch. Der Haken ist die schwaechere der beiden Aussagen und * weicht. * * Gebaut wird die Lage ueber die Phasen: Mit einer Signalgruppe zeigt die * Kette dorthin, und die Testansicht "Phasen" traegt dieselbe Zusage wie * alle - sie waere also ohne diese Regel zugleich erledigt und dran. */ const { wurzel, store } = baueShell('projekt'); store.update( (projekt) => ({ ...projekt, signalGroups: [createSignalGroup({ name: 'K1', mode: 'kfz' })], }), { label: 'Signalgruppe angelegt' }, ); expect(naechsterSchritt(store.getState()).ansicht, 'Ziel nach der Aenderung').toBe('phasen'); const knoten = navKnopf(wurzel, 'Phasen'); expect( knoten.querySelector('.nav-naechster')?.hidden, 'die Marke fehlt am naechsten Schritt', ).toBe(false); expect( knoten.querySelector('.nav-fertig')?.hidden, 'derselbe Eintrag traegt zugleich einen Haken', ).toBe(true); }); }); describe('Aendert sich der naechste Schritt, sagt es die Sprachausgabe', () => { /** Der Ansageraum der Shell. */ function ansage(wurzel: HTMLElement): HTMLElement { const knoten = wurzel.querySelector('[role="status"]'); expect(knoten, 'der Ansageraum fehlt').not.toBeNull(); return knoten as HTMLElement; } it('nennt sie den Schritt beim ersten Aufbau', () => { const { wurzel } = baueShell('projekt'); expect(ansage(wurzel).textContent, 'Ansage beim Start').toContain( 'Nächster Schritt: Legen Sie die Signalgruppen an.', ); }); it('nennt sie den neuen Schritt, wenn er sich aendert', () => { /* * DER BEFUND, GEGEN DEN DIESER FALL GESCHRIEBEN IST: Der naechste Schritt * aenderte sich stumm. Wer eine Signalgruppe anlegte, hoerte, dass zwei * Fehler dazugekommen sind, aber nicht, dass jetzt die Phasen dran sind - * die sichtbarste Auskunft der Oberflaeche war die einzige, die nie * angesagt wurde. */ const { wurzel, store } = baueShell('projekt'); store.update( (projekt) => ({ ...projekt, signalGroups: [createSignalGroup({ name: 'K1', mode: 'kfz' })], }), { label: 'Signalgruppe angelegt' }, ); expect(ansage(wurzel).textContent, 'Ansage nach der Aenderung').toContain( 'Nächster Schritt: Bilden Sie die Phasen.', ); }); it('wiederholt sie den Schritt nicht, wenn nur der Zustand sich aendert', () => { /* * Der Grund fuer zwei getrennte Staende. Haengte der Schritt an derselben * Zeichenkette wie der Zustand, laese die Sprachausgabe ihn bei jeder * geaenderten Zahl noch einmal mit vor - und wer ihn hoeren will, hoerte * ihn im Rauschen nicht. */ const { wurzel, store } = baueShell('projekt'); /* * GEMESSEN WIRD, OB NEU GESCHRIEBEN WURDE, nicht, was dasteht. Ein * Ansageraum behaelt seinen Text, solange niemand ihn tauscht - eine * Sprachausgabe liest ihn aber nur bei der AENDERUNG vor. Der erste Anlauf * dieses Falles suchte im Text nach "Nächster Schritt:" und fand die * Ansage vom Programmstart wieder; er war rot, obwohl nichts gesagt worden * war. */ expect(ansage(wurzel).textContent, 'Ausgangslage: es wurde schon etwas gesagt').not.toBe(''); /* * GEMESSEN WIRD DER SCHREIBVORGANG, nicht der Wert. Der zweite Anlauf * dieses Falles verglich den Text vorher und nachher - und blieb gruen, als * die Mutation "alles bei jeder Aenderung wiederholen" den Ansageraum * unbedingt neu beschrieb: Derselbe Text, geschrieben wie ungeschrieben, * sieht gleich aus. Eine Sprachausgabe liest ihn aber vor, sobald er * gesetzt wird. * * `textContent = ...` ersetzt die Kindknoten. Der Textknoten selbst ist * damit der Zeuge: Ist es derselbe wie vorher, wurde nicht geschrieben. */ const knoten = ansage(wurzel).firstChild; expect(knoten, 'der Ansageraum hat keinen Textknoten').not.toBeNull(); // Der Projektname geht weder in den Zustandssatz noch in den Schritt ein. store.update((projekt) => ({ ...projekt, meta: { ...projekt.meta, name: 'Anders' } }), { label: 'Name geaendert', }); expect(ansage(wurzel).firstChild, 'der Ansageraum wurde neu beschrieben').toBe(knoten); }); it('sagt sie beim Schrittwechsel den Schritt und nicht die halbe Oberflaeche', () => { // Die Gegenprobe zum Fall darueber: Dass NICHTS gesagt wird, ist nur dann // ein Verdienst, wenn im richtigen Fall etwas gesagt wird. const { wurzel, store } = baueShell('projekt'); const vorher = ansage(wurzel).textContent ?? ''; store.update( (projekt) => ({ ...projekt, signalGroups: [createSignalGroup({ name: 'K1', mode: 'kfz' })], }), { label: 'Signalgruppe angelegt' }, ); expect(ansage(wurzel).textContent, 'die Ansage blieb stehen').not.toBe(vorher); expect(ansage(wurzel).textContent, 'der neue Schritt fehlt').toContain( 'Nächster Schritt: Bilden Sie die Phasen.', ); }); }); describe('Der Fokus ueberlebt eine Zustandsaenderung auf dem Sprungknopf', () => { it('bleibt er auf dem Knopf, wenn sich der Zustand aendert', () => { /* * Der Hinweis wird bei JEDER Zustandsaenderung neu gebaut - auch beim * Tippen in einem Feld. Ohne Fokusrettung ersetzt `render` die Kinder, und * der Fokus faellt auf den Seitenkoerper zurueck: Wer den Sprungknopf mit * der Tastatur erreicht hatte, stand danach wieder am Anfang. */ const { wurzel, store } = baueShell('projekt'); const knopf = wurzel.querySelector('.fuehrung button'); expect(knopf, 'der Sprungknopf fehlt').not.toBeNull(); (knopf as HTMLButtonElement).focus(); expect(document.activeElement, 'Ausgangslage: der Knopf hat den Fokus').toBe(knopf); store.update((projekt) => ({ ...projekt, meta: { ...projekt.meta, name: 'Anders' } }), { label: 'Name geaendert', }); const danach = wurzel.querySelector('.fuehrung button'); expect(danach, 'nach der Aenderung fehlt der Knopf').not.toBeNull(); expect(document.activeElement, 'der Fokus ist vom Knopf gefallen').toBe(danach); }); });