import { describe, expect, it } from 'vitest'; import { messeignungSatz, messgrundlageBefunde } from '@/domain/validation/rules'; import { validateProject } from '@/domain/validation/engine'; import { messgenauigkeit, type Lageplan, type Planlinie } from '@/domain/geometrie/lageplan'; import { RILSA_DEFAULTS, type RilsaDefaults } from '@/domain/rilsa/constants'; import { createSignalGroup, createStandardIntersectionProject } from '@/domain/model/factory'; import type { Project } from '@/domain/model/project'; /** * Ist die Bildunterlage zu grob fuer das, was daraus vermessen wurde? * * Der Schaden liegt in der unsicheren Richtung: Aus einem groben Ausschnitt * abgegriffen, faellt der Raeumweg einer Fussgaengerfurt zu kurz aus, und damit * die Zwischenzeit. Dem Signalzeitenplan sieht man das nicht an - er bleibt * formal widerspruchsfrei. Geprueft wird deshalb vor allem, dass die Meldung * ueberhaupt kommt, dass sie die Zahl nennt, an der sie haengt, und dass sie * nicht kommt, wo das Modell nichts weiss. */ const FIXED_DATE = new Date('2026-01-01T00:00:00Z'); /** Massstaebe, an denen die Einstufung kippt - siehe messgenauigkeit.test.ts. */ const FEIN = 0.05; // 5 cm je Bildpunkt: auch die Furt ist abgreifbar const GROB = 2000 / 7000; // 28,6 cm je Bildpunkt: 2000 m Ausschnitt, nur Kfz const SEHR_GROB = 1.0; // 1 m je Bildpunkt: reines Uebersichtsbild /** * Genau auf der Fussgaenger-Schranke: 0,25 s / (2 * (1/1,0 + 1/11,1)) = * 0,114669 m je Bildpunkt. Der Fussgaenger raeumt (mobilitaetseingeschraenkt, * 1,0 m/s), das Kraftfahrzeug faehrt ein (11,1 m/s). Diese Unterlage ist gerade * noch "voll" geeignet - und kippt, sobald ein Kennwert langsamer angesetzt wird. */ const AN_DER_SCHRANKE = 0.25 / (2 * (1 / 1.0 + 1 / 11.1)); /** Fahrlinie mit zwei Punkten - damit ist der Lageplan auswertbar. */ function fahrlinie(id: string): Planlinie { return { id, name: id, mode: 'kfz', movement: 'geradeaus', breiteMeter: 3.25, punkte: [ { x: 10, y: 10 }, { x: 10, y: 90 }, ], haltlinieId: null, startT: 0.5, signalGroupId: null, }; } /** * Lageplan mit einem Massstab von `meterJeBildpunkt`. * Die Bezugsstrecke ist 100 Bildpunkte lang; `gezeichnet` entscheidet, ob auf * der Unterlage bereits eine Fahrlinie liegt. */ function lageplan(meterJeBildpunkt: number, gezeichnet: boolean): Lageplan { return { arbeitsbereiche: [], signalgeber: [], bild: { datenUrl: 'data:image/png;base64,AAAA', breite: 7000, hoehe: 7000, herkunft: 'Pruefbild', geladenAm: '2026-03-04T09:15:00.000Z', }, kalibrierung: { von: { x: 0, y: 0 }, bis: { x: 100, y: 0 }, laengeMeter: meterJeBildpunkt * 100, herkunft: 'georeferenziert', }, haltlinien: [], linien: gezeichnet ? [fahrlinie('l1')] : [], }; } /** Regelknotenpunkt (vier Kfz-Gruppen, zwei Fussgaengerfurten) mit Unterlage. */ function mitLageplan(meterJeBildpunkt: number, gezeichnet = true): Project { const basis = createStandardIntersectionProject('Prueffall', FIXED_DATE); return { ...basis, lageplan: lageplan(meterJeBildpunkt, gezeichnet) }; } /** Dasselbe Projekt, aber ohne Fussgaenger- und Radsignalgruppen. */ function nurKfzGruppen(project: Project): Project { const behalten = project.signalGroups.filter((g) => g.mode !== 'fuss' && g.mode !== 'rad'); const ids = new Set(behalten.map((g) => g.id)); return { ...project, signalGroups: behalten, conflicts: project.conflicts.filter((c) => ids.has(c.fromId) && ids.has(c.toId)), phases: project.phases.map((p) => ({ ...p, signalGroupIds: p.signalGroupIds.filter((id) => ids.has(id)), })), }; } describe('Regel: Eignung der Bildunterlage', () => { it('schweigt ohne Massstab, statt eine Eignung zu behaupten', () => { // Ohne Kalibrierung ist unbekannt, wie viel ein Bildpunkt am Boden misst. // Eine Meldung ueber eine Sicherheitsgroesse aus einer erfundenen Zahl // waere schlimmer als keine Meldung. const project = mitLageplan(SEHR_GROB); const ohneMassstab: Project = { ...project, lageplan: { ...project.lageplan, kalibrierung: null }, }; expect(messgrundlageBefunde(ohneMassstab)).toHaveLength(0); }); it('schweigt bei einer Unterlage, die auch fuer Furten reicht', () => { expect(messgenauigkeit(lageplan(FEIN, true))!.eignung).toBe('voll'); expect(messgrundlageBefunde(mitLageplan(FEIN))).toHaveLength(0); }); it('meldet einen Fehler, wenn nur Kfz abgreifbar sind, die Furt gefuehrt und gezeichnet wird', () => { // Befund A4: Sobald hier abgegriffen wird (Massstab und Fahrlinie // vorhanden), wirkt derselbe Wegfehler an der Furt fast zehnfach staerker // als bei Kfz - derselbe Vorgang, der im Uebersichtsfall sperrt, darf hier // nicht bei einer Warnung bleiben. const befunde = messgrundlageBefunde(mitLageplan(GROB)); expect(befunde).toHaveLength(1); expect(befunde[0]!.rule).toBe('zwischenzeiten.messgrundlage-nur-kfz'); expect(befunde[0]!.severity).toBe('fehler'); expect(befunde[0]!.category).toBe('zwischenzeiten'); // Die betroffenen Gruppen stehen in der Meldung - sonst muss der Bearbeiter // raten, welche Beziehungen er nachmessen soll. expect(befunde[0]!.message).toContain('F1'); expect(befunde[0]!.message).toContain('F2'); }); it('warnt nur, solange auf der groben Unterlage noch nichts gezeichnet ist', () => { // Ohne Fahrlinie wird noch nicht abgegriffen; die Warnung meldet den // Vorgang, bevor er beginnt - wie im Uebersichtsfall, der ohne Zeichnung // gar nichts meldet. const befunde = messgrundlageBefunde(mitLageplan(GROB, false)); expect(befunde).toHaveLength(1); expect(befunde[0]!.rule).toBe('zwischenzeiten.messgrundlage-nur-kfz'); expect(befunde[0]!.severity).toBe('warnung'); }); it('warnt auch fuer eine Radsignalgruppe', () => { const basis = mitLageplan(GROB); const ohneFuss = nurKfzGruppen(basis); const rad = createSignalGroup({ name: 'R1', mode: 'rad', index: 6 }); const mitRad: Project = { ...ohneFuss, signalGroups: [...ohneFuss.signalGroups, rad] }; expect(messgrundlageBefunde(mitRad).map((f) => f.rule)).toEqual([ 'zwischenzeiten.messgrundlage-nur-kfz', ]); }); it('warnt nicht, wenn das Projekt nur Kraftfahrzeugstroeme fuehrt', () => { // Eine reine Kfz-Anlage darf auf einem Uebersichtsausschnitt der Gruenen // Welle vermessen werden - dafuer reicht die Aufloesung. expect(messgrundlageBefunde(nurKfzGruppen(mitLageplan(GROB)))).toHaveLength(0); }); it('meldet einen Fehler, sobald auf einem Uebersichtsbild gezeichnet ist', () => { const befunde = messgrundlageBefunde(mitLageplan(SEHR_GROB)); expect(befunde).toHaveLength(1); expect(befunde[0]!.rule).toBe('zwischenzeiten.messgrundlage-uebersicht'); expect(befunde[0]!.severity).toBe('fehler'); }); it('meldet den Fehler nicht, solange auf der Unterlage nichts gezeichnet ist', () => { // Das Ersatzmerkmal: Ob ein eingetragener Raeumweg aus dem Lageplan stammt, // fuehrt das Modell nicht mit. Eine gezeichnete Fahrlinie hat aber keinen // anderen Zweck als das Vermessen - fehlt sie, ist nichts abgegriffen // worden, und ein Uebersichtsbild allein ist noch kein Fehler. const befunde = messgrundlageBefunde(mitLageplan(SEHR_GROB, false)); expect(befunde).toHaveLength(0); }); it('schliesst Warnung und Fehler gegeneinander aus', () => { // Die Eignung ist genau ein Wert. Zwei Meldungen zur selben Unterlage // wuerden einander widersprechen. for (const massstab of [FEIN, GROB, SEHR_GROB]) { const rules = messgrundlageBefunde(mitLageplan(massstab)).map((f) => f.rule); expect(new Set(rules).size).toBe(rules.length); expect(rules.length).toBeLessThanOrEqual(1); } }); it('nennt die Zahl, an der der Befund haengt', () => { const befund = messgrundlageBefunde(mitLageplan(GROB))[0]!; // 2000 m auf 7000 Bildpunkten: 28,6 cm je Bildpunkt. Der Zeichenfehler // von zwei Bildpunkten kostet an der Furt // 2 * 0,285714 m * (1/1,0 + 1/11,1) = 0,622909 s, ausgegeben mit zwei // Nachkommastellen - das Zweieinhalbfache der hinnehmbaren 0,25 s. expect(befund.message).toContain('28,6 cm je Bildpunkt'); expect(befund.message).toContain('0,62 s'); expect(befund.message).toContain('0,25 s'); }); it('nennt beide Auswege und eine Fundstelle', () => { for (const massstab of [GROB, SEHR_GROB]) { const befund = messgrundlageBefunde(mitLageplan(massstab))[0]!; expect(befund.suggestion).toContain('engeren Ausschnitt'); expect(befund.suggestion).toContain('von Hand'); expect(befund.reference).toBe('RiLSA 2015 - Zwischenzeiten'); expect(befund.title).not.toBe(''); expect(befund.target).not.toBeNull(); } }); it('folgt angepassten Kennwerten, statt sie ein zweites Mal zu fuehren', () => { // Langsamer angesetzte Raeumgeschwindigkeiten vergroessern den Zeitfehler. // 53 cm je Bildpunkt liegen knapp unter der Kfz-Schranke; wird abbiegend // mit 5,0 statt 7,0 m/s geraeumt - ein zulaessiger Wert -, reicht die // Unterlage nicht einmal mehr fuer Kfz, und die Einstufung faellt von // nur-kfz auf Uebersicht. (Seit Befund A4 sind beide Stufen bei // bezeichneter Unterlage Fehler; unterschieden wird an der Regel.) const langsamer: RilsaDefaults = { ...RILSA_DEFAULTS, clearingSpeed: { ...RILSA_DEFAULTS.clearingSpeed, kfzTurning: 5 }, }; const project = mitLageplan(0.53); expect(messgrundlageBefunde(project).map((f) => f.rule)).toEqual([ 'zwischenzeiten.messgrundlage-nur-kfz', ]); expect(messgrundlageBefunde(project, langsamer).map((f) => f.rule)).toEqual([ 'zwischenzeiten.messgrundlage-uebersicht', ]); }); }); describe('Befund in einem Satz', () => { it('behauptet ohne Massstab keine Eignung', () => { expect(messeignungSatz(null)).toContain('Nicht beurteilbar'); }); it('gibt fuer jede Einstufung denselben Zahlensatz aus', () => { for (const massstab of [FEIN, GROB, SEHR_GROB]) { const satz = messeignungSatz(messgenauigkeit(lageplan(massstab, true))); expect(satz).toContain('cm je Bildpunkt'); expect(satz).toContain('Fußgängerfurt'); expect(satz).toContain('0,25 s'); } }); it('erfindet bei einer Geschwindigkeit von 0 keine Zahl', () => { // Ein solcher Kennwertsatz kommt aus der Vorgabenpruefung nicht heraus. // Falls doch: "Infinity s" waere im Pruefbericht sinnlos. // // Auf 0 gesetzt wird die Raeumgeschwindigkeit, die der Fussgaengerfehler // liest - die des mobilitaetseingeschraenkten Fussgaengers. Ueber // clearingSpeed.fuss ist der entartete Fall nicht mehr erreichbar. const entartet: RilsaDefaults = { ...RILSA_DEFAULTS, clearingSpeed: { ...RILSA_DEFAULTS.clearingSpeed, fussReducedMobility: 0 }, }; const satz = messeignungSatz(messgenauigkeit(lageplan(FEIN, true), entartet)); expect(satz).toContain('nicht bestimmbaren Betrag'); expect(satz).not.toContain('Infinity'); }); }); describe('Die Regel im Pruefbericht', () => { it('sperrt den Export, wenn auf einem Uebersichtsbild vermessen wurde', () => { const bericht = validateProject(mitLageplan(SEHR_GROB), undefined, FIXED_DATE); const befund = bericht.findings.find( (f) => f.rule === 'zwischenzeiten.messgrundlage-uebersicht', ); expect(befund).toBeDefined(); expect(bericht.exportBlocked).toBe(true); }); it('sperrt den Export, wenn die fuer Furten zu grobe Unterlage bezeichnet ist', () => { // Befund A4: Vor Fassung 5.4.0 blieb dieser Fall eine // Warnung, und ein Plan mit moeglicherweise verkuerzten Furt-Zwischenzeiten // blieb ausgabefaehig. const bericht = validateProject(mitLageplan(GROB), undefined, FIXED_DATE); expect( bericht.findings.some( (f) => f.rule === 'zwischenzeiten.messgrundlage-nur-kfz' && f.severity === 'fehler', ), ).toBe(true); expect(bericht.exportBlocked).toBe(true); }); it('sperrt den Export bei einer blossen Warnung nicht', () => { const bericht = validateProject(mitLageplan(GROB, false), undefined, FIXED_DATE); expect(bericht.findings.some((f) => f.rule === 'zwischenzeiten.messgrundlage-nur-kfz')).toBe( true, ); expect(bericht.findings.some((f) => f.severity === 'fehler')).toBe(false); }); it('reicht die Kennwerte des Projekts an die Regel durch', () => { // Der Pruefbericht rechnet mit den Vorgaben des Projekts. Setzt der // Bearbeiter die Fussgaengergeschwindigkeit herab, verschaerft sich die // Anforderung an das Bild - eine bis dahin volle Eignung faellt auf nur-kfz. // // Herabgesetzt wird der Kennwert, den die Einstufung liest: die // Raeumgeschwindigkeit des mobilitaetseingeschraenkten Fussgaengers. Auf // der Schrankenunterlage wachsen die 0,25 s auf // 2 * 0,114669 * (1/0,6 + 1/11,1) = 0,402893 s. const project = mitLageplan(AN_DER_SCHRANKE); const streng: Project = { ...project, settings: { ...project.settings, rilsa: { clearingSpeedFussReducedMobility: 0.6 } }, }; expect( validateProject(project, undefined, FIXED_DATE).findings.some((f) => f.rule.startsWith('zwischenzeiten.messgrundlage'), ), ).toBe(false); expect( validateProject(streng, undefined, FIXED_DATE).findings.some( (f) => f.rule === 'zwischenzeiten.messgrundlage-nur-kfz', ), ).toBe(true); }); it('laesst ein Projekt ohne Lageplan unberuehrt', () => { const bericht = validateProject( createStandardIntersectionProject('Ohne Unterlage', FIXED_DATE), undefined, FIXED_DATE, ); expect(bericht.findings.some((f) => f.rule.startsWith('zwischenzeiten.messgrundlage'))).toBe( false, ); }); });