import { createHash } from 'node:crypto' import { test } from 'node:test' import assert from 'node:assert/strict' import { unzipSync, zipSync } from 'fflate' import { createWebCadFacade } from '../src/facade/mockFacade' import { decodeFcstdPropertyValue } from '../src/facade/fcstd' import { encodeFreecadPropertyStatus } from '../src/facade/propertyStatus' import type { DocumentSnapshot, FacadeEvent, IncludedFileValue, ObjectPropertySnapshot } from '../src/facade/types' const hosts: Array<{ id: string; typeId: string; properties: Array<{ name: string; status?: 'ReadOnly' | 'PropOutput' }> }> = [ { id: 'IncludedProbe', typeId: 'App::DocumentObjectFileIncluded', properties: [{ name: 'File' }] }, { id: 'VrmlProbe', typeId: 'App::VRMLObject', properties: [{ name: 'VrmlFile' }] }, { id: 'ImagePlaneProbe', typeId: 'Image::ImagePlane', properties: [{ name: 'ImageFile' }] }, { id: 'RobotProbe', typeId: 'Robot::RobotObject', properties: [{ name: 'RobotKinematicFile' }, { name: 'RobotVrmlFile' }] }, { id: 'SketchFlatProbe', typeId: 'Sketcher::SketchObjectSF', properties: [{ name: 'SketchFlatFile' }] }, { id: 'ComplexSectionProbe', typeId: 'TechDraw::DrawComplexSection', properties: [{ name: 'PatIncluded', status: 'ReadOnly' }, { name: 'SvgIncluded', status: 'ReadOnly' }] }, { id: 'ComplexSectionPythonProbe', typeId: 'TechDraw::DrawComplexSectionPython', properties: [{ name: 'PatIncluded', status: 'ReadOnly' }, { name: 'SvgIncluded', status: 'ReadOnly' }] }, { id: 'GeomHatchProbe', typeId: 'TechDraw::DrawGeomHatch', properties: [{ name: 'PatIncluded' }] }, { id: 'HatchProbe', typeId: 'TechDraw::DrawHatch', properties: [{ name: 'SvgIncluded' }] }, { id: 'SvgTemplateProbe', typeId: 'TechDraw::DrawSVGTemplate', properties: [{ name: 'PageResult', status: 'PropOutput' }] }, { id: 'TileWeldProbe', typeId: 'TechDraw::DrawTileWeld', properties: [{ name: 'SymbolIncluded' }] }, { id: 'TileWeldPythonProbe', typeId: 'TechDraw::DrawTileWeldPython', properties: [{ name: 'SymbolIncluded' }] }, { id: 'ViewImageProbe', typeId: 'TechDraw::DrawViewImage', properties: [{ name: 'ImageIncluded' }] }, { id: 'ViewSectionProbe', typeId: 'TechDraw::DrawViewSection', properties: [{ name: 'PatIncluded', status: 'ReadOnly' }, { name: 'SvgIncluded', status: 'ReadOnly' }] }, { id: 'ViewSectionPythonProbe', typeId: 'TechDraw::DrawViewSectionPython', properties: [{ name: 'PatIncluded', status: 'ReadOnly' }, { name: 'SvgIncluded', status: 'ReadOnly' }] }, ] const property = (name: string, value: IncludedFileValue | null, status?: 'ReadOnly' | 'PropOutput'): ObjectPropertySnapshot => ({ name, label: name, group: 'Resource', scope: 'data', type: 'App::PropertyFileIncluded', value, ...(status ? { nativeStatus: encodeFreecadPropertyStatus([status]) } : {}), recompute: true, }) const fixture = (value?: (objectId: string, propertyName: string, index: number) => IncludedFileValue | null): DocumentSnapshot => { let index = 0 return { id: 'property-fileincluded-facade', label: 'PropertyFileIncluded Facade', version: 1, dirty: false, readOnly: false, units: 'mm', tree: hosts.map(({ id }) => ({ id, label: id, type: 'feature', state: 'valid' })), objects: hosts.map((host) => ({ id: host.id, typeId: host.typeId, properties: host.properties.map(({ name, status }) => property(name, value?.(host.id, name, index++) ?? null, status)) })), dependencies: [], recompute: { generation: 0, status: 'idle', objectStates: Object.fromEntries(hosts.map(({ id }) => [id, 'up-to-date'])), dirtyObjects: [], order: [], errors: [] }, } } test('App::PropertyFileIncluded stores authoritative bytes before committing a structured Facade value', async () => { const facade = createWebCadFacade({ initialDocument: fixture(), initialSelectedObjectIds: ['IncludedProbe'], runtimeMode: 'mock' }) const events: FacadeEvent[] = [] facade.subscribe((event) => { if (event.type === 'property.before-change' || event.type === 'property.changed' || event.type === 'transaction.committed') events.push(event) }) const bytes = new Uint8Array([0, 255, 17, 42, 99]) const first = await facade.app.document.setIncludedFile({ objectId: 'IncludedProbe', propertyName: 'File', fileName: 'payload.bin', bytes }) bytes[0] = 88 assert.match(first.resourceHash, /^[a-f0-9]{64}$/) assert.deepEqual(first, { schemaVersion: 1, archiveName: 'payload.bin', resourceHash: first.resourceHash, byteLength: 5, mediaType: 'application/octet-stream' }) assert.deepEqual(facade.app.document.getObject('IncludedProbe')?.properties[0].value, first) assert.deepEqual([...(await facade.project.resource.get(first.resourceHash))!], [0, 255, 17, 42, 99]) assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed']) const second = await facade.app.document.setIncludedFile({ objectId: 'RobotProbe', propertyName: 'RobotKinematicFile', fileName: 'payload.bin', bytes: new Uint8Array([1, 2, 3]), mediaType: 'text/csv' }) assert.equal(second.archiveName, 'payload1.bin') assert.equal(second.mediaType, 'text/csv') const stable = JSON.stringify(facade.app.document.getActive()) assert.throws(() => facade.app.document.setProperty({ objectId: 'IncludedProbe', propertyName: 'File', value: null }), /cannot be cleared/) assert.throws(() => facade.app.document.setProperty({ objectId: 'IncludedProbe', propertyName: 'File', value: { ...first, archiveName: '../payload.bin' } }), /safe file basename/) assert.throws(() => facade.app.document.setProperty({ objectId: 'IncludedProbe', propertyName: 'File', value: { ...first, resourceHash: 'not-a-hash' } }), /SHA-256/) assert.throws(() => facade.app.document.setProperty({ objectId: 'IncludedProbe', propertyName: 'File', value: { ...first, byteLength: -1 } }), /byteLength/) assert.equal(JSON.stringify(facade.app.document.getActive()), stable) await assert.rejects(facade.app.document.setIncludedFile({ objectId: 'ComplexSectionProbe', propertyName: 'PatIncluded', fileName: 'pattern.pat', bytes: new Uint8Array([1]) }), /read-only/) await facade.project.dispose() }) test('App::PropertyFileIncluded writes, inspects, extracts and stores all 20 native host properties', async () => { const payload = new Uint8Array([60, 115, 118, 103, 62, 0, 255, 60, 47, 115, 118, 103, 62]) const resourceHash = createHash('sha256').update(payload).digest('hex') const document = fixture((_objectId, propertyName, index) => ({ schemaVersion: 1, archiveName: `${String(index).padStart(2, '0')}-${propertyName}.bin`, resourceHash, byteLength: payload.byteLength, mediaType: 'application/octet-stream' })) const facade = createWebCadFacade({ initialDocument: document, runtimeMode: 'mock' }) const stored = await facade.project.resource.put(payload, 'application/octet-stream') assert.equal(stored.hash, resourceHash) assert.throws(() => facade.project.fcstd.serializeMetadata(document), /missing project resource bytes/) const archive = await facade.project.fcstd.serializeMetadataWithResources(document) const files = unzipSync(archive) const documentXml = new TextDecoder().decode(files['Document.xml']) assert.equal((documentXml.match(/type="App::PropertyFileIncluded"/g) ?? []).length, 20) assert.equal((documentXml.match(//g) ?? []).length, 20) for (const match of documentXml.matchAll(/]+type="App::PropertyFileIncluded"[^>]*>/g)) assert.doesNotMatch(match[0], /\s(?:group|doc|attr|ro|hide)=/) const archiveNames = document.objects.flatMap((object) => object.properties.map((candidate) => (candidate.value as IncludedFileValue).archiveName)) assert.equal(new Set(archiveNames).size, 20) for (const archiveName of archiveNames) assert.deepEqual([...files[archiveName]], [...payload]) const inspection = facade.project.fcstd.inspect(archive) const summaries = inspection.objects.flatMap((object) => object.properties.filter((candidate) => candidate.typeId === 'App::PropertyFileIncluded')) assert.equal(summaries.length, 20) assert.ok(summaries.every((summary) => summary.element === 'FileIncluded' && summary.includedFileResource?.byteLength === payload.byteLength && summary.includedFileResource.contentHash.length === 8)) assert.equal(decodeFcstdPropertyValue(summaries[0]).decoded, false) const extracted = facade.project.fcstd.extractIncludedFiles(archive) assert.equal(extracted.length, 20) assert.ok(extracted.every((resource) => resource.references.length === 1 && resource.byteLength === payload.byteLength && resource.bytes.every((byte, index) => byte === payload[index]))) const imported = await facade.project.fcstd.storeIncludedFiles(archive) assert.equal(imported.length, 20) assert.ok(imported.every((resource) => resource.hash === resourceHash && resource.value.resourceHash === resourceHash && resource.value.archiveName === resource.path)) const missing = { ...files } delete missing[archiveNames[0]] assert.throws(() => facade.project.fcstd.inspect(zipSync(missing)), /references missing resource/) const emptyDocument = fixture() const emptyArchive = facade.project.fcstd.serializeMetadata(emptyDocument) const emptySummary = facade.project.fcstd.inspect(emptyArchive).objects[0].properties.find((candidate) => candidate.typeId === 'App::PropertyFileIncluded')! assert.match(new TextDecoder().decode(unzipSync(emptyArchive)['Document.xml']), //) assert.deepEqual(decodeFcstdPropertyValue(emptySummary), { value: null, decoded: true }) await facade.project.dispose() }) test('App::PropertyFileIncluded rewrites one native resource through project resource identities', async () => { const initialBytes = new Uint8Array([0, 17, 34, 51]) const targetBytes = new Uint8Array([255, 128, 64, 32, 16]) const initialHash = createHash('sha256').update(initialBytes).digest('hex') const document = fixture((objectId, propertyName) => objectId === 'IncludedProbe' && propertyName === 'File' ? { schemaVersion: 1, archiveName: 'payload.bin', resourceHash: initialHash, byteLength: initialBytes.byteLength, mediaType: 'application/octet-stream' } : null) const facade = createWebCadFacade({ initialDocument: document, runtimeMode: 'mock' }) const initialResource = await facade.project.resource.put(initialBytes, 'application/octet-stream') const targetResource = await facade.project.resource.put(targetBytes, 'application/octet-stream') const archive = await facade.project.fcstd.serializeMetadataWithResources(document) const before = facade.project.fcstd.inspect(archive).objects.find((object) => object.name === 'IncludedProbe')!.properties.find((candidate) => candidate.name === 'File')! const expectedValue = document.objects[0].properties[0].value as IncludedFileValue const value: IncludedFileValue = { schemaVersion: 1, archiveName: 'payload.bin', resourceHash: targetResource.hash, byteLength: targetBytes.byteLength, mediaType: 'application/octet-stream' } const rewritten = await facade.project.fcstd.rewriteIncludedFile(archive, { objectName: 'IncludedProbe', propertyName: 'File', value, expectedValue }) const files = unzipSync(rewritten) const after = facade.project.fcstd.inspect(rewritten).objects.find((object) => object.name === 'IncludedProbe')!.properties.find((candidate) => candidate.name === 'File')! assert.deepEqual([...files['payload.bin']], [...targetBytes]) assert.equal(before.includedFileResource?.byteLength, initialBytes.byteLength) assert.equal(after.includedFileResource?.byteLength, targetBytes.byteLength) assert.equal(after.resourcePath, 'payload.bin') assert.match(new TextDecoder().decode(files['Document.xml']), /]*name="IncludedProbe")(?=[^>]*Touched="1")[^>]*\/>/) await assert.rejects(facade.project.fcstd.rewriteIncludedFile(archive, { objectName: 'IncludedProbe', propertyName: 'File', value: { ...value, archiveName: 'renamed.bin' }, expectedValue }), /cannot change the native archive name/) await assert.rejects(facade.project.fcstd.rewriteIncludedFile(rewritten, { objectName: 'IncludedProbe', propertyName: 'File', value, expectedValue }), /does not match expectedValue/) await facade.project.resource.release(initialResource.hash) await facade.project.resource.release(targetResource.hash) await facade.project.dispose() })