import { afterEach, describe, expect, it } from 'vitest'; import { ProjectStore } from '@/app/store'; import { ASSISTENT_VORGABE, projektAusAngaben } from '@/ui/assistent'; import { ANLAGENART_GRENZEN } from '@/domain/rilsa/constants'; import type { Anlagenart, Project } from '@/domain/model/project'; import type { ViewDefinition } from '@/ui/shell'; import { conflictsView } from '@/ui/views/conflictsView'; import { phasesView } from '@/ui/views/phasesView'; import { projectView } from '@/ui/views/projectView'; /** * Die Anlagenart muss sich bis in die Eingabefelder durchschlagen. * * Der Schaden, um den es geht, ist keiner der Darstellung: Eine Baustellenampel * an einer 400 m langen Engstelle braucht rund 128 s Umlaufzeit und einen * Raeumweg von 400 m. Solange Umlaufzeitfeld und Raeumwegfeld ihre Schranken * fest mitbringen (120 s, 500 m), kappen sie beides - und die daraus * errechnete Zwischenzeit ist zu kurz. */ const FESTES_DATUM = '2026-01-01T00:00:00.000Z'; function basisProjekt(anlagenart: Anlagenart): Project { const basis = projektAusAngaben( { ...ASSISTENT_VORGABE, fussgaenger: true, rad: true, linksabbieger: true }, FESTES_DATUM, ); return { ...basis, anlagenart }; } function zeichne( view: ViewDefinition, project: Project, ): { wurzel: HTMLElement; store: ProjectStore; aufraeumen: () => void } { const wurzel = document.createElement('div'); document.body.append(wurzel); const store = new ProjectStore(project); const aufraeumen = view.render(wurzel, { store, refresh: () => {}, navigate: () => {}, target: null, }); return { wurzel, store, aufraeumen: () => { aufraeumen(); wurzel.remove(); }, }; } /** * Sichtbarer Text einer Beschriftung - ohne den Hilfeknopf. * * `labelled()` haengt im Zwischenzeitfenster einen Knopf "?" in die * Beschriftung; im `textContent` steht dann "Raeumweg?" und kein Vergleich * trifft mehr. */ function beschriftungstext(label: HTMLLabelElement): string { return [...label.childNodes] .filter((knoten) => !(knoten instanceof Element && knoten.classList.contains('hilfe-knopf'))) .map((knoten) => knoten.textContent ?? '') .join('') .trim(); } /** Feld zu einer sichtbaren Beschriftung - ueber `for`, nicht ueber die Lage. */ function feldZu(wurzel: ParentNode, beschriftung: string): HTMLInputElement | null { const label = [...wurzel.querySelectorAll('label')].find( (l) => beschriftungstext(l) === beschriftung, ); if (label === undefined) return null; return wurzel.querySelector(`#${label.htmlFor}`); } function knopfMit(text: string): HTMLButtonElement | undefined { return [...document.querySelectorAll('button')].find( (b) => (b.textContent ?? '').trim() === text, ); } /** Laesst die Warteschlange der Mikroaufgaben leerlaufen. */ async function abwarten(): Promise { await new Promise((fertig) => setTimeout(fertig, 0)); } afterEach(() => { // Dialoge und Kurzmeldungen haengen an document.body, nicht an der Ansicht. document.body.replaceChildren(); }); describe('Umlaufzeitfeld folgt der Anlagenart', () => { for (const art of Object.keys(ANLAGENART_GRENZEN) as Anlagenart[]) { it(`nennt bei "${art}" dieselben Grenzen im Feld und im sichtbaren Hinweis`, () => { const basis = basisProjekt(art); const projekt: Project = { ...basis, program: { ...basis.program, method: 'manuell' } }; const grenzen = ANLAGENART_GRENZEN[art].cycleTime; const { wurzel, aufraeumen } = zeichne(phasesView, projekt); try { const feld = feldZu(wurzel, 'Umlaufzeit'); expect(feld, 'Kein Feld "Umlaufzeit" in der Ansicht').not.toBeNull(); expect(feld?.getAttribute('min')).toBe(String(grenzen.min)); expect(feld?.getAttribute('max')).toBe(String(grenzen.max)); // Der sichtbare Hinweis darf keine zweite Fassung der Zahlen sein. const hinweis = wurzel.querySelector(`#${feld?.getAttribute('aria-describedby') ?? ''}`); const text = hinweis?.textContent ?? ''; expect(text).toContain(String(grenzen.min)); expect(text).toContain(String(grenzen.max)); } finally { aufraeumen(); } }); } }); describe('Raeumwegfeld folgt der Anlagenart', () => { for (const art of Object.keys(ANLAGENART_GRENZEN) as Anlagenart[]) { it(`laesst bei "${art}" Wege bis ${ANLAGENART_GRENZEN[art].maxClearingDistance} m zu`, () => { const grenze = ANLAGENART_GRENZEN[art].maxClearingDistance; const { wurzel, aufraeumen } = zeichne(conflictsView, basisProjekt(art)); try { // Erste besetzte Zelle der Matrix oeffnet das Bearbeitungsfenster. const zelle = wurzel.querySelector( '.matrix-zelle:not(.matrix-zelle-leer)', ); expect(zelle, 'Die Matrix hat keine besetzte Zelle').not.toBeNull(); zelle?.click(); const fenster = document.querySelector('.dialog'); expect(fenster, 'Das Zwischenzeitfenster ist nicht offen').not.toBeNull(); for (const name of ['Räumweg', 'Einfahrweg']) { const feld = feldZu(fenster as ParentNode, name); expect(feld, `Kein Feld "${name}" im Fenster`).not.toBeNull(); expect(feld?.getAttribute('max'), `"${name}" haelt eine eigene Obergrenze`).toBe( String(grenze), ); } } finally { aufraeumen(); } }); } it('setzt dieselbe Obergrenze im Sammelbearbeiten-Fenster', () => { const grenze = ANLAGENART_GRENZEN.einstreifig.maxClearingDistance; const { aufraeumen } = zeichne(conflictsView, basisProjekt('einstreifig')); try { knopfMit('Alle Wege gemeinsam setzen')?.click(); const fenster = document.querySelector('.dialog'); expect(fenster, 'Das Sammelfenster ist nicht offen').not.toBeNull(); for (const name of ['Räumweg', 'Einfahrweg']) { expect(feldZu(fenster as ParentNode, name)?.getAttribute('max')).toBe(String(grenze)); } } finally { aufraeumen(); } }); }); describe('Wechsel der Anlagenart', () => { /** Das Auswahlfeld der Anlagenart in der Projektansicht. */ function auswahl(wurzel: ParentNode): HTMLSelectElement { const label = [...wurzel.querySelectorAll('label')].find( (l) => (l.textContent ?? '').trim() === 'Anlagenart', ); const feld = wurzel.querySelector(`#${label?.htmlFor ?? ''}`); expect(feld, 'Kein Auswahlfeld "Anlagenart" in der Projektansicht').not.toBeNull(); return feld as HTMLSelectElement; } it('bietet alle Anlagenarten an und steht vor den uebrigen Projektangaben', () => { const { wurzel, aufraeumen } = zeichne(projectView, basisProjekt('knotenpunkt')); try { const feld = auswahl(wurzel); expect([...feld.options].map((o) => o.value)).toEqual(Object.keys(ANLAGENART_GRENZEN)); expect(feld.value).toBe('knotenpunkt'); // Vor den uebrigen Angaben: Die Anlagenart bestimmt, woran alles Weitere // bemessen wird, und gehoert deshalb nicht ans Ende des Formulars. const felder = [...wurzel.querySelectorAll('input, select')]; expect(felder.indexOf(feld)).toBe(0); // Der Hinweis muss die Folgen nennen, nicht nur den Namen wiederholen. const hinweis = wurzel.querySelector(`#${feld.getAttribute('aria-describedby') ?? ''}`); const text = hinweis?.textContent ?? ''; const grenzen = ANLAGENART_GRENZEN.knotenpunkt; expect(text).toContain(String(grenzen.cycleTime.max)); expect(text).toContain(String(grenzen.maxClearingDistance)); } finally { aufraeumen(); } }); it('fragt nach, bevor eine Umstellung bestehende Werte ungueltig macht', async () => { const basis = basisProjekt('einstreifig'); const projekt: Project = { ...basis, program: { ...basis.program, method: 'manuell', manualCycleTime: 200 }, }; const { wurzel, store, aufraeumen } = zeichne(projectView, projekt); try { const feld = auswahl(wurzel); feld.value = 'knotenpunkt'; feld.dispatchEvent(new Event('change')); const dialog = document.querySelector('.dialog'); expect(dialog, 'Die Umstellung geschah ohne Rueckfrage').not.toBeNull(); // Die betroffene Angabe wird beim Namen genannt - sonst weiss niemand, // was nach dem Bestaetigen zu pruefen ist. expect(dialog?.textContent ?? '').toContain('200'); knopfMit('Anlagenart behalten')?.click(); await abwarten(); expect(store.getProject().anlagenart, 'Abbrechen hat trotzdem umgestellt').toBe( 'einstreifig', ); // Und vor allem: Die Umlaufzeit steht unveraendert da. expect(store.getProject().program.manualCycleTime).toBe(200); } finally { aufraeumen(); } }); it('kappt beim Bestaetigen nichts, sondern stellt nur die Anlagenart um', async () => { const basis = basisProjekt('einstreifig'); const projekt: Project = { ...basis, program: { ...basis.program, method: 'manuell', manualCycleTime: 200 }, }; const { wurzel, store, aufraeumen } = zeichne(projectView, projekt); try { const feld = auswahl(wurzel); feld.value = 'knotenpunkt'; feld.dispatchEvent(new Event('change')); knopfMit('Umstellen')?.click(); await abwarten(); expect(store.getProject().anlagenart).toBe('knotenpunkt'); // Eine am Lageplan gemessene Zahl auf die neue Schranke zu ziehen waere // eine stille Faelschung - sie bliebe plausibel und waere falsch. expect(store.getProject().program.manualCycleTime).toBe(200); } finally { aufraeumen(); } }); it('stellt ohne Konflikt sofort um', async () => { const { wurzel, store, aufraeumen } = zeichne(projectView, basisProjekt('knotenpunkt')); try { const feld = auswahl(wurzel); feld.value = 'einstreifig'; feld.dispatchEvent(new Event('change')); await abwarten(); expect(document.querySelector('.dialog'), 'Rueckfrage ohne Anlass').toBeNull(); expect(store.getProject().anlagenart).toBe('einstreifig'); } finally { aufraeumen(); } }); });