Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions packages/drawtonomy-sdk/__tests__/validator/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
48 changes: 48 additions & 0 deletions packages/drawtonomy-sdk/__tests__/validator/corpus.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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 "<rule> <line>" 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')
})
})
Loading
Loading