Files
Web_FreeCAD_Bitbybit/tests/propertyFileIncludedFacade.test.ts
wangdequan d11566403d
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled
feat: close ordered pairs and property codec batches
2026-08-17 04:58:46 -04:00

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()
})