feat: close ordered pairs and property codec batches
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

This commit is contained in:
2026-08-17 04:58:46 -04:00
parent 00ed27b7b8
commit d11566403d
265 changed files with 92107 additions and 809 deletions

View File

@@ -0,0 +1,56 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { unzipSync } from 'fflate'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { decodeFcstdPropertyValue } from '../src/facade/fcstd'
import { convertQuantity, quantityFromUnit } from '../src/facade/units'
import type { DocumentSnapshot } from '../src/facade/types'
const fixture = (): DocumentSnapshot => ({
id: 'property-acceleration-facade',
label: 'PropertyAcceleration Facade',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [
{ id: 'SourceTrajectory', label: 'Source trajectory', type: 'feature', state: 'valid' },
{ id: 'AccelerationProbe', label: 'Acceleration probe', type: 'feature', state: 'valid' },
],
objects: [
{ id: 'SourceTrajectory', typeId: 'Robot::TrajectoryObject', properties: [] },
{
id: 'AccelerationProbe',
typeId: 'Robot::TrajectoryDressUpObject',
properties: [{ name: 'Acceleration', label: 'Acceleration', group: 'TrajectoryDressUp', scope: 'data', type: 'App::PropertyAcceleration', value: 1000, unit: 'mm/s^2', recompute: true }],
},
],
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: { SourceTrajectory: 'up-to-date', AccelerationProbe: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
test('App::PropertyAcceleration uses the typed Facade and native FCStd Float codec', () => {
const facade = createWebCadFacade({ initialDocument: fixture(), initialSelectedObjectIds: ['AccelerationProbe'], runtimeMode: 'mock' })
const before = facade.app.document.getActive()
facade.app.document.setProperty({ objectId: 'AccelerationProbe', propertyName: 'Acceleration', value: 500 })
const edited = facade.app.document.getActive()
const property = edited.objects.find((object) => object.id === 'AccelerationProbe')?.properties.find((candidate) => candidate.name === 'Acceleration')
assert.equal(property?.type, 'App::PropertyAcceleration')
assert.equal(property?.value, 500)
assert.equal(property?.unit, 'mm/s^2')
assert.equal(edited.version, before.version + 1)
assert.equal(edited.dirty, true)
assert.equal(edited.recompute?.objectStates.AccelerationProbe, 'touched')
assert.throws(() => facade.app.document.setProperty({ objectId: 'AccelerationProbe', propertyName: 'Acceleration', value: '500 mm/s^2' }), /finite numeric value/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'AccelerationProbe', propertyName: 'Acceleration', value: Number.POSITIVE_INFINITY }), /finite numeric value/)
const archive = facade.project.fcstd.serializeMetadata(edited)
const documentXml = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
assert.match(documentXml, /<Object name="AccelerationProbe"><Properties Count="1" TransientCount="0"><Property name="Acceleration" type="App::PropertyAcceleration"><Float value="500"\/><\/Property><\/Properties><\/Object>/)
assert.doesNotMatch(documentXml, /<Property name="Acceleration"[^>]+group=/)
const summary = facade.project.fcstd.inspect(archive).objects.find((object) => object.name === 'AccelerationProbe')?.properties.find((candidate) => candidate.name === 'Acceleration')
assert.equal(summary?.typeId, 'App::PropertyAcceleration')
assert.equal(summary?.element, 'Float')
assert.deepEqual(decodeFcstdPropertyValue(summary!), { value: 500, decoded: true })
assert.equal(convertQuantity(quantityFromUnit(9.81, 'm/s^2'), 'mm/s^2'), 9810)
})

View File

@@ -0,0 +1,79 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createWebCadFacade } from '../src/facade/mockFacade'
import type { DocumentSnapshot, FacadeEvent } from '../src/facade/types'
const fixture = (): DocumentSnapshot => ({
id: 'property-acceleration-transaction',
label: 'PropertyAcceleration Transaction',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [{ id: 'AccelerationProbe', label: 'Acceleration probe', type: 'feature', state: 'valid' }],
objects: [{
id: 'AccelerationProbe',
typeId: 'Robot::TrajectoryDressUpObject',
properties: [{ name: 'Acceleration', label: 'Acceleration', group: 'TrajectoryDressUp', scope: 'data', type: 'App::PropertyAcceleration', value: 1000, unit: 'mm/s^2', recompute: true }],
}],
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: { AccelerationProbe: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
const acceleration = (facade: ReturnType<typeof createWebCadFacade>) => facade.app.document.getObject('AccelerationProbe')?.properties.find((property) => property.name === 'Acceleration')?.value
test('App::PropertyAcceleration transaction closes undo, redo, abort, stale, failure and resource ownership', () => {
const facade = createWebCadFacade({ initialDocument: fixture(), 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 resourcesBefore = facade.geometry.capabilities()
facade.app.document.setProperty({ objectId: 'AccelerationProbe', propertyName: 'Acceleration', value: 500, expectedDocumentVersion: 1 })
assert.equal(acceleration(facade), 500)
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
const transactionIds = events.map((event) => 'transactionId' in event ? event.transactionId : '')
assert.equal(new Set(transactionIds).size, 1)
const committed = events[2]
assert.equal(committed.type, 'transaction.committed')
if (committed.type === 'transaction.committed') assert.deepEqual({ operation: committed.operation, beforeVersion: committed.beforeVersion, afterVersion: committed.afterVersion }, { operation: 'property.set', beforeVersion: 1, afterVersion: 2 })
facade.history.undo()
assert.equal(acceleration(facade), 1000)
assert.equal(facade.app.document.getActive().version, 1)
assert.equal(facade.history.canUndo(), false)
assert.equal(facade.history.canRedo(), true)
facade.history.redo()
assert.equal(acceleration(facade), 500)
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const stableState = JSON.stringify(facade.app.document.getActive())
const stableEvents = events.length
assert.throws(() => facade.app.document.setProperty({ objectId: 'AccelerationProbe', propertyName: 'Acceleration', value: 250, expectedDocumentVersion: 1 }), /Stale document version: expected 1, current 2/)
assert.equal(JSON.stringify(facade.app.document.getActive()), stableState)
assert.equal(events.length, stableEvents)
const cancelled = new AbortController()
cancelled.abort()
assert.throws(() => facade.app.document.setProperty({ objectId: 'AccelerationProbe', propertyName: 'Acceleration', value: 250, expectedDocumentVersion: 2, signal: cancelled.signal }), (error: unknown) => error instanceof DOMException && error.name === 'AbortError')
assert.equal(JSON.stringify(facade.app.document.getActive()), stableState)
assert.equal(events.length, stableEvents)
assert.throws(() => facade.app.document.setProperty({ objectId: 'AccelerationProbe', propertyName: 'Acceleration', value: 'invalid' }), /finite numeric value/)
assert.equal(JSON.stringify(facade.app.document.getActive()), stableState)
assert.equal(events.length, stableEvents)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const resourcesAfter = facade.geometry.capabilities()
assert.deepEqual(
{ shapeCount: resourcesAfter.shapeCount, kernelReferenceCount: resourcesAfter.kernelReferenceCount, releasedShapeCount: resourcesAfter.releasedShapeCount },
{ shapeCount: resourcesBefore.shapeCount, kernelReferenceCount: resourcesBefore.kernelReferenceCount, releasedShapeCount: resourcesBefore.releasedShapeCount },
)
})

View File

@@ -0,0 +1,94 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { unzipSync } from 'fflate'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { decodeFcstdPropertyValue } from '../src/facade/fcstd'
import { encodeFreecadPropertyStatus } from '../src/facade/propertyStatus'
import type { DocumentSnapshot, FacadeEvent } from '../src/facade/types'
const outputStatus = encodeFreecadPropertyStatus(['PropReadOnly', 'PropOutput'])
const faceEntry = (persistentId: string, area: number, index: number) => ({
ref: { shapeId: 'AreaBox-shape', kind: 'face' as const, persistentId, topologyVersion: 1, status: 'stable' as const },
signature: { kind: 'face' as const, canonical: persistentId, hash: persistentId, centroid: [index, 0, 0] as [number, number, number], bounds: { min: [index, 0, 0] as [number, number, number], max: [index + 1, 1, 0] as [number, number, number] }, area, normal: [0, 0, 1] as [number, number, number] },
})
const fixture = (): DocumentSnapshot => ({
id: 'property-area-facade',
label: 'PropertyArea Facade',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [
{ id: 'AreaBox', label: 'Area box', type: 'feature', state: 'valid' },
{ id: 'AreaProbe', label: 'Area probe', type: 'feature', state: 'valid' },
],
objects: [
{
id: 'AreaBox',
typeId: 'Part::Box',
properties: [
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10, unit: 'mm', recompute: true },
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 5, unit: 'mm', recompute: true },
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 2, unit: 'mm', recompute: true },
],
topology: {
shapeId: 'AreaBox-shape',
documentVersion: 1,
generation: 1,
entries: [faceEntry('box-face-1', 10, 0), faceEntry('box-face-2', 10, 1)],
migration: { previousGeneration: null, matches: [] },
history: { operationId: 'AreaBox:1', provider: 'occt-native', relations: [], counts: { preserved: 0, modified: 0, generated: 0, deleted: 0, ambiguous: 0 } },
},
},
{
id: 'AreaProbe',
typeId: 'Measure::MeasureArea',
properties: [
{ name: 'Area', label: 'Area', group: 'Measurement', scope: 'data', type: 'App::PropertyArea', value: 10, unit: 'mm^2', nativeStatus: outputStatus, readOnly: true, recompute: false },
{ name: 'Elements', label: 'Elements', group: 'Measurement', scope: 'data', type: 'App::PropertyLinkSubList', value: { schemaVersion: 1, entries: [{ objectId: 'AreaBox', subElement: 'Face1' }] }, recompute: true },
],
},
],
dependencies: [{ sourceId: 'AreaProbe', targetId: 'AreaBox', relation: 'topo-ref', propertyName: 'Elements', reference: 'Face1' }],
recompute: { generation: 0, status: 'idle', objectStates: { AreaBox: 'up-to-date', AreaProbe: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
test('App::PropertyArea is a typed read-only Facade value with native FCStd codecs', () => {
const facade = createWebCadFacade({ initialDocument: fixture(), initialSelectedObjectIds: ['AreaProbe'], runtimeMode: 'mock' })
const events: FacadeEvent[] = []
facade.subscribe((event) => events.push(event))
const before = facade.app.document.getActive()
const area = facade.app.document.getObject('AreaProbe')?.properties.find((property) => property.name === 'Area')
assert.deepEqual({ type: area?.type, value: area?.value, unit: area?.unit, nativeStatus: area?.nativeStatus, readOnly: area?.readOnly }, { type: 'App::PropertyArea', value: 10, unit: 'mm^2', nativeStatus: outputStatus, readOnly: true })
assert.throws(() => facade.app.document.setProperty({ objectId: 'AreaProbe', propertyName: 'Area', value: 20 }), /Area is read-only/)
assert.deepEqual(facade.app.document.getActive(), before)
assert.equal(events.length, 0)
facade.app.document.setProperty({ objectId: 'AreaProbe', propertyName: 'Elements', value: { schemaVersion: 1, entries: [{ objectId: 'AreaBox', subElement: 'Face1' }, { objectId: 'AreaBox', subElement: 'Face2' }] } })
const edited = facade.app.document.getActive()
assert.equal(edited.objects.find((candidate) => candidate.id === 'AreaProbe')?.properties.find((property) => property.name === 'Area')?.value, 20)
assert.equal(edited.recompute?.objectStates.AreaProbe, 'touched')
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'state.changed', 'property.changed', 'transaction.committed', 'notice', 'state.changed'])
const stableEvents = events.length
assert.throws(() => facade.app.document.setProperty({ objectId: 'AreaProbe', propertyName: 'Elements', value: { schemaVersion: 1, entries: [{ objectId: 'AreaBox', subElement: 'Edge1' }] } }), /Cannot calculate area/)
assert.deepEqual(facade.app.document.getActive(), edited)
assert.equal(events.length, stableEvents)
const archive = facade.project.fcstd.serializeMetadata(before)
const documentXml = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
assert.match(documentXml, /<Object name="AreaProbe" type="Measure::MeasureArea"/)
assert.match(documentXml, new RegExp(`<Property name="Area" type="App::PropertyArea" status="${outputStatus}"><Float value="10"/></Property>`))
assert.match(documentXml, /<Property name="Elements" type="App::PropertyLinkSubList"><LinkSubList count="1"><Link obj="AreaBox" sub="Face1"\/><\/LinkSubList><\/Property>/)
assert.doesNotMatch(documentXml, /<Property name="(?:Area|Elements)"[^>]+group=/)
const inspection = facade.project.fcstd.inspect(archive)
const object = inspection.objects.find((candidate) => candidate.name === 'AreaProbe')
assert.equal(object?.typeId, 'Measure::MeasureArea')
assert.equal(object?.support, 'recognized')
const areaSummary = object?.properties.find((property) => property.name === 'Area')
const elementsSummary = object?.properties.find((property) => property.name === 'Elements')
assert.deepEqual({ typeId: areaSummary?.typeId, element: areaSummary?.element, nativeStatus: areaSummary?.nativeStatus, statusNames: areaSummary?.statusNames }, { typeId: 'App::PropertyArea', element: 'Float', nativeStatus: outputStatus, statusNames: ['PropReadOnly', 'PropOutput'] })
assert.deepEqual(decodeFcstdPropertyValue(areaSummary!), { value: 10, decoded: true })
assert.deepEqual(decodeFcstdPropertyValue(elementsSummary!), { value: { schemaVersion: 1, entries: [{ objectId: 'AreaBox', subElement: 'Face1' }] }, decoded: true })
})

View File

@@ -0,0 +1,100 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { encodeFreecadPropertyStatus } from '../src/facade/propertyStatus'
import type { DocumentSnapshot, FacadeEvent } from '../src/facade/types'
const faceEntry = (persistentId: string, area: number, index: number) => ({
ref: { shapeId: 'AreaBox-shape', kind: 'face' as const, persistentId, topologyVersion: 1, status: 'stable' as const },
signature: { kind: 'face' as const, canonical: persistentId, hash: persistentId, centroid: [index, 0, 0] as [number, number, number], bounds: { min: [index, 0, 0] as [number, number, number], max: [index + 1, 1, 0] as [number, number, number] }, area, normal: [0, 0, 1] as [number, number, number] },
})
const fixture = (): DocumentSnapshot => ({
id: 'property-area-transaction',
label: 'PropertyArea Transaction',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [{ id: 'AreaBox', label: 'Area box', type: 'feature', state: 'valid' }, { id: 'AreaProbe', label: 'Area probe', type: 'feature', state: 'valid' }],
objects: [
{
id: 'AreaBox',
typeId: 'Part::Box',
properties: [],
topology: {
shapeId: 'AreaBox-shape', documentVersion: 1, generation: 1,
entries: [faceEntry('box-face-1', 10, 0), faceEntry('box-face-2', 10, 1)],
migration: { previousGeneration: null, matches: [] },
history: { operationId: 'AreaBox:1', provider: 'occt-native', relations: [], counts: { preserved: 0, modified: 0, generated: 0, deleted: 0, ambiguous: 0 } },
},
},
{
id: 'AreaProbe',
typeId: 'Measure::MeasureArea',
properties: [
{ name: 'Area', label: 'Area', group: 'Measurement', scope: 'data', type: 'App::PropertyArea', value: 10, unit: 'mm^2', nativeStatus: encodeFreecadPropertyStatus(['PropReadOnly', 'PropOutput']), readOnly: true, recompute: false },
{ name: 'Elements', label: 'Elements', group: 'Measurement', scope: 'data', type: 'App::PropertyLinkSubList', value: { schemaVersion: 1, entries: [{ objectId: 'AreaBox', subElement: 'Face1' }] }, recompute: true },
],
},
],
dependencies: [{ sourceId: 'AreaProbe', targetId: 'AreaBox', relation: 'link', propertyName: 'Elements', reference: 'Elements[0]' }],
recompute: { generation: 0, status: 'idle', objectStates: { AreaBox: 'up-to-date', AreaProbe: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
const propertyValue = (facade: ReturnType<typeof createWebCadFacade>, name: 'Area' | 'Elements') => facade.app.document.getObject('AreaProbe')?.properties.find((property) => property.name === name)?.value
test('App::PropertyArea transaction closes undo, redo, stale, cancel, failure and resource ownership', async () => {
const facade = createWebCadFacade({ initialDocument: fixture(), 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 resourcesBefore = facade.geometry.capabilities()
const editedElements = { schemaVersion: 1 as const, entries: [{ objectId: 'AreaBox', subElement: 'Face1' }, { objectId: 'AreaBox', subElement: 'Face2' }] }
facade.app.document.setProperty({ objectId: 'AreaProbe', propertyName: 'Elements', value: editedElements, expectedDocumentVersion: 1 })
assert.equal(propertyValue(facade, 'Area'), 20)
assert.deepEqual(propertyValue(facade, 'Elements'), editedElements)
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.app.document.getActive().dirty, true)
assert.equal(facade.app.document.getActive().recompute?.objectStates.AreaProbe, 'touched')
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
assert.equal(new Set(events.map((event) => 'transactionId' in event ? event.transactionId : '')).size, 1)
const committed = events[2]
assert.equal(committed.type, 'transaction.committed')
if (committed.type === 'transaction.committed') assert.deepEqual({ operation: committed.operation, beforeVersion: committed.beforeVersion, afterVersion: committed.afterVersion }, { operation: 'property.set', beforeVersion: 1, afterVersion: 2 })
facade.history.undo()
assert.equal(propertyValue(facade, 'Area'), 10)
assert.deepEqual(propertyValue(facade, 'Elements'), { schemaVersion: 1, entries: [{ objectId: 'AreaBox', subElement: 'Face1' }] })
assert.equal(facade.app.document.getActive().version, 1)
assert.equal(facade.history.canUndo(), false)
assert.equal(facade.history.canRedo(), true)
facade.history.redo()
assert.equal(propertyValue(facade, 'Area'), 20)
assert.deepEqual(propertyValue(facade, 'Elements'), editedElements)
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const stableState = JSON.stringify(facade.app.document.getActive())
const stableEvents = events.length
assert.throws(() => facade.app.document.setProperty({ objectId: 'AreaProbe', propertyName: 'Elements', value: { schemaVersion: 1, entries: [] }, expectedDocumentVersion: 1 }), /Stale document version: expected 1, current 2/)
const cancelled = new AbortController()
cancelled.abort()
assert.throws(() => facade.app.document.setProperty({ objectId: 'AreaProbe', propertyName: 'Elements', value: { schemaVersion: 1, entries: [] }, expectedDocumentVersion: 2, signal: cancelled.signal }), (error: unknown) => error instanceof DOMException && error.name === 'AbortError')
assert.throws(() => facade.app.document.setProperty({ objectId: 'AreaProbe', propertyName: 'Elements', value: { schemaVersion: 1, entries: [{ objectId: 'AreaBox', subElement: 'Edge1' }] }, expectedDocumentVersion: 2 }), /Cannot calculate area/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'AreaProbe', propertyName: 'Area', value: 30, expectedDocumentVersion: 2 }), /Area is read-only/)
assert.equal(JSON.stringify(facade.app.document.getActive()), stableState)
assert.equal(events.length, stableEvents)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const resourcesAfter = facade.geometry.capabilities()
assert.deepEqual(
{ shapeCount: resourcesAfter.shapeCount, kernelReferenceCount: resourcesAfter.kernelReferenceCount, releasedShapeCount: resourcesAfter.releasedShapeCount },
{ shapeCount: resourcesBefore.shapeCount, kernelReferenceCount: resourcesBefore.kernelReferenceCount, releasedShapeCount: resourcesBefore.releasedShapeCount },
)
await facade.project.dispose()
})

View File

@@ -0,0 +1,73 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { unzipSync } from 'fflate'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { decodeFcstdPropertyValue } from '../src/facade/fcstd'
import { encodeFreecadPropertyStatus } from '../src/facade/propertyStatus'
import type { DocumentSnapshot, FacadeEvent } from '../src/facade/types'
const visibilityStatus = encodeFreecadPropertyStatus(['Immutable', 'Hidden', 'LockDynamic'])
const fixture = (): DocumentSnapshot => ({
id: 'property-boollist-facade',
label: 'PropertyBoolList Facade',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [
{ id: 'BoolListProbe', label: 'Bool list probe', type: 'feature', state: 'valid' },
{ id: 'VisibilityProbe', label: 'Visibility probe', type: 'feature', state: 'valid' },
],
objects: [
{
id: 'BoolListProbe',
typeId: 'App::FeatureTest',
properties: [{ name: 'BoolList', label: 'Bool list', group: 'Test', scope: 'data', type: 'App::PropertyBoolList', value: [false], recompute: true }],
},
{
id: 'VisibilityProbe',
typeId: 'App::Link',
properties: [{ name: 'VisibilityList', label: 'Visibility list', group: 'Link', scope: 'data', type: 'App::PropertyBoolList', value: [true, true], nativeStatus: visibilityStatus, hidden: true, readOnly: true, recompute: true }],
},
],
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: { BoolListProbe: 'up-to-date', VisibilityProbe: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
const valueOf = (facade: ReturnType<typeof createWebCadFacade>, objectId: string, propertyName: string) => facade.app.document.getObject(objectId)?.properties.find((property) => property.name === propertyName)?.value
test('App::PropertyBoolList uses typed boolean values and the native BoolList bitset codec', () => {
const facade = createWebCadFacade({ initialDocument: fixture(), initialSelectedObjectIds: ['BoolListProbe'], 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)
})
facade.app.document.setProperty({ objectId: 'BoolListProbe', propertyName: 'BoolList', value: [true, false, true] })
assert.deepEqual(valueOf(facade, 'BoolListProbe', 'BoolList'), [true, false, true])
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.app.document.getActive().recompute?.objectStates.BoolListProbe, 'touched')
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
assert.throws(() => facade.app.document.setProperty({ objectId: 'BoolListProbe', propertyName: 'BoolList', value: [true, 1 as unknown as boolean] }), /boolean list/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'BoolListProbe', propertyName: 'BoolList', value: '101' }), /boolean list/)
assert.deepEqual(valueOf(facade, 'BoolListProbe', 'BoolList'), [true, false, true])
assert.throws(() => facade.app.document.setProperty({ objectId: 'VisibilityProbe', propertyName: 'VisibilityList', value: [false, true] }), /Visibility list is read-only/)
assert.deepEqual(valueOf(facade, 'VisibilityProbe', 'VisibilityList'), [true, true])
const archive = facade.project.fcstd.serializeMetadata(facade.app.document.getActive())
const documentXml = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
assert.match(documentXml, /<Object name="BoolListProbe" type="App::FeatureTest"/)
assert.match(documentXml, /<Property name="BoolList" type="App::PropertyBoolList"><BoolList value="101"\/><\/Property>/)
assert.match(documentXml, /<Property name="VisibilityList" type="App::PropertyBoolList" status="266"><BoolList value="11"\/><\/Property>/)
assert.doesNotMatch(documentXml, /<Property name="(?:BoolList|VisibilityList)"[^>]+group=/)
const inspection = facade.project.fcstd.inspect(archive)
const object = inspection.objects.find((candidate) => candidate.name === 'BoolListProbe')
assert.equal(object?.support, 'recognized')
const summary = object?.properties.find((property) => property.name === 'BoolList')
assert.deepEqual({ typeId: summary?.typeId, element: summary?.element }, { typeId: 'App::PropertyBoolList', element: 'BoolList' })
assert.deepEqual(decodeFcstdPropertyValue(summary!), { value: [true, false, true], decoded: true })
})

View File

@@ -0,0 +1,90 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { encodeFreecadPropertyStatus } from '../src/facade/propertyStatus'
import type { DocumentSnapshot, FacadeEvent } from '../src/facade/types'
const fixture = (): DocumentSnapshot => ({
id: 'property-boollist-transaction',
label: 'PropertyBoolList Transaction',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [
{ id: 'BoolListProbe', label: 'Bool list probe', type: 'feature', state: 'valid' },
{ id: 'VisibilityProbe', label: 'Visibility probe', type: 'feature', state: 'valid' },
],
objects: [
{
id: 'BoolListProbe',
typeId: 'App::FeatureTest',
properties: [{ name: 'BoolList', label: 'Bool list', group: 'Test', scope: 'data', type: 'App::PropertyBoolList', value: [false], recompute: true }],
},
{
id: 'VisibilityProbe',
typeId: 'App::Link',
properties: [{ name: 'VisibilityList', label: 'Visibility list', group: 'Link', scope: 'data', type: 'App::PropertyBoolList', value: [true, true], nativeStatus: encodeFreecadPropertyStatus(['Immutable', 'Hidden', 'LockDynamic']), hidden: true, readOnly: true, recompute: true }],
},
],
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: { BoolListProbe: 'up-to-date', VisibilityProbe: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
const valueOf = (facade: ReturnType<typeof createWebCadFacade>, objectId = 'BoolListProbe', propertyName = 'BoolList') => facade.app.document.getObject(objectId)?.properties.find((property) => property.name === propertyName)?.value
test('App::PropertyBoolList transaction closes undo, redo, stale, cancel, failure and resource ownership', async () => {
const facade = createWebCadFacade({ initialDocument: fixture(), 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 resourcesBefore = facade.geometry.capabilities()
const requested = [true, false, true]
facade.app.document.setProperty({ objectId: 'BoolListProbe', propertyName: 'BoolList', value: requested, expectedDocumentVersion: 1 })
requested[0] = false
assert.deepEqual(valueOf(facade), [true, false, true])
const exposed = valueOf(facade) as boolean[]
exposed[1] = true
assert.deepEqual(valueOf(facade), [true, false, true])
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.app.document.getActive().dirty, true)
assert.equal(facade.app.document.getActive().recompute?.objectStates.BoolListProbe, 'touched')
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
assert.equal(new Set(events.map((event) => 'transactionId' in event ? event.transactionId : '')).size, 1)
const committed = events[2]
assert.equal(committed.type, 'transaction.committed')
if (committed.type === 'transaction.committed') assert.deepEqual({ operation: committed.operation, beforeVersion: committed.beforeVersion, afterVersion: committed.afterVersion }, { operation: 'property.set', beforeVersion: 1, afterVersion: 2 })
facade.history.undo()
assert.deepEqual(valueOf(facade), [false])
assert.equal(facade.app.document.getActive().version, 1)
assert.equal(facade.history.canUndo(), false)
assert.equal(facade.history.canRedo(), true)
facade.history.redo()
assert.deepEqual(valueOf(facade), [true, false, true])
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const stableState = JSON.stringify(facade.app.document.getActive())
const stableEvents = events.length
assert.throws(() => facade.app.document.setProperty({ objectId: 'BoolListProbe', propertyName: 'BoolList', value: [], expectedDocumentVersion: 1 }), /Stale document version: expected 1, current 2/)
const cancelled = new AbortController()
cancelled.abort()
assert.throws(() => facade.app.document.setProperty({ objectId: 'BoolListProbe', propertyName: 'BoolList', value: [], expectedDocumentVersion: 2, signal: cancelled.signal }), (error: unknown) => error instanceof DOMException && error.name === 'AbortError')
assert.throws(() => facade.app.document.setProperty({ objectId: 'BoolListProbe', propertyName: 'BoolList', value: [true, 'false' as unknown as boolean] }), /boolean list/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'VisibilityProbe', propertyName: 'VisibilityList', value: [false, true] }), /Visibility list is read-only/)
assert.equal(JSON.stringify(facade.app.document.getActive()), stableState)
assert.equal(events.length, stableEvents)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const resourcesAfter = facade.geometry.capabilities()
assert.deepEqual(
{ shapeCount: resourcesAfter.shapeCount, kernelReferenceCount: resourcesAfter.kernelReferenceCount, releasedShapeCount: resourcesAfter.releasedShapeCount },
{ shapeCount: resourcesBefore.shapeCount, kernelReferenceCount: resourcesBefore.kernelReferenceCount, releasedShapeCount: resourcesBefore.releasedShapeCount },
)
await facade.project.dispose()
})

View File

@@ -0,0 +1,107 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { unzipSync } from 'fflate'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { decodeFcstdPropertyValue, serializeFcstdMetadataArchive } from '../src/facade/fcstd'
import type { ColorValue, DocumentSnapshot, FacadeEvent, ObjectPropertySnapshot } from '../src/facade/types'
const hosts: Array<[string, string, string, ColorValue]> = [
['FeatureTestColor', 'App::FeatureTest', 'Colour', [0, 0, 0, 1]],
['FeatureTestExceptionColor', 'App::FeatureTestException', 'Colour', [0, 0, 0, 1]],
['PartColor', 'App::Part', 'Color', [1, 1, 1, 0]],
['AssemblyLinkColor', 'Assembly::AssemblyLink', 'Color', [1, 1, 1, 0]],
['AssemblyObjectColor', 'Assembly::AssemblyObject', 'Color', [1, 1, 1, 0]],
['AnnotationColor', 'TechDraw::DrawViewAnnotation', 'TextColor', [0, 0, 0, 1]],
['DraftColor', 'TechDraw::DrawViewDraft', 'Color', [0, 0, 0, 1]],
['SpreadsheetColor', 'TechDraw::DrawViewSpreadsheet', 'TextColor', [0, 0, 0, 1]],
]
const colorProperty = (name: string, value: ColorValue): ObjectPropertySnapshot => ({ name, label: name, group: 'Color', scope: 'data', type: 'App::PropertyColor', value: [...value] as ColorValue, recompute: true })
const fixture = (): DocumentSnapshot => ({
id: 'property-color-facade',
label: 'PropertyColor Facade',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: hosts.map(([id]) => ({ id, label: id, type: 'feature', state: 'valid' })),
objects: hosts.map(([id, typeId, propertyName, value], index) => ({
id,
typeId,
properties: [
colorProperty(propertyName, value),
...(index === 0 ? [{ name: 'LineColor', label: 'Line color', group: 'Appearance', scope: 'view' as const, type: 'App::PropertyColor' as const, value: '#123456' }] : []),
],
})),
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: Object.fromEntries(hosts.map(([id]) => [id, 'up-to-date'])), dirtyObjects: [], order: [], errors: [] },
})
const valueOf = (facade: ReturnType<typeof createWebCadFacade>, objectId: string, propertyName: string) => facade.app.document.getObject(objectId)?.properties.find((property) => property.name === propertyName)?.value
test('App::PropertyColor data values use four RGBA channels while view values retain #RRGGBB', () => {
const facade = createWebCadFacade({ initialDocument: fixture(), initialSelectedObjectIds: ['FeatureTestColor'], 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 target: ColorValue = [17 / 255, 34 / 255, 51 / 255, 68 / 255]
facade.app.document.setProperty({ objectId: 'FeatureTestColor', propertyName: 'Colour', value: target })
target[0] = 1
assert.deepEqual(valueOf(facade, 'FeatureTestColor', 'Colour'), [17 / 255, 34 / 255, 51 / 255, 68 / 255])
const exposed = valueOf(facade, 'FeatureTestColor', 'Colour') as number[]
exposed[1] = 1
assert.deepEqual(valueOf(facade, 'FeatureTestColor', 'Colour'), [17 / 255, 34 / 255, 51 / 255, 68 / 255])
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.app.document.getActive().recompute?.objectStates.FeatureTestColor, 'touched')
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
facade.app.document.setProperty({ objectId: 'FeatureTestColor', propertyName: 'Colour', value: [2, -1, 0.5, 1.5] })
assert.deepEqual(valueOf(facade, 'FeatureTestColor', 'Colour'), [2, -1, 0.5, 1.5])
facade.app.document.setProperty({ objectId: 'FeatureTestColor', propertyName: 'LineColor', value: '#abcdef' })
assert.equal(valueOf(facade, 'FeatureTestColor', 'LineColor'), '#abcdef')
const stable = JSON.stringify(facade.app.document.getActive())
assert.throws(() => facade.app.document.setProperty({ objectId: 'FeatureTestColor', propertyName: 'Colour', value: '#112233' }), /four finite RGBA channels/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'FeatureTestColor', propertyName: 'Colour', value: [0, 0, 0] }), /four finite RGBA channels/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'FeatureTestColor', propertyName: 'Colour', value: [0, 0, 0, Number.NaN] }), /four finite RGBA channels/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'FeatureTestColor', propertyName: 'LineColor', value: [0, 0, 0, 1] }), /#RRGGBB color/)
assert.equal(JSON.stringify(facade.app.document.getActive()), stable)
})
test('App::PropertyColor serializes all eight native hosts with packed RRGGBBAA values', () => {
const document = fixture()
;(document.objects[0].properties[0].value as ColorValue) = [17 / 255, 34 / 255, 51 / 255, 68 / 255]
const archive = serializeFcstdMetadataArchive(document)
const documentXml = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
const guiXml = new TextDecoder().decode(unzipSync(archive)['GuiDocument.xml'])
assert.equal((documentXml.match(/type="App::PropertyColor"/g) ?? []).length, 8)
assert.match(documentXml, /<Property name="Colour" type="App::PropertyColor"><PropertyColor value="287454020"\/><\/Property>/)
assert.match(documentXml, /<Property name="Color" type="App::PropertyColor"><PropertyColor value="4294967040"\/><\/Property>/)
assert.match(documentXml, /<Property name="TextColor" type="App::PropertyColor"><PropertyColor value="255"\/><\/Property>/)
assert.doesNotMatch(documentXml, /<Property name="(?:Colour|Color|TextColor)"[^>]+(?:group|doc|attr|ro|hide)=/)
assert.match(guiXml, /<Property name="LineColor" type="App::PropertyColor" status="1"><PropertyColor value="305420031"\/><\/Property>/)
const inspection = createWebCadFacade({ initialDocument: document, runtimeMode: 'mock' }).project.fcstd.inspect(archive)
assert.equal(inspection.objects.length, 8)
assert.ok(inspection.objects.every((object) => object.support === 'recognized'))
const summary = inspection.objects[0].properties.find((property) => property.name === 'Colour')
assert.deepEqual({ typeId: summary?.typeId, element: summary?.element, value: summary?.value }, { typeId: 'App::PropertyColor', element: 'PropertyColor', value: '287454020' })
assert.deepEqual(decodeFcstdPropertyValue(summary!), { value: [17 / 255, 34 / 255, 51 / 255, 68 / 255], decoded: true })
assert.equal(decodeFcstdPropertyValue({ name: 'Colour', typeId: 'App::PropertyColor', element: 'PropertyColor', value: '4294967296' }).decoded, false)
assert.equal(decodeFcstdPropertyValue({ name: 'Colour', typeId: 'App::PropertyColor', element: 'String', value: '287454020' }).decoded, false)
const target: ColorValue = [51 / 255, 102 / 255, 153 / 255, 204 / 255]
const rewritten = createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.rewriteColor(archive, {
objectName: 'FeatureTestColor',
propertyName: 'Colour',
value: target,
expectedValue: [17 / 255, 34 / 255, 51 / 255, 68 / 255],
})
const rewrittenProperty = createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.inspect(rewritten).objects[0].properties.find((property) => property.name === 'Colour')
assert.equal(rewrittenProperty?.value, '862362060')
assert.deepEqual(decodeFcstdPropertyValue(rewrittenProperty!), { value: target, decoded: true })
assert.throws(() => createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.rewriteColor(archive, { objectName: 'FeatureTestColor', propertyName: 'Colour', value: target, expectedValue: [0, 0, 0, 1] }), /does not match expectedValue/)
assert.throws(() => createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.rewriteColor(archive, { objectName: 'FeatureTestColor', propertyName: 'Colour', value: [0, 0, 0, Number.NaN] }), /four finite RGBA channels/)
})

View File

@@ -0,0 +1,137 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { unzipSync, zipSync } from 'fflate'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { decodeFcstdPropertyValue, serializeFcstdMetadataArchive } from '../src/facade/fcstd'
import type { ColorListValue, DocumentSnapshot, FacadeEvent, ObjectPropertySnapshot } from '../src/facade/types'
const hosts: Array<[string, string]> = [
['FeatureTestColorList', 'App::FeatureTest'],
['FeatureTestExceptionColorList', 'App::FeatureTestException'],
]
const colorListProperty = (value: ColorListValue): ObjectPropertySnapshot => ({
name: 'ColourList',
label: 'Colour list',
group: 'Color',
scope: 'data',
type: 'App::PropertyColorList',
value: value.map((color) => [...color]),
recompute: true,
})
const fixture = (): DocumentSnapshot => ({
id: 'property-colorlist-facade',
label: 'PropertyColorList Facade',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: hosts.map(([id]) => ({ id, label: id, type: 'feature', state: 'valid' })),
objects: hosts.map(([id, typeId]) => ({ id, typeId, properties: [colorListProperty([[0, 0, 0, 1]])] })),
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: Object.fromEntries(hosts.map(([id]) => [id, 'up-to-date'])), dirtyObjects: [], order: [], errors: [] },
})
const valueOf = (facade: ReturnType<typeof createWebCadFacade>, objectId: string) => facade.app.document.getObject(objectId)?.properties.find((property) => property.name === 'ColourList')?.value
test('App::PropertyColorList uses atomic ordered RGBA lists with deep snapshot isolation', () => {
const facade = createWebCadFacade({ initialDocument: fixture(), initialSelectedObjectIds: ['FeatureTestColorList'], 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 target: ColorListValue = [
[0.125, 0.375, 0.625, 0.875],
[17 / 255, 34 / 255, 51 / 255, 68 / 255],
]
facade.app.document.setProperty({ objectId: 'FeatureTestColorList', propertyName: 'ColourList', value: target })
target[0][0] = 1
assert.deepEqual(valueOf(facade, 'FeatureTestColorList'), [
[0.125, 0.375, 0.625, 0.875],
[17 / 255, 34 / 255, 51 / 255, 68 / 255],
])
const exposed = valueOf(facade, 'FeatureTestColorList') as ColorListValue
exposed[1][1] = 1
assert.deepEqual(valueOf(facade, 'FeatureTestColorList'), [
[0.125, 0.375, 0.625, 0.875],
[17 / 255, 34 / 255, 51 / 255, 68 / 255],
])
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.app.document.getActive().recompute?.objectStates.FeatureTestColorList, 'touched')
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
const stable = JSON.stringify(facade.app.document.getActive())
const eventCount = events.length
assert.throws(() => facade.app.document.setProperty({ objectId: 'FeatureTestColorList', propertyName: 'ColourList', value: [[0, 0, 0, Number.NaN]] }), /list of four finite RGBA channels/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'FeatureTestColorList', propertyName: 'ColourList', value: [[0, 0, 0], [0, 0, 0, 1]] as unknown as ColorListValue }), /list of four finite RGBA channels/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'FeatureTestColorList', propertyName: 'ColourList', value: '#112233' }), /list of four finite RGBA channels/)
assert.equal(JSON.stringify(facade.app.document.getActive()), stable)
assert.equal(events.length, eventCount)
})
test('App::PropertyColorList uses native external little-endian ColorList resources', () => {
const document = fixture()
document.objects[0].properties[0].value = [
[17 / 255, 34 / 255, 51 / 255, 68 / 255],
[51 / 255, 102 / 255, 153 / 255, 204 / 255],
]
document.objects[1].properties[0].value = [[0, 0, 0, 1]]
const archive = serializeFcstdMetadataArchive(document)
const files = unzipSync(archive)
const documentXml = new TextDecoder().decode(files['Document.xml'])
assert.equal((documentXml.match(/type="App::PropertyColorList"/g) ?? []).length, 2)
assert.match(documentXml, /<Property name="ColourList" type="App::PropertyColorList"><ColorList file="ColourList"\/><\/Property>/)
assert.match(documentXml, /<Property name="ColourList" type="App::PropertyColorList"><ColorList file="ColourList1"\/><\/Property>/)
assert.doesNotMatch(documentXml, /<Property name="ColourList"[^>]+(?:group|doc|attr|ro|hide)=/)
const first = new DataView(files.ColourList.buffer, files.ColourList.byteOffset, files.ColourList.byteLength)
assert.equal(first.byteLength, 12)
assert.equal(first.getUint32(0, true), 2)
assert.equal(first.getUint32(4, true), 0x11223344)
assert.equal(first.getUint32(8, true), 0x336699cc)
const second = new DataView(files.ColourList1.buffer, files.ColourList1.byteOffset, files.ColourList1.byteLength)
assert.equal(second.byteLength, 8)
assert.equal(second.getUint32(0, true), 1)
assert.equal(second.getUint32(4, true), 0x000000ff)
const inspection = createWebCadFacade({ initialDocument: document, runtimeMode: 'mock' }).project.fcstd.inspect(archive)
assert.ok(inspection.objects.every((object) => object.support === 'recognized'))
const summaries = inspection.objects.map((object) => object.properties.find((property) => property.name === 'ColourList'))
assert.ok(summaries.every((summary) => summary?.typeId === 'App::PropertyColorList' && summary.element === 'ColorList'))
assert.deepEqual(decodeFcstdPropertyValue(summaries[0]!), { value: document.objects[0].properties[0].value, decoded: true })
assert.deepEqual(decodeFcstdPropertyValue(summaries[1]!), { value: document.objects[1].properties[0].value, decoded: true })
assert.equal(decodeFcstdPropertyValue({ name: 'ColourList', typeId: 'App::PropertyColorList', element: 'String', value: '[]' }).decoded, false)
const directlyRewritten = createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.rewriteColorList(archive, {
objectName: 'FeatureTestColorList',
propertyName: 'ColourList',
value: [[1, 0.5, 0.25, 0]],
expectedValue: document.objects[0].properties[0].value as ColorListValue,
})
const directlyRewrittenSummary = createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.inspect(directlyRewritten).objects[0].properties.find((property) => property.name === 'ColourList')
assert.deepEqual(decodeFcstdPropertyValue(directlyRewrittenSummary!), { value: [[1, 128 / 255, 64 / 255, 0]], decoded: true })
assert.throws(() => createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.rewriteColorList(archive, { objectName: 'FeatureTestColorList', propertyName: 'ColourList', value: [], expectedValue: [[0, 0, 0, 1]] }), /does not match expectedValue/)
assert.throws(() => createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.rewriteColorList(archive, { objectName: 'FeatureTestColorList', propertyName: 'ColourList', value: [[0, 0, 0, Number.NaN]] }), /list of four finite RGBA channels/)
const replacement: ColorListValue = [[1, 0.5, 0.25, 0]]
document.objects[0].properties[0].value = replacement
const rewritten = createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.rewriteMetadata(archive, document)
const rewrittenSummary = createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.inspect(rewritten).objects[0].properties.find((property) => property.name === 'ColourList')
assert.deepEqual(decodeFcstdPropertyValue(rewrittenSummary!), { value: [
[1, 128 / 255, 64 / 255, 0],
], decoded: true })
assert.equal(unzipSync(rewritten).ColourList.byteLength, 8)
const emptyDocument = fixture()
emptyDocument.objects.forEach((object) => { object.properties[0].value = [] })
const emptyFiles = unzipSync(serializeFcstdMetadataArchive(emptyDocument))
assert.equal((new TextDecoder().decode(emptyFiles['Document.xml']).match(/<ColorList file=""\/>/g) ?? []).length, 2)
assert.equal(emptyFiles.ColourList, undefined)
const emptyInspection = createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.inspect(zipSync(emptyFiles))
assert.ok(emptyInspection.objects.every((object) => decodeFcstdPropertyValue(object.properties.find((property) => property.name === 'ColourList')!).decoded))
const missingResource = { ...files }
delete missingResource.ColourList
assert.throws(() => createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.inspect(zipSync(missingResource)), /references missing resource: ColourList/)
})

View File

@@ -0,0 +1,102 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { encodeFreecadPropertyStatus } from '../src/facade/propertyStatus'
import type { ColorListValue, DocumentSnapshot, FacadeEvent } from '../src/facade/types'
const fixture = (): DocumentSnapshot => ({
id: 'property-colorlist-transaction',
label: 'PropertyColorList Transaction',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [
{ id: 'ColorListProbe', label: 'Color list probe', type: 'feature', state: 'valid' },
{ id: 'ImmutableColorListProbe', label: 'Immutable color list probe', type: 'feature', state: 'valid' },
],
objects: [
{
id: 'ColorListProbe',
typeId: 'App::FeatureTest',
properties: [{ name: 'ColourList', label: 'Colour list', group: 'Test', scope: 'data', type: 'App::PropertyColorList', value: [[0, 0, 0, 1]], recompute: true }],
},
{
id: 'ImmutableColorListProbe',
typeId: 'App::FeatureTestException',
properties: [{ name: 'ColourList', label: 'Immutable colour list', group: 'Test', scope: 'data', type: 'App::PropertyColorList', value: [[1, 1, 1, 0]], nativeStatus: encodeFreecadPropertyStatus(['Immutable']), readOnly: true, recompute: true }],
},
],
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: { ColorListProbe: 'up-to-date', ImmutableColorListProbe: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
const valueOf = (facade: ReturnType<typeof createWebCadFacade>, objectId = 'ColorListProbe') => facade.app.document.getObject(objectId)?.properties.find((property) => property.name === 'ColourList')?.value
test('App::PropertyColorList transaction closes undo, redo, stale, cancel, failure and resource ownership', async () => {
const facade = createWebCadFacade({ initialDocument: fixture(), 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 resourcesBefore = facade.geometry.capabilities()
const requested: ColorListValue = [
[0.125, 0.375, 0.625, 0.875],
[17 / 255, 34 / 255, 51 / 255, 68 / 255],
]
facade.app.document.setProperty({ objectId: 'ColorListProbe', propertyName: 'ColourList', value: requested, expectedDocumentVersion: 1 })
requested[0][0] = 1
assert.deepEqual(valueOf(facade), [
[0.125, 0.375, 0.625, 0.875],
[17 / 255, 34 / 255, 51 / 255, 68 / 255],
])
const exposed = valueOf(facade) as ColorListValue
exposed[1][1] = 1
assert.deepEqual(valueOf(facade), [
[0.125, 0.375, 0.625, 0.875],
[17 / 255, 34 / 255, 51 / 255, 68 / 255],
])
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.app.document.getActive().dirty, true)
assert.equal(facade.app.document.getActive().recompute?.objectStates.ColorListProbe, 'touched')
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
assert.equal(new Set(events.map((event) => 'transactionId' in event ? event.transactionId : '')).size, 1)
const committed = events[2]
assert.equal(committed.type, 'transaction.committed')
if (committed.type === 'transaction.committed') assert.deepEqual({ operation: committed.operation, beforeVersion: committed.beforeVersion, afterVersion: committed.afterVersion }, { operation: 'property.set', beforeVersion: 1, afterVersion: 2 })
facade.history.undo()
assert.deepEqual(valueOf(facade), [[0, 0, 0, 1]])
assert.equal(facade.app.document.getActive().version, 1)
assert.equal(facade.history.canUndo(), false)
assert.equal(facade.history.canRedo(), true)
facade.history.redo()
assert.deepEqual(valueOf(facade), [
[0.125, 0.375, 0.625, 0.875],
[17 / 255, 34 / 255, 51 / 255, 68 / 255],
])
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const stableState = JSON.stringify(facade.app.document.getActive())
const stableEvents = events.length
assert.throws(() => facade.app.document.setProperty({ objectId: 'ColorListProbe', propertyName: 'ColourList', value: [], expectedDocumentVersion: 1 }), /Stale document version: expected 1, current 2/)
const cancelled = new AbortController()
cancelled.abort()
assert.throws(() => facade.app.document.setProperty({ objectId: 'ColorListProbe', propertyName: 'ColourList', value: [], expectedDocumentVersion: 2, signal: cancelled.signal }), (error: unknown) => error instanceof DOMException && error.name === 'AbortError')
assert.throws(() => facade.app.document.setProperty({ objectId: 'ColorListProbe', propertyName: 'ColourList', value: [[0, 0, 0, 1], [1, Number.NaN, 1, 1]] }), /list of four finite RGBA channels/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'ImmutableColorListProbe', propertyName: 'ColourList', value: [[0, 0, 0, 1]] }), /Immutable colour list is read-only/)
assert.equal(JSON.stringify(facade.app.document.getActive()), stableState)
assert.equal(events.length, stableEvents)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const resourcesAfter = facade.geometry.capabilities()
assert.deepEqual(
{ shapeCount: resourcesAfter.shapeCount, kernelReferenceCount: resourcesAfter.kernelReferenceCount, releasedShapeCount: resourcesAfter.releasedShapeCount },
{ shapeCount: resourcesBefore.shapeCount, kernelReferenceCount: resourcesBefore.kernelReferenceCount, releasedShapeCount: resourcesBefore.releasedShapeCount },
)
await facade.project.dispose()
})

View File

@@ -0,0 +1,90 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { encodeFreecadPropertyStatus } from '../src/facade/propertyStatus'
import type { ColorValue, DocumentSnapshot, FacadeEvent } from '../src/facade/types'
const fixture = (): DocumentSnapshot => ({
id: 'property-color-transaction',
label: 'PropertyColor Transaction',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [
{ id: 'ColorProbe', label: 'Color probe', type: 'feature', state: 'valid' },
{ id: 'ImmutableColorProbe', label: 'Immutable color probe', type: 'feature', state: 'valid' },
],
objects: [
{
id: 'ColorProbe',
typeId: 'App::FeatureTest',
properties: [{ name: 'Colour', label: 'Colour', group: 'Test', scope: 'data', type: 'App::PropertyColor', value: [0, 0, 0, 1], recompute: true }],
},
{
id: 'ImmutableColorProbe',
typeId: 'App::FeatureTest',
properties: [{ name: 'Colour', label: 'Immutable colour', group: 'Test', scope: 'data', type: 'App::PropertyColor', value: [1, 1, 1, 0], nativeStatus: encodeFreecadPropertyStatus(['Immutable']), readOnly: true, recompute: true }],
},
],
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: { ColorProbe: 'up-to-date', ImmutableColorProbe: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
const valueOf = (facade: ReturnType<typeof createWebCadFacade>, objectId = 'ColorProbe') => facade.app.document.getObject(objectId)?.properties.find((property) => property.name === 'Colour')?.value
test('App::PropertyColor transaction closes undo, redo, stale, cancel, failure and resource ownership', async () => {
const facade = createWebCadFacade({ initialDocument: fixture(), 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 resourcesBefore = facade.geometry.capabilities()
const requested: ColorValue = [0.125, 0.375, 0.625, 0.875]
facade.app.document.setProperty({ objectId: 'ColorProbe', propertyName: 'Colour', value: requested, expectedDocumentVersion: 1 })
requested[0] = 1
assert.deepEqual(valueOf(facade), [0.125, 0.375, 0.625, 0.875])
const exposed = valueOf(facade) as number[]
exposed[1] = 1
assert.deepEqual(valueOf(facade), [0.125, 0.375, 0.625, 0.875])
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.app.document.getActive().dirty, true)
assert.equal(facade.app.document.getActive().recompute?.objectStates.ColorProbe, 'touched')
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
assert.equal(new Set(events.map((event) => 'transactionId' in event ? event.transactionId : '')).size, 1)
const committed = events[2]
assert.equal(committed.type, 'transaction.committed')
if (committed.type === 'transaction.committed') assert.deepEqual({ operation: committed.operation, beforeVersion: committed.beforeVersion, afterVersion: committed.afterVersion }, { operation: 'property.set', beforeVersion: 1, afterVersion: 2 })
facade.history.undo()
assert.deepEqual(valueOf(facade), [0, 0, 0, 1])
assert.equal(facade.app.document.getActive().version, 1)
assert.equal(facade.history.canUndo(), false)
assert.equal(facade.history.canRedo(), true)
facade.history.redo()
assert.deepEqual(valueOf(facade), [0.125, 0.375, 0.625, 0.875])
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const stableState = JSON.stringify(facade.app.document.getActive())
const stableEvents = events.length
assert.throws(() => facade.app.document.setProperty({ objectId: 'ColorProbe', propertyName: 'Colour', value: [0, 0, 0, 0], expectedDocumentVersion: 1 }), /Stale document version: expected 1, current 2/)
const cancelled = new AbortController()
cancelled.abort()
assert.throws(() => facade.app.document.setProperty({ objectId: 'ColorProbe', propertyName: 'Colour', value: [0, 0, 0, 0], expectedDocumentVersion: 2, signal: cancelled.signal }), (error: unknown) => error instanceof DOMException && error.name === 'AbortError')
assert.throws(() => facade.app.document.setProperty({ objectId: 'ColorProbe', propertyName: 'Colour', value: [0, 0, 0, Number.NaN] }), /four finite RGBA channels/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'ImmutableColorProbe', propertyName: 'Colour', value: [0, 0, 0, 1] }), /Immutable colour is read-only/)
assert.equal(JSON.stringify(facade.app.document.getActive()), stableState)
assert.equal(events.length, stableEvents)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const resourcesAfter = facade.geometry.capabilities()
assert.deepEqual(
{ shapeCount: resourcesAfter.shapeCount, kernelReferenceCount: resourcesAfter.kernelReferenceCount, releasedShapeCount: resourcesAfter.releasedShapeCount },
{ shapeCount: resourcesBefore.shapeCount, kernelReferenceCount: resourcesBefore.kernelReferenceCount, releasedShapeCount: resourcesBefore.releasedShapeCount },
)
await facade.project.dispose()
})

View File

@@ -0,0 +1,101 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { unzipSync } from 'fflate'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { decodeFcstdPropertyValue, serializeFcstdMetadataArchive } from '../src/facade/fcstd'
import type { DocumentSnapshot, FacadeEvent, ObjectPropertySnapshot, VectorValue } from '../src/facade/types'
const hosts: Array<{ id: string; typeId: string; propertyName: string; group: string }> = [
{ id: 'DirectionMirror', typeId: 'Part::Mirroring', propertyName: 'Normal', group: 'Plane' },
{ id: 'DirectionProjection', typeId: 'Part::ProjectOnSurface', propertyName: 'Direction', group: 'Projection' },
]
const directionProperty = (name: string, group: string, value: VectorValue): ObjectPropertySnapshot => ({
name,
label: name,
group,
scope: 'data',
type: 'App::PropertyDirection',
value: { ...value },
unit: 'mm',
recompute: true,
})
const fixture = (): DocumentSnapshot => ({
id: 'property-direction-facade',
label: 'PropertyDirection Facade',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: hosts.map(({ id }) => ({ id, label: id, type: 'feature', state: 'valid' })),
objects: hosts.map(({ id, typeId, propertyName, group }) => ({ id, typeId, properties: [directionProperty(propertyName, group, { x: 0, y: 0, z: 1 })] })),
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: Object.fromEntries(hosts.map(({ id }) => [id, 'up-to-date'])), dirtyObjects: [], order: [], errors: [] },
})
const valueOf = (facade: ReturnType<typeof createWebCadFacade>, objectId = 'DirectionMirror') => facade.app.document.getObject(objectId)?.properties[0].value as VectorValue
test('App::PropertyDirection keeps finite non-normalized vectors isolated in Facade snapshots', () => {
const facade = createWebCadFacade({ initialDocument: fixture(), initialSelectedObjectIds: ['DirectionMirror'], 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 target = { x: 0.25, y: -0.5, z: 1.5 }
facade.app.document.setProperty({ objectId: 'DirectionMirror', propertyName: 'Normal', value: target })
target.x = 99
assert.deepEqual(valueOf(facade), { x: 0.25, y: -0.5, z: 1.5 })
const exposed = valueOf(facade)
exposed.y = 99
assert.deepEqual(valueOf(facade), { x: 0.25, y: -0.5, z: 1.5 })
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.app.document.getActive().dirty, true)
assert.equal(facade.app.document.getActive().recompute?.objectStates.DirectionMirror, 'touched')
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
facade.app.document.setProperty({ objectId: 'DirectionMirror', propertyName: 'Normal', value: { x: 0, y: 0, z: 0 } })
assert.deepEqual(valueOf(facade), { x: 0, y: 0, z: 0 })
const stable = JSON.stringify(facade.app.document.getActive())
const eventCount = events.length
assert.throws(() => facade.app.document.setProperty({ objectId: 'DirectionMirror', propertyName: 'Normal', value: { x: Number.NaN, y: 0, z: 1 } }), /components must be finite/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'DirectionMirror', propertyName: 'Normal', value: [0, 0, 1] as unknown as VectorValue }), /requires a Vector value/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'DirectionMirror', propertyName: 'Normal', value: { x: 0, y: 1 } as unknown as VectorValue }), /components must be finite/)
assert.equal(JSON.stringify(facade.app.document.getActive()), stable)
assert.equal(events.length, eventCount)
})
test('App::PropertyDirection uses its native TypeId around PropertyVector FCStd payloads', () => {
const document = fixture()
document.objects[0].properties[0].value = { x: 0.25, y: -0.5, z: 1.5 }
document.objects[1].properties[0].value = { x: 1, y: 2, z: 3 }
const archive = serializeFcstdMetadataArchive(document)
const documentXml = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
assert.match(documentXml, /<Object name="DirectionMirror" type="Part::Mirroring"/)
assert.match(documentXml, /<Property name="Normal" type="App::PropertyDirection"><PropertyVector valueX="0.25" valueY="-0.5" valueZ="1.5"\/><\/Property>/)
assert.match(documentXml, /<Object name="DirectionProjection" type="Part::ProjectOnSurface"/)
assert.match(documentXml, /<Property name="Direction" type="App::PropertyDirection"><PropertyVector valueX="1" valueY="2" valueZ="3"\/><\/Property>/)
assert.doesNotMatch(documentXml, /<Property name="(?:Normal|Direction)"[^>]+(?:group|doc|attr|ro|hide)=/)
const inspection = createWebCadFacade({ initialDocument: document, runtimeMode: 'mock' }).project.fcstd.inspect(archive)
assert.equal(inspection.objects.filter(({ support }) => support === 'recognized').length, 2)
for (const [index, host] of hosts.entries()) {
const object = inspection.objects.find(({ name }) => name === host.id)
assert.equal(object?.support, 'recognized')
const summary = object?.properties.find(({ name }) => name === host.propertyName)
assert.deepEqual({ typeId: summary?.typeId, element: summary?.element }, { typeId: 'App::PropertyDirection', element: 'PropertyVector' })
assert.deepEqual(decodeFcstdPropertyValue(summary!), { value: document.objects[index].properties[0].value, decoded: true })
}
assert.equal(decodeFcstdPropertyValue({ name: 'Normal', typeId: 'App::PropertyDirection', element: 'String', value: '{"x":0,"y":0,"z":1}' }).decoded, false)
const rewritten = createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.rewriteDirection(archive, {
objectName: 'DirectionMirror',
propertyName: 'Normal',
value: { x: 3, y: -2, z: 0.5 },
expectedValue: { x: 0.25, y: -0.5, z: 1.5 },
})
const rewrittenProperty = createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.inspect(rewritten).objects[0].properties.find(({ name }) => name === 'Normal')
assert.deepEqual(decodeFcstdPropertyValue(rewrittenProperty!), { value: { x: 3, y: -2, z: 0.5 }, decoded: true })
assert.throws(() => createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.rewriteDirection(archive, { objectName: 'DirectionMirror', propertyName: 'Normal', value: { x: 1, y: 0, z: 0 }, expectedValue: { x: 0, y: 0, z: 1 } }), /does not match expectedValue/)
assert.throws(() => createWebCadFacade({ runtimeMode: 'mock' }).project.fcstd.rewriteDirection(archive, { objectName: 'DirectionMirror', propertyName: 'Normal', value: { x: Number.NaN, y: 0, z: 1 } }), /value.x must be finite/)
})

View File

@@ -0,0 +1,90 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { encodeFreecadPropertyStatus } from '../src/facade/propertyStatus'
import type { DocumentSnapshot, FacadeEvent, VectorValue } from '../src/facade/types'
const fixture = (): DocumentSnapshot => ({
id: 'property-direction-transaction',
label: 'PropertyDirection Transaction',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [
{ id: 'DirectionProbe', label: 'Direction probe', type: 'feature', state: 'valid' },
{ id: 'ImmutableDirectionProbe', label: 'Immutable direction probe', type: 'feature', state: 'valid' },
],
objects: [
{
id: 'DirectionProbe',
typeId: 'Part::Mirroring',
properties: [{ name: 'Normal', label: 'Normal', group: 'Plane', scope: 'data', type: 'App::PropertyDirection', value: { x: 0, y: 0, z: 1 }, recompute: true }],
},
{
id: 'ImmutableDirectionProbe',
typeId: 'Part::Mirroring',
properties: [{ name: 'Normal', label: 'Immutable normal', group: 'Plane', scope: 'data', type: 'App::PropertyDirection', value: { x: 0, y: 1, z: 0 }, nativeStatus: encodeFreecadPropertyStatus(['Immutable']), readOnly: true, recompute: true }],
},
],
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: { DirectionProbe: 'up-to-date', ImmutableDirectionProbe: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
const valueOf = (facade: ReturnType<typeof createWebCadFacade>, objectId = 'DirectionProbe') => facade.app.document.getObject(objectId)?.properties.find((property) => property.name === 'Normal')?.value as VectorValue
test('App::PropertyDirection transaction closes undo, redo, stale, cancel, failure and resource ownership', async () => {
const facade = createWebCadFacade({ initialDocument: fixture(), 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 resourcesBefore = facade.geometry.capabilities()
const requested: VectorValue = { x: 0.25, y: -0.5, z: 1.5 }
facade.app.document.setProperty({ objectId: 'DirectionProbe', propertyName: 'Normal', value: requested, expectedDocumentVersion: 1 })
requested.x = 99
assert.deepEqual(valueOf(facade), { x: 0.25, y: -0.5, z: 1.5 })
const exposed = valueOf(facade)
exposed.y = 99
assert.deepEqual(valueOf(facade), { x: 0.25, y: -0.5, z: 1.5 })
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.app.document.getActive().dirty, true)
assert.equal(facade.app.document.getActive().recompute?.objectStates.DirectionProbe, 'touched')
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
assert.equal(new Set(events.map((event) => 'transactionId' in event ? event.transactionId : '')).size, 1)
const committed = events[2]
assert.equal(committed.type, 'transaction.committed')
if (committed.type === 'transaction.committed') assert.deepEqual({ operation: committed.operation, beforeVersion: committed.beforeVersion, afterVersion: committed.afterVersion }, { operation: 'property.set', beforeVersion: 1, afterVersion: 2 })
facade.history.undo()
assert.deepEqual(valueOf(facade), { x: 0, y: 0, z: 1 })
assert.equal(facade.app.document.getActive().version, 1)
assert.equal(facade.history.canUndo(), false)
assert.equal(facade.history.canRedo(), true)
facade.history.redo()
assert.deepEqual(valueOf(facade), { x: 0.25, y: -0.5, z: 1.5 })
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const stableState = JSON.stringify(facade.app.document.getActive())
const stableEvents = events.length
assert.throws(() => facade.app.document.setProperty({ objectId: 'DirectionProbe', propertyName: 'Normal', value: { x: 0, y: 0, z: 0 }, expectedDocumentVersion: 1 }), /Stale document version: expected 1, current 2/)
const cancelled = new AbortController()
cancelled.abort()
assert.throws(() => facade.app.document.setProperty({ objectId: 'DirectionProbe', propertyName: 'Normal', value: { x: 0, y: 0, z: 0 }, expectedDocumentVersion: 2, signal: cancelled.signal }), (error: unknown) => error instanceof DOMException && error.name === 'AbortError')
assert.throws(() => facade.app.document.setProperty({ objectId: 'DirectionProbe', propertyName: 'Normal', value: { x: 0, y: Number.POSITIVE_INFINITY, z: 1 } }), /components must be finite/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'ImmutableDirectionProbe', propertyName: 'Normal', value: { x: 0, y: 0, z: 1 } }), /Immutable normal is read-only/)
assert.equal(JSON.stringify(facade.app.document.getActive()), stableState)
assert.equal(events.length, stableEvents)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const resourcesAfter = facade.geometry.capabilities()
assert.deepEqual(
{ shapeCount: resourcesAfter.shapeCount, kernelReferenceCount: resourcesAfter.kernelReferenceCount, releasedShapeCount: resourcesAfter.releasedShapeCount },
{ shapeCount: resourcesBefore.shapeCount, kernelReferenceCount: resourcesBefore.kernelReferenceCount, releasedShapeCount: resourcesBefore.releasedShapeCount },
)
await facade.project.dispose()
})

View File

@@ -0,0 +1,67 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { unzipSync } from 'fflate'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { decodeFcstdPropertyValue } from '../src/facade/fcstd'
import type { DocumentSnapshot, FacadeEvent } from '../src/facade/types'
const fixture = (): DocumentSnapshot => ({
id: 'property-file-facade',
label: 'PropertyFile Facade',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [{ id: 'FileProbe', label: 'File probe', type: 'feature', state: 'valid' }],
objects: [{ id: 'FileProbe', typeId: 'Mesh::Import', properties: [{ name: 'FileName', label: 'File name', group: '', scope: 'data', type: 'App::PropertyFile', value: 'mesh-data/cube.stl', recompute: true }] }],
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: { FileProbe: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
test('App::PropertyFile accepts only project-relative resource references through the Facade', () => {
const facade = createWebCadFacade({ initialDocument: fixture(), initialSelectedObjectIds: ['FileProbe'], 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)
})
facade.app.document.setProperty({ objectId: 'FileProbe', propertyName: 'FileName', value: 'project-files/updated.stl' })
assert.equal(facade.app.document.getObject('FileProbe')?.properties[0].value, 'project-files/updated.stl')
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.app.document.getActive().dirty, true)
assert.equal(facade.app.document.getActive().recompute?.objectStates.FileProbe, 'touched')
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
const stable = JSON.stringify(facade.app.document.getActive())
for (const value of ['/home/user/model.stl', '../model.stl', 'folder/../model.stl', 'C:\\model.stl', 'https://example.test/model.stl', 'folder\\model.stl', 'folder//model.stl', `model\0.stl`]) {
assert.throws(() => facade.app.document.setProperty({ objectId: 'FileProbe', propertyName: 'FileName', value }), /safe project-relative file reference/)
assert.equal(JSON.stringify(facade.app.document.getActive()), stable)
}
assert.throws(() => facade.app.document.setProperty({ objectId: 'FileProbe', propertyName: 'FileName', value: 42 as unknown as string }), /project-relative file reference/)
})
test('App::PropertyFile uses the native String element and keeps unsafe host paths non-editable', () => {
const document = fixture()
const facade = createWebCadFacade({ initialDocument: document, runtimeMode: 'mock' })
const archive = facade.project.fcstd.serializeMetadata(document)
const documentXml = new TextDecoder().decode(unzipSync(archive)['Document.xml'])
assert.match(documentXml, /<Object name="FileProbe" type="Mesh::Import"/)
assert.match(documentXml, /<Property name="FileName" type="App::PropertyFile"><String value="mesh-data\/cube\.stl"\/><\/Property>/)
const inspection = facade.project.fcstd.inspect(archive)
const object = inspection.objects.find(({ name }) => name === 'FileProbe')
assert.equal(object?.support, 'recognized')
const summary = object?.properties.find(({ name }) => name === 'FileName')
assert.deepEqual({ typeId: summary?.typeId, element: summary?.element }, { typeId: 'App::PropertyFile', element: 'String' })
assert.deepEqual(decodeFcstdPropertyValue(summary!), { value: 'mesh-data/cube.stl', decoded: true })
assert.equal(decodeFcstdPropertyValue({ name: 'FileName', typeId: 'App::PropertyFile', element: 'String', value: '/home/user/model.stl' }).decoded, false)
assert.equal(decodeFcstdPropertyValue({ name: 'FileName', typeId: 'App::PropertyFile', element: 'PropertyFile', value: 'mesh-data/cube.stl' }).decoded, false)
const rewritten = facade.project.fcstd.rewriteFile(archive, { objectName: 'FileProbe', propertyName: 'FileName', value: 'project-files/rewritten.stl', expectedValue: 'mesh-data/cube.stl' })
const rewrittenProperty = facade.project.fcstd.inspect(rewritten).objects[0].properties.find(({ name }) => name === 'FileName')
assert.deepEqual(decodeFcstdPropertyValue(rewrittenProperty!), { value: 'project-files/rewritten.stl', decoded: true })
assert.match(new TextDecoder().decode(unzipSync(rewritten)['Document.xml']), /<Object(?=[^>]*name="FileProbe")(?=[^>]*type="Mesh::Import")(?=[^>]*Touched="1")[^>]*\/>/)
assert.throws(() => facade.project.fcstd.rewriteFile(archive, { objectName: 'FileProbe', propertyName: 'FileName', value: 'project-files/rewritten.stl', expectedValue: 'mesh-data/other.stl' }), /does not match expectedValue/)
assert.throws(() => facade.project.fcstd.rewriteFile(archive, { objectName: 'FileProbe', propertyName: 'FileName', value: '/home/user/model.stl' }), /safe project-relative file reference/)
document.objects[0].properties[0].value = '/home/user/model.stl'
assert.throws(() => facade.project.fcstd.serializeMetadata(document), /safe project-relative file reference/)
})

View File

@@ -0,0 +1,151 @@
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()
})

View File

@@ -0,0 +1,126 @@
import { createHash } from 'node:crypto'
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { encodeFreecadPropertyStatus } from '../src/facade/propertyStatus'
import type { DocumentSnapshot, FacadeEvent, IncludedFileValue } from '../src/facade/types'
const fixture = (): DocumentSnapshot => ({
id: 'property-fileincluded-transaction',
label: 'PropertyFileIncluded Transaction',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [
{ id: 'IncludedProbe', label: 'Included file probe', type: 'feature', state: 'valid' },
{ id: 'ImmutableIncludedProbe', label: 'Immutable included file probe', type: 'feature', state: 'valid' },
],
objects: [
{
id: 'IncludedProbe',
typeId: 'App::DocumentObjectFileIncluded',
properties: [{ name: 'File', label: 'Included file', group: 'Resource', scope: 'data', type: 'App::PropertyFileIncluded', value: null, recompute: true }],
},
{
id: 'ImmutableIncludedProbe',
typeId: 'App::DocumentObjectFileIncluded',
properties: [{ name: 'File', label: 'Immutable included file', group: 'Resource', scope: 'data', type: 'App::PropertyFileIncluded', value: null, nativeStatus: encodeFreecadPropertyStatus(['Immutable']), readOnly: true, recompute: true }],
},
],
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: { IncludedProbe: 'up-to-date', ImmutableIncludedProbe: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
const valueOf = (facade: ReturnType<typeof createWebCadFacade>) => facade.app.document.getObject('IncludedProbe')?.properties.find((property) => property.name === 'File')?.value as IncludedFileValue | null
const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
test('App::PropertyFileIncluded transaction closes undo, redo, stale, abort, failure and resource ownership', async () => {
const facade = createWebCadFacade({ initialDocument: fixture(), 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 resourcesBefore = facade.geometry.capabilities()
const requested = new Uint8Array([0, 17, 34, 51, 255])
const committedValue = await facade.app.document.setIncludedFile({ objectId: 'IncludedProbe', propertyName: 'File', fileName: 'payload.bin', bytes: requested, expectedDocumentVersion: 1 })
requested[0] = 99
assert.deepEqual(valueOf(facade), committedValue)
assert.deepEqual([...(await facade.project.resource.get(committedValue.resourceHash))!], [0, 17, 34, 51, 255])
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.app.document.getActive().dirty, true)
assert.equal(facade.app.document.getActive().recompute?.objectStates.IncludedProbe, 'touched')
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
assert.equal(new Set(events.map((event) => 'transactionId' in event ? event.transactionId : '')).size, 1)
const committed = events[2]
assert.equal(committed.type, 'transaction.committed')
if (committed.type === 'transaction.committed') assert.deepEqual({ operation: committed.operation, beforeVersion: committed.beforeVersion, afterVersion: committed.afterVersion }, { operation: 'property.set', beforeVersion: 1, afterVersion: 2 })
facade.history.undo()
assert.equal(valueOf(facade), null)
assert.equal(facade.app.document.getActive().version, 1)
assert.equal(facade.history.canUndo(), false)
assert.equal(facade.history.canRedo(), true)
assert.deepEqual([...(await facade.project.resource.get(committedValue.resourceHash))!], [0, 17, 34, 51, 255])
facade.history.redo()
assert.deepEqual(valueOf(facade), committedValue)
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const stableState = JSON.stringify(facade.app.document.getActive())
const stableEvents = events.length
await assert.rejects(facade.app.document.setIncludedFile({ objectId: 'IncludedProbe', propertyName: 'File', fileName: 'stale.bin', bytes: new Uint8Array([1]), expectedDocumentVersion: 1 }), /Stale document version: expected 1, current 2/)
const preCancelled = new AbortController()
preCancelled.abort()
await assert.rejects(facade.app.document.setIncludedFile({ objectId: 'IncludedProbe', propertyName: 'File', fileName: 'pre-cancelled.bin', bytes: new Uint8Array([2]), expectedDocumentVersion: 2, signal: preCancelled.signal }), (error: unknown) => error instanceof DOMException && error.name === 'AbortError')
const cancelledBytes = new Uint8Array([3, 4, 5, 6])
const cancelledHash = sha256(cancelledBytes)
const cancelled = new AbortController()
const cancelledMutation = facade.app.document.setIncludedFile({ objectId: 'IncludedProbe', propertyName: 'File', fileName: 'cancelled.bin', bytes: cancelledBytes, expectedDocumentVersion: 2, signal: cancelled.signal })
cancelled.abort()
await assert.rejects(cancelledMutation, (error: unknown) => error instanceof DOMException && error.name === 'AbortError')
assert.equal(await facade.project.resource.get(cancelledHash), null)
const invalidBytes = new Uint8Array([7, 8, 9])
const invalidHash = sha256(invalidBytes)
await assert.rejects(facade.app.document.setIncludedFile({ objectId: 'IncludedProbe', propertyName: 'File', fileName: 'invalid.bin', bytes: invalidBytes, mediaType: 'invalid', expectedDocumentVersion: 2 }), /simple MIME type/)
assert.equal(await facade.project.resource.get(invalidHash), null)
await assert.rejects(facade.app.document.setIncludedFile({ objectId: 'ImmutableIncludedProbe', propertyName: 'File', fileName: 'locked.bin', bytes: new Uint8Array([10]), expectedDocumentVersion: 2 }), /Immutable included file is read-only/)
assert.equal(JSON.stringify(facade.app.document.getActive()), stableState)
assert.equal(events.length, stableEvents)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const raceLeft = new Uint8Array([11, 12, 13])
const raceRight = new Uint8Array([14, 15, 16])
const race = await Promise.allSettled([
facade.app.document.setIncludedFile({ objectId: 'IncludedProbe', propertyName: 'File', fileName: 'race-left.bin', bytes: raceLeft, expectedDocumentVersion: 2 }),
facade.app.document.setIncludedFile({ objectId: 'IncludedProbe', propertyName: 'File', fileName: 'race-right.bin', bytes: raceRight, expectedDocumentVersion: 2 }),
])
assert.equal(race.filter((result) => result.status === 'fulfilled').length, 1)
assert.equal(race.filter((result) => result.status === 'rejected').length, 1)
const winner = race.find((result): result is PromiseFulfilledResult<IncludedFileValue> => result.status === 'fulfilled')!.value
const loserHash = winner.resourceHash === sha256(raceLeft) ? sha256(raceRight) : sha256(raceLeft)
assert.deepEqual(valueOf(facade), winner)
assert.ok(await facade.project.resource.get(winner.resourceHash))
assert.equal(await facade.project.resource.get(loserHash), null)
assert.equal(facade.app.document.getActive().version, 3)
assert.deepEqual(events.slice(stableEvents).map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
facade.history.undo()
assert.deepEqual(valueOf(facade), committedValue)
assert.ok(await facade.project.resource.get(winner.resourceHash))
facade.history.redo()
assert.deepEqual(valueOf(facade), winner)
const resourcesAfter = facade.geometry.capabilities()
assert.deepEqual(
{ shapeCount: resourcesAfter.shapeCount, kernelReferenceCount: resourcesAfter.kernelReferenceCount, releasedShapeCount: resourcesAfter.releasedShapeCount },
{ shapeCount: resourcesBefore.shapeCount, kernelReferenceCount: resourcesBefore.kernelReferenceCount, releasedShapeCount: resourcesBefore.releasedShapeCount },
)
await facade.project.dispose()
})

View File

@@ -0,0 +1,86 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createWebCadFacade } from '../src/facade/mockFacade'
import { encodeFreecadPropertyStatus } from '../src/facade/propertyStatus'
import type { DocumentSnapshot, FacadeEvent } from '../src/facade/types'
const fixture = (): DocumentSnapshot => ({
id: 'property-file-transaction',
label: 'PropertyFile Transaction',
version: 1,
dirty: false,
readOnly: false,
units: 'mm',
tree: [
{ id: 'FileProbe', label: 'File probe', type: 'feature', state: 'valid' },
{ id: 'ImmutableFileProbe', label: 'Immutable file probe', type: 'feature', state: 'valid' },
],
objects: [
{
id: 'FileProbe',
typeId: 'Mesh::Import',
properties: [{ name: 'FileName', label: 'File name', group: '', scope: 'data', type: 'App::PropertyFile', value: 'mesh-data/cube.stl', recompute: true }],
},
{
id: 'ImmutableFileProbe',
typeId: 'Mesh::Import',
properties: [{ name: 'FileName', label: 'Immutable file name', group: '', scope: 'data', type: 'App::PropertyFile', value: 'mesh-data/locked.stl', nativeStatus: encodeFreecadPropertyStatus(['Immutable']), readOnly: true, recompute: true }],
},
],
dependencies: [],
recompute: { generation: 0, status: 'idle', objectStates: { FileProbe: 'up-to-date', ImmutableFileProbe: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
})
const valueOf = (facade: ReturnType<typeof createWebCadFacade>, objectId = 'FileProbe') => facade.app.document.getObject(objectId)?.properties.find((property) => property.name === 'FileName')?.value
test('App::PropertyFile transaction closes undo, redo, stale, cancel, failure and resource ownership', async () => {
const facade = createWebCadFacade({ initialDocument: fixture(), 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 resourcesBefore = facade.geometry.capabilities()
facade.app.document.setProperty({ objectId: 'FileProbe', propertyName: 'FileName', value: 'project-files/updated.stl', expectedDocumentVersion: 1 })
assert.equal(valueOf(facade), 'project-files/updated.stl')
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.app.document.getActive().dirty, true)
assert.equal(facade.app.document.getActive().recompute?.objectStates.FileProbe, 'touched')
assert.deepEqual(events.map((event) => event.type), ['property.before-change', 'property.changed', 'transaction.committed'])
assert.equal(new Set(events.map((event) => 'transactionId' in event ? event.transactionId : '')).size, 1)
const committed = events[2]
assert.equal(committed.type, 'transaction.committed')
if (committed.type === 'transaction.committed') assert.deepEqual({ operation: committed.operation, beforeVersion: committed.beforeVersion, afterVersion: committed.afterVersion }, { operation: 'property.set', beforeVersion: 1, afterVersion: 2 })
facade.history.undo()
assert.equal(valueOf(facade), 'mesh-data/cube.stl')
assert.equal(facade.app.document.getActive().version, 1)
assert.equal(facade.history.canUndo(), false)
assert.equal(facade.history.canRedo(), true)
facade.history.redo()
assert.equal(valueOf(facade), 'project-files/updated.stl')
assert.equal(facade.app.document.getActive().version, 2)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const stableState = JSON.stringify(facade.app.document.getActive())
const stableEvents = events.length
assert.throws(() => facade.app.document.setProperty({ objectId: 'FileProbe', propertyName: 'FileName', value: 'project-files/stale.stl', expectedDocumentVersion: 1 }), /Stale document version: expected 1, current 2/)
const cancelled = new AbortController()
cancelled.abort()
assert.throws(() => facade.app.document.setProperty({ objectId: 'FileProbe', propertyName: 'FileName', value: 'project-files/cancelled.stl', expectedDocumentVersion: 2, signal: cancelled.signal }), (error: unknown) => error instanceof DOMException && error.name === 'AbortError')
assert.throws(() => facade.app.document.setProperty({ objectId: 'FileProbe', propertyName: 'FileName', value: '../outside.stl' }), /safe project-relative file reference/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'FileProbe', propertyName: 'FileName', value: `${'a'.repeat(4097)}.stl` }), /file reference is too long/)
assert.throws(() => facade.app.document.setProperty({ objectId: 'ImmutableFileProbe', propertyName: 'FileName', value: 'project-files/unlocked.stl' }), /Immutable file name is read-only/)
assert.equal(JSON.stringify(facade.app.document.getActive()), stableState)
assert.equal(events.length, stableEvents)
assert.equal(facade.history.canUndo(), true)
assert.equal(facade.history.canRedo(), false)
const resourcesAfter = facade.geometry.capabilities()
assert.deepEqual(
{ shapeCount: resourcesAfter.shapeCount, kernelReferenceCount: resourcesAfter.kernelReferenceCount, releasedShapeCount: resourcesAfter.releasedShapeCount },
{ shapeCount: resourcesBefore.shapeCount, kernelReferenceCount: resourcesBefore.kernelReferenceCount, releasedShapeCount: resourcesBefore.releasedShapeCount },
)
await facade.project.dispose()
})