322 lines
19 KiB
TypeScript
322 lines
19 KiB
TypeScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { createMockFacade } from '../src/facade/mockFacade'
|
|
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from '../src/facade/projectSchema'
|
|
import { PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore'
|
|
import { DependencyGraph } from '../src/facade/dependencyGraph'
|
|
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
|
|
import { createSubshapeRefs, matchSubshapes, signatureForFace } from '../src/facade/topologyNaming'
|
|
import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime'
|
|
import type { ShapeHandle } from '../src/facade/types'
|
|
|
|
test('facade exposes a stable initial document projection', () => {
|
|
const facade = createMockFacade()
|
|
const state = facade.getState()
|
|
assert.equal(state.apiVersion, '0.1')
|
|
assert.equal(state.document.id, 'doc-pump-housing')
|
|
assert.equal(state.document.tree.find((item) => item.id === 'body')?.type, 'body')
|
|
assert.equal(facade.gui.workbench.getActive(), 'Part Design')
|
|
assert.equal(facade.geometry.capabilities().provider, 'Bitbybit OCCT')
|
|
})
|
|
|
|
test('geometry boundary validates dimensions before invoking OCCT', () => {
|
|
assert.throws(() => validateBoxInput({ width: 0, length: 2, height: 3, documentId: 'doc', documentVersion: 1 }), /width/)
|
|
assert.throws(() => validateBoxInput({ width: 1, length: 2, height: 3, center: [0, Number.NaN, 0], documentId: 'doc', documentVersion: 1 }), /center/)
|
|
assert.doesNotThrow(() => validateBoxInput({ width: 1, length: 2, height: 3, documentId: 'doc', documentVersion: 0 }))
|
|
assert.throws(() => validateCylinderInput({ radius: 1, height: 2, direction: [0, 0, 0], documentId: 'doc', documentVersion: 1 }), /direction/)
|
|
assert.throws(() => validateSphereInput({ radius: -1, documentId: 'doc', documentVersion: 1 }), /radius/)
|
|
assert.throws(() => validateConeInput({ radius1: 0, radius2: 0, height: 2, documentId: 'doc', documentVersion: 1 }), /radius/)
|
|
assert.throws(() => validatePlacementInput({ shape: { id: 'shape', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 1 }, placement: { translation: [0, 0, 0], rotationAxis: [0, 0, 0], rotationAngle: 0 }, documentId: 'doc', documentVersion: 2 }), /rotationAxis/)
|
|
})
|
|
|
|
test('geometry handles reject forged document ownership', () => {
|
|
const expected: ShapeHandle = { id: 'shape-one', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-one', documentVersion: 4 }
|
|
assert.doesNotThrow(() => assertShapeHandleIntegrity({ ...expected }, expected))
|
|
assert.throws(() => assertShapeHandleIntegrity({ ...expected, documentVersion: 5 }, expected), /integrity/)
|
|
assert.throws(() => assertShapeHandleIntegrity({ ...expected, id: 'shape-two' }, expected), /integrity/)
|
|
assert.throws(() => assertShapeHandleIntegrity({ ...expected, documentId: 'doc-two' }, expected), /integrity/)
|
|
})
|
|
|
|
test('boolean geometry rejects cross-document and future-version operands', () => {
|
|
const first: ShapeHandle = { id: 'shape-one', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-one', documentVersion: 2 }
|
|
const second: ShapeHandle = { ...first, id: 'shape-two', documentVersion: 3 }
|
|
assert.doesNotThrow(() => validateBooleanUnionInput({ shapes: [first, second], documentId: 'doc-one', documentVersion: 3 }))
|
|
assert.throws(() => validateBooleanUnionInput({ shapes: [first, { ...second, documentId: 'doc-two' }], documentId: 'doc-one', documentVersion: 3 }), /another document/)
|
|
assert.throws(() => validateBooleanUnionInput({ shapes: [first, second], documentId: 'doc-one', documentVersion: 2 }), /newer/)
|
|
assert.throws(() => validateBooleanUnionInput({ shapes: [first], documentId: 'doc-one', documentVersion: 2 }), /at least 2/)
|
|
})
|
|
|
|
test('feature profiles reject degenerate and non-planar geometry', () => {
|
|
const rectangle = { outer: [[-1, 0, -1], [1, 0, -1], [1, 0, 1], [-1, 0, 1]] as [number, number, number][] }
|
|
assert.doesNotThrow(() => validatePlanarProfile(rectangle))
|
|
assert.doesNotThrow(() => validatePadInput({ profile: rectangle, length: 4, documentId: 'doc', documentVersion: 1 }))
|
|
assert.throws(() => validatePlanarProfile({ outer: [[0, 0, 0], [1, 0, 0], [2, 0, 0]] }), /collinear/)
|
|
assert.throws(() => validatePlanarProfile({ outer: [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0.1, 1]] }), /coplanar/)
|
|
assert.throws(() => validateRevolutionInput({ profile: rectangle, axisDirection: [0, 0, 0], documentId: 'doc', documentVersion: 1 }), /axisDirection/)
|
|
})
|
|
|
|
test('Bitbybit face meshes are normalized into a facade-owned indexed asset', () => {
|
|
const shape: ShapeHandle = { id: 'shape-test', kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc', documentVersion: 7 }
|
|
const mesh = normalizeBitbybitMesh(shape, {
|
|
faceList: [{ faceIndex: 1, vertexCoord: [0, 0, 0, 2, 0, 0, 0, 3, 0], normalCoord: [0, 0, 1, 0, 0, 1, 0, 0, 1], triIndexes: [0, 1, 2], numberOfTriangles: 1 }],
|
|
edgeList: [], pointsList: [],
|
|
} as never)
|
|
assert.deepEqual([...mesh.positions], [0, 0, 0, 2, 0, 0, 0, 3, 0])
|
|
assert.deepEqual([...mesh.indices], [0, 1, 2])
|
|
assert.deepEqual(mesh.bounds, { min: [0, 0, 0], max: [2, 3, 0] })
|
|
assert.equal(mesh.topologyVersion, 7)
|
|
assert.equal(mesh.subshapes?.length, 1)
|
|
assert.equal(mesh.subshapes?.[0].status, 'stable')
|
|
assert.throws(() => normalizeBitbybitMesh(shape, {
|
|
faceList: [{ faceIndex: 2, vertexCoord: [0, 0, 0], normalCoord: [], triIndexes: [0, 1, 0], numberOfTriangles: 1 }],
|
|
edgeList: [], pointsList: [],
|
|
} as never), /out-of-range/)
|
|
})
|
|
|
|
test('project schema is versioned and covers the FreeCAD document graph', () => {
|
|
assert.equal(PROJECT_SCHEMA_VERSION, 3)
|
|
assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3])
|
|
for (const table of ['projects', 'documents', 'objects', 'object_properties', 'dependencies', 'transactions', 'resources']) assert.match(PROJECT_SCHEMA_SQL, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`))
|
|
assert.match(PROJECT_SCHEMA_SQL, /CREATE INDEX IF NOT EXISTS objects_document_ordinal/)
|
|
})
|
|
|
|
test('quantity expressions convert units and reject incompatible dimensions', () => {
|
|
const result = evaluateQuantityExpression('1 in + 2 mm')
|
|
assert.equal(result.value.dimension, 'length')
|
|
assert.ok(Math.abs(result.value.value - 27.4) < 1e-9)
|
|
const variables = new Map([['pad.Length', { value: 42, dimension: 'length' as const }]])
|
|
const reference = evaluateQuantityExpression('pad.Length * 2', variables)
|
|
assert.deepEqual(reference.references, ['pad.Length'])
|
|
assert.equal(reference.value.value, 84)
|
|
assert.throws(() => evaluateQuantityExpression('1 in + 90 deg'), /Cannot add/)
|
|
assert.throws(() => evaluateQuantityExpression('1 mm / 0'), /Division by zero/)
|
|
assert.equal(evaluateQuantityExpression('2 + 3').value.value, 5)
|
|
assert.equal(quantityFromNumber(7).dimension, 'dimensionless')
|
|
assert.equal(evaluateQuantityExpression('50%').value.dimension, 'percent')
|
|
})
|
|
|
|
test('topology signatures are independent of transient face indexes and flag ambiguity', () => {
|
|
const square = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 0, 2, 0, 0, 2], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const signature = signatureForFace(square)
|
|
const sameSignature = signatureForFace({ ...square, triIndexes: [3, 0, 1, 1, 2, 3] })
|
|
assert.equal(signature.hash, sameSignature.hash)
|
|
const first = createSubshapeRefs('shape-a', 1, [square])
|
|
assert.equal(first.refs[0].status, 'stable')
|
|
const duplicate = createSubshapeRefs('shape-b', 2, [square, square])
|
|
assert.equal(duplicate.refs[0].status, 'ambiguous')
|
|
assert.deepEqual(duplicate.refs[0].candidates, [duplicate.refs[0].persistentId, duplicate.refs[1].persistentId])
|
|
const matches = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }], [{ ref: duplicate.refs[0], signature: duplicate.signatures[0] }])
|
|
assert.equal(matches[0].status, 'stable')
|
|
assert.equal(matches[0].previousId, first.refs[0].persistentId)
|
|
})
|
|
|
|
test('dependency graph propagates dirty state and orders dependencies', () => {
|
|
const graph = new DependencyGraph([
|
|
{ sourceId: 'pocket', targetId: 'pad', relation: 'link' },
|
|
{ sourceId: 'fillet', targetId: 'pocket', relation: 'link' },
|
|
{ sourceId: 'body', targetId: 'fillet', relation: 'link' },
|
|
], ['pad', 'pocket', 'fillet', 'body'])
|
|
const plan = graph.plan(['pad'])
|
|
assert.deepEqual(plan.cycles, [])
|
|
assert.deepEqual(plan.order, ['pad', 'pocket', 'fillet', 'body'])
|
|
assert.deepEqual(plan.affected, ['pad', 'pocket', 'fillet', 'body'])
|
|
graph.addEdge({ sourceId: 'pad', targetId: 'body', relation: 'expression' })
|
|
assert.deepEqual(graph.findCycles(new Set(['pad', 'pocket', 'fillet', 'body'])), [['pad', 'pocket', 'fillet', 'body']])
|
|
})
|
|
|
|
test('persistence writes are serialized and continue after a failed write', async () => {
|
|
const queue = new PersistenceWriteQueue()
|
|
let inFlight = 0
|
|
let maximumInFlight = 0
|
|
const order: string[] = []
|
|
const write = (id: string, shouldFail = false) => queue.run(async () => {
|
|
inFlight += 1
|
|
maximumInFlight = Math.max(maximumInFlight, inFlight)
|
|
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
order.push(id)
|
|
inFlight -= 1
|
|
if (shouldFail) throw new Error('expected write failure')
|
|
return id
|
|
})
|
|
const results = await Promise.allSettled([write('one'), write('two', true), write('three')])
|
|
assert.equal(maximumInFlight, 1)
|
|
assert.deepEqual(order, ['one', 'two', 'three'])
|
|
assert.equal(results[0].status, 'fulfilled')
|
|
assert.equal(results[1].status, 'rejected')
|
|
assert.equal(results[2].status, 'fulfilled')
|
|
})
|
|
|
|
test('autosave scheduler coalesces idle document changes and flushes the latest version', async () => {
|
|
const savedVersions: number[] = []
|
|
const scheduler = new ProjectAutosaveScheduler(async (document) => { savedVersions.push(document.version); return { documentId: document.id, documentVersion: document.version, persistedAt: Date.now(), mode: 'sqlite-memory' } }, 5)
|
|
const document = (version: number) => ({ id: 'doc', label: 'Autosave', version, dirty: true, readOnly: false, units: 'mm', tree: [], objects: [] })
|
|
scheduler.schedule(document(1))
|
|
scheduler.schedule(document(2))
|
|
await new Promise((resolve) => setTimeout(resolve, 15))
|
|
assert.deepEqual(savedVersions, [2])
|
|
scheduler.schedule(document(3))
|
|
const result = await scheduler.flush()
|
|
assert.equal(result?.documentVersion, 3)
|
|
scheduler.cancel()
|
|
})
|
|
|
|
test('workbench and selection changes are event driven', () => {
|
|
const facade = createMockFacade()
|
|
const events: string[] = []
|
|
const unsubscribe = facade.subscribe((event) => events.push(event.type))
|
|
facade.gui.workbench.setActive('Sketcher')
|
|
facade.selection.select('sketch')
|
|
unsubscribe()
|
|
assert.equal(facade.getState().activeWorkbench, 'Sketcher')
|
|
assert.equal(facade.selection.getObjectId(), 'sketch')
|
|
assert.ok(events.includes('state.changed'))
|
|
assert.ok(events.includes('notice'))
|
|
})
|
|
|
|
test('commands and tasks preserve apply/cancel lifecycle', () => {
|
|
const facade = createMockFacade()
|
|
facade.gui.command.execute({ commandId: 'create-sketch' })
|
|
assert.equal(facade.task.getActive()?.status, 'preview')
|
|
facade.task.update({ support: 'XY_Plane' })
|
|
assert.equal(facade.task.getActive()?.draft.support, 'XY_Plane')
|
|
facade.task.cancel()
|
|
assert.equal(facade.task.getActive()?.status, 'cancelled')
|
|
})
|
|
|
|
test('disabled commands return a reason instead of mutating the document', () => {
|
|
const facade = createMockFacade()
|
|
const before = facade.getState().document.version
|
|
const state = facade.gui.command.getState('pad')
|
|
assert.equal(state.status, 'enabled')
|
|
facade.selection.clear()
|
|
facade.gui.workbench.setActive('Sketcher')
|
|
const disabled = facade.gui.command.getState('pad')
|
|
assert.equal(disabled.status, 'disabled')
|
|
assert.match(disabled.reason || '', /Part Design|compatible object/)
|
|
facade.gui.command.execute({ commandId: 'pad' })
|
|
assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_DISABLED')
|
|
assert.equal(facade.getState().document.version, before)
|
|
})
|
|
|
|
test('command events carry a document-scoped request context', () => {
|
|
const facade = createMockFacade()
|
|
const events: string[] = []
|
|
let requestId = ''
|
|
facade.subscribe((event) => { events.push(event.type); if (event.type === 'command.started') requestId = event.context.requestId })
|
|
facade.gui.command.execute({ commandId: 'create-sketch' })
|
|
assert.ok(events.includes('command.started'))
|
|
assert.ok(events.includes('command.completed'))
|
|
assert.match(requestId, /^req-/)
|
|
})
|
|
|
|
test('document mutations use the facade history boundary', () => {
|
|
const facade = createMockFacade()
|
|
facade.gui.command.execute({ commandId: 'new-document' })
|
|
assert.equal(facade.app.document.getActive().label, 'Untitled document')
|
|
assert.equal(facade.history.canUndo(), true)
|
|
facade.history.undo()
|
|
assert.equal(facade.app.document.getActive().label, 'Pump Housing')
|
|
assert.equal(facade.history.canRedo(), true)
|
|
facade.history.redo()
|
|
assert.equal(facade.app.document.getActive().label, 'Untitled document')
|
|
})
|
|
|
|
test('typed properties are validated, versioned and undoable through the document boundary', () => {
|
|
const facade = createMockFacade()
|
|
const before = facade.app.document.getActive().version
|
|
assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Length')?.value, 42)
|
|
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 50 })
|
|
assert.equal(facade.app.document.getActive().version, before + 1)
|
|
assert.equal(facade.app.document.getActive().tree.find((item) => item.id === 'pad')?.state, 'dirty')
|
|
assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Status')?.value, 'Touched')
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: -1 }), /negative/)
|
|
assert.equal(facade.app.document.getActive().version, before + 1)
|
|
facade.history.undo()
|
|
assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Length')?.value, 42)
|
|
facade.history.redo()
|
|
assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Length')?.value, 50)
|
|
})
|
|
|
|
test('expressions are evaluated in the facade and create document dependencies', () => {
|
|
const facade = createMockFacade()
|
|
facade.app.document.setExpression({ objectId: 'pad', propertyName: 'Length', expression: '1 in + 2 mm' })
|
|
const property = facade.app.document.getObject('pad')?.properties.find((candidate) => candidate.name === 'Length')
|
|
assert.equal(property?.expression, '1 in + 2 mm')
|
|
assert.ok(Math.abs(Number(property?.value) - 27.4) < 1e-9)
|
|
assert.equal(facade.getState().document.recompute?.objectStates.pad, 'touched')
|
|
|
|
facade.app.document.setExpression({ objectId: 'pocket', propertyName: 'Length', expression: 'pad.Length * 2' })
|
|
const edge = facade.app.document.getDependencies().find((candidate) => candidate.sourceId === 'pocket' && candidate.targetId === 'pad' && candidate.relation === 'expression')
|
|
assert.equal(edge?.propertyName, 'Length')
|
|
const result = facade.app.document.recompute()
|
|
assert.equal(result.status, 'completed')
|
|
assert.deepEqual(result.order, ['pad', 'pocket', 'fillet', 'body'])
|
|
assert.equal(facade.getState().document.recompute?.dirtyObjects.length, 0)
|
|
assert.equal(facade.app.document.getObject('pocket')?.properties.find((candidate) => candidate.name === 'Length')?.value, 54.8)
|
|
})
|
|
|
|
test('expression cycles fail recompute without silently accepting a result', () => {
|
|
const facade = createMockFacade()
|
|
facade.app.document.setExpression({ objectId: 'pad', propertyName: 'Length', expression: 'pocket.Length' })
|
|
facade.app.document.setExpression({ objectId: 'pocket', propertyName: 'Length', expression: 'pad.Length' })
|
|
const result = facade.app.document.recompute()
|
|
assert.equal(result.status, 'failed')
|
|
assert.ok(result.errors.every((error) => error.code === 'DEPENDENCY_CYCLE'))
|
|
assert.equal(facade.getState().document.recompute?.objectStates.pad, 'error')
|
|
assert.equal(facade.getState().document.recompute?.objectStates.pocket, 'error')
|
|
})
|
|
|
|
test('view properties do not mark the geometry object touched', () => {
|
|
const facade = createMockFacade()
|
|
facade.app.document.setProperty({ objectId: 'fillet', propertyName: 'Visibility', value: false })
|
|
assert.equal(facade.app.document.getActive().tree.find((item) => item.id === 'fillet')?.state, 'valid')
|
|
assert.equal(facade.app.document.getObject('fillet')?.properties.find((property) => property.name === 'Visibility')?.value, false)
|
|
})
|
|
|
|
test('project persistence remains behind the facade contract', async () => {
|
|
const facade = createMockFacade()
|
|
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 55 })
|
|
const saved = await facade.project.save()
|
|
assert.equal(saved.documentId, 'doc-pump-housing')
|
|
const loaded = await facade.project.load('doc-pump-housing')
|
|
assert.equal(loaded?.label, 'Pump Housing')
|
|
assert.equal(loaded?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value, 55)
|
|
assert.equal(facade.project.capabilities().mode, 'sqlite-memory')
|
|
})
|
|
|
|
test('project resources use content identity and reference counting', async () => {
|
|
const facade = createMockFacade()
|
|
const bytes = new Uint8Array([1, 2, 3, 5, 8])
|
|
const first = await facade.project.resource.put(bytes, 'application/octet-stream')
|
|
const second = await facade.project.resource.put(bytes, 'application/octet-stream')
|
|
assert.equal(first.hash, second.hash)
|
|
assert.equal(second.refCount, 2)
|
|
bytes[0] = 99
|
|
assert.deepEqual([...((await facade.project.resource.get(first.hash)) || [])], [1, 2, 3, 5, 8])
|
|
await facade.project.resource.release(first.hash)
|
|
assert.deepEqual([...((await facade.project.resource.get(first.hash)) || [])], [1, 2, 3, 5, 8])
|
|
await facade.project.resource.release(first.hash)
|
|
assert.equal(await facade.project.resource.get(first.hash), null)
|
|
})
|
|
|
|
test('Part Design feature tasks commit a document object and remain undoable', () => {
|
|
const facade = createMockFacade()
|
|
const before = facade.app.document.getActive()
|
|
facade.gui.command.execute({ commandId: 'pad' })
|
|
assert.equal(facade.task.getActive()?.commandId, 'pad')
|
|
facade.task.update({ length: 42, reversed: false })
|
|
facade.task.apply()
|
|
|
|
const committed = facade.app.document.getActive()
|
|
const created = committed.tree.find((item) => item.id === 'pad001')
|
|
assert.equal(created?.label, 'Pad')
|
|
assert.equal(committed.version, before.version + 1)
|
|
assert.equal(facade.selection.getObjectId(), 'pad001')
|
|
assert.equal(committed.dirty, true)
|
|
|
|
facade.history.undo()
|
|
assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), false)
|
|
facade.history.redo()
|
|
assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), true)
|
|
})
|