78 lines
9.5 KiB
TypeScript
78 lines
9.5 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import { existsSync } from 'node:fs'
|
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
import { spawnSync } from 'node:child_process'
|
|
import { resolve } from 'node:path'
|
|
import { isDeepStrictEqual } from 'node:util'
|
|
import { unzipSync } from 'fflate'
|
|
import { createWebCadFacade } from '../src/facade/mockFacade'
|
|
import { decodeFcstdPropertyValue } from '../src/facade/fcstd'
|
|
import type { LinkSubValue } from '../src/facade/types'
|
|
|
|
const root = resolve(new URL('..', import.meta.url).pathname)
|
|
const executable = process.env.FREECAD_CMD || resolve(root, '.cache/freecad/install-desktop/bin/FreeCADCmd')
|
|
const sysroot = resolve(root, '.cache/freecad/sysroot')
|
|
const scriptPath = resolve(root, 'scripts/freecad-property-linksubhidden-roundtrip.py')
|
|
const outputDirectory = resolve(root, '.cache/freecad/property-linksubhidden-roundtrip')
|
|
const nativePath = resolve(outputDirectory, 'native-initial.FCStd')
|
|
const webPath = resolve(outputDirectory, 'web-edited.FCStd')
|
|
const resavedPath = resolve(outputDirectory, 'native-resaved.FCStd')
|
|
const reportPath = resolve(root, 'config/freecad-property-linksubhidden-roundtrip.json')
|
|
if (!existsSync(executable)) throw new Error(`FreeCAD PropertyLinkSubHidden roundtrip executable is missing: ${executable}`)
|
|
await mkdir(outputDirectory, { recursive: true })
|
|
|
|
const runNative = (mode: 'create' | 'verify', path: string, resaved = '') => {
|
|
const execution = spawnSync(executable, ['--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), scriptPath], { cwd: root, encoding: 'utf8', timeout: 120_000, maxBuffer: 32 * 1024 * 1024, env: { ...process.env, FREECAD_PROPERTY_LINKSUBHIDDEN_ROUNDTRIP_MODE: mode, FREECAD_PROPERTY_LINKSUBHIDDEN_ROUNDTRIP_PATH: path, FREECAD_PROPERTY_LINKSUBHIDDEN_ROUNDTRIP_RESAVED_PATH: resaved, PYTHONPATH: `${resolve(sysroot, 'usr/lib/python3/dist-packages')}${process.env.PYTHONPATH ? `:${process.env.PYTHONPATH}` : ''}`, LD_LIBRARY_PATH: `${resolve(sysroot, 'usr/lib/x86_64-linux-gnu')}${process.env.LD_LIBRARY_PATH ? `:${process.env.LD_LIBRARY_PATH}` : ''}` } })
|
|
const output = `${execution.stdout || ''}\n${execution.stderr || ''}`
|
|
const marker = 'FREECAD_PROPERTY_LINKSUBHIDDEN_ROUNDTRIP_RESULT='
|
|
const line = output.split(/\r?\n/).find((candidate) => candidate.includes(marker))
|
|
if (execution.error || execution.status !== 0 || !line) throw new Error(`FreeCAD PropertyLinkSubHidden ${mode} probe failed with status ${execution.status}: ${execution.error?.message || output.trim()}`)
|
|
return JSON.parse(line.slice(line.indexOf(marker) + marker.length))
|
|
}
|
|
|
|
const initial: LinkSubValue = { schemaVersion: 1, objectId: 'ColorSourceA', subElements: ['Face1', 'Face3'] }
|
|
const target: LinkSubValue = { schemaVersion: 1, objectId: 'ColorSourceB', subElements: ['Face5'] }
|
|
const native = runNative('create', nativePath)
|
|
const nativeBytes = new Uint8Array(await readFile(nativePath))
|
|
const facade = createWebCadFacade({ runtimeMode: 'mock' })
|
|
const inspectedBefore = facade.project.fcstd.inspect(nativeBytes)
|
|
const property = (inspection: typeof inspectedBefore) => inspection.objects.find((object) => object.name === 'LinkGroupProbe')?.properties.find((candidate) => candidate.name === 'ColoredElements')
|
|
const editedBytes = facade.project.fcstd.rewriteLinkSubHidden(nativeBytes, { objectName: 'LinkGroupProbe', propertyName: 'ColoredElements', value: target, expectedValue: initial })
|
|
const inspectedAfter = facade.project.fcstd.inspect(editedBytes)
|
|
await facade.project.dispose()
|
|
await writeFile(webPath, editedBytes)
|
|
|
|
const initialFiles = unzipSync(nativeBytes)
|
|
const editedFiles = unzipSync(editedBytes)
|
|
const opaquePaths = Object.keys(initialFiles).filter((path) => path.toLowerCase() !== 'document.xml')
|
|
const opaqueEntriesPreserved = opaquePaths.every((path) => Buffer.from(initialFiles[path]).equals(Buffer.from(editedFiles[path] ?? new Uint8Array())))
|
|
const withoutEditedProperty = (inspection: typeof inspectedBefore) => inspection.objects.map((object) => ({ ...object, properties: object.name === 'LinkGroupProbe' ? object.properties.filter((candidate) => candidate.name !== 'ColoredElements') : object.properties }))
|
|
const semanticObjectsPreserved = isDeepStrictEqual(withoutEditedProperty(inspectedBefore), withoutEditedProperty(inspectedAfter))
|
|
const dependencyTargets = (xml: string, _objectName: string): string[] => { const match = xml.match(/<ObjectDeps(?=[^>]*Name="LinkGroupProbe")[^>]*>([\s\S]*?)<\/ObjectDeps>/); return match ? [...match[1].matchAll(/<Dep\s+Name="([^"]+)"\s*\/>/g)].map((entry) => entry[1]) : [] }
|
|
const initialDocumentXml = new TextDecoder().decode(initialFiles['Document.xml'])
|
|
const editedDocumentXml = new TextDecoder().decode(editedFiles['Document.xml'])
|
|
const initialDependencyTargets = dependencyTargets(initialDocumentXml, 'LinkGroupProbe')
|
|
const webDependencyTargets = dependencyTargets(editedDocumentXml, 'LinkGroupProbe')
|
|
const targetMarkedTouched = /<Object(?=[^>]*name="LinkGroupProbe")(?=[^>]*Touched="1")[^>]*\/>/.test(editedDocumentXml)
|
|
const hiddenDependencyMetadataPreserved = isDeepStrictEqual(initialDependencyTargets, ['ColorSourceA']) && isDeepStrictEqual(webDependencyTargets, ['ColorSourceA'])
|
|
const decodedAfter = property(inspectedAfter) ? decodeFcstdPropertyValue(property(inspectedAfter)!) : undefined
|
|
const webOutputMatches = decodedAfter?.decoded === true && isDeepStrictEqual(decodedAfter.value, target)
|
|
|
|
const nativeAfter = runNative('verify', webPath, resavedPath)
|
|
const resavedBytes = new Uint8Array(await readFile(resavedPath))
|
|
const resavedFiles = unzipSync(resavedBytes)
|
|
const nativeResavedDependencyTargets = dependencyTargets(new TextDecoder().decode(resavedFiles['Document.xml']), 'LinkGroupProbe')
|
|
const snapshots = [native.result, nativeAfter.result.reopened, nativeAfter.result.resaved]
|
|
const nativeStructured = (value: any): LinkSubValue | null => value === null ? null : { schemaVersion: 1, objectId: value.object, subElements: value.subElements }
|
|
const nativeOutputMatches = isDeepStrictEqual(nativeStructured(native.result.object.value), initial) && [nativeAfter.result.reopened, nativeAfter.result.resaved].every((snapshot: any) => isDeepStrictEqual(nativeStructured(snapshot.object.value), target))
|
|
const objectSetPreserved = snapshots.every((snapshot: any) => isDeepStrictEqual(snapshot.objectSet, native.result.objectSet))
|
|
const hiddenLinksPreserved = snapshots.every((snapshot: any) => isDeepStrictEqual(snapshot.object.outList, []) && isDeepStrictEqual(snapshot.targets.ColorSourceA.inList, []) && isDeepStrictEqual(snapshot.targets.ColorSourceB.inList, []))
|
|
const shapeStructure = (shape: any) => { const { brepSha256: _brepSha256, ...structure } = shape; return structure }
|
|
const targetShapeStructurePreserved = snapshots.every((snapshot: any) => isDeepStrictEqual(shapeStructure(snapshot.targets.ColorSourceA.shape), shapeStructure(native.result.targets.ColorSourceA.shape)) && isDeepStrictEqual(shapeStructure(snapshot.targets.ColorSourceB.shape), shapeStructure(native.result.targets.ColorSourceB.shape)))
|
|
const ownerShapePreserved = snapshots.every((snapshot: any) => isDeepStrictEqual(snapshot.object.shape, native.result.object.shape))
|
|
const nativeDependencyMetadataNormalized = isDeepStrictEqual(nativeResavedDependencyTargets, ['ColorSourceB'])
|
|
const zeroUnknownDrift = opaqueEntriesPreserved && semanticObjectsPreserved && targetMarkedTouched && hiddenDependencyMetadataPreserved && nativeDependencyMetadataNormalized && webOutputMatches && nativeOutputMatches && objectSetPreserved && hiddenLinksPreserved && targetShapeStructurePreserved && ownerShapePreserved
|
|
const report = { schemaVersion: 1, status: zeroUnknownDrift ? 'pass' : 'fail', baselineId: 'freecad-1.1.1-property-linksubhidden-roundtrip', freecadVersion: native.freecadVersion, gitCommit: native.gitCommit, propertyType: 'App::PropertyLinkSubHidden', archives: { nativeInitial: { path: '.cache/freecad/property-linksubhidden-roundtrip/native-initial.FCStd', bytes: nativeBytes.byteLength, sha256: createHash('sha256').update(nativeBytes).digest('hex') }, webEdited: { path: '.cache/freecad/property-linksubhidden-roundtrip/web-edited.FCStd', bytes: editedBytes.byteLength, sha256: createHash('sha256').update(editedBytes).digest('hex') }, nativeResaved: { path: '.cache/freecad/property-linksubhidden-roundtrip/native-resaved.FCStd', bytes: resavedBytes.byteLength, sha256: createHash('sha256').update(resavedBytes).digest('hex') } }, nativeInitial: native.result, web: { before: property(inspectedBefore), after: property(inspectedAfter), targetMarkedTouched, initialDependencyTargets, webDependencyTargets, hiddenDependencyMetadataPreserved, webOutputMatches, objectSetPreserved, semanticObjectsPreserved, opaquePathsPreserved: opaquePaths.length, opaqueEntriesPreserved }, nativeAfter: nativeAfter.result, classification: { requestedValue: target, webValue: decodedAfter?.value, reopenedValue: nativeStructured(nativeAfter.result.reopened.object.value), resavedValue: nativeStructured(nativeAfter.result.resaved.object.value), nativeOutputMatches, hiddenLinksPreserved, nativeResavedDependencyTargets, nativeDependencyMetadataNormalized, targetShapeStructurePreserved, ownerShapePreserved, nativeTargetBrepHashes: snapshots.map((snapshot: any) => ({ ColorSourceA: snapshot.targets.ColorSourceA.shape.brepSha256, ColorSourceB: snapshot.targets.ColorSourceB.shape.brepSha256 })), unknownSemanticDrift: !zeroUnknownDrift, zeroUnknownDrift } }
|
|
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`)
|
|
console.log(JSON.stringify({ status: report.status, output: 'config/freecad-property-linksubhidden-roundtrip.json', values: report.classification, opaqueEntriesPreserved, semanticObjectsPreserved }, null, 2))
|