152 lines
12 KiB
TypeScript
152 lines
12 KiB
TypeScript
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(/<FileIncluded file="[^"]+"\/>/g) ?? []).length, 20)
|
|
for (const match of documentXml.matchAll(/<Property[^>]+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']), /<FileIncluded file=""\/>/)
|
|
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']), /<Object(?=[^>]*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()
|
|
})
|