118 lines
9.0 KiB
TypeScript
118 lines
9.0 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { writeFile } from 'node:fs/promises'
|
|
import { performance } from 'node:perf_hooks'
|
|
import {
|
|
validateBooleanCutInput,
|
|
validateBooleanUnionInput,
|
|
validateBoxInput,
|
|
validateChamferInput,
|
|
validateConeInput,
|
|
validateCylinderInput,
|
|
validateFilletInput,
|
|
validateGeometryFileImport,
|
|
validateGrooveInput,
|
|
validateLoftInput,
|
|
validateMirrorInput,
|
|
validatePadInput,
|
|
validatePipeInput,
|
|
validatePlacementInput,
|
|
validatePocketInput,
|
|
validateRevolutionInput,
|
|
validateTorusInput,
|
|
} from '../src/facade/geometryRuntime'
|
|
import type { PlanarProfile, ShapeHandle } from '../src/facade/types'
|
|
|
|
type GeometryFuzzCase = { name: string; expected: 'accept' | 'reject'; invoke: () => void }
|
|
|
|
let state = 0x9e3779b9
|
|
const random = () => {
|
|
state ^= state << 13
|
|
state ^= state >>> 17
|
|
state ^= state << 5
|
|
return state >>> 0
|
|
}
|
|
const positive = () => (random() % 100_000 + 1) / 1000
|
|
const context = () => ({ documentId: `geometry-fuzz-${random() % 7}`, documentVersion: random() % 100 })
|
|
const shape = (documentId: string, documentVersion: number, suffix: string): ShapeHandle => ({ id: `shape-${suffix}`, kernel: 'bitbybit-occt', kind: 'solid', documentId, documentVersion })
|
|
const square = (size = positive()): PlanarProfile => ({ outer: [[0, 0, 0], [size, 0, 0], [size, size, 0], [0, size, 0]] })
|
|
const elevatedSquare = (): PlanarProfile => {
|
|
const profile = square()
|
|
const z = positive()
|
|
return { outer: profile.outer.map(([x, y]): [number, number, number] => [x, y, z]) }
|
|
}
|
|
const invalidScalar = () => [0, -positive(), Number.NaN, Number.POSITIVE_INFINITY][random() % 4]
|
|
|
|
const createCase = (index: number): GeometryFuzzCase => {
|
|
const owner = context()
|
|
const base = shape(owner.documentId, Math.max(0, owner.documentVersion - 1), 'base')
|
|
const tool = shape(owner.documentId, owner.documentVersion, 'tool')
|
|
switch (index % 30) {
|
|
case 0: return { name: 'valid-box', expected: 'accept', invoke: () => validateBoxInput({ ...owner, width: positive(), length: positive(), height: positive(), center: [positive(), -positive(), 0] }) }
|
|
case 1: return { name: 'invalid-box-scalar', expected: 'reject', invoke: () => validateBoxInput({ ...owner, width: invalidScalar(), length: positive(), height: positive() }) }
|
|
case 2: return { name: 'valid-cylinder', expected: 'accept', invoke: () => validateCylinderInput({ ...owner, radius: positive(), height: positive(), direction: [positive(), positive(), positive()], angle: positive() % 360 || 360 }) }
|
|
case 3: return { name: 'zero-cylinder-axis', expected: 'reject', invoke: () => validateCylinderInput({ ...owner, radius: positive(), height: positive(), direction: [0, 0, 0] }) }
|
|
case 4: return { name: 'zero-cone-radii', expected: 'reject', invoke: () => validateConeInput({ ...owner, radius1: 0, radius2: 0, height: positive() }) }
|
|
case 5: return { name: 'valid-placement', expected: 'accept', invoke: () => validatePlacementInput({ ...owner, shape: base, placement: { translation: [positive(), 0, -positive()], rotationAxis: [0, 0, 1], rotationAngle: random() % 361 } }) }
|
|
case 6: return { name: 'foreign-placement-shape', expected: 'reject', invoke: () => validatePlacementInput({ ...owner, shape: { ...base, documentId: 'foreign-document' }, placement: { translation: [0, 0, 0], rotationAxis: [0, 1, 0], rotationAngle: 0 } }) }
|
|
case 7: return { name: 'valid-union', expected: 'accept', invoke: () => validateBooleanUnionInput({ ...owner, shapes: [base, tool] }) }
|
|
case 8: return { name: 'short-union', expected: 'reject', invoke: () => validateBooleanUnionInput({ ...owner, shapes: [base] }) }
|
|
case 9: return { name: 'empty-cut-tools', expected: 'reject', invoke: () => validateBooleanCutInput({ ...owner, base, tools: [] }) }
|
|
case 10: return { name: 'fractional-fillet-index', expected: 'reject', invoke: () => validateFilletInput({ ...owner, base, radius: positive(), indexes: [random() % 10 + 0.5] }) }
|
|
case 11: return { name: 'valid-chamfer', expected: 'accept', invoke: () => validateChamferInput({ ...owner, base, distance: positive(), indexes: [random() % 20] }) }
|
|
case 12: return { name: 'valid-pad', expected: 'accept', invoke: () => validatePadInput({ ...owner, profile: square(), length: positive(), direction: [0, 0, 1] }) }
|
|
case 13: return { name: 'self-intersecting-pad', expected: 'reject', invoke: () => validatePadInput({ ...owner, profile: { outer: [[0, 0, 0], [2, 2, 0], [0, 2, 0], [2, 0, 0]] }, length: positive() }) }
|
|
case 14: return { name: 'future-pocket-base', expected: 'reject', invoke: () => validatePocketInput({ ...owner, base: { ...base, documentVersion: owner.documentVersion + 1 }, profile: square(), length: positive() }) }
|
|
case 15: return { name: 'zero-revolution-axis', expected: 'reject', invoke: () => validateRevolutionInput({ ...owner, profile: square(), axisDirection: [0, 0, 0], angle: 360 }) }
|
|
case 16: return { name: 'invalid-groove-angle', expected: 'reject', invoke: () => validateGrooveInput({ ...owner, base, profile: square(), axisDirection: [0, 1, 0], angle: 361 }) }
|
|
case 17: return { name: 'valid-step-import', expected: 'accept', invoke: () => validateGeometryFileImport({ ...owner, format: 'step', text: `ISO-10303-21;\n/* ${random().toString(16)} */\nEND-ISO-10303-21;` }) }
|
|
case 18: return { name: 'unsupported-import-format', expected: 'reject', invoke: () => validateGeometryFileImport({ ...owner, format: 'stl', text: 'solid fuzz' } as never) }
|
|
case 19: return { name: 'zero-mirror-normal', expected: 'reject', invoke: () => validateMirrorInput({ ...owner, shape: base, origin: [0, 0, 0], normal: [0, 0, 0] }) }
|
|
case 20: return { name: 'valid-standalone-loft', expected: 'accept', invoke: () => validateLoftInput({ ...owner, sections: [square(), elevatedSquare()] }) }
|
|
case 21: return { name: 'valid-additive-loft', expected: 'accept', invoke: () => validateLoftInput({ ...owner, sections: [square(), elevatedSquare()], mode: 'additive', base }) }
|
|
case 22: return { name: 'short-loft-sections', expected: 'reject', invoke: () => validateLoftInput({ ...owner, sections: [square()] }) }
|
|
case 23: return { name: 'loft-hole-unsupported', expected: 'reject', invoke: () => validateLoftInput({ ...owner, sections: [{ ...square(), holes: [square(0.1).outer] }, square()] }) }
|
|
case 24: return { name: 'valid-standalone-pipe', expected: 'accept', invoke: () => validatePipeInput({ ...owner, profile: square(), path: [[0, 0, 0], [positive(), 0, positive()], [positive(), positive(), positive()]] }) }
|
|
case 25: return { name: 'valid-subtractive-pipe', expected: 'accept', invoke: () => validatePipeInput({ ...owner, profile: square(), path: [[0, 0, 0], [0, positive(), positive()]], mode: 'subtractive', base }) }
|
|
case 26: return { name: 'duplicate-pipe-path-point', expected: 'reject', invoke: () => validatePipeInput({ ...owner, profile: square(), path: [[0, 0, 0], [0, 0, 0]] }) }
|
|
case 27: return { name: 'pipe-hole-unsupported', expected: 'reject', invoke: () => validatePipeInput({ ...owner, profile: { ...square(), holes: [square(0.1).outer] }, path: [[0, 0, 0], [0, 0, positive()]] }) }
|
|
case 28: return { name: 'valid-torus', expected: 'accept', invoke: () => validateTorusInput({ ...owner, majorRadius: positive(), minorRadius: positive(), direction: [0, 0, 1], angle: random() % 360 + 1 }) }
|
|
default: return { name: 'invalid-torus-radius', expected: 'reject', invoke: () => validateTorusInput({ ...owner, majorRadius: positive(), minorRadius: invalidScalar() }) }
|
|
}
|
|
}
|
|
|
|
const durations: number[] = []
|
|
const categories = new Map<string, number>()
|
|
const counts = { accepted: 0, rejected: 0, validCases: 0, invalidCases: 0 }
|
|
for (let index = 0; index < 2000; index += 1) {
|
|
const fuzzCase = createCase(index)
|
|
categories.set(fuzzCase.name, (categories.get(fuzzCase.name) ?? 0) + 1)
|
|
const started = performance.now()
|
|
let outcome: 'accept' | 'reject' = 'accept'
|
|
let caught: unknown
|
|
try {
|
|
fuzzCase.invoke()
|
|
} catch (error) {
|
|
outcome = 'reject'
|
|
caught = error
|
|
}
|
|
durations.push(performance.now() - started)
|
|
counts[outcome === 'accept' ? 'accepted' : 'rejected'] += 1
|
|
counts[fuzzCase.expected === 'accept' ? 'validCases' : 'invalidCases'] += 1
|
|
assert.equal(outcome, fuzzCase.expected, `Geometry fuzz category ${fuzzCase.name} produced ${outcome}, expected ${fuzzCase.expected}.`)
|
|
if (outcome === 'reject') assert.ok(caught instanceof Error, `Geometry fuzz category ${fuzzCase.name} rejected with a non-Error value.`)
|
|
}
|
|
|
|
durations.sort((left, right) => left - right)
|
|
const p95Ms = durations[Math.floor(durations.length * 0.95)]
|
|
assert.ok(p95Ms < 10, `Geometry input fuzz p95 exceeded 10 ms: ${p95Ms.toFixed(3)} ms.`)
|
|
const report = {
|
|
status: 'geometry-input-fuzz-pass',
|
|
seed: '0x9e3779b9',
|
|
cases: durations.length,
|
|
counts,
|
|
categories: Object.fromEntries([...categories].sort(([left], [right]) => left.localeCompare(right))),
|
|
timing: { p95Ms: Number(p95Ms.toFixed(3)), maxMs: Number(durations.at(-1)?.toFixed(3)) },
|
|
}
|
|
await writeFile(new URL('../config/qa04-geometry-fuzz-verification.json', import.meta.url), `${JSON.stringify(report, null, 2)}\n`)
|
|
console.log(JSON.stringify(report, null, 2))
|