feat: establish reproducible FreeCAD web compatibility baseline
This commit is contained in:
117
scripts/run-geometry-input-fuzz.ts
Normal file
117
scripts/run-geometry-input-fuzz.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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))
|
||||
Reference in New Issue
Block a user