test: add reproducible FreeCAD golden oracle

This commit is contained in:
2026-08-03 03:20:37 -04:00
parent 81fcc69a74
commit 2ce261b982
27 changed files with 678 additions and 12 deletions

View File

@@ -0,0 +1,10 @@
import { execFileSync } from 'node:child_process'
import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const build = resolve(root, '.cache/freecad/build-native')
const install = resolve(root, '.cache/freecad/install-native')
const jobs = process.env.FREECAD_BUILD_JOBS || '4'
execFileSync('cmake', ['--build', build, '--parallel', jobs], { cwd: root, stdio: 'inherit' })
execFileSync('cmake', ['--install', build], { cwd: root, stdio: 'inherit' })
console.log(`FreeCAD native oracle installed: ${install}`)

View File

@@ -0,0 +1,44 @@
import { createHash } from 'node:crypto'
import { access, readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadGoldenManifest } from './freecad-golden-contract.mjs'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const readJson = async (relative) => JSON.parse(await readFile(resolve(root, relative), 'utf8'))
const fail = (message) => { throw new Error(message) }
const baseline = await readJson('config/freecad-baseline.json')
const toolchain = await readJson('config/freecad-toolchain.json')
const nativeBuild = await readJson('config/freecad-native-build.json')
const runtime = await readJson('config/runtime-baseline.json')
const packageJson = await readJson('package.json')
const nodeRuntime = await readFile(resolve(root, 'config/node-runtime.env'), 'utf8')
if (baseline.baselineId !== toolchain.baselineId || baseline.freecadVersion !== toolchain.source.tag) fail('FreeCAD baseline and toolchain tag disagree.')
if (!baseline.sourceRef.endsWith(`@${toolchain.source.commit}`) || !/^[0-9a-f]{40}$/.test(toolchain.source.commit)) fail('FreeCAD source commit is not an exact 40-character revision.')
if (nativeBuild.baselineId !== baseline.baselineId || nativeBuild.source.commit !== toolchain.source.commit) fail('Native oracle build manifest does not match the locked FreeCAD source.')
if (!nodeRuntime.includes(`NODE_VERSION=${packageJson.engines.node}`) || runtime.projectRuntime.node !== packageJson.engines.node) fail('Project Node runtime declarations disagree.')
for (const [packageName, expectedVersion] of [['@bitbybit-dev/occt', runtime.geometry.version], ['@bitbybit-dev/occt-worker', runtime.geometry.version], ['three', runtime.viewport.version], ['@sqlite.org/sqlite-wasm', '3.53.0-build1']]) {
const declared = packageJson.dependencies[packageName]
const installed = await readJson(`node_modules/${packageName}/package.json`)
if (declared !== expectedVersion || installed.version !== expectedVersion) fail(`${packageName} must be declared and installed at ${expectedVersion}.`)
}
const wasmFile = resolve(root, 'node_modules/@bitbybit-dev/occt/bitbybit-dev-occt/bitbybit-dev-occt.a4a6ec2a.wasm')
const wasmHash = createHash('sha256').update(await readFile(wasmFile)).digest('hex')
if (wasmHash !== runtime.geometry.wasmSha256) fail(`OCCT WASM checksum mismatch: ${wasmHash}.`)
const manifest = await loadGoldenManifest(resolve(root, toolchain.goldenContract.manifest))
if (manifest.baselineId !== baseline.baselineId || manifest.driver !== toolchain.goldenContract.driver) fail('Golden manifest does not match the locked baseline contract.')
if (nativeBuild.verification.goldenManifest !== toolchain.goldenContract.manifest || nativeBuild.verification.scenarioCount !== manifest.scenarios.length) fail('Native oracle verification does not match the golden manifest.')
const nativeOracle = resolve(root, nativeBuild.artifact.path)
try {
await access(nativeOracle)
const nativeHash = createHash('sha256').update(await readFile(nativeOracle)).digest('hex')
if (nativeHash !== nativeBuild.artifact.sha256) fail(`Native FreeCADCmd checksum mismatch: ${nativeHash}.`)
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
console.log(`Baseline contract OK: FreeCAD ${baseline.freecadVersion} @ ${toolchain.source.commit.slice(0, 12)}, ${manifest.scenarios.length} golden scenarios, OCCT WASM ${wasmHash.slice(0, 12)}.`)

View File

@@ -0,0 +1,29 @@
import { execFileSync } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const root = resolve(new URL('..', import.meta.url).pathname)
const toolchain = JSON.parse(await readFile(new URL('../config/freecad-toolchain.json', import.meta.url), 'utf8'))
const source = resolve(root, toolchain.sourceCheckout.defaultDirectory)
const build = resolve(root, '.cache/freecad/build-native')
const install = resolve(root, '.cache/freecad/install-native')
const bootstrap = resolve(root, 'config/freecad-native-bootstrap.cmake')
const revision = execFileSync('git', ['-C', source, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()
if (revision !== toolchain.source.commit) throw new Error(`FreeCAD source must be checked out at ${toolchain.source.commit}; received ${revision}.`)
const disabledModules = ['ADDONMGR', 'ASSEMBLY', 'BIM', 'CAM', 'DRAFT', 'FEM', 'FLAT_MESH', 'HELP', 'IDF', 'IMPORT', 'INSPECTION', 'MATERIAL_EXTERNAL', 'MESH', 'MESH_PART', 'OPENSCAD', 'PART_DESIGN', 'PLOT', 'POINTS', 'REVERSEENGINEERING', 'ROBOT', 'SHOW', 'SKETCHER', 'SPREADSHEET', 'START', 'SURFACE', 'TECHDRAW', 'TEST', 'TUX', 'WEB']
const definitions = [
`-DCMAKE_BUILD_TYPE=Release`,
`-DCMAKE_INSTALL_PREFIX=${install}`,
`-DCMAKE_PROJECT_INCLUDE=${bootstrap}`,
'-DBUILD_GUI=OFF',
'-DBUILD_PART=ON',
'-DENABLE_DEVELOPER_TESTS=OFF',
'-DFREECAD_USE_FREETYPE=OFF',
'-DFREECAD_USE_EXTERNAL_FMT=ON',
'-DFREECAD_USE_PCH=OFF',
'-DINSTALL_TO_SITEPACKAGES=OFF',
...disabledModules.map((module) => `-DBUILD_${module}=OFF`),
]
execFileSync('cmake', ['-S', source, '-B', build, '-G', 'Ninja', '-U', 'CMAKE_PROJECT_TOP_LEVEL_INCLUDES', ...definitions], { cwd: root, stdio: 'inherit' })
console.log(`FreeCAD native build configured: ${build}`)

View File

@@ -0,0 +1,27 @@
import { execFileSync } from 'node:child_process'
import { mkdir, stat } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { readFile } from 'node:fs/promises'
const root = resolve(new URL('..', import.meta.url).pathname)
const toolchain = JSON.parse(await readFile(new URL('../config/freecad-toolchain.json', import.meta.url), 'utf8'))
const directoryArgument = process.argv.slice(2).find((argument) => argument.startsWith('--directory='))
const target = resolve(root, directoryArgument?.slice('--directory='.length) || toolchain.sourceCheckout.defaultDirectory)
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], timeout: 300000 }).trim()
const exists = await stat(target).then(() => true, () => false)
if (!exists) {
await mkdir(dirname(target), { recursive: true })
git('clone', '--filter=blob:none', '--no-checkout', toolchain.source.repository, target)
} else {
const remote = git('-C', target, 'remote', 'get-url', 'origin')
if (remote !== toolchain.source.repository) throw new Error(`Existing checkout has unexpected origin: ${remote}`)
const dirty = git('-C', target, 'status', '--porcelain')
if (dirty) throw new Error(`Existing FreeCAD checkout is dirty: ${target}`)
}
git('-C', target, 'fetch', '--depth=1', 'origin', `refs/tags/${toolchain.source.tag}`)
const fetched = git('-C', target, 'rev-parse', 'FETCH_HEAD')
if (fetched !== toolchain.source.commit) throw new Error(`Fetched FreeCAD tag resolved to ${fetched}, expected ${toolchain.source.commit}.`)
git('-C', target, 'checkout', '--detach', toolchain.source.commit)
console.log(`FreeCAD source ready: ${target} @ ${toolchain.source.commit}`)

View File

@@ -0,0 +1,94 @@
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
const readJson = async (file) => JSON.parse(await readFile(file, 'utf8'))
const finite = (value) => typeof value === 'number' && Number.isFinite(value)
const positive = (value) => finite(value) && value > 0
function validatePlacement(placement, label) {
if (placement === undefined) return
if (!placement || typeof placement !== 'object' || Array.isArray(placement)) throw new TypeError(`${label}.placement must be an object.`)
if (placement.translation !== undefined && (!Array.isArray(placement.translation) || placement.translation.length !== 3 || !placement.translation.every(finite))) throw new TypeError(`${label}.placement.translation must contain three finite numbers.`)
if (placement.rotation !== undefined) {
if (!placement.rotation || typeof placement.rotation !== 'object' || Array.isArray(placement.rotation)) throw new TypeError(`${label}.placement.rotation must be an object.`)
if (!Array.isArray(placement.rotation.axis) || placement.rotation.axis.length !== 3 || !placement.rotation.axis.every(finite) || Math.hypot(...placement.rotation.axis) === 0) throw new TypeError(`${label}.placement.rotation.axis must be a non-zero vector.`)
if (!finite(placement.rotation.angle)) throw new TypeError(`${label}.placement.rotation.angle must be finite.`)
}
}
export function validateGoldenOperation(operation, label = 'operation') {
if (!operation || typeof operation !== 'object' || Array.isArray(operation) || typeof operation.type !== 'string') throw new TypeError(`${label} must have a type.`)
validatePlacement(operation.placement, label)
if (operation.type === 'box') {
if (![operation.length, operation.width, operation.height].every(positive)) throw new RangeError(`${label} box dimensions must be positive.`)
} else if (operation.type === 'cylinder') {
if (!positive(operation.radius) || !positive(operation.height) || !positive(operation.angle) || operation.angle > 360) throw new RangeError(`${label} cylinder parameters are invalid.`)
} else if (operation.type === 'sphere') {
if (!positive(operation.radius)) throw new RangeError(`${label} sphere radius must be positive.`)
} else if (operation.type === 'cone') {
if (!finite(operation.radius1) || operation.radius1 < 0 || !finite(operation.radius2) || operation.radius2 < 0 || operation.radius1 + operation.radius2 <= 0 || !positive(operation.height) || !positive(operation.angle) || operation.angle > 360) throw new RangeError(`${label} cone parameters are invalid.`)
} else if (operation.type === 'cut' || operation.type === 'fuse' || operation.type === 'common') {
validateGoldenOperation(operation.base, `${label}.base`)
validateGoldenOperation(operation.tool, `${label}.tool`)
} else throw new RangeError(`${label} uses unsupported operation type ${operation.type}.`)
}
export function validateGoldenScenario(scenario, expectedId) {
if (!scenario || scenario.schemaVersion !== 1) throw new TypeError(`${expectedId || 'Scenario'} must use schemaVersion 1.`)
if (typeof scenario.id !== 'string' || !scenario.id || (expectedId && scenario.id !== expectedId)) throw new RangeError(`Scenario ID does not match ${expectedId}.`)
if (typeof scenario.category !== 'string' || !scenario.category || typeof scenario.description !== 'string' || !scenario.description) throw new TypeError(`${scenario.id} requires category and description.`)
validateGoldenOperation(scenario.operation, scenario.id)
if (!scenario.tolerance || !positive(scenario.tolerance.linear) || !positive(scenario.tolerance.scalar)) throw new RangeError(`${scenario.id} requires positive linear and scalar tolerances.`)
if (!scenario.expected || typeof scenario.expected !== 'object' || Array.isArray(scenario.expected)) throw new TypeError(`${scenario.id} requires expected output.`)
const allowedShapeTypes = new Set(['Solid', 'Compound'])
if (scenario.expected.isNull !== false || scenario.expected.isValid !== true || !allowedShapeTypes.has(scenario.expected.shapeType) || !Number.isInteger(scenario.expected.solids) || scenario.expected.solids < 1) {
throw new RangeError(`${scenario.id} must declare a valid non-null oracle containing at least one Solid.`)
}
}
export async function loadGoldenManifest(manifestFile) {
const absoluteManifest = resolve(manifestFile)
const manifest = await readJson(absoluteManifest)
if (manifest.schemaVersion !== 1 || typeof manifest.baselineId !== 'string' || !manifest.baselineId) throw new TypeError('Golden manifest must use schemaVersion 1 and declare baselineId.')
if (!Array.isArray(manifest.scenarios) || manifest.scenarios.length === 0) throw new RangeError('Golden manifest requires at least one scenario.')
const ids = new Set()
const scenarios = []
for (const entry of manifest.scenarios) {
if (!entry || typeof entry.id !== 'string' || !entry.id || ids.has(entry.id) || typeof entry.file !== 'string' || !entry.file) throw new RangeError('Golden manifest scenario IDs must be non-empty and unique.')
ids.add(entry.id)
const file = resolve(dirname(absoluteManifest), entry.file)
const scenario = await readJson(file)
validateGoldenScenario(scenario, entry.id)
scenarios.push({ ...entry, file, scenario })
}
return { ...manifest, file: absoluteManifest, scenarios }
}
const toleranceForPath = (scenario, path) => path.includes('boundingBox') ? scenario.tolerance.linear : scenario.tolerance.scalar
export function compareGoldenResult(scenario, actual) {
const differences = []
const compare = (expected, received, path) => {
if (typeof expected === 'number') {
if (!finite(received)) differences.push(`${path}: expected finite ${expected}, received ${String(received)}`)
else {
const tolerance = toleranceForPath(scenario, path)
if (Math.abs(expected - received) > tolerance) differences.push(`${path}: expected ${expected} +/- ${tolerance}, received ${received}`)
}
return
}
if (Array.isArray(expected)) {
if (!Array.isArray(received) || received.length !== expected.length) differences.push(`${path}: expected an array of length ${expected.length}`)
else expected.forEach((value, index) => compare(value, received[index], `${path}[${index}]`))
return
}
if (expected && typeof expected === 'object') {
if (!received || typeof received !== 'object' || Array.isArray(received)) differences.push(`${path}: expected an object`)
else for (const [key, value] of Object.entries(expected)) compare(value, received[key], `${path}.${key}`)
return
}
if (received !== expected) differences.push(`${path}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(received)}`)
}
compare(scenario.expected, actual, scenario.id)
return differences
}

View File

@@ -0,0 +1,69 @@
import json
import os
import FreeCAD as App
import Part
def make_shape(operation):
kind = operation["type"]
if kind == "box":
shape = Part.makeBox(operation["length"], operation["width"], operation["height"])
elif kind == "cylinder":
shape = Part.makeCylinder(operation["radius"], operation["height"], App.Vector(0, 0, 0), App.Vector(0, 0, 1), operation["angle"])
elif kind == "sphere":
shape = Part.makeSphere(operation["radius"])
elif kind == "cone":
shape = Part.makeCone(operation["radius1"], operation["radius2"], operation["height"], App.Vector(0, 0, 0), App.Vector(0, 0, 1), operation["angle"])
elif kind in ("cut", "fuse", "common"):
base = make_shape(operation["base"])
tool = make_shape(operation["tool"])
shape = base.cut(tool) if kind == "cut" else base.fuse(tool) if kind == "fuse" else base.common(tool)
else:
raise ValueError("Unsupported golden operation: " + kind)
placement_value = operation.get("placement")
if placement_value:
placement = App.Placement()
placement.Base = App.Vector(*(placement_value.get("translation") or [0, 0, 0]))
rotation = placement_value.get("rotation")
if rotation:
placement.Rotation = App.Rotation(App.Vector(*rotation["axis"]), rotation["angle"])
shape = shape.copy()
shape.Placement = placement
return shape
def vector(x, y, z):
return [float(x), float(y), float(z)]
scenario_path = os.environ.get("FREECAD_GOLDEN_SCENARIO")
if not scenario_path:
raise RuntimeError("FREECAD_GOLDEN_SCENARIO is required")
with open(scenario_path, "r", encoding="utf-8") as scenario_file:
scenario = json.load(scenario_file)
shape = make_shape(scenario["operation"])
box = shape.BoundBox
version = App.Version()
result = {
"schemaVersion": 1,
"fixtureId": scenario["id"],
"freecadVersion": ".".join(str(value) for value in version[:3]),
"shapeType": shape.ShapeType,
"isNull": shape.isNull(),
"isValid": shape.isValid(),
"solids": len(shape.Solids),
"faces": len(shape.Faces),
"edges": len(shape.Edges),
"vertices": len(shape.Vertexes),
"volume": float(shape.Volume),
"area": float(shape.Area),
"boundingBox": {
"min": vector(box.XMin, box.YMin, box.ZMin),
"max": vector(box.XMax, box.YMax, box.ZMax),
},
}
print("FREECAD_GOLDEN_RESULT=" + json.dumps(result, sort_keys=True, separators=(",", ":")))

View File

@@ -0,0 +1,63 @@
import { spawnSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadGoldenManifest, compareGoldenResult } from './freecad-golden-contract.mjs'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const args = new Set(process.argv.slice(2))
const scenarioArgument = process.argv.slice(2).find((argument) => argument.startsWith('--scenario='))
const requestedScenario = scenarioArgument?.slice('--scenario='.length)
const jsonOutput = args.has('--json')
const allowMissing = args.has('--allow-missing')
const manifest = await loadGoldenManifest(resolve(root, 'fixtures/freecad-golden/manifest.json'))
const driver = resolve(root, manifest.driver)
const localOracle = resolve(root, '.cache/freecad/install-native/bin/FreeCADCmd')
const candidates = process.env.FREECAD_CMD
? [process.env.FREECAD_CMD]
: [localOracle, 'FreeCADCmd', 'freecadcmd']
let command = null
for (const candidate of candidates) {
const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8', timeout: 15000 })
if (!probe.error || probe.error.code !== 'ENOENT') { command = candidate; break }
}
if (!command) {
const message = `FreeCADCmd 1.1.1 is unavailable. Set FREECAD_CMD to the locked desktop oracle executable.`
if (allowMissing) { console.log(message); process.exit(0) }
console.error(message)
process.exit(2)
}
const scenarios = requestedScenario ? manifest.scenarios.filter((entry) => entry.id === requestedScenario) : manifest.scenarios
if (scenarios.length === 0) { console.error(`Unknown golden scenario: ${requestedScenario}`); process.exit(2) }
const reports = []
for (const entry of scenarios) {
const execution = spawnSync(command, [driver], {
cwd: root,
encoding: 'utf8',
timeout: 120000,
maxBuffer: 10 * 1024 * 1024,
env: { ...process.env, FREECAD_GOLDEN_SCENARIO: entry.file },
})
const output = `${execution.stdout || ''}\n${execution.stderr || ''}`
const resultLine = output.split(/\r?\n/).find((line) => line.startsWith('FREECAD_GOLDEN_RESULT='))
if (execution.error || execution.status !== 0 || !resultLine) {
reports.push({ id: entry.id, passed: false, differences: [`FreeCADCmd execution failed with status ${execution.status}: ${execution.error?.message || output.trim()}`] })
continue
}
let actual
try { actual = JSON.parse(resultLine.slice('FREECAD_GOLDEN_RESULT='.length)) }
catch (error) { reports.push({ id: entry.id, passed: false, differences: [`Invalid result JSON: ${error.message}`] }); continue }
const differences = []
if (actual.fixtureId !== entry.id) differences.push(`fixtureId: expected ${entry.id}, received ${actual.fixtureId}`)
if (actual.freecadVersion !== '1.1.1') differences.push(`freecadVersion: expected 1.1.1, received ${actual.freecadVersion}`)
differences.push(...compareGoldenResult(entry.scenario, actual))
reports.push({ id: entry.id, passed: differences.length === 0, differences, actual })
}
if (jsonOutput) console.log(JSON.stringify({ schemaVersion: 1, baselineId: manifest.baselineId, command, reports }, null, 2))
else for (const report of reports) {
console.log(`${report.passed ? 'PASS' : 'FAIL'} ${report.id}`)
report.differences.forEach((difference) => console.log(` ${difference}`))
}
if (reports.some((report) => !report.passed)) process.exit(1)

View File

@@ -0,0 +1,12 @@
import { execFileSync } from 'node:child_process'
import { readFile } from 'node:fs/promises'
const toolchain = JSON.parse(await readFile(new URL('../config/freecad-toolchain.json', import.meta.url), 'utf8'))
const reference = `refs/tags/${toolchain.source.tag}`
const output = execFileSync('git', ['ls-remote', toolchain.source.repository, reference], { encoding: 'utf8', timeout: 30000 }).trim()
const [resolved, resolvedReference] = output.split(/\s+/)
if (resolvedReference !== reference || resolved !== toolchain.source.commit) {
console.error(`FreeCAD tag mismatch: expected ${reference} @ ${toolchain.source.commit}, received ${output || 'no result'}.`)
process.exit(1)
}
console.log(`FreeCAD source tag verified: ${reference} @ ${resolved}.`)