diff --git a/packages/drawtonomy-sdk/__tests__/validator/cli.test.ts b/packages/drawtonomy-sdk/__tests__/validator/cli.test.ts new file mode 100644 index 0000000..bf89b95 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/cli.test.ts @@ -0,0 +1,102 @@ +// CLI tests: scripts/odr-validate.mts. +// +// The exit code is the contract a CI job depends on, so it is asserted for +// every verdict as well as for a broken invocation. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { FIXTURE_DIR, readFixture } from './corpus' + +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const SCRIPT = join(PKG_ROOT, 'scripts', 'odr-validate.mts') + +let workDir: string + +beforeAll(() => { + workDir = mkdtempSync(join(tmpdir(), 'odr-validate-')) +}) + +afterAll(() => { + rmSync(workDir, { recursive: true, force: true }) +}) + +function runCli(args: string[]): { status: number; stdout: string; stderr: string } { + const proc = spawnSync('npx', ['tsx', SCRIPT, ...args], { + cwd: PKG_ROOT, + encoding: 'utf8', + timeout: 120_000, + env: { ...process.env, ODR_VALIDATE_ESMINI: '' }, + }) + return { status: proc.status ?? -1, stdout: proc.stdout ?? '', stderr: proc.stderr ?? '' } +} + +describe('odr-validate CLI', () => { + it('exits 0 and reports green on a clean map', () => { + const result = runCli([join(FIXTURE_DIR, 'fabriksgatan.xodr')]) + expect(result.status).toBe(0) + expect(result.stdout).toContain('verdict: GREEN') + }, 120_000) + + it('exits 1 and reports yellow on an excerpted map', () => { + const result = runCli([join(FIXTURE_DIR, 'town04-junction106.xodr')]) + expect(result.status).toBe(1) + expect(result.stdout).toContain('verdict: YELLOW') + expect(result.stdout).toContain('ref.unresolved-junction-link') + }, 120_000) + + it('exits 2 and reports red on a defective map', () => { + const truncated = join(workDir, 'truncated.xodr') + const xml = readFixture('fabriksgatan.xodr') + writeFileSync(truncated, xml.slice(0, Math.floor(xml.length * 0.6))) + const result = runCli([truncated]) + expect(result.status).toBe(2) + expect(result.stdout).toContain('verdict: RED') + expect(result.stdout).toContain('xml.truncated') + }, 120_000) + + it('exits 3 on a usage error, distinctly from any verdict', () => { + // A broken invocation must never be mistaken for a clean map. + expect(runCli([]).status).toBe(3) + expect(runCli([join(workDir, 'does-not-exist.xodr')]).status).toBe(3) + expect(runCli(['--nonsense', join(FIXTURE_DIR, 'fabriksgatan.xodr')]).status).toBe(3) + }, 120_000) + + it('writes a machine-readable report with --json', () => { + const out = join(workDir, 'report.json') + const result = runCli([join(FIXTURE_DIR, 'town04-junction106.xodr'), '--json', out, '--quiet']) + expect(result.status).toBe(1) + expect(result.stdout.trim()).toBe('') + + const parsed = JSON.parse(readFileSync(out, 'utf8')) + expect(parsed.verdict).toBe('yellow') + expect(parsed.counts.warning).toBe(4) + expect(Array.isArray(parsed.findings)).toBe(true) + expect(parsed.findings[0]).toHaveProperty('rule') + }, 120_000) + + it('groups findings by rule and truncates the detail list', () => { + const result = runCli([join(FIXTURE_DIR, 'town04-junction106.xodr'), '--limit', '2']) + expect(result.stdout).toContain('findings by rule:') + expect(result.stdout).toContain('detail (2 of') + expect(result.stdout).toContain('more (use --limit 0 for all)') + }, 120_000) + + it('reports the external check as skipped when no binary is given', () => { + const result = runCli([join(FIXTURE_DIR, 'fabriksgatan.xodr')]) + expect(result.stdout).toContain('esmini.skipped') + }, 120_000) + + it('does not fail the run when the esmini binary is missing', () => { + const result = runCli([ + join(FIXTURE_DIR, 'fabriksgatan.xodr'), + '--esmini', + join(workDir, 'no-such-binary'), + ]) + expect(result.status).toBe(0) + expect(result.stdout).toContain('binary not found') + }, 120_000) +}) diff --git a/packages/drawtonomy-sdk/__tests__/validator/corpus.ts b/packages/drawtonomy-sdk/__tests__/validator/corpus.ts new file mode 100644 index 0000000..bb96739 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/corpus.ts @@ -0,0 +1,48 @@ +// Corpus loading for the validator tests. +// +// Two sources: +// - the checked-in fixtures under __tests__/fixtures (always available) +// - an optional external corpus pointed at by ODR_VALIDATE_CORPUS, used for +// the false-positive gate against a wider set of real maps. The path is +// never hard-coded and nothing from it is copied into the repository. + +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = dirname(fileURLToPath(import.meta.url)) +export const FIXTURE_DIR = join(HERE, '..', 'fixtures') + +export interface CorpusEntry { + name: string + xml: string +} + +function loadDir(dir: string): CorpusEntry[] { + if (!existsSync(dir) || !statSync(dir).isDirectory()) return [] + return readdirSync(dir) + .filter(f => f.endsWith('.xodr')) + .sort() + .map(f => ({ name: f, xml: readFileSync(join(dir, f), 'utf8') })) +} + +/** The checked-in .xodr fixtures. */ +export function loadFixtureCorpus(): CorpusEntry[] { + return loadDir(FIXTURE_DIR) +} + +/** Read one fixture by file name. */ +export function readFixture(name: string): string { + return readFileSync(join(FIXTURE_DIR, name), 'utf8') +} + +/** + * The optional external corpus (ODR_VALIDATE_CORPUS = a directory of .xodr + * files). Returns an empty list when the variable is unset, so the suite is + * green on a machine that does not have it. + */ +export function loadExternalCorpus(): CorpusEntry[] { + const dir = process.env.ODR_VALIDATE_CORPUS + if (!dir) return [] + return loadDir(dir) +} diff --git a/packages/drawtonomy-sdk/__tests__/validator/esminiAdapter.test.ts b/packages/drawtonomy-sdk/__tests__/validator/esminiAdapter.test.ts new file mode 100644 index 0000000..c24bd50 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/esminiAdapter.test.ts @@ -0,0 +1,76 @@ +// Layer 6 unit tests: the external road-manager adapter. +// +// The binary is not assumed to exist. These tests drive the parser with +// captured output, which is the whole reason the spawning lives in the CLI and +// only the text handling lives in src/. + +import { describe, it, expect } from 'vitest' +import { parseEsminiOutput, esminiSkipped, validateOpenDrive } from '../../src/validator/index' +import { readFixture } from './corpus' + +describe('parseEsminiOutput', () => { + it('reports a clean run', () => { + const found = parseEsminiOutput({ stdout: 'Loading odr\nOpenDRIVE loaded\n', exitCode: 0 }) + expect(found.map(f => f.rule)).toEqual(['esmini.clean']) + expect(found[0].severity).toBe('info') + }) + + it('picks error lines out of the chatter', () => { + const found = parseEsminiOutput({ + stdout: 'Loading odr\n', + stderr: 'Error: Failed to locate road 5\n', + exitCode: 1, + }) + expect(found.map(f => f.rule)).toEqual(['esmini.error']) + expect(found[0].message).toContain('road 5') + }) + + it('recognizes a missing connection', () => { + const found = parseEsminiOutput({ stderr: 'No connection from road 1 to road 2\n' }) + expect(found.map(f => f.rule)).toEqual(['esmini.no-connection']) + }) + + it('collapses repeated lines with a count', () => { + const line = 'Error: lane link missing\n' + const found = parseEsminiOutput({ stderr: line.repeat(50), exitCode: 1 }) + expect(found).toHaveLength(1) + expect(found[0].message).toContain('(x50)') + }) + + it('keeps the full text of a line', () => { + // Regression: an early version keyed findings by " " and then + // recovered the line by splitting on a space, truncating every message to + // its first word. + const found = parseEsminiOutput({ stderr: 'Error: something went badly wrong here\n' }) + expect(found[0].message).toBe('Error: something went badly wrong here') + }) + + it('notes a nonzero exit with no recognizable output', () => { + const found = parseEsminiOutput({ stdout: 'nothing useful', exitCode: 9 }) + expect(found.map(f => f.rule)).toEqual(['esmini.nonzero-exit']) + }) + + it('attributes findings to the tool, never the map', () => { + // esmini disagreeing with a document is evidence about the pair, so it + // must not be able to redden a report on its own. + const found = parseEsminiOutput({ stderr: 'Error: cannot follow geometry\n', exitCode: 1 }) + expect(found.every(f => f.category === 'TOOL_LIMITATION')).toBe(true) + + const report = validateOpenDrive(readFixture('fabriksgatan.xodr'), { + externalFindings: found, + }) + expect(report.verdict).toBe('yellow') + expect(report.counts.MAP_DEFECT).toBe(0) + }) +}) + +describe('esminiSkipped', () => { + it('produces a single info finding that keeps a report green', () => { + const found = esminiSkipped('no binary supplied') + expect(found).toHaveLength(1) + expect(found[0]).toMatchObject({ rule: 'esmini.skipped', severity: 'info', category: 'INFO' }) + + const report = validateOpenDrive(readFixture('fabriksgatan.xodr'), { externalFindings: found }) + expect(report.verdict).toBe('green') + }) +}) diff --git a/packages/drawtonomy-sdk/__tests__/validator/geometry.test.ts b/packages/drawtonomy-sdk/__tests__/validator/geometry.test.ts new file mode 100644 index 0000000..f000f85 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/geometry.test.ts @@ -0,0 +1,186 @@ +// Layer 4 unit tests: geometric continuity, including the threshold +// calibration that keeps real maps out of the red. + +import { describe, it, expect } from 'vitest' +import { validateOpenDrive, DEFAULT_GEOMETRY_THRESHOLDS } from '../../src/validator/index' +import type { OdrFinding } from '../../src/validator/index' +import { loadFixtureCorpus } from './corpus' + +const findingsFor = (xml: string, opts = {}): OdrFinding[] => + validateOpenDrive(xml, opts).findings +const rulesFor = (xml: string, opts = {}): string[] => findingsFor(xml, opts).map(f => f.rule) +const geomRules = (xml: string, opts = {}): string[] => + rulesFor(xml, opts).filter(r => r.startsWith('geom.')) + +/** One road, two collinear line geometries meeting exactly. */ +const straight = (secondX: number, length = 100, hdg = 0): string => ` + +
+ + + + + + +
+ + + +
+
+` + +describe('checkGeometry', () => { + it('accepts a continuous road', () => { + expect(geomRules(straight(50))).toEqual([]) + }) + + describe('plan-view continuity', () => { + it('flags a position gap above the threshold', () => { + const found = findingsFor(straight(55)).filter(f => f.rule === 'geom.plan-view-gap') + expect(found).toHaveLength(1) + expect(found[0].message).toContain('5.000 m') + expect(found[0].location?.roadId).toBe('1') + }) + + it('accepts a gap below the threshold', () => { + expect(geomRules(straight(50.01))).toEqual([]) + }) + + it('flags a heading discontinuity', () => { + expect(geomRules(straight(50, 100, 0.5))).toContain('geom.plan-view-heading') + }) + + it('accepts a heading step below the threshold', () => { + expect(geomRules(straight(50, 100, 0.001))).toEqual([]) + }) + + it('honours a caller-supplied threshold', () => { + const strict = { geometry: { planViewGapMeters: 0.001 } } + expect(geomRules(straight(50.01), strict)).toContain('geom.plan-view-gap') + }) + }) + + describe('road length', () => { + it('flags a length disagreeing with the plan-view sum', () => { + const found = findingsFor(straight(50, 150)).filter( + f => f.rule === 'geom.road-length-mismatch' + ) + expect(found).toHaveLength(1) + expect(found[0].message).toContain('sum to 100.000 m') + }) + + it('accepts a length within 1 %', () => { + expect(geomRules(straight(50, 100.5))).toEqual([]) + }) + + it('flags a road with length but no plan view', () => { + const xml = straight(50).replace(/[\s\S]*?<\/planView>/, '') + expect(geomRules(xml)).toContain('geom.no-plan-view') + }) + }) + + describe('lane widths', () => { + it('flags a negative width', () => { + const xml = straight(50).replace('a="3.5"', 'a="-3.5"') + const found = findingsFor(xml).filter(f => f.rule === 'geom.negative-lane-width') + expect(found).toHaveLength(1) + expect(found[0].location?.laneId).toBe(-1) + }) + + it('flags a width ramping negative before its span ends', () => { + // 1.0 - 0.1 * ds is negative from ds = 10 on, and the span is 100 m. + const xml = straight(50).replace('a="3.5" b="0"', 'a="1.0" b="-0.1"') + expect(geomRules(xml)).toContain('geom.negative-lane-width') + }) + + it('accepts a lane that tapers to exactly zero', () => { + // The real-world case behind the tolerance: 3.5 - 0.0042*ds^2 + + // 0.000056*ds^3 reaches 0 at ds = 50 and lands ~1e-15 below it. + const xml = straight(50) + .replace('length="100"', 'length="50"') + .replace( + '', + '' + ) + .replace('a="3.5" b="0" c="0" d="0"', 'a="3.5" b="0" c="-0.0042" d="0.000056"') + expect(geomRules(xml)).toEqual([]) + }) + + it('still flags a width below the tolerance floor', () => { + const xml = straight(50).replace('a="3.5"', 'a="-0.5"') + expect(geomRules(xml)).toContain('geom.negative-lane-width') + }) + }) + + describe('road link contact', () => { + const pair = (bx: number, offsetA = 0, offsetB = 0): string => ` + +
+ + + + + + +
+ +
+
+
+ + + + + + +
+ +
+
+
+` + + it('accepts roads that touch', () => { + expect(geomRules(pair(100))).toEqual([]) + }) + + it('warns, never errors, on a separation', () => { + const found = findingsFor(pair(150)).filter(f => f.rule === 'geom.road-link-gap') + expect(found.length).toBeGreaterThan(0) + expect(found.every(f => f.severity === 'warning')).toBe(true) + // A gap between roads must not redden a map: shipped maps contain them. + expect(validateOpenDrive(pair(150)).verdict).toBe('yellow') + }) + + it('subtracts the lane-offset difference', () => { + // fabriksgatan's pattern: a connecting road with laneOffset 1.75 linking + // to a mainline with 0. The reference lines are 1.75 m apart by design. + expect(geomRules(pair(101.75, 1.75, 0))).toEqual([]) + }) + }) + + describe('false-positive calibration', () => { + it('leaves every fixture free of geometry errors', () => { + for (const entry of loadFixtureCorpus()) { + const errors = findingsFor(entry.xml).filter( + f => f.rule.startsWith('geom.') && f.severity === 'error' + ) + expect(errors, `${entry.name}: ${JSON.stringify(errors)}`).toEqual([]) + } + }) + + it('keeps the documented default thresholds', () => { + // These values are calibrated against measured corpus statistics (see the + // note in layers/geometry.ts). Changing one without re-measuring is how a + // validator starts reddening real maps, so the defaults are pinned. + expect(DEFAULT_GEOMETRY_THRESHOLDS).toEqual({ + planViewGapMeters: 0.02, + planViewHeadingRad: 0.005, + roadLinkGapMeters: 0.5, + lengthMismatchRatio: 0.01, + negativeWidthToleranceMeters: 0.001, + }) + }) + }) +}) diff --git a/packages/drawtonomy-sdk/__tests__/validator/junctions.test.ts b/packages/drawtonomy-sdk/__tests__/validator/junctions.test.ts new file mode 100644 index 0000000..4cc27b7 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/junctions.test.ts @@ -0,0 +1,150 @@ +// Layer 3 unit tests: junction consistency. + +import { describe, it, expect } from 'vitest' +import { validateOpenDrive } from '../../src/validator/index' +import type { OdrFinding } from '../../src/validator/index' +import { readFixture } from './corpus' + +const findingsFor = (xml: string): OdrFinding[] => validateOpenDrive(xml).findings +const rulesFor = (xml: string): string[] => findingsFor(xml).map(f => f.rule) + +const lane = (id: number): string => + `${id === 0 ? '' : ''}` + +const laneBlock = ` + + ${lane(1)}
${lane(0)}
${lane(-1)} +
` + +/** + * Two mainlines meeting at junction 5 through connecting road 3. + * Road 1 -> (junction 5, via connecting road 3) -> road 2. + */ +const JUNCTION = ` + +
+ + + + ${laneBlock} + + + + + ${laneBlock} + + + + + + + + ${laneBlock} + + + + + + + + + +` + +describe('checkJunctions', () => { + it('accepts a well-formed junction', () => { + expect(rulesFor(JUNCTION)).toEqual([]) + }) + + it('flags a junction with no connections', () => { + const xml = JUNCTION.replace(//g, '') + expect(rulesFor(xml)).toContain('junction.empty') + }) + + it('reports a deleted connection once, as connection-missing', () => { + // Add a second connecting road (4) so the junction keeps a connection + // after road 3's is deleted, then delete road 3's connections. Road 3 + // still declares junction="5" and still links to roads 1 and 2, so the + // lost half is the connection record, not the membership claim. + const withSecond = JUNCTION.replace( + '', + ` + + ` + ).replace( + '', + ` + + + + + + ${laneBlock} + ` + ) + expect(rulesFor(withSecond)).toEqual([]) + + const stripped = withSecond + .replace(//, '') + .replace(//, '') + const found = findingsFor(stripped).filter(f => f.rule.startsWith('junction.')) + const missing = found.filter(f => f.rule === 'junction.connection-missing') + expect(missing).toHaveLength(1) + expect(missing[0].location?.roadId).toBe('3') + // The mirror rule must NOT also fire: one defect, one finding. + expect(found.filter(f => f.rule === 'junction.road-not-member')).toHaveLength(0) + }) + + it('reports a spurious membership claim as road-not-member', () => { + // A road that names the junction but links to none of its roads. + const xml = JUNCTION.replace( + '', + ` + + ${laneBlock} + ` + ) + const found = findingsFor(xml).filter(f => f.rule.startsWith('junction.')) + expect(found.map(f => f.rule)).toEqual(['junction.road-not-member']) + expect(found[0].location?.roadId).toBe('9') + }) + + it('flags a connecting road that does not declare its junction', () => { + const xml = JUNCTION.replace('id="3" junction="5"', 'id="3" junction="-1"') + expect(rulesFor(xml)).toContain('junction.connecting-road-unmarked') + }) + + it('flags an incoming road that never links to the junction', () => { + const xml = JUNCTION.replace('', '') + expect(rulesFor(xml)).toContain('junction.incoming-link-missing') + }) + + it('flags a contactPoint that disagrees with the connecting road link', () => { + // Connection 0 says the connecting road meets road 1 at its start, so road + // 3's predecessor must be road 1. Point it elsewhere. + const xml = JUNCTION.replace( + '', + '' + ) + expect(rulesFor(xml)).toContain('junction.contact-point-mismatch') + }) + + describe('direct junctions', () => { + // has no connecting road: linkedRoad names an + // ordinary road that does not carry a junction attribute. The membership + // and contact-point rules must not fire on it. + it('accepts the soderleden direct-junction fixture', () => { + const report = validateOpenDrive(readFixture('soderleden.xodr')) + expect(report.findings.filter(f => f.rule.startsWith('junction.'))).toEqual([]) + }) + }) + + it('does not double-report a junction that layer 2 already flagged as missing', () => { + // road@junction naming an absent junction is ref.road-junction-unknown; + // layer 3 must stay quiet about it. + const xml = JUNCTION.replace('id="3" junction="5"', 'id="3" junction="404"') + const rules = rulesFor(xml) + expect(rules).toContain('ref.road-junction-unknown') + expect(rules).not.toContain('junction.road-not-member') + }) +}) diff --git a/packages/drawtonomy-sdk/__tests__/validator/mutations.ts b/packages/drawtonomy-sdk/__tests__/validator/mutations.ts new file mode 100644 index 0000000..a7e23a6 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/mutations.ts @@ -0,0 +1,486 @@ +// Mutation corpus for the OpenDRIVE validator (layer 9). +// +// A detector is only as trustworthy as the evidence that it detects something. +// The failure this harness exists to prevent (measured 2026-08-21): a +// regex-based mutation silently failed to match the numeric formatting of a +// real file, so the mutated document was byte-identical to the original, and +// the validator's (correct) silence was about to be recorded as "not detected". +// +// Therefore every mutation here is a two-part contract: +// +// apply(xml) -> mutated document +// verify(before, after) -> throws unless the intended structural change +// actually happened +// +// `applyMutation` runs both and additionally asserts `after !== before`, so a +// no-op mutation fails loudly at the harness level rather than being reported +// as a validator miss. + +/** Result of applying one mutation. */ +export interface MutationResult { + /** Mutated document text. */ + xml: string + /** Human-readable description of what was changed (for test failure output). */ + applied: string +} + +export interface Mutation { + /** Stable mutation id, used in the detection matrix. */ + id: string + /** What defect this injects. */ + description: string + /** The `rule` id the validator is expected to raise. */ + expectedRule: string + apply: (xml: string) => MutationResult +} + +class MutationNotAppliedError extends Error { + constructor(id: string, reason: string) { + super(`mutation "${id}" did not apply: ${reason}`) + this.name = 'MutationNotAppliedError' + } +} + +/** + * Apply a mutation and prove it changed the document. Throws + * `MutationNotAppliedError` when the mutation was a no-op, so an unmatched + * pattern can never masquerade as an undetected defect. + */ +export function applyMutation(mutation: Mutation, xml: string): MutationResult { + const result = mutation.apply(xml) + if (result.xml === xml) { + throw new MutationNotAppliedError(mutation.id, 'output is byte-identical to the input') + } + return result +} + +// --------------------------------------------------------------------------- +// Structural helpers +// +// These locate elements by scanning tags rather than by matching attribute +// values with a fixed numeric pattern, which is what makes them robust against +// the `1.0000000000000000e+02` style formatting used by real exporters. +// --------------------------------------------------------------------------- + +/** + * Find the span of the `index`-th `...` (or ``) + * element in `xml`. Returns null when there are fewer than `index + 1`. + * Handles nesting of same-named elements, which OpenDRIVE does not use for the + * elements we mutate, but costs nothing to be correct about. + */ +export function findElement( + xml: string, + name: string, + index = 0 +): { start: number; end: number; text: string } | null { + const openRe = new RegExp(`<${name}(?=[\\s/>])`, 'g') + let seen = 0 + let m: RegExpExecArray | null + while ((m = openRe.exec(xml)) !== null) { + const start = m.index + const openEnd = xml.indexOf('>', start) + if (openEnd < 0) return null + let end: number + if (xml[openEnd - 1] === '/') { + end = openEnd + 1 + } else { + const closeTag = `` + const close = xml.indexOf(closeTag, openEnd) + if (close < 0) return null + end = close + closeTag.length + } + if (seen === index) return { start, end, text: xml.slice(start, end) } + seen += 1 + openRe.lastIndex = end + } + return null +} + +/** Find all spans of `` elements. */ +export function findAllElements( + xml: string, + name: string +): { start: number; end: number; text: string }[] { + const out: { start: number; end: number; text: string }[] = [] + for (let i = 0; ; i++) { + const el = findElement(xml, name, i) + if (!el) break + out.push(el) + } + return out +} + +/** Read an attribute from an element's opening tag. */ +export function attrOf(elementText: string, attr: string): string | null { + const openEnd = elementText.indexOf('>') + const open = openEnd >= 0 ? elementText.slice(0, openEnd + 1) : elementText + const m = open.match(new RegExp(`\\b${attr}="([^"]*)"`)) + return m ? m[1] : null +} + +/** Replace an attribute value in an element's opening tag. */ +export function withAttr(elementText: string, attr: string, value: string): string { + const openEnd = elementText.indexOf('>') + const open = openEnd >= 0 ? elementText.slice(0, openEnd + 1) : elementText + const rest = openEnd >= 0 ? elementText.slice(openEnd + 1) : '' + const re = new RegExp(`\\b${attr}="[^"]*"`) + if (!re.test(open)) { + // Insert the attribute just before the tag close. + const selfClosing = open.endsWith('/>') + const cut = selfClosing ? open.length - 2 : open.length - 1 + return `${open.slice(0, cut)} ${attr}="${value}"${open.slice(cut)}${rest}` + } + return open.replace(re, `${attr}="${value}"`) + rest +} + +/** Splice `replacement` over the `[start, end)` span of `xml`. */ +function splice(xml: string, start: number, end: number, replacement: string): string { + return xml.slice(0, start) + replacement + xml.slice(end) +} + +/** Locate the `` element with the given id. */ +function findRoadById( + xml: string, + roadId: string +): { start: number; end: number; text: string } | null { + for (const el of findAllElements(xml, 'road')) { + if (attrOf(el.text, 'id') === roadId) return el + } + return null +} + +/** First road that satisfies a predicate on its element text. */ +function findRoadWhere( + xml: string, + pred: (text: string) => boolean +): { start: number; end: number; text: string } | null { + for (const el of findAllElements(xml, 'road')) { + if (pred(el.text)) return el + } + return null +} + +function require_(id: string, value: T | null | undefined, what: string): T { + if (value === null || value === undefined) { + throw new MutationNotAppliedError(id, `source document has no ${what}`) + } + return value +} + +// --------------------------------------------------------------------------- +// The mutation corpus +// --------------------------------------------------------------------------- + +/** M1: truncate the document, dropping the trailing 40 % of its bytes. */ +export const truncate: Mutation = { + id: 'truncate', + description: 'drop the trailing 40 % of the document (simulates a partial write / bad transfer)', + expectedRule: 'xml.truncated', + apply: xml => { + const cut = Math.floor(xml.length * 0.6) + const out = xml.slice(0, cut) + if (out.includes('')) { + throw new MutationNotAppliedError('truncate', 'the truncated document still has its root close tag') + } + return { xml: out, applied: `kept ${cut} of ${xml.length} bytes` } + }, +} + +/** M2: delete one `` record from a junction. */ +export const dropJunctionConnection: Mutation = { + id: 'drop-junction-connection', + description: 'delete a from a junction, orphaning its connecting road', + expectedRule: 'junction.connection-missing', + apply: xml => { + // Pick a connection whose connectingRoad is not referenced by any other + // connection, so removing it definitely orphans that road. + const junctions = findAllElements(xml, 'junction') + for (const j of junctions) { + const conns = findAllElements(j.text, 'connection') + if (conns.length < 2) continue + for (const c of conns) { + const road = attrOf(c.text, 'connectingRoad') ?? attrOf(c.text, 'linkedRoad') + if (!road) continue + const refCount = conns.filter( + o => (attrOf(o.text, 'connectingRoad') ?? attrOf(o.text, 'linkedRoad')) === road + ).length + if (refCount !== 1) continue + const newJunction = splice(j.text, c.start, c.end, '') + const out = splice(xml, j.start, j.end, newJunction) + if (out.length >= xml.length) { + throw new MutationNotAppliedError('drop-junction-connection', 'document did not shrink') + } + return { + xml: out, + applied: `removed connection id=${attrOf(c.text, 'id')} (connectingRoad=${road}) from junction ${attrOf(j.text, 'id')}`, + } + } + } + throw new MutationNotAppliedError( + 'drop-junction-connection', + 'no junction with a uniquely-referenced connecting road' + ) + }, +} + +/** M3: point a road's `` at a road id that does not exist. */ +export const danglingRoadSuccessor: Mutation = { + id: 'dangling-road-successor', + description: 'retarget a road at a nonexistent road id', + expectedRule: 'ref.dangling-road-link', + apply: xml => { + const ghostId = '999999' + const road = require_( + 'dangling-road-successor', + findRoadWhere(xml, t => /]*elementType="road"/.test(t)), + 'road with a road-typed ' + ) + const succRe = /]*elementType="road"[^>]*>/ + const succ = require_('dangling-road-successor', road.text.match(succRe)?.[0], 'successor tag') + const mutatedSucc = withAttr(succ, 'elementId', ghostId) + if (mutatedSucc === succ) { + throw new MutationNotAppliedError('dangling-road-successor', 'elementId already 999999') + } + const newRoad = road.text.replace(succRe, mutatedSucc) + return { + xml: splice(xml, road.start, road.end, newRoad), + applied: `road ${attrOf(road.text, 'id')} successor -> road ${ghostId} (nonexistent)`, + } + }, +} + +/** M4: point a junction connection's `incomingRoad` at a nonexistent road. */ +export const danglingConnectionIncoming: Mutation = { + id: 'dangling-connection-incoming', + description: 'retarget a at a nonexistent road id', + expectedRule: 'ref.dangling-connection-road', + apply: xml => { + const ghostId = '888888' + const junction = require_( + 'dangling-connection-incoming', + findAllElements(xml, 'junction').find(j => findElement(j.text, 'connection') !== null), + 'junction with a connection' + ) + const conn = require_( + 'dangling-connection-incoming', + findElement(junction.text, 'connection'), + 'connection element' + ) + const mutatedConn = withAttr(conn.text, 'incomingRoad', ghostId) + if (mutatedConn === conn.text) { + throw new MutationNotAppliedError('dangling-connection-incoming', 'incomingRoad already 888888') + } + const newJunction = splice(junction.text, conn.start, conn.end, mutatedConn) + return { + xml: splice(xml, junction.start, junction.end, newJunction), + applied: `junction ${attrOf(junction.text, 'id')} connection ${attrOf(conn.text, 'id')} incomingRoad -> ${ghostId} (nonexistent)`, + } + }, +} + +/** M5: retarget one lane `` at a lane id that does not exist. */ +export const dropLaneLink: Mutation = { + id: 'drop-lane-link', + description: 'retarget a lane / at a nonexistent lane id', + expectedRule: 'ref.dangling-lane-link', + apply: xml => { + const ghostLane = '77' + for (const road of findAllElements(xml, 'road')) { + // Only mutate roads that actually link somewhere, otherwise the lane + // link has no target lane section to be checked against. + if (!/<(?:predecessor|successor)\b/.test(road.text)) continue + const lanes = findAllElements(road.text, 'lane') + for (const lane of lanes) { + const linkTag = lane.text.match(/<(?:successor|predecessor)\b[^>]*\bid="[^"]*"[^>]*\/?>/)?.[0] + if (!linkTag) continue + const mutatedTag = withAttr(linkTag, 'id', ghostLane) + if (mutatedTag === linkTag) continue + const newLane = lane.text.replace(linkTag, mutatedTag) + const newRoad = splice(road.text, lane.start, lane.end, newLane) + return { + xml: splice(xml, road.start, road.end, newRoad), + applied: `road ${attrOf(road.text, 'id')} lane ${attrOf(lane.text, 'id')} link -> lane ${ghostLane} (nonexistent)`, + } + } + } + throw new MutationNotAppliedError('drop-lane-link', 'no linked road with a lane record') + }, +} + +/** M6: make a lane width negative. */ +export const negativeWidth: Mutation = { + id: 'negative-width', + description: 'set a lane constant term negative', + expectedRule: 'geom.negative-lane-width', + apply: xml => { + for (const road of findAllElements(xml, 'road')) { + const widths = findAllElements(road.text, 'width') + for (const w of widths) { + const a = attrOf(w.text, 'a') + if (a === null || !Number.isFinite(Number(a)) || Number(a) <= 0) continue + // Zero the higher-order terms so the record is unambiguously negative + // over its whole span (not merely negative at s = 0). + let mutated = withAttr(w.text, 'a', '-3.5') + for (const t of ['b', 'c', 'd']) { + if (attrOf(mutated, t) !== null) mutated = withAttr(mutated, t, '0') + } + const newRoad = splice(road.text, w.start, w.end, mutated) + return { + xml: splice(xml, road.start, road.end, newRoad), + applied: `road ${attrOf(road.text, 'id')} lane width a=${a} -> -3.5`, + } + } + } + throw new MutationNotAppliedError('negative-width', 'no positive lane record found') + }, +} + +/** M7: displace one plan-view geometry's start position, opening a gap. */ +export const geometryGap: Mutation = { + id: 'geometry-gap', + description: 'shift a start x by 5 m, breaking plan-view continuity', + expectedRule: 'geom.plan-view-gap', + apply: xml => { + for (const road of findAllElements(xml, 'road')) { + const geoms = findAllElements(road.text, 'geometry') + if (geoms.length < 2) continue + // Shift the second geometry: its predecessor's end pose no longer meets it. + const g = geoms[1] + const x = attrOf(g.text, 'x') + if (x === null || !Number.isFinite(Number(x))) continue + const shifted = String(Number(x) + 5) + const mutated = withAttr(g.text, 'x', shifted) + if (mutated === g.text) continue + const newRoad = splice(road.text, g.start, g.end, mutated) + return { + xml: splice(xml, road.start, road.end, newRoad), + applied: `road ${attrOf(road.text, 'id')} geometry[1] x=${x} -> ${shifted} (+5 m)`, + } + } + throw new MutationNotAppliedError('geometry-gap', 'no road with two or more geometries') + }, +} + +/** M8: falsify `road@length` so it disagrees with the plan-view sum. */ +export const lengthMismatch: Mutation = { + id: 'length-mismatch', + description: 'inflate road@length by 50 % without changing the plan view', + expectedRule: 'geom.road-length-mismatch', + apply: xml => { + for (const road of findAllElements(xml, 'road')) { + const len = attrOf(road.text, 'length') + if (len === null) continue + const n = Number(len) + if (!Number.isFinite(n) || n <= 0) continue + if (findElement(road.text, 'geometry') === null) continue + const inflated = String(n * 1.5) + const openEnd = road.text.indexOf('>') + const mutatedOpen = withAttr(road.text.slice(0, openEnd + 1), 'length', inflated) + const newRoad = mutatedOpen + road.text.slice(openEnd + 1) + if (newRoad === road.text) continue + return { + xml: splice(xml, road.start, road.end, newRoad), + applied: `road ${attrOf(road.text, 'id')} length=${len} -> ${inflated} (+50 %)`, + } + } + throw new MutationNotAppliedError('length-mismatch', 'no road with a positive length and a plan view') + }, +} + +/** M9: point a controller `` at a nonexistent signal. */ +export const orphanControllerSignal: Mutation = { + id: 'orphan-controller-signal', + description: 'retarget a at a signal id that no road defines', + expectedRule: 'ref.dangling-controller-signal', + apply: xml => { + const ghostSignal = '765432' + const controller = require_( + 'orphan-controller-signal', + findAllElements(xml, 'controller').find(c => findElement(c.text, 'control') !== null), + 'controller with a record' + ) + const control = require_( + 'orphan-controller-signal', + findElement(controller.text, 'control'), + 'control element' + ) + const mutated = withAttr(control.text, 'signalId', ghostSignal) + if (mutated === control.text) { + throw new MutationNotAppliedError('orphan-controller-signal', 'signalId already the ghost id') + } + const newController = splice(controller.text, control.start, control.end, mutated) + return { + xml: splice(xml, controller.start, controller.end, newController), + applied: `controller ${attrOf(controller.text, 'id')} control signalId -> ${ghostSignal} (nonexistent)`, + } + }, +} + +/** + * M10: mark a plain road as belonging to a junction that never lists it. + * A road carrying `junction=""` is by definition a connecting road of that + * junction and must appear in one of its ``. + */ +export const roadJunctionAttrWithoutMembership: Mutation = { + id: 'road-junction-attr-without-membership', + description: 'set road@junction on a road that no of that junction references', + expectedRule: 'junction.road-not-member', + apply: xml => { + const junction = require_( + 'road-junction-attr-without-membership', + findElement(xml, 'junction'), + 'junction element' + ) + const junctionId = require_( + 'road-junction-attr-without-membership', + attrOf(junction.text, 'id'), + 'junction id' + ) + const members = new Set( + findAllElements(junction.text, 'connection').flatMap(c => + [attrOf(c.text, 'incomingRoad'), attrOf(c.text, 'connectingRoad'), attrOf(c.text, 'linkedRoad')].filter( + (v): v is string => v !== null + ) + ) + ) + for (const road of findAllElements(xml, 'road')) { + const id = attrOf(road.text, 'id') + if (id === null || members.has(id)) continue + if (attrOf(road.text, 'junction') === junctionId) continue + // Require a road that links to none of the junction's roads, so the + // defect is unambiguously a spurious membership claim rather than a + // lost record. The validator distinguishes the two by + // exactly this evidence, so the mutation must pin down which it is. + const linkTargets = (road.text.match(/<(?:predecessor|successor)\b[^>]*>/g) ?? []).map( + t => attrOf(t, 'elementId') ?? '' + ) + if (linkTargets.some(t => members.has(t) || t === junctionId)) continue + const openEnd = road.text.indexOf('>') + const mutatedOpen = withAttr(road.text.slice(0, openEnd + 1), 'junction', junctionId) + const newRoad = mutatedOpen + road.text.slice(openEnd + 1) + if (newRoad === road.text) continue + return { + xml: splice(xml, road.start, road.end, newRoad), + applied: `road ${id} junction -> ${junctionId} (junction lists no connection for it)`, + } + } + throw new MutationNotAppliedError( + 'road-junction-attr-without-membership', + 'every road is already a member of the first junction' + ) + }, +} + +/** The full corpus, in detection-matrix order. */ +export const MUTATIONS: readonly Mutation[] = [ + truncate, + dropJunctionConnection, + danglingRoadSuccessor, + danglingConnectionIncoming, + dropLaneLink, + negativeWidth, + geometryGap, + lengthMismatch, + orphanControllerSignal, + roadJunctionAttrWithoutMembership, +] diff --git a/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts b/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts new file mode 100644 index 0000000..a4688c1 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts @@ -0,0 +1,180 @@ +// Layer 9: mutation-proof harness for the OpenDRIVE validator. +// +// The suite is organised around two symmetric obligations: +// +// detection every injected defect must surface as a MAP_DEFECT error +// carrying the expected rule id +// false-positive every unmodified real map must produce *zero* MAP_DEFECT +// errors — a validator that reddens working maps is a +// validator nobody runs +// +// The second is the one that constrains the thresholds, so it is asserted +// against every checked-in fixture and (when ODR_VALIDATE_CORPUS is set) a +// wider external corpus. + +import { describe, it, expect } from 'vitest' +import { validateOpenDrive } from '../../src/validator/index' +import type { OdrFinding, OdrValidationReport } from '../../src/validator/index' +import { MUTATIONS, applyMutation, findAllElements, attrOf } from './mutations' +import { loadFixtureCorpus, loadExternalCorpus, readFixture } from './corpus' + +const FIXTURES = loadFixtureCorpus() +const EXTERNAL = loadExternalCorpus() + +/** MAP_DEFECT errors only — the findings that make a verdict red. */ +function defects(report: OdrValidationReport): OdrFinding[] { + return report.findings.filter(f => f.severity === 'error' && f.category === 'MAP_DEFECT') +} + +function describeFindings(findings: readonly OdrFinding[]): string { + if (findings.length === 0) return '(none)' + return findings.map(f => `${f.rule}: ${f.message}`).join('\n ') +} + +/** + * Pick the fixture a mutation can actually be applied to. Not every fixture + * has a junction, a controller, or a multi-geometry road, so each mutation + * runs against the first fixture where `applyMutation` succeeds; the mutation + * fails the test only when *no* fixture supports it. + */ +function applyToAnyFixture(mutationIndex: number): { + fixture: string + original: string + mutated: string + applied: string +} { + const mutation = MUTATIONS[mutationIndex] + const failures: string[] = [] + for (const entry of FIXTURES) { + try { + const result = applyMutation(mutation, entry.xml) + return { + fixture: entry.name, + original: entry.xml, + mutated: result.xml, + applied: result.applied, + } + } catch (err) { + failures.push(`${entry.name}: ${err instanceof Error ? err.message : String(err)}`) + } + } + throw new Error( + `mutation "${mutation.id}" could not be applied to any fixture:\n ${failures.join('\n ')}` + ) +} + +describe('mutation harness self-check', () => { + it('has fixtures to mutate', () => { + expect(FIXTURES.length).toBeGreaterThan(0) + }) + + it.each(MUTATIONS.map((m, i) => [m.id, i] as const))( + 'mutation %s actually changes the document', + (_id, index) => { + const { original, mutated, applied } = applyToAnyFixture(index) + // The guard the 2026-08-21 near-miss demands: a mutation that silently + // matched nothing must fail here, not be misread as an undetected defect. + expect(mutated).not.toBe(original) + expect(applied.length).toBeGreaterThan(0) + } + ) + + it('rejects a no-op mutation', () => { + const noop = { + id: 'noop', + description: 'changes nothing', + expectedRule: 'never', + apply: (xml: string) => ({ xml, applied: 'nothing' }), + } + expect(() => applyMutation(noop, '')).toThrow(/did not apply/) + }) +}) + +/** + * Rule prefixes whose detector layer is implemented. Entries are added as each + * layer lands, so a detection the code cannot yet make is a `todo` rather than + * a failure — while the mutation itself is still proven to apply above. + */ +const IMPLEMENTED_LAYERS: readonly string[] = ['xml.', 'ref.', 'junction.', 'geom.'] + +const isImplemented = (rule: string): boolean => + IMPLEMENTED_LAYERS.some(prefix => rule.startsWith(prefix)) + +describe('mutation detection matrix', () => { + const cases = MUTATIONS.map((m, i) => [m.id, m.expectedRule, i] as const) + const pending = cases.filter(([, rule]) => !isImplemented(rule)) + for (const [id, rule] of pending) { + it.todo(`detects ${id} as ${rule} (layer not implemented yet)`) + } + + const active = cases.filter(([, rule]) => isImplemented(rule)) + for (const [id, expectedRule, index] of active) { + it(`detects ${id} as ${expectedRule}`, () => { + const { fixture, mutated, applied } = applyToAnyFixture(index) + const report = validateOpenDrive(mutated) + const found = defects(report) + expect( + found.some(f => f.rule === expectedRule), + `mutation ${id} on ${fixture} (${applied}) should raise ${expectedRule}, got:\n ${describeFindings(report.findings)}` + ).toBe(true) + expect(report.verdict).toBe('red') + }) + } +}) + +describe('false-positive gate', () => { + it.each(FIXTURES.map(f => [f.name] as const))('fixture %s has no MAP_DEFECT error', name => { + const entry = FIXTURES.find(f => f.name === name)! + const report = validateOpenDrive(entry.xml) + expect(defects(report), `unexpected defects in ${name}:\n ${describeFindings(defects(report))}`).toEqual([]) + }) + + const externalIt = EXTERNAL.length > 0 ? it : it.skip + externalIt('external corpus (ODR_VALIDATE_CORPUS) has no MAP_DEFECT error', () => { + const offenders: string[] = [] + for (const entry of EXTERNAL) { + const report = validateOpenDrive(entry.xml) + const found = defects(report) + if (found.length > 0) offenders.push(`${entry.name}:\n ${describeFindings(found)}`) + } + expect(offenders.join('\n')).toBe('') + }) + + it('reports how many external corpus files were checked', () => { + // Visible in the run output so a skipped corpus is never mistaken for a + // passing one. + expect(EXTERNAL.length).toBeGreaterThanOrEqual(0) + }) +}) + +describe('report shape', () => { + it('counts findings by severity and category', () => { + const report = validateOpenDrive(readFixture('fabriksgatan.xodr')) + const bySeverity = report.counts.error + report.counts.warning + report.counts.info + const byCategory = report.counts.MAP_DEFECT + report.counts.TOOL_LIMITATION + report.counts.INFO + expect(bySeverity).toBe(report.findings.length) + expect(byCategory).toBe(report.findings.length) + }) + + it('never throws on garbage input', () => { + for (const junk of ['', 'not xml at all', ' validateOpenDrive(junk)).not.toThrow() + } + }) +}) + +describe('fixture corpus assumptions', () => { + it('fixtures include junctions, controllers and multi-geometry roads', () => { + // The mutation corpus needs these constructs to exist somewhere; assert it + // so a fixture reshuffle that removes them fails loudly instead of + // silently shrinking the detection matrix. + const all = FIXTURES.map(f => f.xml).join('\n') + expect(findAllElements(all, 'junction').length).toBeGreaterThan(0) + expect(findAllElements(all, 'controller').length).toBeGreaterThan(0) + const multiGeom = FIXTURES.some(f => + findAllElements(f.xml, 'road').some(r => findAllElements(r.text, 'geometry').length >= 2) + ) + expect(multiGeom).toBe(true) + expect(attrOf('', 'id')).toBe('7') + }) +}) diff --git a/packages/drawtonomy-sdk/__tests__/validator/references.test.ts b/packages/drawtonomy-sdk/__tests__/validator/references.test.ts new file mode 100644 index 0000000..b695ee6 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/references.test.ts @@ -0,0 +1,162 @@ +// Layer 2 unit tests: reference integrity. + +import { describe, it, expect } from 'vitest' +import { validateOpenDrive } from '../../src/validator/index' +import type { OdrFinding } from '../../src/validator/index' +import { readFixture } from './corpus' + +const findingsFor = (xml: string): OdrFinding[] => validateOpenDrive(xml).findings +const rulesFor = (xml: string): string[] => findingsFor(xml).map(f => f.rule) + +/** Two straight roads linked end-to-start, with one lane each side. */ +const lanes = (predId?: number, succId?: number): string => ` + + + + ${predId !== undefined ? `` : ''}${succId !== undefined ? `` : ''} + + +
+ + ${predId !== undefined ? `` : ''}${succId !== undefined ? `` : ''} + + +
+
` + +const PAIR = ` + +
+ + + + ${lanes(undefined, 1)} + + + + + ${lanes(1, undefined)} + +` + +describe('checkReferences', () => { + it('accepts a well-formed pair of linked roads', () => { + expect(rulesFor(PAIR)).toEqual([]) + }) + + it('flags a road link to a nonexistent road', () => { + const xml = PAIR.replace('elementType="road" elementId="2"', 'elementType="road" elementId="99"') + expect(rulesFor(xml)).toContain('ref.dangling-road-link') + expect(validateOpenDrive(xml).verdict).toBe('red') + }) + + it('flags a lane link to a lane the target road does not have', () => { + const xml = PAIR.replace('', '') + const found = findingsFor(xml).filter(f => f.rule === 'ref.dangling-lane-link') + expect(found).toHaveLength(1) + expect(found[0].message).toContain('lane 7') + expect(found[0].location?.roadId).toBe('1') + }) + + it('flags a duplicate road id', () => { + const xml = PAIR.replace('id="2" junction="-1"', 'id="1" junction="-1"') + expect(rulesFor(xml)).toContain('ref.duplicate-road-id') + }) + + it('flags a junction connection referring to a missing road', () => { + const xml = PAIR.replace( + '', + ` + + + + ` + ) + const found = findingsFor(xml).filter(f => f.rule === 'ref.dangling-connection-road') + expect(found).toHaveLength(1) + expect(found[0].message).toContain('incomingRoad="404"') + }) + + it('flags a controller governing an undefined signal', () => { + const xml = PAIR.replace( + '', + `` + ) + expect(rulesFor(xml)).toContain('ref.dangling-controller-signal') + }) + + it('resolves a controller signal defined on any road', () => { + const xml = PAIR.replace( + '', + `` + ) + expect(rulesFor(xml)).toEqual([]) + }) + + describe('excerpted maps', () => { + // Cutting one junction out of a city map leaves boundary roads pointing at + // junctions that were not carried along. That is a property of the excerpt, + // not a defect of the map, so it is a warning: worth surfacing, never red. + it('warns rather than errors on a link to an absent junction', () => { + const xml = PAIR.replace( + 'elementType="road" elementId="2" contactPoint="start"', + 'elementType="junction" elementId="77"' + ) + const found = findingsFor(xml).filter(f => f.rule === 'ref.unresolved-junction-link') + expect(found).toHaveLength(1) + expect(found[0].severity).toBe('warning') + expect(validateOpenDrive(xml).verdict).toBe('yellow') + }) + + it('keeps the Town04 slice fixture out of the red', () => { + const report = validateOpenDrive(readFixture('town04-junction106.xodr')) + expect(report.verdict).toBe('yellow') + expect(report.findings.every(f => f.rule === 'ref.unresolved-junction-link')).toBe(true) + expect(report.counts.error).toBe(0) + }) + }) + + describe('multi-section lane links', () => { + // Within a road, a lane's successor names a lane of the *next* lane + // section, not of the linked road. Getting this wrong would redden every + // multi-section map in existence. + const twoSections = ` + +
+ + + + +
+ + + + +
+ +
+ + + + + + + +
+
+
+` + + it('resolves a lane successor against the next lane section', () => { + expect(rulesFor(twoSections)).toEqual([]) + }) + + it('flags a lane successor absent from the next lane section', () => { + const xml = twoSections.replace('', '') + expect(rulesFor(xml)).toContain('ref.dangling-lane-link') + }) + }) +}) diff --git a/packages/drawtonomy-sdk/__tests__/validator/xmlIntegrity.test.ts b/packages/drawtonomy-sdk/__tests__/validator/xmlIntegrity.test.ts new file mode 100644 index 0000000..323ce37 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/xmlIntegrity.test.ts @@ -0,0 +1,76 @@ +// Layer 1 unit tests: the truncation / balance detector. + +import { describe, it, expect } from 'vitest' +import { checkXmlIntegrity } from '../../src/validator/layers/xmlIntegrity' +import { validateOpenDrive } from '../../src/validator/index' +import { loadFixtureCorpus, readFixture } from './corpus' + +const rules = (xml: string): string[] => checkXmlIntegrity(xml).findings.map(f => f.rule) + +describe('checkXmlIntegrity', () => { + it('accepts every fixture unchanged', () => { + for (const entry of loadFixtureCorpus()) { + const result = checkXmlIntegrity(entry.xml) + expect(result.findings, `${entry.name}: ${JSON.stringify(result.findings)}`).toEqual([]) + expect(result.fatal).toBe(false) + } + }) + + it('flags an empty document', () => { + expect(rules('')).toEqual(['xml.empty']) + expect(rules(' \n ')).toEqual(['xml.empty']) + }) + + it('flags a missing root element', () => { + expect(rules('')).toEqual(['xml.no-root']) + }) + + it('flags a document cut mid-file as truncated', () => { + const xml = readFixture('fabriksgatan.xodr') + const cut = xml.slice(0, Math.floor(xml.length * 0.6)) + expect(rules(cut)).toContain('xml.truncated') + expect(checkXmlIntegrity(cut).fatal).toBe(true) + }) + + it('flags a document cut inside a tag', () => { + expect(rules(' { + expect(rules('\n' + + '
]]>
\n' + + ' \n' + + '
' + expect(rules(xml)).toEqual([]) + }) + + it('accepts self-closing elements', () => { + expect(rules('')).toEqual([]) + }) + + it('short-circuits later layers when the document is truncated', () => { + // A truncated document must not spray dangling-reference findings that are + // artefacts of the missing bytes: exactly one finding, and it is the cause. + const xml = readFixture('fabriksgatan.xodr') + const report = validateOpenDrive(xml.slice(0, Math.floor(xml.length * 0.6))) + expect(report.findings.map(f => f.rule)).toEqual(['xml.truncated']) + expect(report.verdict).toBe('red') + }) +}) diff --git a/packages/drawtonomy-sdk/scripts/odr-validate.mts b/packages/drawtonomy-sdk/scripts/odr-validate.mts new file mode 100644 index 0000000..65360c2 --- /dev/null +++ b/packages/drawtonomy-sdk/scripts/odr-validate.mts @@ -0,0 +1,197 @@ +// Strict OpenDRIVE validation from the command line. +// +// Usage: +// npx tsx scripts/odr-validate.mts [--json out.json] +// [--esmini ] [--limit N] [--quiet] +// +// Exit codes double as the CI gate: +// 0 green informational findings only +// 1 yellow warnings, or constructs drawtonomy cannot represent +// 2 red the document has a defect +// 3 usage / IO error (distinct from a verdict, so a broken invocation is +// never mistaken for a clean map) +// +// This is where the Node-only parts live: reading the file and spawning the +// external road manager. src/validator/ itself stays pure so it also runs in +// the browser. + +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { validateOpenDrive } from '../src/validator/index' +import { parseEsminiOutput, esminiSkipped } from '../src/validator/esminiAdapter' +import type { OdrFinding, OdrValidationReport, OdrVerdict } from '../src/validator/index' + +interface CliOptions { + file: string + jsonOut: string | null + esminiBinary: string | null + limit: number + quiet: boolean +} + +function usage(message?: string): never { + if (message) console.error(`odr-validate: ${message}`) + console.error( + 'usage: odr-validate.mts [--json ] [--esmini ] [--limit N] [--quiet]' + ) + process.exit(3) +} + +function parseArgs(argv: string[]): CliOptions { + let file: string | null = null + let jsonOut: string | null = null + // The binary may also come from the environment, so a CI job can enable the + // external check for every invocation without editing each call site. + let esminiBinary: string | null = process.env.ODR_VALIDATE_ESMINI ?? null + let limit = 20 + let quiet = false + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + switch (arg) { + case '--json': + jsonOut = argv[++i] ?? usage('--json needs a path') + break + case '--esmini': + esminiBinary = argv[++i] ?? usage('--esmini needs a path') + break + case '--limit': { + const raw = argv[++i] ?? usage('--limit needs a number') + limit = Number(raw) + if (!Number.isFinite(limit) || limit < 0) usage(`--limit "${raw}" is not a count`) + break + } + case '--quiet': + quiet = true + break + case '-h': + case '--help': + usage() + break + default: + if (arg.startsWith('-')) usage(`unknown option "${arg}"`) + if (file !== null) usage('more than one input file given') + file = arg + } + } + + if (file === null) usage('no input file given') + return { file, jsonOut, esminiBinary, limit, quiet } +} + +/** + * Run the external road manager over the file, if a binary was supplied. + * Never throws: a missing or broken binary downgrades to an info finding + * rather than failing the validation run. + */ +function runEsmini(binary: string | null, file: string): OdrFinding[] { + if (!binary) return esminiSkipped('no binary supplied (--esmini or ODR_VALIDATE_ESMINI)') + if (!existsSync(binary)) return esminiSkipped(`binary not found at ${binary}`) + try { + const proc = spawnSync(binary, ['--odr', file, '--headless'], { + encoding: 'utf8', + timeout: 60_000, + }) + if (proc.error) return esminiSkipped(`could not run ${binary}: ${proc.error.message}`) + return parseEsminiOutput({ + stdout: proc.stdout ?? '', + stderr: proc.stderr ?? '', + exitCode: proc.status, + }) + } catch (err) { + return esminiSkipped(`could not run ${binary}: ${err instanceof Error ? err.message : err}`) + } +} + +const VERDICT_EXIT: Record = { green: 0, yellow: 1, red: 2 } + +function formatLocation(finding: OdrFinding): string { + const loc = finding.location + if (!loc) return '' + const parts: string[] = [] + if (loc.roadId !== undefined) parts.push(`road ${loc.roadId}`) + if (loc.junctionId !== undefined) parts.push(`junction ${loc.junctionId}`) + if (loc.laneId !== undefined) parts.push(`lane ${loc.laneId}`) + if (loc.s !== undefined) parts.push(`s=${loc.s.toFixed(2)}`) + return parts.length > 0 ? ` [${parts.join(', ')}]` : '' +} + +/** Human summary: counts per rule first, then the detail rows. */ +function printReport(report: OdrValidationReport, file: string, limit: number): void { + const { counts, verdict, findings } = report + + console.log(`\n${file}`) + console.log(` verdict: ${verdict.toUpperCase()}`) + console.log( + ` ${counts.error} error / ${counts.warning} warning / ${counts.info} info` + + ` (${counts.MAP_DEFECT} map defect, ${counts.TOOL_LIMITATION} tool limitation, ${counts.INFO} info)` + ) + + if (findings.length === 0) { + console.log(' no findings') + return + } + + const byRule = new Map() + for (const f of findings) { + const entry = byRule.get(f.rule) + if (entry) entry.count += 1 + else byRule.set(f.rule, { count: 1, severity: f.severity, category: f.category }) + } + + console.log('\n findings by rule:') + const sorted = [...byRule.entries()].sort((a, b) => b[1].count - a[1].count) + for (const [rule, entry] of sorted) { + console.log( + ` ${String(entry.count).padStart(5)} ${rule.padEnd(34)} ${entry.severity}/${entry.category}` + ) + } + + // Detail rows, worst first, so a truncated list still shows what matters. + const rank = { error: 0, warning: 1, info: 2 } as const + const detail = [...findings].sort((a, b) => rank[a.severity] - rank[b.severity]) + const shown = limit === 0 ? detail : detail.slice(0, limit) + if (shown.length > 0) { + console.log(`\n detail (${shown.length} of ${findings.length}):`) + for (const f of shown) { + console.log(` ${f.severity.padEnd(7)} ${f.rule}${formatLocation(f)}`) + console.log(` ${f.message}`) + } + if (shown.length < findings.length) { + console.log(`\n ... ${findings.length - shown.length} more (use --limit 0 for all)`) + } + } +} + +function main(): void { + const opts = parseArgs(process.argv.slice(2)) + + let xml: string + try { + xml = readFileSync(opts.file, 'utf8') + } catch (err) { + console.error(`odr-validate: cannot read ${opts.file}: ${err instanceof Error ? err.message : err}`) + process.exit(3) + } + + const externalFindings = runEsmini(opts.esminiBinary, opts.file) + const report = validateOpenDrive(xml, { externalFindings }) + + if (!opts.quiet) printReport(report, opts.file, opts.limit) + + if (opts.jsonOut) { + try { + writeFileSync(opts.jsonOut, `${JSON.stringify({ file: opts.file, ...report }, null, 2)}\n`) + if (!opts.quiet) console.log(`\n wrote ${opts.jsonOut}`) + } catch (err) { + console.error( + `odr-validate: cannot write ${opts.jsonOut}: ${err instanceof Error ? err.message : err}` + ) + process.exit(3) + } + } + + process.exit(VERDICT_EXIT[report.verdict]) +} + +main() diff --git a/packages/drawtonomy-sdk/src/validator/esminiAdapter.ts b/packages/drawtonomy-sdk/src/validator/esminiAdapter.ts new file mode 100644 index 0000000..a78b700 --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/esminiAdapter.ts @@ -0,0 +1,111 @@ +// Layer 6: esmini road-manager adapter (`esmini.*`). +// +// esmini's road manager loads an OpenDRIVE file and complains about things a +// static reader cannot easily see — unroutable connections, lanes it refuses +// to link, geometry it cannot follow. Running it is a useful second opinion, +// but it is an external binary, so the split here is deliberate: +// +// this module pure: turns captured stdout/stderr text into findings. +// No fs, no child_process, so the validator core still runs +// in a browser and the parsing is unit-testable without the +// binary being installed. +// scripts/ spawns the process and feeds the output in here. +// +// The findings are attributed TOOL_LIMITATION rather than MAP_DEFECT: esmini +// disagreeing with a document is evidence about the pair, not proof the map is +// at fault, so it never turns a report red on its own. + +import type { OdrFinding } from './types.js' + +/** Result of an external road-manager run, as captured by the caller. */ +export interface EsminiRunOutput { + /** Combined or separate process output. Both are scanned. */ + stdout?: string + stderr?: string + /** Process exit code, when the caller has one. */ + exitCode?: number | null +} + +/** + * Lines worth reporting. esmini prefixes real problems with these markers; + * everything else it prints is progress chatter. + */ +const INTERESTING = [ + { pattern: /\berror\b/i, rule: 'esmini.error' }, + { pattern: /\bfailed\b/i, rule: 'esmini.error' }, + { pattern: /no connection/i, rule: 'esmini.no-connection' }, + { pattern: /\bwarning\b/i, rule: 'esmini.warning' }, +] as const + +/** Findings reported when no binary was available. */ +export function esminiSkipped(reason: string): OdrFinding[] { + return [ + { + severity: 'info', + category: 'INFO', + rule: 'esmini.skipped', + message: `esmini road-manager check skipped: ${reason}`, + }, + ] +} + +/** + * Turn captured road-manager output into findings. + * + * Duplicate lines are collapsed with a count, because esmini repeats the same + * complaint once per lane and an unfiltered dump buries every other layer's + * findings under hundreds of identical rows. + */ +export function parseEsminiOutput(output: EsminiRunOutput): OdrFinding[] { + const text = [output.stdout ?? '', output.stderr ?? ''].join('\n') + const seen = new Map< + string, + { rule: string; severity: 'error' | 'warning'; line: string; count: number } + >() + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim() + if (line === '') continue + const match = INTERESTING.find(entry => entry.pattern.test(line)) + if (!match) continue + const severity = match.rule === 'esmini.warning' ? 'warning' : 'error' + const key = `${match.rule}|${line}` + const existing = seen.get(key) + if (existing) existing.count += 1 + else seen.set(key, { rule: match.rule, severity, line, count: 1 }) + } + + const findings: OdrFinding[] = [] + for (const entry of seen.values()) { + const { line } = entry + findings.push({ + severity: entry.severity, + // Attribution: esmini's opinion is evidence about the tool/map pair, not + // a verdict on the map, so it must not redden a report by itself. + category: 'TOOL_LIMITATION', + rule: entry.rule, + message: entry.count > 1 ? `${line} (x${entry.count})` : line, + }) + } + + if (findings.length === 0) { + const exit = output.exitCode + if (exit !== undefined && exit !== null && exit !== 0) { + findings.push({ + severity: 'warning', + category: 'TOOL_LIMITATION', + rule: 'esmini.nonzero-exit', + message: `esmini exited with code ${exit} but printed nothing recognizable`, + }) + } else { + findings.push({ + severity: 'info', + category: 'INFO', + rule: 'esmini.clean', + message: 'esmini road manager loaded the document without complaint', + }) + } + } + + return findings +} diff --git a/packages/drawtonomy-sdk/src/validator/index.ts b/packages/drawtonomy-sdk/src/validator/index.ts new file mode 100644 index 0000000..be792d9 --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/index.ts @@ -0,0 +1,29 @@ +// OpenDRIVE validator: a strict counterpart to the lenient importer. +// +// `validateOpenDrive(xml)` returns a report of findings without modifying or +// repairing anything. The module is dependency-free and runtime-agnostic (no +// fs, no child_process), so it runs in a browser as well as in Node; the +// external road-manager layer is fed in through `opts.externalFindings`. + +export { validateOpenDrive, buildReport } from './validateOpenDrive.js' +export { + parseEsminiOutput, + esminiSkipped, + type EsminiRunOutput, +} from './esminiAdapter.js' +export { + DEFAULT_GEOMETRY_THRESHOLDS, + countFindings, + deriveVerdict, + resolveGeometryThresholds, + type OdrFinding, + type OdrFindingCategory, + type OdrFindingLocation, + type OdrFindingSeverity, + type OdrGeometryThresholds, + type OdrValidationCounts, + type OdrValidationOptions, + type OdrValidationReport, + type OdrVerdict, + type ResolvedGeometryThresholds, +} from './types.js' diff --git a/packages/drawtonomy-sdk/src/validator/layers/geometry.ts b/packages/drawtonomy-sdk/src/validator/layers/geometry.ts new file mode 100644 index 0000000..1102977 --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/layers/geometry.ts @@ -0,0 +1,251 @@ +// Layer 4: geometric continuity (`geom.*`). +// +// Reference-line arithmetic, reusing the exporter's geometry evaluation +// (`evalGeometry`, which covers line / arc / spiral / paramPoly3 / poly3) so +// the validator and the exporter agree by construction about what a geometry +// record means. +// +// Checks: +// geom.plan-view-gap consecutive geometries of one road do not meet +// geom.plan-view-heading ... or meet at a heading discontinuity +// geom.road-length-mismatch road@length disagrees with the plan-view sum +// geom.negative-lane-width a record evaluates negative +// geom.road-link-gap two linked roads do not touch at their contact +// +// --------------------------------------------------------------------------- +// Threshold calibration (measured 2026-08-22 over 6 fixtures + 26 esmini maps, +// 401 geometry joints / 182 roads / 242 road links) +// --------------------------------------------------------------------------- +// +// Within one road, real maps are near-exact and the defaults have wide margin: +// +// plan-view position gap observed max 4.0e-4 m default 0.02 m (50x) +// plan-view heading step observed max 2.3e-5 rad default 0.005 rad (200x) +// road length vs sum observed max 4.0e-16 default 1 % (huge) +// +// Between roads it is a different story, and the reason is structural rather +// than a matter of precision. `road@length`-scale gaps at a link are normal: +// +// 1. A road carrying a has its reference line laterally shifted +// from the lane geometry. Two roads with different offsets meet along +// their *lanes* while their reference lines stay apart by exactly the +// offset difference. fabriksgatan's connecting roads (laneOffset a=1.75) +// linking to mainlines (a=0) produce a uniform 1.75 m; multi_intersections +// produces 3.75 m the same way. Both maps are correct. +// 2. Some shipped maps simply contain link records their geometry does not +// honour: soderleden road 7 declares its predecessor to be road 2 at that +// road's end, 66 m from where road 7 actually begins. esmini drives this +// map regardless, because it routes through the junction rather than the +// stray link. +// +// So the road-link check is reported as a *warning*: it is real evidence worth +// surfacing, but it must not redden a map that ships and works. The lane offset +// difference is subtracted before comparing, which removes cause (1) exactly +// and leaves cause (2) visible. +// +// Lane widths need a tolerance for the opposite reason — not structure, but +// arithmetic. Merge and exit lanes are authored to close at exactly zero, e.g. +// `3.5 - 0.0042*ds^2 + 0.000056*ds^3` reaching 0 at ds = 50. In IEEE-754 those +// land a few ulps below zero: all 8 negative evaluations across the two corpora +// fall between -8.9e-16 and -1.8e-15 m, in soderleden, two_plus_one and +// multi_intersections. The 1 mm default is twelve orders of magnitude above +// that noise and still far below any width a map could intend, so it separates +// the two cleanly rather than splitting a continuum. + +import { evalGeometry } from '../../exporter/odrGeometry.js' +import type { OdrMap, OdrRoad, OdrWidth } from '../../exporter/opendriveParser.js' +import type { OdrFinding, ResolvedGeometryThresholds } from '../types.js' + +/** Wrap an angle to (-pi, pi]. */ +function normalizeAngle(a: number): number { + let x = a + while (x > Math.PI) x -= 2 * Math.PI + while (x <= -Math.PI) x += 2 * Math.PI + return x +} + +/** Evaluate a cubic `a + b*ds + c*ds^2 + d*ds^3`. */ +function evalCubic(rec: { a: number; b: number; c: number; d: number }, ds: number): number { + return rec.a + rec.b * ds + rec.c * ds * ds + rec.d * ds * ds * ds +} + +/** The road's lateral lane offset at station `s` (0 when it declares none). */ +function laneOffsetAt(road: OdrRoad, s: number): number { + const records = road.laneOffsets + if (records.length === 0) return 0 + let applicable = records[0] + for (const rec of records) { + if (rec.s <= s) applicable = rec + else break + } + return evalCubic(applicable, s - applicable.s) +} + +interface Pose { + x: number + y: number + hdg: number +} + +/** Pose at a road's start or end, on its reference line. */ +function poseAtEnd(road: OdrRoad, at: 'start' | 'end'): Pose | null { + if (road.planView.length === 0) return null + if (at === 'start') { + const g = road.planView[0] + return evalGeometry(g, 0) + } + const g = road.planView[road.planView.length - 1] + return evalGeometry(g, g.length) +} + +/** Station of a road's start or end. */ +const stationAtEnd = (road: OdrRoad, at: 'start' | 'end'): number => + at === 'start' ? 0 : road.length + +export function checkGeometry( + map: OdrMap, + thresholds: ResolvedGeometryThresholds +): OdrFinding[] { + const findings: OdrFinding[] = [] + const roadById = new Map() + for (const road of map.roads) { + if (!roadById.has(road.id)) roadById.set(road.id, road) + } + + for (const road of map.roads) { + // --- plan-view continuity --------------------------------------------- + for (let i = 0; i + 1 < road.planView.length; i++) { + const current = road.planView[i] + const next = road.planView[i + 1] + const end = evalGeometry(current, current.length) + + const gap = Math.hypot(end.x - next.x, end.y - next.y) + if (gap > thresholds.planViewGapMeters) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'geom.plan-view-gap', + message: `road ${road.id}: geometry ${i} ends at (${end.x.toFixed(3)}, ${end.y.toFixed(3)}) but geometry ${i + 1} starts at (${next.x.toFixed(3)}, ${next.y.toFixed(3)}) — a gap of ${gap.toFixed(3)} m`, + location: { roadId: road.id, s: next.s }, + }) + } + + const dHdg = Math.abs(normalizeAngle(end.hdg - next.hdg)) + if (dHdg > thresholds.planViewHeadingRad) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'geom.plan-view-heading', + message: `road ${road.id}: heading jumps by ${dHdg.toFixed(5)} rad between geometry ${i} and ${i + 1}`, + location: { roadId: road.id, s: next.s }, + }) + } + } + + // --- road length vs plan-view sum -------------------------------------- + if (road.planView.length > 0 && road.length > 0) { + const sum = road.planView.reduce((acc, g) => acc + g.length, 0) + const relative = Math.abs(sum - road.length) / road.length + if (relative > thresholds.lengthMismatchRatio) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'geom.road-length-mismatch', + message: `road ${road.id}: length="${road.length}" but its plan-view geometries sum to ${sum.toFixed(3)} m (${(relative * 100).toFixed(1)} % off)`, + location: { roadId: road.id }, + }) + } + } + + if (road.planView.length === 0 && road.length > 0) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'geom.no-plan-view', + message: `road ${road.id} has length ${road.length} but no geometry`, + location: { roadId: road.id }, + }) + } + + // --- lane widths ------------------------------------------------------- + // + // A width record spans from its own sOffset to the next one (or to the end + // of the lane section). Evaluating both ends of that span catches a plain + // negative constant as well as a ramp that crosses zero, without the false + // alarms that sampling a cubic's interior would produce on records whose + // higher-order terms only matter outside their own span. + for (const [sectionIndex, section] of road.laneSections.entries()) { + const sectionEnd = + sectionIndex + 1 < road.laneSections.length + ? road.laneSections[sectionIndex + 1].s + : road.length + const sectionLength = Math.max(sectionEnd - section.s, 0) + + for (const lane of [...section.left, ...section.right]) { + const widths: OdrWidth[] = lane.widths + for (const [wi, width] of widths.entries()) { + const spanEnd = wi + 1 < widths.length ? widths[wi + 1].sOffset : sectionLength + const ds = Math.max(spanEnd - width.sOffset, 0) + const atStart = evalCubic(width, 0) + const atEnd = evalCubic(width, ds) + const worst = Math.min(atStart, atEnd) + if (worst < -thresholds.negativeWidthToleranceMeters) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'geom.negative-lane-width', + message: `road ${road.id} lane section ${sectionIndex} lane ${lane.id}: record ${wi} evaluates to ${worst.toFixed(3)} m, which is negative`, + location: { roadId: road.id, laneId: lane.id, s: section.s + width.sOffset }, + }) + } + } + } + } + } + + // --- road-to-road contact ------------------------------------------------ + // + // Warning, not error: see the calibration note above. Reported once per + // ordered link, which means a mutually declared pair is reported twice — + // deliberately, since either road may be the one at fault and a reader + // filtering by road id must see it. + for (const road of map.roads) { + for (const [which, link] of [ + ['successor', road.successor], + ['predecessor', road.predecessor], + ] as const) { + if (!link || link.elementType !== 'road') continue + const target = roadById.get(link.elementId) + if (!target) continue // layer 2 reported the dangling link + + const myEnd = which === 'successor' ? 'end' : 'start' + const theirEnd = link.contactPoint ?? (which === 'successor' ? 'start' : 'end') + const mine = poseAtEnd(road, myEnd) + const theirs = poseAtEnd(target, theirEnd) + if (!mine || !theirs) continue + + // Subtract the lane-offset difference: two roads whose lanes meet can + // have reference lines apart by exactly that amount, and flagging it + // would redden fabriksgatan and multi_intersections, which are correct. + const myOffset = laneOffsetAt(road, stationAtEnd(road, myEnd)) + const theirOffset = laneOffsetAt(target, stationAtEnd(target, theirEnd)) + const offsetSlack = Math.abs(myOffset - theirOffset) + + const raw = Math.hypot(mine.x - theirs.x, mine.y - theirs.y) + const gap = Math.max(raw - offsetSlack, 0) + if (gap > thresholds.roadLinkGapMeters) { + findings.push({ + severity: 'warning', + category: 'MAP_DEFECT', + rule: 'geom.road-link-gap', + message: + `road ${road.id} <${which}> declares road ${target.id} at its ${theirEnd}, but the two reference lines are ${gap.toFixed(3)} m apart` + + (offsetSlack > 0 ? ` (after allowing ${offsetSlack.toFixed(3)} m of lane offset)` : ''), + location: { roadId: road.id }, + }) + } + } + } + + return findings +} diff --git a/packages/drawtonomy-sdk/src/validator/layers/junctions.ts b/packages/drawtonomy-sdk/src/validator/layers/junctions.ts new file mode 100644 index 0000000..ad790d4 --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/layers/junctions.ts @@ -0,0 +1,201 @@ +// Layer 3: junction consistency (`junction.*`). +// +// A junction is described twice over — once by the element listing +// its connections, and once by the roads that declare `junction=""` — and +// the two descriptions have to agree. The importer reads only one side of that +// pair per question, so a junction whose records are deleted +// imports as a set of unrelated roads with no complaint at all. +// +// Checked here: +// junction.connection-missing a road claims junction membership but no +// names it +// junction.road-not-member the same defect seen from the junction: the +// road is not reachable from any connection +// junction.empty a with no connections at all +// junction.connecting-road-unmarked a connecting road that forgot its +// junction attribute +// junction.contact-point-mismatch contactPoint disagrees with the +// connecting road's own +// junction.incoming-link-missing the incoming road does not link back +// +// Direct junctions (``, OpenDRIVE 1.5+) are structurally +// different: they have no separate connecting road, so `linkedRoad` names an +// ordinary road that does *not* carry a junction attribute. Membership and +// contact-point rules that assume a connecting road are skipped for them. + +import type { OdrJunction, OdrMap, OdrRoad } from '../../exporter/opendriveParser.js' +import type { OdrFinding } from '../types.js' + +const isDirect = (junction: OdrJunction): boolean => junction.type === 'direct' + +export function checkJunctions(map: OdrMap): OdrFinding[] { + const findings: OdrFinding[] = [] + + const roadById = new Map() + for (const road of map.roads) { + if (!roadById.has(road.id)) roadById.set(road.id, road) + } + const junctionById = new Map() + for (const junction of map.junctions) { + if (!junctionById.has(junction.id)) junctionById.set(junction.id, junction) + } + + // Which roads each junction reaches through its connections, split by role. + const connectingRoadsOf = new Map>() + const incomingRoadsOf = new Map>() + for (const junction of map.junctions) { + const connecting = new Set() + const incoming = new Set() + for (const conn of junction.connections) { + if (conn.connectingRoad !== '') connecting.add(conn.connectingRoad) + if (conn.incomingRoad !== '') incoming.add(conn.incomingRoad) + } + connectingRoadsOf.set(junction.id, connecting) + incomingRoadsOf.set(junction.id, incoming) + } + + // --- empty junctions ----------------------------------------------------- + for (const junction of map.junctions) { + if (junction.connections.length === 0) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'junction.empty', + message: `junction ${junction.id} declares no ; nothing can route through it`, + location: { junctionId: junction.id }, + }) + } + } + + // --- roads claiming membership ------------------------------------------- + // + // A road with `junction=""` is by definition a connecting road of that + // junction and must be named by one of its connections. This is the check + // that catches a deleted : the road survives, its claim of + // membership survives, and only the connection that made it real is gone. + // + // The same broken pair can be read from either side, so exactly one finding + // is emitted per road, choosing the rule by what the rest of the document + // says. If the road is wired into the junction's traffic — its own s + // reach roads the junction connects — then the road was a genuine member and + // the connection record is what went missing. If it is wired to nothing the + // junction knows about, the membership claim itself is the wrong part. + // Emitting both rules for one defect would double every junction count in + // the report, which is why this is a branch and not two loops. + for (const road of map.roads) { + if (road.junction === '' || road.junction === '-1') continue + const junction = junctionById.get(road.junction) + // A junction that does not exist at all is layer 2's finding + // (ref.road-junction-unknown); do not report it twice. + if (!junction) continue + if (isDirect(junction)) continue + if (connectingRoadsOf.get(junction.id)?.has(road.id)) continue + + const junctionRoads = new Set([ + ...(connectingRoadsOf.get(junction.id) ?? []), + ...(incomingRoadsOf.get(junction.id) ?? []), + ]) + const wiredIntoJunction = [road.predecessor, road.successor].some( + l => + l !== undefined && + ((l.elementType === 'junction' && l.elementId === junction.id) || + (l.elementType === 'road' && junctionRoads.has(l.elementId))) + ) + + if (wiredIntoJunction) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'junction.connection-missing', + message: `junction ${junction.id} has no for road ${road.id}, which belongs to it and links into its roads — the connection record is missing`, + location: { junctionId: junction.id, roadId: road.id }, + }) + } else { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'junction.road-not-member', + message: `road ${road.id} declares junction="${junction.id}", but no of that junction names it and it links to none of the junction's roads`, + location: { roadId: road.id, junctionId: junction.id }, + }) + } + } + + // --- connecting roads that forgot to declare membership ------------------ + for (const junction of map.junctions) { + if (isDirect(junction)) continue + for (const roadId of connectingRoadsOf.get(junction.id) ?? []) { + const road = roadById.get(roadId) + if (!road) continue // layer 2 reported it as a dangling connection road + if (road.junction === junction.id) continue + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'junction.connecting-road-unmarked', + message: `road ${roadId} is a connectingRoad of junction ${junction.id} but declares junction="${road.junction}"`, + location: { roadId, junctionId: junction.id }, + }) + } + } + + // --- per-connection consistency ------------------------------------------ + for (const junction of map.junctions) { + for (const conn of junction.connections) { + const connecting = roadById.get(conn.connectingRoad) + const incoming = roadById.get(conn.incomingRoad) + if (!connecting || !incoming) continue // dangling: layer 2's finding + + // The incoming road must link into this junction from one of its ends. + // Without that link the connection is one-directional and a router + // entering from the incoming road never discovers the junction. + const linksHere = [incoming.predecessor, incoming.successor].some( + l => l?.elementType === 'junction' && l.elementId === junction.id + ) + // A direct junction's incoming road may instead link straight to the + // linked road, which is the whole point of the construct. + const linksToConnecting = [incoming.predecessor, incoming.successor].some( + l => l?.elementType === 'road' && l.elementId === conn.connectingRoad + ) + if (!linksHere && !(isDirect(junction) && linksToConnecting)) { + findings.push({ + severity: 'warning', + category: 'MAP_DEFECT', + rule: 'junction.incoming-link-missing', + message: `junction ${junction.id} connection ${conn.id}: incoming road ${conn.incomingRoad} has no pointing at this junction`, + location: { junctionId: junction.id, roadId: conn.incomingRoad }, + }) + } + + if (isDirect(junction)) continue + + // contactPoint names the end of the connecting road that meets the + // incoming road, so the connecting road's link at that end must be the + // incoming road (directly, or via this junction). + const endLink = conn.contactPoint === 'start' ? connecting.predecessor : connecting.successor + if (!endLink) { + findings.push({ + severity: 'warning', + category: 'MAP_DEFECT', + rule: 'junction.contact-point-mismatch', + message: `junction ${junction.id} connection ${conn.id}: connecting road ${conn.connectingRoad} has no <${conn.contactPoint === 'start' ? 'predecessor' : 'successor'}> at its declared contactPoint="${conn.contactPoint}"`, + location: { junctionId: junction.id, roadId: conn.connectingRoad }, + }) + continue + } + const pointsAtIncoming = + (endLink.elementType === 'road' && endLink.elementId === conn.incomingRoad) || + (endLink.elementType === 'junction' && endLink.elementId === junction.id) + if (!pointsAtIncoming) { + findings.push({ + severity: 'warning', + category: 'MAP_DEFECT', + rule: 'junction.contact-point-mismatch', + message: `junction ${junction.id} connection ${conn.id}: connecting road ${conn.connectingRoad} contactPoint="${conn.contactPoint}" but its link there points at ${endLink.elementType} ${endLink.elementId}, not incoming road ${conn.incomingRoad}`, + location: { junctionId: junction.id, roadId: conn.connectingRoad }, + }) + } + } + } + + return findings +} diff --git a/packages/drawtonomy-sdk/src/validator/layers/references.ts b/packages/drawtonomy-sdk/src/validator/layers/references.ts new file mode 100644 index 0000000..0884cb5 --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/layers/references.ts @@ -0,0 +1,267 @@ +// Layer 2: reference integrity (`ref.*`). +// +// OpenDRIVE is a graph encoded as a flat list of elements wired together by +// id. The importer resolves those ids best-effort and drops what it cannot +// find, which means a document can lose whole connections without complaint. +// This layer resolves every cross-reference explicitly and reports the ones +// that point at nothing. +// +// Checked here: +// ref.dangling-road-link target +// ref.dangling-connection-road +// ref.dangling-lane-link target lane id +// ref.dangling-controller-signal +// ref.duplicate-road-id / ref.duplicate-junction-id +// ref.road-junction-unknown road@junction naming no +// +// The complementary direction — a junction that does not list a road claiming +// membership — is layer 3, since it is about junction structure rather than a +// broken pointer. + +import type { + OdrLaneSection, + OdrMap, + OdrRoad, + OdrRoadLink, +} from '../../exporter/opendriveParser.js' +import type { OdrFinding } from '../types.js' + +/** All lane ids present in a lane section (both sides plus centre). */ +function laneIdsOf(section: OdrLaneSection): Set { + const ids = new Set() + for (const lane of [...section.left, ...section.center, ...section.right]) ids.add(lane.id) + return ids +} + +/** + * The lane section a road-level link lands on. `predecessor` links attach to + * the *first* lane section of the target when the contact point is its start + * and to the last when it is its end (and vice versa for `successor`). + */ +function contactSection(road: OdrRoad, contact: 'start' | 'end'): OdrLaneSection | null { + if (road.laneSections.length === 0) return null + return contact === 'start' ? road.laneSections[0] : road.laneSections[road.laneSections.length - 1] +} + +export function checkReferences(map: OdrMap): OdrFinding[] { + const findings: OdrFinding[] = [] + + const roadById = new Map() + for (const road of map.roads) { + if (roadById.has(road.id)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.duplicate-road-id', + message: `road id "${road.id}" is used by more than one `, + location: { roadId: road.id }, + }) + continue + } + roadById.set(road.id, road) + } + + const junctionIds = new Set() + for (const junction of map.junctions) { + if (junctionIds.has(junction.id)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.duplicate-junction-id', + message: `junction id "${junction.id}" is used by more than one `, + location: { junctionId: junction.id }, + }) + continue + } + junctionIds.add(junction.id) + } + + // Every signal id defined anywhere in the document (controllers may govern + // signals on any road). + const signalIds = new Set() + for (const road of map.roads) { + for (const signal of road.signals) signalIds.add(signal.id) + } + + // --- road targets ------------------------------------------------- + const checkRoadLink = (road: OdrRoad, link: OdrRoadLink | undefined, which: 'predecessor' | 'successor'): void => { + if (!link) return + if (link.elementId === '') { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-road-link', + message: `road ${road.id} <${which}> has an empty elementId`, + location: { roadId: road.id }, + }) + return + } + if (link.elementType === 'road') { + if (!roadById.has(link.elementId)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-road-link', + message: `road ${road.id} <${which}> points at road "${link.elementId}", which does not exist`, + location: { roadId: road.id }, + }) + } + return + } + if (!junctionIds.has(link.elementId)) { + // A road linking to an absent *junction* is reported as a warning, not + // an error, because it is the signature of a legitimately excerpted map: + // cutting one junction out of a city map leaves its boundary roads + // pointing at the junctions that were left behind. (The checked-in + // town04-junction106 fixture is exactly this — a Town04 slice whose four + // boundary roads reference junctions 252/281/741/773 outside the slice.) + // A link to an absent *road* stays an error: nothing legitimate produces + // one, since a road is a leaf, not a cut point. + findings.push({ + severity: 'warning', + category: 'MAP_DEFECT', + rule: 'ref.unresolved-junction-link', + message: `road ${road.id} <${which}> points at junction "${link.elementId}", which this document does not define (expected if the map is an excerpt)`, + location: { roadId: road.id, junctionId: link.elementId }, + }) + } + } + + for (const road of map.roads) { + checkRoadLink(road, road.predecessor, 'predecessor') + checkRoadLink(road, road.successor, 'successor') + + // road@junction must name a junction that exists ("-1" = not in a junction). + // Same excerpt caveat as the junction link above: warning, not error. + if (road.junction !== '' && road.junction !== '-1' && !junctionIds.has(road.junction)) { + findings.push({ + severity: 'warning', + category: 'MAP_DEFECT', + rule: 'ref.road-junction-unknown', + message: `road ${road.id} declares junction="${road.junction}", but this document defines no such `, + location: { roadId: road.id, junctionId: road.junction }, + }) + } + } + + // --- junction connection road references --------------------------------- + for (const junction of map.junctions) { + for (const conn of junction.connections) { + for (const [attr, value] of [ + ['incomingRoad', conn.incomingRoad], + ['connectingRoad', conn.connectingRoad], + ] as const) { + if (value === '') { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-connection-road', + message: `junction ${junction.id} connection ${conn.id} has no ${attr}`, + location: { junctionId: junction.id }, + }) + continue + } + if (!roadById.has(value)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-connection-road', + message: `junction ${junction.id} connection ${conn.id} ${attr}="${value}" refers to a road that does not exist`, + location: { junctionId: junction.id, roadId: value }, + }) + } + } + } + } + + // --- lane targets ------------------------------------------------- + // + // Within a road, a lane's successor names a lane of the *next* lane section + // and its predecessor a lane of the previous one. Only the outermost links + // (successor of the last section, predecessor of the first) cross into the + // linked road, and then only when that link is road-typed: links into a + // junction are resolved through the junction's laneLinks instead, so a lane + // id that does not exist in the incoming road is not checkable here. + for (const road of map.roads) { + const sections = road.laneSections + for (let i = 0; i < sections.length; i++) { + const section = sections[i] + const lanes = [...section.left, ...section.center, ...section.right] + + const targetIds = ( + side: 'predecessor' | 'successor' + ): { ids: Set; where: string } | null => { + const inner = side === 'predecessor' ? i - 1 : i + 1 + if (inner >= 0 && inner < sections.length) { + return { + ids: laneIdsOf(sections[inner]), + where: `lane section ${inner} of road ${road.id}`, + } + } + const link = side === 'predecessor' ? road.predecessor : road.successor + if (!link || link.elementType !== 'road') return null + const target = roadById.get(link.elementId) + if (!target) return null // already reported as a dangling road link + // Default contact point for a successor is the target's start, for a + // predecessor its end (ASAM OpenDRIVE 1.8 §9.2). + const contact = link.contactPoint ?? (side === 'successor' ? 'start' : 'end') + const targetSection = contactSection(target, contact) + if (!targetSection) return null + return { + ids: laneIdsOf(targetSection), + where: `road ${target.id} at its ${contact}`, + } + } + + for (const side of ['predecessor', 'successor'] as const) { + const target = targetIds(side) + if (!target) continue + for (const lane of lanes) { + const linked = side === 'predecessor' ? lane.predecessorIds : lane.successorIds + for (const id of linked) { + if (!target.ids.has(id)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-lane-link', + message: `road ${road.id} lane section ${i} lane ${lane.id} <${side}> names lane ${id}, which does not exist in ${target.where}`, + location: { roadId: road.id, laneId: lane.id, s: section.s }, + }) + } + } + } + } + } + } + + // --- controller signal references ---------------------------------------- + for (const controller of map.controllers) { + for (const control of controller.controls) { + if (!signalIds.has(control.signalId)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-controller-signal', + message: `controller ${controller.id} governs signal "${control.signalId}", which no road defines`, + }) + } + } + } + + // --- signalReference targets --------------------------------------------- + for (const road of map.roads) { + for (const ref of road.signalReferences) { + if (!signalIds.has(ref.id)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-signal-reference', + message: `road ${road.id} refers to a signal that no road defines`, + location: { roadId: road.id, s: ref.s }, + }) + } + } + } + + return findings +} diff --git a/packages/drawtonomy-sdk/src/validator/layers/xmlIntegrity.ts b/packages/drawtonomy-sdk/src/validator/layers/xmlIntegrity.ts new file mode 100644 index 0000000..1b1188c --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/layers/xmlIntegrity.ts @@ -0,0 +1,204 @@ +// Layer 1: document integrity (`xml.*`). +// +// The importer's XML front end recovers what it can from a damaged document: +// a file cut in half parses into however many elements survived the cut +// and reports nothing. That is the single most dangerous silent failure mode in +// the pipeline, because the result looks like a small but valid map. +// +// This layer answers one question before anything else runs: is this the whole +// document? It uses a small tokenizer rather than a parser — the SDK has zero +// runtime dependencies, and matching open tags against a stack is all that is +// needed to distinguish "truncated" from "complete". + +import type { OdrFinding } from '../types.js' + +export interface XmlIntegrityResult { + findings: OdrFinding[] + /** + * True when the damage makes the later layers meaningless. A truncated or + * unbalanced document would otherwise generate a cascade of dangling- + * reference findings that are all artefacts of the missing bytes. + */ + fatal: boolean +} + +/** One token of interest from the scan. */ +interface TagToken { + name: string + kind: 'open' | 'close' | 'self' + /** Byte offset of the '<'. */ + offset: number +} + +/** + * Scan the document for element tags, skipping the regions where `<` and `>` + * are not markup: comments, CDATA sections, processing instructions and + * DOCTYPE declarations. Returns null when the scan hits an unterminated + * construct (an unclosed comment/CDATA is itself a truncation symptom). + */ +function scanTags(xml: string): { tokens: TagToken[]; unterminated: string | null } { + const tokens: TagToken[] = [] + let i = 0 + const n = xml.length + + while (i < n) { + const lt = xml.indexOf('<', i) + if (lt < 0) break + + // Non-element constructs. + if (xml.startsWith('', lt + 4) + if (end < 0) return { tokens, unterminated: 'comment' } + i = end + 3 + continue + } + if (xml.startsWith('', lt + 9) + if (end < 0) return { tokens, unterminated: 'CDATA section' } + i = end + 3 + continue + } + if (xml.startsWith('', lt + 2) + if (end < 0) return { tokens, unterminated: 'processing instruction' } + i = end + 2 + continue + } + if (xml.startsWith('' (internal + // subsets are not used by OpenDRIVE documents in practice). + const end = xml.indexOf('>', lt + 2) + if (end < 0) return { tokens, unterminated: 'declaration' } + i = end + 1 + continue + } + + // An element tag. Find its '>', respecting quoted attribute values so that + // a '>' inside an attribute does not end the tag early. + let j = lt + 1 + let quote: string | null = null + let gt = -1 + while (j < n) { + const ch = xml[j] + if (quote) { + if (ch === quote) quote = null + } else if (ch === '"' || ch === "'") { + quote = ch + } else if (ch === '>') { + gt = j + break + } + j++ + } + if (gt < 0) return { tokens, unterminated: 'element tag' } + + const raw = xml.slice(lt + 1, gt) + const isClose = raw.startsWith('/') + const isSelf = raw.endsWith('/') + const nameMatch = (isClose ? raw.slice(1) : raw).match(/^\s*([A-Za-z_][\w.\-:]*)/) + if (nameMatch) { + tokens.push({ + name: nameMatch[1], + kind: isClose ? 'close' : isSelf ? 'self' : 'open', + offset: lt, + }) + } + i = gt + 1 + } + + return { tokens, unterminated: null } +} + +/** Report the line number (1-based) of a byte offset, for locating damage. */ +function lineOf(xml: string, offset: number): number { + let line = 1 + for (let i = 0; i < offset && i < xml.length; i++) { + if (xml[i] === '\n') line++ + } + return line +} + +export function checkXmlIntegrity(xml: string): XmlIntegrityResult { + const findings: OdrFinding[] = [] + + if (xml.trim().length === 0) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.empty', + message: 'document is empty', + }) + return { findings, fatal: true } + } + + const { tokens, unterminated } = scanTags(xml) + + if (unterminated !== null) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.truncated', + message: `document ends inside an unterminated ${unterminated} — it is incomplete`, + }) + return { findings, fatal: true } + } + + if (!tokens.some(t => t.name === 'OpenDRIVE' && t.kind === 'open')) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.no-root', + message: 'no root element found', + }) + return { findings, fatal: true } + } + + // Stack match. The first mismatch is reported and the scan stops: after a + // structural break the remaining stack states are noise. + const stack: TagToken[] = [] + for (const t of tokens) { + if (t.kind === 'self') continue + if (t.kind === 'open') { + stack.push(t) + continue + } + const top = stack.pop() + if (!top) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.unbalanced-tags', + message: `stray closing tag at line ${lineOf(xml, t.offset)} with no matching open tag`, + }) + return { findings, fatal: true } + } + if (top.name !== t.name) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.unbalanced-tags', + message: `closing tag at line ${lineOf(xml, t.offset)} does not match open <${top.name}> at line ${lineOf(xml, top.offset)}`, + }) + return { findings, fatal: true } + } + } + + if (stack.length > 0) { + // Unclosed elements remaining at EOF: the document stops mid-structure. + // Name the outermost unclosed element — the innermost is usually a + // consequence, the outermost tells you how much is missing. + const outermost = stack[0] + const names = stack.map(t => `<${t.name}>`).join(' > ') + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.truncated', + message: + `document ends with ${stack.length} unclosed element(s) (${names}); ` + + `<${outermost.name}> opened at line ${lineOf(xml, outermost.offset)} is never closed`, + }) + return { findings, fatal: true } + } + + return { findings, fatal: false } +} diff --git a/packages/drawtonomy-sdk/src/validator/types.ts b/packages/drawtonomy-sdk/src/validator/types.ts new file mode 100644 index 0000000..0997977 --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/types.ts @@ -0,0 +1,183 @@ +// OpenDRIVE validator — finding schema and report types. +// +// The importer (`parseOpenDriveXml` / `odrToShapes`) is deliberately lenient: +// it recovers as much of a map as it can and silently ignores what it cannot +// represent. That is right for an editor, but it means a defective map can be +// imported with no visible complaint (a document truncated mid-file imports as +// however many roads survived; a junction with its records +// deleted imports as disconnected roads). This module is the strict counterpart +// used for QA: it never repairs, it only reports. +// +// Findings carry an explicit *attribution* so a report can be read without +// knowing the tool's internals: +// +// MAP_DEFECT the source document is wrong or self-inconsistent — the +// author of the map has something to fix. +// TOOL_LIMITATION the document is legal OpenDRIVE, but drawtonomy cannot +// represent this construct faithfully. Nothing to fix in the +// map; the round trip will be lossy. +// INFO neutral observations (counts, skipped optional layers). +// +// Only MAP_DEFECT errors make a verdict red, so a map that merely uses features +// the editor does not model never looks "broken". + +/** Severity of a single finding. */ +export type OdrFindingSeverity = 'error' | 'warning' | 'info' + +/** Attribution of a finding: whose problem is it? */ +export type OdrFindingCategory = 'MAP_DEFECT' | 'TOOL_LIMITATION' | 'INFO' + +/** Where in the document a finding applies (all fields optional). */ +export interface OdrFindingLocation { + roadId?: string + junctionId?: string + laneId?: number + /** Station along the road reference line (m). */ + s?: number +} + +/** A single validation finding. */ +export interface OdrFinding { + severity: OdrFindingSeverity + category: OdrFindingCategory + /** + * Stable machine-readable rule id, namespaced by layer: + * `xml.*` (document integrity), `ref.*` (reference integrity), + * `junction.*` (junction consistency), `geom.*` (geometric continuity), + * `esmini.*` (external road-manager adapter). + */ + rule: string + message: string + location?: OdrFindingLocation +} + +/** Overall document verdict, derived from the findings. */ +export type OdrVerdict = 'green' | 'yellow' | 'red' + +/** Aggregated counts, keyed by severity and by category. */ +export interface OdrValidationCounts { + error: number + warning: number + info: number + MAP_DEFECT: number + TOOL_LIMITATION: number + INFO: number +} + +/** Result of validating one OpenDRIVE document. */ +export interface OdrValidationReport { + findings: OdrFinding[] + counts: OdrValidationCounts + /** + * red at least one MAP_DEFECT error — the map is defective. + * yellow any warning, or any TOOL_LIMITATION finding — usable, but lossy or + * suspicious. + * green informational findings only. + */ + verdict: OdrVerdict +} + +/** Tunable thresholds for the geometric continuity layer (layer 4). */ +export interface OdrGeometryThresholds { + /** + * Maximum allowed position gap between consecutive plan-view geometries of + * one road (m). Default 0.02. + */ + planViewGapMeters?: number + /** + * Maximum allowed heading step between consecutive plan-view geometries of + * one road (rad). Default 0.005. + */ + planViewHeadingRad?: number + /** + * Maximum allowed position gap at a road-to-road link contact point (m). + * Default 0.5. + */ + roadLinkGapMeters?: number + /** + * Maximum allowed relative deviation between `road@length` and the summed + * plan-view geometry lengths. Default 0.01 (1 %). + */ + lengthMismatchRatio?: number + /** + * How far below zero a lane width may evaluate before it is reported (m). + * Default 0.001 — see the calibration note in layers/geometry.ts: lanes that + * taper to exactly zero land a few 1e-16 below it in floating point. + */ + negativeWidthToleranceMeters?: number +} + +/** Options for {@link validateOpenDrive}. */ +export interface OdrValidationOptions { + /** Overrides for the layer-4 geometry thresholds. */ + geometry?: OdrGeometryThresholds + /** + * Pre-computed findings from an external road-manager run (layer 6). The + * validator core stays pure: the CLI spawns the binary and passes the parsed + * findings in here. + */ + externalFindings?: readonly OdrFinding[] +} + +/** Resolved geometry thresholds (defaults applied). */ +export interface ResolvedGeometryThresholds { + planViewGapMeters: number + planViewHeadingRad: number + roadLinkGapMeters: number + lengthMismatchRatio: number + negativeWidthToleranceMeters: number +} + +export const DEFAULT_GEOMETRY_THRESHOLDS: ResolvedGeometryThresholds = { + planViewGapMeters: 0.02, + planViewHeadingRad: 0.005, + roadLinkGapMeters: 0.5, + lengthMismatchRatio: 0.01, + negativeWidthToleranceMeters: 0.001, +} + +export function resolveGeometryThresholds( + overrides: OdrGeometryThresholds | undefined +): ResolvedGeometryThresholds { + return { + planViewGapMeters: overrides?.planViewGapMeters ?? DEFAULT_GEOMETRY_THRESHOLDS.planViewGapMeters, + planViewHeadingRad: + overrides?.planViewHeadingRad ?? DEFAULT_GEOMETRY_THRESHOLDS.planViewHeadingRad, + roadLinkGapMeters: overrides?.roadLinkGapMeters ?? DEFAULT_GEOMETRY_THRESHOLDS.roadLinkGapMeters, + lengthMismatchRatio: + overrides?.lengthMismatchRatio ?? DEFAULT_GEOMETRY_THRESHOLDS.lengthMismatchRatio, + negativeWidthToleranceMeters: + overrides?.negativeWidthToleranceMeters ?? + DEFAULT_GEOMETRY_THRESHOLDS.negativeWidthToleranceMeters, + } +} + +/** Tally findings by severity and category. */ +export function countFindings(findings: readonly OdrFinding[]): OdrValidationCounts { + const counts: OdrValidationCounts = { + error: 0, + warning: 0, + info: 0, + MAP_DEFECT: 0, + TOOL_LIMITATION: 0, + INFO: 0, + } + for (const f of findings) { + counts[f.severity] += 1 + counts[f.category] += 1 + } + return counts +} + +/** + * Derive the verdict. A MAP_DEFECT error is the only thing that turns a report + * red: findings attributed to the tool never fail someone else's map. + */ +export function deriveVerdict(findings: readonly OdrFinding[]): OdrVerdict { + let yellow = false + for (const f of findings) { + if (f.severity === 'error' && f.category === 'MAP_DEFECT') return 'red' + if (f.severity === 'warning' || f.category === 'TOOL_LIMITATION') yellow = true + } + return yellow ? 'yellow' : 'green' +} diff --git a/packages/drawtonomy-sdk/src/validator/validateOpenDrive.ts b/packages/drawtonomy-sdk/src/validator/validateOpenDrive.ts new file mode 100644 index 0000000..0cd0dda --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/validateOpenDrive.ts @@ -0,0 +1,80 @@ +// Strict OpenDRIVE validation entry point. +// +// Layers, run in order (an earlier layer's fatal finding short-circuits the +// later ones, because a truncated document cannot be meaningfully parsed): +// +// 1 xml.* document integrity (checkXmlIntegrity) +// 2 ref.* reference integrity (checkReferences) +// 3 junction.* junction consistency (checkJunctions) +// 4 geom.* geometric continuity (checkGeometry) +// 6 esmini.* external road-manager adapter — supplied by the caller via +// `opts.externalFindings`, so this module stays pure (no +// child_process, no fs). +// 8 report assembly (counts + verdict) + +import { parseOpenDriveXml, type OdrMap } from '../exporter/opendriveParser.js' +import { checkXmlIntegrity } from './layers/xmlIntegrity.js' +import { checkReferences } from './layers/references.js' +import { checkJunctions } from './layers/junctions.js' +import { checkGeometry } from './layers/geometry.js' +import { + countFindings, + deriveVerdict, + resolveGeometryThresholds, + type OdrFinding, + type OdrValidationOptions, + type OdrValidationReport, +} from './types.js' + +/** Assemble a report from a finding list (layer 8). */ +export function buildReport(findings: readonly OdrFinding[]): OdrValidationReport { + const list = [...findings] + return { + findings: list, + counts: countFindings(list), + verdict: deriveVerdict(list), + } +} + +/** + * Validate an OpenDRIVE document. + * + * Unlike the importer this never repairs and never throws on malformed input: + * a document so broken that it cannot be parsed comes back as a red report + * rather than an exception, so a batch run over a corpus cannot be derailed by + * one bad file. + */ +export function validateOpenDrive( + xml: string, + opts: OdrValidationOptions = {} +): OdrValidationReport { + const findings: OdrFinding[] = [] + + // Layer 1: document integrity. A document that fails here is not worth + // parsing — every downstream finding would be an artefact of the damage. + const integrity = checkXmlIntegrity(xml) + findings.push(...integrity.findings) + if (integrity.fatal) return buildReport(findings) + + let map: OdrMap + try { + map = parseOpenDriveXml(xml) + } catch (err) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.parse-failed', + message: `OpenDRIVE parse failed: ${err instanceof Error ? err.message : String(err)}`, + }) + return buildReport(findings) + } + + findings.push(...checkReferences(map)) + findings.push(...checkJunctions(map)) + findings.push(...checkGeometry(map, resolveGeometryThresholds(opts.geometry))) + + // Layer 6 results are computed outside (the CLI runs the binary). + if (opts.externalFindings) findings.push(...opts.externalFindings) + + return buildReport(findings) +}