import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import * as actions from '@/app/actions'; import { createStandardIntersectionProject } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; import { buildSignalPlan } from '@/domain/plan/signalPlan'; import { mitBewahrtemFokus } from '@/ui/fokus'; import type { ViewContext } from '@/ui/shell'; import { projectView } from '@/ui/views/projectView'; /** * Fassung 5.10.0 - Ansicht "Projekt". * * - Befund 23 (mittel): Unter der zulaessigen Hoechstgeschwindigkeit des * KNOTENPUNKTS stand "Bestimmt die Gelbzeit: ...". Die Gelbzeit folgt aber * ausschliesslich `SignalGroup.vZul`; `intersection.vZul` ist die * Vorbelegung neuer Zufahrten und Signalgruppen, Grundlage der * 70-km/h-Warnung und eine Zeile im Ausdruck - eine Rechnung liest ihn * nicht. Wer ihn hochsetzt, um laengere Gelb- und damit Ueberfahrzeiten zu * bekommen, behaelt die kuerzeren: die unsichere Richtung. * * - Befund 24 (mittel): Der Hinweis am Auswahlfeld "Anlagenart" las die * Rohschranken aus `ANLAGENART_GRENZEN`. Bemessen und beurteilt wird gegen * `anlagenartGrenzen(project, defaults)`, das eine ausdruecklich * eingetragene Vorgabe verengt. Dieselbe Rohschranke stand in der * Kurzmeldung nach dem Wechsel und in `grenzverletzungen()`, das vor dem * Wechsel warnt. * * - Befund 54 (gering): "Zufahrt hinzufügen" bildete den Vorgabenamen aus der * ANZAHL der Zufahrten. Nach einem Loeschen entstand ein bereits vergebener * Name - die Auswahlliste der Signalgruppentabelle fuehrte zwei * gleichlautende Eintraege, und der Name der Loeschschaltflaeche benannte * nicht mehr eindeutig. */ const FESTES_DATUM = new Date('2026-01-01T00:00:00Z'); let aufraeumen: (() => void) | null = null; afterEach(() => { aufraeumen?.(); aufraeumen = null; document.body.replaceChildren(); }); interface Ansicht { readonly wurzel: HTMLElement; readonly store: ProjectStore; } function zeichne(project: Project): Ansicht { const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(project); let abmelden: () => void = () => {}; const context: ViewContext = { store, refresh: () => { mitBewahrtemFokus(wurzel, () => { abmelden(); wurzel.replaceChildren(); abmelden = projectView.render(wurzel, context); }); }, navigate: () => {}, target: null, }; abmelden = projectView.render(wurzel, context); aufraeumen = () => { abmelden(); wurzel.remove(); }; return { wurzel, store }; } async function abwarten(): Promise { await new Promise((fertig) => setTimeout(fertig, 0)); } /** Sichtbarer Text einer Beschriftung - ohne den Hilfeknopf daneben. */ function beschriftungstext(label: HTMLLabelElement): string { return [...label.childNodes] .filter((knoten) => !(knoten instanceof Element && knoten.classList.contains('hilfe-knopf'))) .map((knoten) => knoten.textContent ?? '') .join('') .trim(); } /** Der vorgelesene Hinweis eines Feldes - ueber `aria-describedby`. */ function hinweisZu(wurzel: HTMLElement, beschriftung: string): string { const label = [...wurzel.querySelectorAll('label')].find( (l) => beschriftungstext(l) === beschriftung, ); if (label === undefined) throw new Error(`Kein Feld "${beschriftung}"`); const feld = wurzel.querySelector(`#${label.htmlFor}`); const kennung = feld?.getAttribute('aria-describedby') ?? ''; return kennung .split(' ') .map((id) => wurzel.querySelector(`#${id}`)?.textContent ?? '') .join(' '); } function knopfMit(text: string): HTMLButtonElement | undefined { return [...document.querySelectorAll('button')].find( (b) => (b.textContent ?? '').trim() === text, ); } describe('Befund 23 - Feldhinweis an der Hoechstgeschwindigkeit des Knotenpunkts', () => { /* * Die Messung, an der der Feldhinweis zu pruefen ist: Der Knotenpunktwert * bewegt keine Gelbzeit. Sie steht hier und nicht nur im Text, damit die * Aussage des Hinweises an einem Ergebnis haengt und nicht an einer * Behauptung. */ it('bewegt keine Gelbzeit, wenn er geaendert wird', () => { const vorher = createStandardIntersectionProject('Gelbzeit', FESTES_DATUM); const nachher = actions.setIntersection(vorher, { vZul: 70 }); expect(nachher.intersection.vZul).toBe(70); expect(nachher.signalGroups.map((g) => g.vZul)).toEqual(vorher.signalGroups.map((g) => g.vZul)); expect(buildSignalPlan(nachher).groups.map((g) => g.times.yellow)).toEqual( buildSignalPlan(vorher).groups.map((g) => g.times.yellow), ); }); it('schreibt die Gelbzeit nicht dem Knotenpunktwert zu', () => { const { wurzel } = zeichne(createStandardIntersectionProject('Hinweis', FESTES_DATUM)); const text = hinweisZu(wurzel, 'Zulässige Höchstgeschwindigkeit'); expect( text, 'Der Hinweis schreibt dem Knotenpunktwert eine Wirkung zu, die er nicht hat', ).not.toContain('Bestimmt die Gelbzeit'); // Was er tatsaechlich tut, und wo die Gelbzeit haengt. expect(text).toContain('Vorbelegung'); expect(text).toContain('Signalgruppe'); // Die 70-km/h-Warnung bleibt am Feld - sie haengt wirklich an ihm. expect(text).toContain('70'); }); }); describe('Befund 24 - Anlagenart-Hinweis nennt die wirksamen Schranken', () => { /** Knotenpunkt mit eingetragenen Umlaufzeitvorgaben 45 / 100 / 80. */ function mitVorgaben(): Project { const basis = createStandardIntersectionProject('Vorgaben', FESTES_DATUM); return { ...basis, settings: { ...basis.settings, rilsa: { ...basis.settings.rilsa, cycleTimeMin: 45, cycleTimeMax: 100, cycleTimeRecommendedMax: 80, }, }, }; } it('nennt am Auswahlfeld die Schranken, gegen die bemessen wird', () => { const projekt = mitVorgaben(); const grenzen = buildSignalPlan(projekt).grenzen.cycleTime; expect([grenzen.min, grenzen.max, grenzen.recommendedMax]).toEqual([45, 100, 80]); const { wurzel } = zeichne(projekt); const text = hinweisZu(wurzel, 'Anlagenart'); expect(text).toContain(`Umlaufzeit ${grenzen.min} bis ${grenzen.max} s`); expect(text).toContain(`empfohlen ${grenzen.recommendedMin} bis ${grenzen.recommendedMax} s`); // Die Rohschranken der Anlagenart gelten hier gerade nicht. expect(text, 'Der Hinweis nennt einen weiteren Bereich, als tatsächlich gilt').not.toContain( 'Umlaufzeit 30 bis 120 s', ); // Unverengt und deshalb unveraendert: fuer sie gibt es keine Vorgabenfelder. expect(text).toContain('Räumweg bis 500 m'); }); it('warnt vor einem Wechsel, den erst die eingetragene Vorgabe unzulaessig macht', async () => { const basis = mitVorgaben(); const projekt: Project = { ...basis, anlagenart: 'einstreifig', program: { ...basis.program, method: 'manuell', manualCycleTime: 110 }, }; const { wurzel, store } = zeichne(projekt); const auswahl = wurzel.querySelector('select'); expect(auswahl?.value).toBe('einstreifig'); auswahl!.value = 'knotenpunkt'; auswahl!.dispatchEvent(new Event('change')); const dialog = document.querySelector('.dialog'); expect( dialog, 'Die Umstellung geschah ohne Rückfrage, obwohl 110 s über der wirksamen Schranke von 100 s liegen', ).not.toBeNull(); expect(dialog?.textContent ?? '').toContain('100'); knopfMit('Umstellen')?.click(); await abwarten(); expect(store.getProject().anlagenart).toBe('knotenpunkt'); // Die Kurzmeldung nach dem Wechsel nennt dieselben Schranken. const meldung = [...document.querySelectorAll('.meldung')] .map((m) => m.textContent ?? '') .join(' '); expect(meldung).toContain('100'); expect(meldung, 'Die Kurzmeldung nennt die Rohschranke der Anlagenart').not.toContain('120'); }); }); describe('Befund 54 - "Zufahrt hinzufügen" vergibt keinen bereits belegten Namen', () => { function ohneZufahrten(): Project { const basis = createStandardIntersectionProject('Zufahrten', FESTES_DATUM); return { ...basis, intersection: { ...basis.intersection, arms: [] } }; } it('zaehlt an den vergebenen Namen weiter, nicht an der Anzahl', () => { const { wurzel, store } = zeichne(ohneZufahrten()); const hinzufuegen = (): void => { const knopf = [...wurzel.querySelectorAll('button')].find( (b) => (b.textContent ?? '').trim() === 'Zufahrt hinzufügen', ); if (knopf === undefined) throw new Error('Keine Schaltfläche "Zufahrt hinzufügen"'); knopf.click(); }; hinzufuegen(); hinzufuegen(); expect(store.getProject().intersection.arms.map((a) => a.name)).toEqual([ 'Zufahrt 1', 'Zufahrt 2', ]); const erste = store.getProject().intersection.arms[0]!; store.update((p) => actions.removeArm(p, erste.id), { label: 'Zufahrt gelöscht' }); hinzufuegen(); const namen = store.getProject().intersection.arms.map((a) => a.name); expect(new Set(namen).size, `Zwei Zufahrten heißen gleich: ${namen.join(' | ')}`).toBe( namen.length, ); }); it('haelt die Namen der Loeschschaltflaechen unterscheidbar', () => { const { wurzel, store } = zeichne(ohneZufahrten()); const hinzufuegen = (): void => { [...wurzel.querySelectorAll('button')] .find((b) => (b.textContent ?? '').trim() === 'Zufahrt hinzufügen') ?.click(); }; hinzufuegen(); hinzufuegen(); const erste = store.getProject().intersection.arms[0]!; store.update((p) => actions.removeArm(p, erste.id), { label: 'Zufahrt gelöscht' }); hinzufuegen(); const namen = [...wurzel.querySelectorAll('button')] .map((b) => b.getAttribute('aria-label') ?? '') .filter((n) => n.startsWith('Zufahrt "')); expect(namen).toHaveLength(2); expect(new Set(namen).size, 'Die Namen sind nicht unterscheidbar').toBe(namen.length); }); });