1477 lines
94 KiB
TypeScript
1477 lines
94 KiB
TypeScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { strToU8, zipSync } from 'fflate'
|
|
import { createMockFacade } from '../src/facade/mockFacade'
|
|
import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations } from '../src/facade/projectSchema'
|
|
import { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore'
|
|
import { DependencyGraph } from '../src/facade/dependencyGraph'
|
|
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
|
|
import { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from '../src/facade/topologyNaming'
|
|
import { createPersistedTopoRef, migrateDocumentTopologyReferences, migrateTopoRefs, parseTopoRef, resolveTopoRef, serializeTopoRef } from '../src/facade/topologyReferences'
|
|
import { captureSignatureTopologyHistory } from '../src/facade/topologyHistory'
|
|
import { assessResourceQuota, planResourceSweep } from '../src/facade/resourcePolicy'
|
|
import { cloneSketch, createSketch, solveSketch } from '../src/facade/sketcher'
|
|
import { BasicSketchSolverProvider, SKETCH_SOLVER_PROTOCOL_VERSION, SketchSolverCoordinator, SketchSolverUnavailableError, UnavailablePlanegcsProvider, runSketchSolverReplay, type SketchSolverProvider, type SketchSolverRequest } from '../src/facade/sketchSolverProtocol'
|
|
import { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeGeometryRuntime } from '../src/facade/recomputeEngine'
|
|
import { inspectFcstdArchive } from '../src/facade/fcstd'
|
|
import { assertShapeHandleIntegrity, normalizeBitbybitMesh, validateBooleanUnionInput, validateBoxInput, validateChamferInput, validateConeInput, validateCylinderInput, validateFilletInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validateRevolutionInput, validateSphereInput } from '../src/facade/geometryRuntime'
|
|
import type { DocumentObjectSnapshot, DocumentSnapshot, ObjectTopologySnapshot, ShapeHandle, SubshapeTopology } from '../src/facade/types'
|
|
|
|
const recomputeDocumentFixture = (edges: DocumentSnapshot['dependencies'] = []): DocumentSnapshot => ({
|
|
id: 'doc-recompute',
|
|
label: 'Recompute fixture',
|
|
version: 1,
|
|
dirty: true,
|
|
readOnly: false,
|
|
units: 'mm',
|
|
tree: [
|
|
{ id: 'root', label: 'Root', type: 'feature', state: 'dirty' },
|
|
{ id: 'child', label: 'Child', type: 'feature', state: 'dirty' },
|
|
],
|
|
objects: [
|
|
{ id: 'root', typeId: 'Part::Feature', properties: [] },
|
|
{ id: 'child', typeId: 'Part::Feature', properties: [] },
|
|
],
|
|
dependencies: edges,
|
|
recompute: {
|
|
generation: 0,
|
|
status: 'idle',
|
|
objectStates: { root: 'touched', child: 'touched' },
|
|
dirtyObjects: ['root'],
|
|
order: [],
|
|
errors: [],
|
|
},
|
|
})
|
|
|
|
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/)
|
|
assert.throws(() => validateFilletInput({ base: first, radius: 0, documentId: 'doc-one', documentVersion: 2 }), /radius/)
|
|
assert.throws(() => validateChamferInput({ base: first, distance: 1, indexes: [-1], documentId: 'doc-one', documentVersion: 2 }), /indexes/)
|
|
})
|
|
|
|
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, 6)
|
|
assert.deepEqual(PROJECT_SCHEMA_MIGRATIONS.map((migration) => migration.version), [1, 2, 3, 4, 5, 6])
|
|
for (const table of ['projects', 'documents', 'objects', 'object_properties', 'dependencies', 'transactions', 'resources', 'document_checkpoints']) 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('project migrations are ordered, skip applied versions and roll back atomically', () => {
|
|
const calls: string[] = []
|
|
const applied = new Set([1])
|
|
const transaction = {
|
|
begin: () => { calls.push('begin') },
|
|
isApplied: (version: number) => applied.has(version),
|
|
execute: (sql: string) => { calls.push(`execute:${sql}`); if (sql === 'broken') throw new Error('migration failed') },
|
|
markApplied: (version: number) => { calls.push(`mark:${version}`); applied.add(version) },
|
|
commit: () => { calls.push('commit') },
|
|
rollback: () => { calls.push('rollback') },
|
|
}
|
|
assert.deepEqual(runProjectSchemaMigrations(transaction, [{ version: 2, sql: 'second' }, { version: 1, sql: 'first' }, { version: 3, sql: 'third' }], 123), [2, 3])
|
|
assert.deepEqual(calls, ['begin', 'execute:second', 'mark:2', 'execute:third', 'mark:3', 'commit'])
|
|
calls.length = 0
|
|
assert.throws(() => runProjectSchemaMigrations(transaction, [{ version: 4, sql: 'fourth' }, { version: 5, sql: 'broken' }], 124), /migration failed/)
|
|
assert.deepEqual(calls, ['begin', 'execute:fourth', 'mark:4', 'execute:broken', 'rollback'])
|
|
assert.throws(() => runProjectSchemaMigrations(transaction, [{ version: 6, sql: 'one' }, { version: 6, sql: 'duplicate' }]), /duplicate/)
|
|
})
|
|
|
|
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')
|
|
assert.ok(Math.abs(evaluateQuantityExpression('sin(90 deg)').value.value - 1) < 1e-12)
|
|
assert.equal(evaluateQuantityExpression('max(2 mm, 1 cm)').value.value, 10)
|
|
assert.equal(evaluateQuantityExpression('clamp(15, 0, 10)').value.value, 10)
|
|
assert.equal(evaluateQuantityExpression('round(2.6)').value.value, 3)
|
|
assert.equal(evaluateQuantityExpression('2 mm ^ 2').value.dimension, 'area')
|
|
assert.equal(evaluateQuantityExpression('2 mm ^ 3').value.dimension, 'volume')
|
|
assert.equal(evaluateQuantityExpression('1 cm2 + 100 mm2').value.value, 200)
|
|
assert.equal(evaluateQuantityExpression('2 ^ 3').value.value, 8)
|
|
assert.throws(() => evaluateQuantityExpression('sin(2 mm)'), /angle or dimensionless/)
|
|
assert.throws(() => evaluateQuantityExpression('unknown(1)'), /Unknown expression function/)
|
|
assert.throws(() => evaluateQuantityExpression('2 mm ^ 0.5'), /dimensionless integer/)
|
|
})
|
|
|
|
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])
|
|
assert.notEqual(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, 'ambiguous')
|
|
const shifted = { ...square, vertexCoord: square.vertexCoord.map((value, index) => index % 3 === 0 ? value + 10 : value) }
|
|
const previousShifted = createSubshapeRefs('shape-shifted', 1, [shifted])
|
|
const currentSingle = createSubshapeRefs('shape-current', 2, [square])
|
|
const deleted = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }, { ref: previousShifted.refs[0], signature: previousShifted.signatures[0] }], [{ ref: currentSingle.refs[0], signature: currentSingle.signatures[0] }])
|
|
assert.equal(deleted.at(-1)?.status, 'deleted')
|
|
|
|
const translated = { ...square, vertexCoord: square.vertexCoord.map((value, index) => index % 3 === 0 ? value + 5 : value) }
|
|
const moved = createSubshapeRefs('shape-moved', 3, [translated])
|
|
const movedMatches = matchSubshapes([{ ref: first.refs[0], signature: first.signatures[0] }], [{ ref: moved.refs[0], signature: moved.signatures[0] }])
|
|
assert.equal(movedMatches[0].status, 'stable')
|
|
assert.equal(movedMatches[0].previousId, first.refs[0].persistentId)
|
|
})
|
|
|
|
test('versioned TopoRefs serialize without transient indexes and resolve migration states', () => {
|
|
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const previous = createSubshapeRefs('shape-old', 4, [face])
|
|
const record = createPersistedTopoRef('pad', previous.refs[0], 7)
|
|
const restored = parseTopoRef(serializeTopoRef(record))
|
|
assert.deepEqual(restored, record)
|
|
assert.equal(resolveTopoRef(restored, previous.refs).status, 'resolved')
|
|
assert.throws(() => parseTopoRef(JSON.stringify({ ...record, faceIndex: 1 })), /must not persist transient faceIndex/)
|
|
|
|
const split = createSubshapeRefs('shape-new', 5, [face, face])
|
|
const migration = migrateTopoRefs('pad', [{ ref: previous.refs[0], signature: previous.signatures[0] }], split.refs.map((ref, index) => ({ ref, signature: split.signatures[index] })), 8)
|
|
assert.equal(migration.counts.ambiguous, 2)
|
|
assert.equal(migration.counts.deleted, 0)
|
|
assert.ok(migration.records.every((candidate) => candidate.status === 'ambiguous' && candidate.candidates && candidate.candidates.length >= 2))
|
|
assert.equal(resolveTopoRef(restored, split.refs).status, 'ambiguous')
|
|
})
|
|
|
|
test('signature topology history conservatively classifies boolean fallback relations', () => {
|
|
const square = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const translated = { ...square, vertexCoord: square.vertexCoord.map((value, index) => index % 3 === 0 ? value + 5 : value) }
|
|
const triangle = { vertexCoord: [0, 0, 1, 1, 0, 1, 0, 1, 1], normalCoord: [], triIndexes: [0, 1, 2] }
|
|
const before = createSubshapeRefs('base-shape', 1, [square])
|
|
const after = createSubshapeRefs('result-shape', 2, [translated, triangle])
|
|
const history = captureSignatureTopologyHistory('boolean-1', [{ objectId: 'base', entries: before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })) }], after.refs.map((ref, index) => ({ ref, signature: after.signatures[index] })))
|
|
assert.equal(history.provider, 'signature-fallback')
|
|
assert.equal(history.counts.modified, 1)
|
|
assert.equal(history.counts.generated, 1)
|
|
|
|
const deleted = captureSignatureTopologyHistory('boolean-2', [{ objectId: 'base', entries: before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })) }], [])
|
|
assert.equal(deleted.counts.deleted, 1)
|
|
const ambiguous = captureSignatureTopologyHistory('boolean-3', [
|
|
{ objectId: 'base', entries: before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })) },
|
|
{ objectId: 'tool', entries: before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })) },
|
|
], before.refs.map((ref, index) => ({ ref, signature: before.signatures[index] })))
|
|
assert.equal(ambiguous.counts.ambiguous, 1)
|
|
assert.deepEqual(new Set(ambiguous.relations[0].candidates?.map((candidate) => candidate.sourceObjectId)), new Set(['base', 'tool']))
|
|
})
|
|
|
|
test('edge and vertex topology signatures are orientation/index independent', () => {
|
|
const edge = signatureForEdge([0, 0, 0], [2, 0, 0])
|
|
assert.equal(edge.hash, signatureForEdge([2, 0, 0], [0, 0, 0]).hash)
|
|
assert.equal(signatureForVertex([1, 2, 3]).hash, signatureForVertex([1.000001, 2, 3], 1e-5).hash)
|
|
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 0, 2, 0, 0, 2], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const edges = createEdgeSubshapeRefs('shape-topology', 3, [face])
|
|
const vertices = createVertexSubshapeRefs('shape-topology', 3, [face])
|
|
assert.equal(edges.refs.length, 5)
|
|
assert.equal(vertices.refs.length, 4)
|
|
assert.ok(edges.refs.every((ref) => ref.kind === 'edge' && ref.persistentId.startsWith('topo-edge-')))
|
|
assert.ok(vertices.refs.every((ref) => ref.kind === 'vertex' && ref.persistentId.startsWith('topo-vertex-')))
|
|
})
|
|
|
|
test('basic sketch solver applies geometric constraints and reports remaining degrees of freedom', () => {
|
|
const sketch = createSketch('sketch-test', [{ id: 'line-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }], [
|
|
{ id: 'horizontal-1', type: 'horizontal', geometryId: 'line-1' },
|
|
{ id: 'length-1', type: 'distance', first: { geometryId: 'line-1', point: 'start' }, second: { geometryId: 'line-1', point: 'end' }, value: 5 },
|
|
])
|
|
const result = solveSketch(sketch)
|
|
assert.equal(result.status, 'under-constrained')
|
|
assert.ok(result.residual <= 1e-7)
|
|
assert.equal(result.snapshot.geometry[0].type, 'line')
|
|
assert.equal((result.snapshot.geometry[0] as Extract<typeof result.snapshot.geometry[number], { type: 'line' }>).end.x, 5)
|
|
|
|
const conflict = solveSketch(createSketch('conflict', [{ id: 'line-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }], [
|
|
{ id: 'block-1', type: 'block', geometryId: 'line-1' },
|
|
{ id: 'horizontal-1', type: 'horizontal', geometryId: 'line-1' },
|
|
]))
|
|
assert.equal(conflict.status, 'conflicting')
|
|
assert.ok(conflict.diagnostics.some((diagnostic) => diagnostic.code === 'SOLVER_NOT_CONVERGED'))
|
|
})
|
|
|
|
test('Sketcher solver supports diameter, symmetric and tangent constraints at the model boundary', () => {
|
|
const sketch = createSketch('advanced-constraints', [
|
|
{ id: 'circle-1', type: 'circle', center: { x: 0, y: 1 }, radius: 1 },
|
|
{ id: 'line-1', type: 'line', start: { x: -5, y: 0 }, end: { x: 5, y: 0 } },
|
|
{ id: 'point-a', type: 'point', position: { x: -2, y: 2 } },
|
|
{ id: 'point-b', type: 'point', position: { x: 4, y: 0 } },
|
|
{ id: 'center', type: 'point', position: { x: 1, y: 1 } },
|
|
], [
|
|
{ id: 'diameter-1', type: 'diameter', geometryId: 'circle-1', value: 4 },
|
|
{ id: 'tangent-1', type: 'tangent', firstGeometryId: 'line-1', secondGeometryId: 'circle-1' },
|
|
{ id: 'symmetric-1', type: 'symmetric', first: { geometryId: 'point-a', point: 'position' }, second: { geometryId: 'point-b', point: 'position' }, center: { geometryId: 'center', point: 'position' } },
|
|
])
|
|
const result = solveSketch(sketch)
|
|
assert.ok(result.residual <= 1e-7)
|
|
assert.equal((result.snapshot.geometry.find((geometry) => geometry.id === 'circle-1') as Extract<typeof result.snapshot.geometry[number], { type: 'circle' }>).radius, 2)
|
|
assert.equal((result.snapshot.geometry.find((geometry) => geometry.id === 'circle-1') as Extract<typeof result.snapshot.geometry[number], { type: 'circle' }>).center.y, 2)
|
|
})
|
|
|
|
test('Sketcher model round-trips ellipse and B-spline data without claiming solver support', () => {
|
|
const source = createSketch('advanced-curves', [
|
|
{ id: 'ellipse-1', type: 'ellipse', center: { x: 1, y: 2 }, majorRadius: 8, minorRadius: 3, rotation: 0.25 },
|
|
{ id: 'bspline-1', type: 'bspline', degree: 3, controlPoints: [{ x: 0, y: 0 }, { x: 2, y: 4 }, { x: 6, y: 2 }, { x: 8, y: 0 }], weights: [1, 0.8, 0.8, 1], knots: [0, 0, 0, 0, 1, 1, 1, 1] },
|
|
], [{ id: 'block-ellipse', type: 'block', geometryId: 'ellipse-1' }])
|
|
const cloned = cloneSketch(source)
|
|
const ellipse = cloned.geometry[0]
|
|
const spline = cloned.geometry[1]
|
|
assert.equal(ellipse.type, 'ellipse')
|
|
assert.equal(spline.type, 'bspline')
|
|
if (ellipse.type === 'ellipse') ellipse.center.x = 99
|
|
if (spline.type === 'bspline') { spline.controlPoints[0].x = 99; spline.weights![0] = 2; spline.knots![0] = 3 }
|
|
assert.deepEqual(source.geometry[0], { id: 'ellipse-1', type: 'ellipse', center: { x: 1, y: 2 }, majorRadius: 8, minorRadius: 3, rotation: 0.25 })
|
|
assert.deepEqual(source.geometry[1], { id: 'bspline-1', type: 'bspline', degree: 3, controlPoints: [{ x: 0, y: 0 }, { x: 2, y: 4 }, { x: 6, y: 2 }, { x: 8, y: 0 }], weights: [1, 0.8, 0.8, 1], knots: [0, 0, 0, 0, 1, 1, 1, 1] })
|
|
const result = solveSketch(source)
|
|
assert.equal(result.status, 'invalid')
|
|
assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ['UNSUPPORTED_GEOMETRY', 'UNSUPPORTED_GEOMETRY'])
|
|
})
|
|
|
|
test('sketch solver protocol exposes honest capabilities and deterministic replay', async () => {
|
|
const provider = new BasicSketchSolverProvider()
|
|
assert.deepEqual(provider.capabilities().engine, 'typescript-basic')
|
|
assert.equal(provider.capabilities().compatibility, 'experimental')
|
|
const fixture = createSketch('protocol-sketch', [{ id: 'line-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 4, y: 2 } }], [
|
|
{ id: 'horizontal-1', type: 'horizontal', geometryId: 'line-1' },
|
|
])
|
|
const replay = await runSketchSolverReplay(provider, [{
|
|
id: 'basic-horizontal',
|
|
request: { requestId: 'replay-1', documentId: 'doc', documentVersion: 1, snapshot: fixture },
|
|
expected: { status: 'under-constrained', degreesOfFreedom: 3, maxResidual: 1e-7 },
|
|
}])
|
|
assert.deepEqual(replay, [{ id: 'basic-horizontal', passed: true, differences: [] }])
|
|
|
|
const unavailable = new UnavailablePlanegcsProvider()
|
|
assert.equal(unavailable.capabilities().availability, 'unavailable')
|
|
assert.equal(unavailable.capabilities().engine, 'planegcs-wasm')
|
|
await assert.rejects(() => unavailable.solve({} as SketchSolverRequest, new AbortController().signal), SketchSolverUnavailableError)
|
|
})
|
|
|
|
test('sketch solver coordinator rejects cancelled and stale generations', async () => {
|
|
let documentVersion = 1
|
|
let releaseFirst: (() => void) | undefined
|
|
const basic = new BasicSketchSolverProvider()
|
|
const delayed: SketchSolverProvider = {
|
|
capabilities: () => basic.capabilities(),
|
|
solve: (request, signal) => new Promise((resolve, reject) => {
|
|
const release = () => basic.solve(request, signal).then(resolve, reject)
|
|
if (request.requestId === 'first') releaseFirst = release
|
|
else release()
|
|
}),
|
|
}
|
|
const coordinator = new SketchSolverCoordinator(() => documentVersion)
|
|
const snapshot = createSketch('coordinator')
|
|
const first = coordinator.solve(delayed, { requestId: 'first', documentId: 'doc', documentVersion: 1, snapshot })
|
|
const second = coordinator.solve(delayed, { requestId: 'second', documentId: 'doc', documentVersion: 1, snapshot })
|
|
releaseFirst?.()
|
|
assert.equal((await first).status, 'cancelled')
|
|
assert.equal((await second).status, 'completed')
|
|
|
|
documentVersion = 2
|
|
const stale = await coordinator.solve(basic, { requestId: 'stale', documentId: 'doc', documentVersion: 1, snapshot })
|
|
assert.equal(stale.status, 'stale')
|
|
assert.equal(stale.generation, 3)
|
|
assert.equal(SKETCH_SOLVER_PROTOCOL_VERSION, 1)
|
|
})
|
|
|
|
test('sketcher operations are facade transactions and survive the project fallback store', async () => {
|
|
const facade = createMockFacade()
|
|
const before = facade.app.document.getActive().version
|
|
const added = facade.app.sketcher.addGeometry('sketch', { id: 'line-1', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } })
|
|
assert.equal(added.geometry.length, 1)
|
|
facade.app.sketcher.addConstraint('sketch', { id: 'horizontal-1', type: 'horizontal', geometryId: 'line-1' })
|
|
const solved = facade.app.sketcher.solve('sketch')
|
|
assert.equal(solved.status, 'under-constrained')
|
|
assert.ok(facade.app.document.getActive().version >= before + 3)
|
|
assert.match(String(facade.app.document.getObject('sketch')?.properties.find((property) => property.name === 'ConstraintStatus')?.value), /Under-constrained/)
|
|
await facade.project.save()
|
|
const loaded = await facade.project.load('doc-pump-housing')
|
|
assert.equal(loaded?.objects.find((object) => object.id === 'sketch')?.sketch?.constraints[0].id, 'horizontal-1')
|
|
})
|
|
|
|
test('external sketch geometry persists stable topology references and dependency edges', async () => {
|
|
const facade = createMockFacade()
|
|
const reference = createPersistedTopoRef('pad', { shapeId: 'pad', kind: 'edge', persistentId: 'edge:profile:1', topologyVersion: 2, status: 'stable', signature: 'line:10' }, 4)
|
|
facade.app.sketcher.addExternalGeometry('sketch', {
|
|
id: 'external-1',
|
|
source: reference,
|
|
projection: { id: 'projected-line', type: 'line', start: { x: 0, y: 0 }, end: { x: 10, y: 0 } },
|
|
construction: true,
|
|
})
|
|
const sketch = facade.app.sketcher.get('sketch')
|
|
assert.equal(sketch?.externalGeometry.length, 1)
|
|
assert.deepEqual(facade.app.document.getDependencies().find((edge) => edge.propertyName === 'ExternalGeometry:external-1'), {
|
|
sourceId: 'sketch', targetId: 'pad', relation: 'topo-ref', propertyName: 'ExternalGeometry:external-1', reference: 'edge:profile:1',
|
|
})
|
|
const persistence = createSqliteProjectPersistence()
|
|
await persistence.save(facade.app.document.getActive())
|
|
const loaded = await persistence.load('doc-pump-housing')
|
|
assert.deepEqual(loaded?.objects.find((object) => object.id === 'sketch')?.sketch?.externalGeometry, sketch?.externalGeometry)
|
|
assert.throws(() => facade.app.sketcher.addExternalGeometry('sketch', { id: 'self', source: { ...reference, objectId: 'sketch' }, projection: { id: 'self-line', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }, construction: true }), /cannot import external geometry from itself/)
|
|
await persistence.dispose()
|
|
})
|
|
|
|
test('Sketcher solve command is selection-aware and emits the standard command lifecycle', () => {
|
|
const facade = createMockFacade()
|
|
facade.gui.workbench.setActive('Sketcher')
|
|
facade.selection.select('sketch')
|
|
const events: string[] = []
|
|
facade.subscribe((event) => { if (event.type.startsWith('command.')) events.push(event.type) })
|
|
facade.gui.command.execute({ commandId: 'solve-sketch' })
|
|
assert.deepEqual(events, ['command.started', 'command.completed'])
|
|
assert.equal(facade.app.document.getObject('sketch')?.sketch?.solver.status, 'solved')
|
|
facade.selection.select('pad')
|
|
assert.equal(facade.gui.command.getState('solve-sketch').status, 'disabled')
|
|
assert.match(facade.gui.command.getState('solve-sketch').reason || '', /Sketcher sketch/)
|
|
facade.selection.clear()
|
|
assert.equal(facade.gui.command.getState('solve-sketch').status, 'disabled')
|
|
})
|
|
|
|
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.levels, [['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']])
|
|
const branched = new DependencyGraph([{ sourceId: 'left', targetId: 'root', relation: 'link' }, { sourceId: 'right', targetId: 'root', relation: 'link' }], ['root', 'left', 'right'])
|
|
assert.deepEqual(branched.plan(['root']).levels, [['root'], ['left', 'right']])
|
|
})
|
|
|
|
test('generation-aware recompute cancels an older run before accepting its result', async () => {
|
|
let version = 1
|
|
const coordinator = new RecomputeCoordinator(async (object, _document, context) => {
|
|
if (context.generation === 1) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(resolve, 30)
|
|
context.signal.addEventListener('abort', () => {
|
|
clearTimeout(timer)
|
|
const error = new Error('cancelled')
|
|
error.name = 'AbortError'
|
|
reject(error)
|
|
}, { once: true })
|
|
})
|
|
}
|
|
return { status: 'success', updatedObject: object }
|
|
}, () => version)
|
|
const document = recomputeDocumentFixture()
|
|
const first = coordinator.run(document)
|
|
const second = coordinator.run(document)
|
|
assert.equal((await first).status, 'cancelled')
|
|
const accepted = await second
|
|
assert.equal(accepted.status, 'completed')
|
|
assert.equal(accepted.generation, 2)
|
|
assert.deepEqual(accepted.completed, ['root'])
|
|
version = 2
|
|
})
|
|
|
|
test('recompute executes independent dependency levels concurrently and merges in stable order', async () => {
|
|
const document: DocumentSnapshot = {
|
|
...recomputeDocumentFixture(),
|
|
tree: [
|
|
{ id: 'root', label: 'Root', type: 'feature', state: 'dirty' },
|
|
{ id: 'left', label: 'Left', type: 'feature', state: 'dirty' },
|
|
{ id: 'right', label: 'Right', type: 'feature', state: 'dirty' },
|
|
],
|
|
objects: [
|
|
{ id: 'root', typeId: 'Part::Feature', properties: [] },
|
|
{ id: 'left', typeId: 'Part::Feature', properties: [] },
|
|
{ id: 'right', typeId: 'Part::Feature', properties: [] },
|
|
],
|
|
dependencies: [
|
|
{ sourceId: 'left', targetId: 'root', relation: 'link' },
|
|
{ sourceId: 'right', targetId: 'root', relation: 'link' },
|
|
],
|
|
recompute: {
|
|
generation: 0,
|
|
status: 'idle',
|
|
objectStates: { root: 'touched', left: 'touched', right: 'touched' },
|
|
dirtyObjects: ['root'],
|
|
order: [],
|
|
errors: [],
|
|
},
|
|
}
|
|
let active = 0
|
|
let maximumActive = 0
|
|
const coordinator = new RecomputeCoordinator(async (object) => {
|
|
active += 1
|
|
maximumActive = Math.max(maximumActive, active)
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
active -= 1
|
|
return { status: 'success', updatedObject: object }
|
|
}, () => 1)
|
|
|
|
const result = await coordinator.run(document)
|
|
assert.equal(result.status, 'completed')
|
|
assert.equal(maximumActive, 2)
|
|
assert.deepEqual(result.order, ['root', 'left', 'right'])
|
|
assert.deepEqual(result.completed, ['root', 'left', 'right'])
|
|
assert.deepEqual(result.levels, [['root'], ['left', 'right']])
|
|
})
|
|
|
|
test('recompute rejects results from an obsolete document version', async () => {
|
|
let version = 1
|
|
const coordinator = new RecomputeCoordinator(async () => {
|
|
version = 2
|
|
return { status: 'success' }
|
|
}, () => version)
|
|
const result = await coordinator.run(recomputeDocumentFixture())
|
|
assert.equal(result.status, 'stale')
|
|
assert.deepEqual(result.dirtyObjects, ['root'])
|
|
})
|
|
|
|
test('recompute failure marks dependent objects as upstream-failed', async () => {
|
|
const document = recomputeDocumentFixture([{ sourceId: 'child', targetId: 'root', relation: 'link' }])
|
|
const coordinator = new RecomputeCoordinator(async (object) => object.id === 'root'
|
|
? { status: 'failed', errors: [{ objectId: object.id, code: 'TEST_FAILURE', message: 'expected failure' }] }
|
|
: { status: 'success' }, () => 1)
|
|
const result = await coordinator.run(document)
|
|
assert.equal(result.status, 'failed')
|
|
assert.deepEqual(result.failed, ['root'])
|
|
assert.deepEqual(result.skipped, ['child'])
|
|
assert.equal(result.objectStates.child, 'upstream-failed')
|
|
assert.ok(result.errors.some((error) => error.code === 'UPSTREAM_FAILED'))
|
|
})
|
|
|
|
test('recompute suppression skips dependent nodes without reporting a failure', async () => {
|
|
const document = recomputeDocumentFixture([{ sourceId: 'child', targetId: 'root', relation: 'link' }])
|
|
document.objects[0].properties.push({ name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data', type: 'App::PropertyBool', value: true, recompute: true })
|
|
const executed: string[] = []
|
|
const coordinator = new RecomputeCoordinator(async (object, source, context) => {
|
|
executed.push(object.id)
|
|
return executeFacadeRecomputeNode(object, source, context)
|
|
}, () => 1)
|
|
const result = await coordinator.run(document)
|
|
assert.equal(result.status, 'completed')
|
|
assert.deepEqual(executed, ['root'])
|
|
assert.deepEqual(result.suppressed, ['root'])
|
|
assert.deepEqual(result.skipped, ['root', 'child'])
|
|
assert.equal(result.objectStates.root, 'suppressed')
|
|
assert.equal(result.objectStates.child, 'upstream-suppressed')
|
|
assert.deepEqual(result.dirtyObjects, [])
|
|
assert.deepEqual(result.errors, [])
|
|
})
|
|
|
|
test('OCCT feature executor builds a Sketch to Pad to Pocket chain and retains the last valid Shape', async () => {
|
|
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-feature', documentVersion: 2 })
|
|
const calls: string[] = []
|
|
let padShouldFail = false
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
createBox: async () => shape('box-shape'),
|
|
createCylinder: async () => shape('cylinder-shape'),
|
|
createSphere: async () => shape('sphere-shape'),
|
|
createCone: async () => shape('cone-shape'),
|
|
applyPlacement: async (input) => { calls.push(`placement:${input.placement.translation.join(',')}:${input.placement.rotationAxis.join(',')}:${input.placement.rotationAngle}`); return shape(`pattern-copy-${calls.filter((call) => call.startsWith('placement:')).length}`) },
|
|
union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}`); return shape('union-shape') },
|
|
cut: async () => shape('cut-shape'),
|
|
intersection: async () => shape('intersection-shape'),
|
|
pad: async (input) => { calls.push(`pad:${input.profile.outer.length}`); if (padShouldFail) throw new Error('OCCT pad failed'); return shape('pad-shape') },
|
|
pocket: async (input) => { calls.push(`pocket:${input.base.id}:${String(input.throughAll)}:${input.profile.outer.length}:${input.length}`); return shape('pocket-shape') },
|
|
revolution: async (input) => { calls.push(`revolution:${input.angle}:${input.axisDirection?.[1]}`); return shape('revolution-shape') },
|
|
fillet: async () => shape('fillet-shape'),
|
|
chamfer: async () => shape('chamfer-shape'),
|
|
release: async (released) => { calls.push(`release:${released.id}`) },
|
|
}
|
|
const sketch = { id: 'sketch', typeId: 'Sketcher::SketchObject', properties: [], sketch: createSketch('sketch', [
|
|
{ id: 'a', type: 'line' as const, start: { x: -1, y: -1 }, end: { x: 1, y: -1 } },
|
|
{ id: 'b', type: 'line' as const, start: { x: 1, y: -1 }, end: { x: 1, y: 1 } },
|
|
{ id: 'c', type: 'line' as const, start: { x: 1, y: 1 }, end: { x: -1, y: 1 } },
|
|
{ id: 'd', type: 'line' as const, start: { x: -1, y: 1 }, end: { x: -1, y: -1 } },
|
|
]) }
|
|
const pad = { id: 'pad', typeId: 'PartDesign::Pad', properties: [
|
|
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 10, unit: 'mm' },
|
|
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' },
|
|
] }
|
|
const pocket = { id: 'pocket', typeId: 'PartDesign::Pocket', properties: [
|
|
{ name: 'Type', label: 'Type', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Through all', options: ['Dimension', 'Through all', 'Up to face'] },
|
|
{ name: 'Length', label: 'Length', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 5, unit: 'mm' },
|
|
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' },
|
|
{ name: 'Base', label: 'Base', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'pad' },
|
|
] }
|
|
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), version: 2, objects: [sketch, pad, pocket], dependencies: [
|
|
{ sourceId: 'pad', targetId: 'sketch', relation: 'link' },
|
|
{ sourceId: 'pocket', targetId: 'pad', relation: 'link' },
|
|
{ sourceId: 'pocket', targetId: 'sketch', relation: 'link' },
|
|
], recompute: { generation: 0, status: 'idle', objectStates: { sketch: 'touched', pad: 'touched', pocket: 'touched' }, dirtyObjects: ['sketch'], order: [], errors: [] } }
|
|
const shapes = new Map<string, ShapeHandle>()
|
|
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
|
|
const context = { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal }
|
|
const result = await new RecomputeCoordinator(executor, () => 2).run(document)
|
|
assert.equal(result.status, 'completed')
|
|
assert.deepEqual(calls.slice(0, 2), ['pad:3', 'pocket:pad-shape:true:3:5'])
|
|
assert.equal(shapes.get('pad')?.id, 'pad-shape')
|
|
assert.equal(shapes.get('pocket')?.id, 'pocket-shape')
|
|
|
|
padShouldFail = true
|
|
const failed = await executor(pad, document, context)
|
|
assert.equal(failed.status, 'failed')
|
|
assert.equal(failed.errors?.[0].code, 'GEOMETRY_EXECUTION_FAILED')
|
|
assert.equal(shapes.get('pad')?.id, 'pad-shape')
|
|
const upToFace = { ...pocket, properties: pocket.properties.map((property) => property.name === 'Type' ? { ...property, value: 'Up to face' } : property) }
|
|
const unsupported = await executor(upToFace, document, context)
|
|
assert.equal(unsupported.status, 'failed')
|
|
assert.equal(unsupported.errors?.[0].code, 'UP_TO_FACE_UNSUPPORTED')
|
|
|
|
const revolution = { id: 'revolution', typeId: 'PartDesign::Revolution', properties: [
|
|
{ name: 'Angle', label: 'Angle', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 270, unit: 'deg' },
|
|
{ name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'sketch' },
|
|
{ name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true },
|
|
] }
|
|
const revolutionResult = await executor(revolution, document, context)
|
|
assert.equal(revolutionResult.status, 'success')
|
|
assert.equal(shapes.get('revolution')?.id, 'revolution-shape')
|
|
assert.ok(calls.includes('revolution:270:-1'))
|
|
|
|
const linearPattern = { id: 'linear-pattern', typeId: 'PartDesign::LinearPattern', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'pad' },
|
|
{ name: 'Occurrences', label: 'Occurrences', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyInteger' as const, value: 3 },
|
|
{ name: 'Length', label: 'Length', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 20, unit: 'mm' },
|
|
{ name: 'Direction', label: 'Direction', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Vertical', options: ['Horizontal', 'Vertical', 'Normal'] },
|
|
] }
|
|
const patternResult = await executor(linearPattern, document, context)
|
|
assert.equal(patternResult.status, 'success')
|
|
assert.equal(shapes.get('linear-pattern')?.id, 'union-shape')
|
|
assert.ok(calls.includes('placement:0,10,0:0,0,1:0'))
|
|
assert.ok(calls.includes('placement:0,20,0:0,0,1:0'))
|
|
assert.ok(calls.includes('union:pad-shape,pattern-copy-1,pattern-copy-2'))
|
|
assert.ok(calls.includes('release:pattern-copy-1'))
|
|
assert.ok(calls.includes('release:pattern-copy-2'))
|
|
|
|
const invalidPattern = { ...linearPattern, properties: linearPattern.properties.map((property) => property.name === 'Occurrences' ? { ...property, value: 1 } : property) }
|
|
const invalidPatternResult = await executor(invalidPattern, document, context)
|
|
assert.equal(invalidPatternResult.status, 'failed')
|
|
assert.equal(invalidPatternResult.errors?.[0].code, 'PATTERN_OCCURRENCES_INVALID')
|
|
|
|
const polarPattern = { id: 'polar-pattern', typeId: 'PartDesign::PolarPattern', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'pad' },
|
|
{ name: 'Occurrences', label: 'Occurrences', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyInteger' as const, value: 3 },
|
|
{ name: 'Angle', label: 'Angle', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyAngle' as const, value: 360, unit: 'deg' },
|
|
{ name: 'Axis', label: 'Axis', group: 'Pattern', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Normal', options: ['Normal', 'Horizontal', 'Vertical'] },
|
|
] }
|
|
const polarResult = await executor(polarPattern, document, context)
|
|
assert.equal(polarResult.status, 'success')
|
|
assert.ok(calls.includes('placement:0,0,0:0,0,1:120'))
|
|
assert.ok(calls.includes('placement:0,0,0:0,0,1:240'))
|
|
assert.ok(calls.includes('union:pad-shape,pattern-copy-3,pattern-copy-4'))
|
|
|
|
const partialPolar = { ...polarPattern, id: 'polar-pattern-partial', properties: polarPattern.properties.map((property) => property.name === 'Angle' ? { ...property, value: 180 } : property) }
|
|
const partialPolarResult = await executor(partialPolar, document, context)
|
|
assert.equal(partialPolarResult.status, 'success')
|
|
assert.ok(calls.includes('placement:0,0,0:0,0,1:90'))
|
|
assert.ok(calls.includes('placement:0,0,0:0,0,1:180'))
|
|
|
|
const hole = { id: 'hole', typeId: 'PartDesign::Hole', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Hole', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'pad' },
|
|
{ name: 'Diameter', label: 'Diameter', group: 'Hole', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 6, unit: 'mm' },
|
|
{ name: 'Depth', label: 'Depth', group: 'Hole', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 8, unit: 'mm' },
|
|
{ name: 'Type', label: 'Type', group: 'Hole', scope: 'data' as const, type: 'App::PropertyEnumeration' as const, value: 'Dimension', options: ['Dimension', 'Through all'] },
|
|
] }
|
|
const holeResult = await executor(hole, document, context)
|
|
assert.equal(holeResult.status, 'success')
|
|
assert.equal(shapes.get('hole')?.id, 'pocket-shape')
|
|
assert.ok(calls.includes('pocket:pad-shape:false:32:8'))
|
|
const invalidHole = { ...hole, properties: hole.properties.map((property) => property.name === 'Diameter' ? { ...property, value: 0 } : property) }
|
|
const invalidHoleResult = await executor(invalidHole, document, context)
|
|
assert.equal(invalidHoleResult.status, 'failed')
|
|
assert.equal(invalidHoleResult.errors?.[0].code, 'HOLE_DIAMETER_INVALID')
|
|
|
|
const suppressedPad = { ...pad, properties: [...pad.properties, { name: 'Suppressed', label: 'Suppressed', group: 'Feature state', scope: 'data' as const, type: 'App::PropertyBool' as const, value: true }] }
|
|
const suppressedResult = await executor(suppressedPad, document, context)
|
|
assert.equal(suppressedResult.status, 'suppressed')
|
|
assert.equal(shapes.has('pad'), false)
|
|
assert.ok(calls.includes('release:pad-shape'))
|
|
})
|
|
|
|
test('Part primitive and boolean nodes execute through the same Shape cache', async () => {
|
|
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-part', documentVersion: 2 })
|
|
const calls: string[] = []
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
createBox: async (input) => { calls.push(`box:${input.width}:${input.length}:${input.height}`); return shape('box-shape') },
|
|
createCylinder: async () => shape('cylinder-shape'),
|
|
createSphere: async () => shape('sphere-shape'),
|
|
createCone: async () => shape('cone-shape'),
|
|
applyPlacement: async (input) => { calls.push(`placement:${input.placement.translation.join(',')}`); return shape('placed-shape') },
|
|
union: async (input) => { calls.push(`union:${input.shapes.map((entry) => entry.id).join(',')}`); return shape('fuse-shape') },
|
|
cut: async () => shape('cut-shape'),
|
|
intersection: async () => shape('common-shape'),
|
|
pad: async () => shape('pad-shape'),
|
|
pocket: async () => shape('pocket-shape'),
|
|
revolution: async () => shape('revolution-shape'),
|
|
fillet: async () => shape('fillet-shape'),
|
|
chamfer: async () => shape('chamfer-shape'),
|
|
release: async () => undefined,
|
|
}
|
|
const boxProperties = (width: number) => [
|
|
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: width },
|
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 4 },
|
|
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 5 },
|
|
]
|
|
const document: DocumentSnapshot = {
|
|
id: 'doc-part', label: 'Part fixture', version: 2, dirty: true, readOnly: false, units: 'mm',
|
|
tree: [{ id: 'boxA', label: 'Box', type: 'feature', state: 'dirty' }, { id: 'boxB', label: 'Box001', type: 'feature', state: 'dirty' }, { id: 'fuse', label: 'Union', type: 'feature', state: 'dirty' }],
|
|
objects: [
|
|
{ id: 'boxA', typeId: 'Part::Box', properties: boxProperties(2) },
|
|
{ id: 'boxB', typeId: 'Part::Box', properties: boxProperties(3) },
|
|
{ id: 'fuse', typeId: 'Part::Fuse', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Boolean', scope: 'data', type: 'App::PropertyLink' as const, value: 'boxA' },
|
|
{ name: 'Tool', label: 'Tool', group: 'Boolean', scope: 'data', type: 'App::PropertyLink' as const, value: 'boxB' },
|
|
] },
|
|
],
|
|
dependencies: [{ sourceId: 'fuse', targetId: 'boxA', relation: 'link' }, { sourceId: 'fuse', targetId: 'boxB', relation: 'link' }],
|
|
recompute: { generation: 0, status: 'idle', objectStates: { boxA: 'touched', boxB: 'touched', fuse: 'touched' }, dirtyObjects: ['boxA', 'boxB'], order: [], errors: [] },
|
|
}
|
|
const shapes = new Map<string, ShapeHandle>()
|
|
const result = await new RecomputeCoordinator(createFacadeGeometryRecomputeExecutor(runtime, shapes), () => 2).run(document)
|
|
assert.equal(result.status, 'completed')
|
|
assert.equal(shapes.get('boxA')?.id, 'box-shape')
|
|
assert.equal(shapes.get('fuse')?.id, 'fuse-shape')
|
|
assert.ok(calls.includes('box:2:4:5'))
|
|
assert.ok(calls.includes('union:box-shape,box-shape'))
|
|
})
|
|
|
|
test('object Placement transforms recomputed shapes before committing the cache', async () => {
|
|
const shape = (id: string): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-placement', documentVersion: 3 })
|
|
const calls: string[] = []
|
|
let localShapeId = 'box-local'
|
|
let placementShouldFail = false
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
createBox: async () => { calls.push(`box:${localShapeId}`); return shape(localShapeId) },
|
|
createCylinder: async () => shape('cylinder-local'),
|
|
createSphere: async () => shape('sphere-local'),
|
|
createCone: async () => shape('cone-local'),
|
|
applyPlacement: async (input) => {
|
|
calls.push(`placement:${input.shape.id}:${input.placement.translation.join(',')}:${input.placement.rotationAxis.join(',')}:${input.placement.rotationAngle}`)
|
|
if (placementShouldFail) throw new Error('OCCT placement failed')
|
|
return shape('box-placed')
|
|
},
|
|
union: async () => shape('union'),
|
|
cut: async () => shape('cut'),
|
|
intersection: async () => shape('common'),
|
|
pad: async () => shape('pad'),
|
|
pocket: async () => shape('pocket'),
|
|
revolution: async () => shape('revolution'),
|
|
fillet: async () => shape('fillet'),
|
|
chamfer: async () => shape('chamfer'),
|
|
release: async (released) => { calls.push(`release:${released.id}`) },
|
|
}
|
|
const object = { id: 'box', typeId: 'Part::Box', properties: [
|
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 2 },
|
|
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 3 },
|
|
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data' as const, type: 'App::PropertyLength' as const, value: 4 },
|
|
{ name: 'Placement', label: 'Placement', group: 'Attachment', scope: 'data' as const, type: 'App::PropertyPlacement' as const, value: { position: { x: 5, y: -2, z: 1 }, rotation: { axis: { x: 0, y: 1, z: 0 }, angle: 90 } } },
|
|
] }
|
|
const document: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-placement', version: 3, tree: [{ id: 'box', label: 'Box', type: 'feature' }], objects: [object], dependencies: [] }
|
|
const shapes = new Map<string, ShapeHandle>()
|
|
const result = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(object, document, { documentId: document.id, documentVersion: document.version, generation: 1, signal: new AbortController().signal })
|
|
assert.equal(result.status, 'success')
|
|
assert.equal(shapes.get('box')?.id, 'box-placed')
|
|
assert.deepEqual(calls, ['box:box-local', 'placement:box-local:5,-2,1:0,1,0:90', 'release:box-local'])
|
|
|
|
const identity = { ...object, id: 'box-identity', properties: object.properties.map((property) => property.name === 'Placement' ? { ...property, value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } } } : property) }
|
|
await createFacadeGeometryRecomputeExecutor(runtime, shapes)(identity, { ...document, objects: [identity] }, { documentId: document.id, documentVersion: document.version, generation: 2, signal: new AbortController().signal })
|
|
assert.equal(calls.filter((call) => call.startsWith('placement:')).length, 1)
|
|
|
|
localShapeId = 'box-retry'
|
|
placementShouldFail = true
|
|
const failed = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(object, document, { documentId: document.id, documentVersion: document.version, generation: 3, signal: new AbortController().signal })
|
|
assert.equal(failed.status, 'failed')
|
|
assert.equal(failed.errors?.[0].code, 'GEOMETRY_EXECUTION_FAILED')
|
|
assert.match(failed.errors?.[0].message ?? '', /OCCT placement failed/)
|
|
assert.equal(shapes.get('box')?.id, 'box-placed')
|
|
assert.ok(calls.includes('release:box-retry'))
|
|
assert.equal(calls.includes('release:box-placed'), false)
|
|
|
|
const malformed = { ...object, properties: object.properties.map((property) => property.name === 'Placement' ? { ...property, value: { position: null, rotation: {} } as never } : property) }
|
|
const boxCallsBeforeMalformedInput = calls.filter((call) => call.startsWith('box:')).length
|
|
const malformedResult = await createFacadeGeometryRecomputeExecutor(runtime, shapes)(malformed, { ...document, objects: [malformed] }, { documentId: document.id, documentVersion: document.version, generation: 4, signal: new AbortController().signal })
|
|
assert.equal(malformedResult.status, 'failed')
|
|
assert.equal(malformedResult.errors?.[0].code, 'GEOMETRY_EXECUTION_FAILED')
|
|
assert.match(malformedResult.errors?.[0].message ?? '', /invalid structure/)
|
|
assert.equal(calls.filter((call) => call.startsWith('box:')).length, boxCallsBeforeMalformedInput)
|
|
|
|
const persistence = createSqliteProjectPersistence()
|
|
await persistence.save(document)
|
|
const loaded = await persistence.load(document.id)
|
|
const loadedObject = loaded?.objects.find((candidate) => candidate.id === object.id)
|
|
assert.ok(loaded && loadedObject)
|
|
calls.length = 0
|
|
localShapeId = 'box-restored'
|
|
placementShouldFail = false
|
|
const restoredShapes = new Map<string, ShapeHandle>()
|
|
const restored = await createFacadeGeometryRecomputeExecutor(runtime, restoredShapes)(loadedObject, loaded, { documentId: loaded.id, documentVersion: loaded.version, generation: 5, signal: new AbortController().signal })
|
|
assert.equal(restored.status, 'success')
|
|
assert.equal(restoredShapes.get('box')?.id, 'box-placed')
|
|
assert.ok(calls.includes('placement:box-restored:5,-2,1:0,1,0:90'))
|
|
await persistence.dispose()
|
|
})
|
|
|
|
test('geometry recompute captures generation topology history and isolates capture failures', async () => {
|
|
const square = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const translated = { ...square, vertexCoord: square.vertexCoord.map((value, index) => index % 3 === 0 ? value + 5 : value) }
|
|
let currentFace = square
|
|
let topologyShouldFail = false
|
|
const releases: string[] = []
|
|
const shape = (id: string, documentVersion: number): ShapeHandle => ({ id, kernel: 'bitbybit-occt', kind: 'solid', documentId: 'doc-topology-generation', documentVersion })
|
|
const runtime: RecomputeGeometryRuntime = {
|
|
capabilities: () => ({ status: 'ready' }),
|
|
createBox: async (input) => shape(`box-${input.documentVersion}`, input.documentVersion),
|
|
createCylinder: async (input) => shape('cylinder', input.documentVersion),
|
|
createSphere: async (input) => shape('sphere', input.documentVersion),
|
|
createCone: async (input) => shape('cone', input.documentVersion),
|
|
applyPlacement: async (input) => input.shape,
|
|
union: async (input) => shape('union', input.documentVersion),
|
|
cut: async (input) => shape('cut', input.documentVersion),
|
|
intersection: async (input) => shape('intersection', input.documentVersion),
|
|
pad: async (input) => shape('pad', input.documentVersion),
|
|
pocket: async (input) => shape('pocket', input.documentVersion),
|
|
revolution: async (input) => shape('revolution', input.documentVersion),
|
|
fillet: async (input) => shape('fillet', input.documentVersion),
|
|
chamfer: async (input) => shape('chamfer', input.documentVersion),
|
|
topology: async (handle): Promise<SubshapeTopology> => {
|
|
if (topologyShouldFail) throw new Error('topology capture failed')
|
|
const created = createSubshapeRefs(handle.id, handle.documentVersion, [currentFace])
|
|
return { faces: created.refs, edges: [], vertices: [], entries: created.refs.map((ref, index) => ({ ref, signature: created.signatures[index] })) }
|
|
},
|
|
release: async (released) => { releases.push(released.id) },
|
|
}
|
|
const object: DocumentObjectSnapshot = { id: 'box', typeId: 'Part::Box', properties: [] }
|
|
const shapes = new Map<string, ShapeHandle>()
|
|
const executor = createFacadeGeometryRecomputeExecutor(runtime, shapes)
|
|
const firstDocument: DocumentSnapshot = { ...recomputeDocumentFixture(), id: 'doc-topology-generation', version: 1, tree: [{ id: 'box', label: 'Box', type: 'feature' }], objects: [object], dependencies: [] }
|
|
const first = await executor(object, firstDocument, { documentId: firstDocument.id, documentVersion: 1, generation: 1, signal: new AbortController().signal })
|
|
assert.equal(first.status, 'success')
|
|
assert.equal(first.updatedObject?.topology?.generation, 1)
|
|
assert.equal(first.updatedObject?.topology?.history.counts.generated, 1)
|
|
assert.equal(first.updatedObject?.topology?.entries[0].ref.status, 'new')
|
|
|
|
currentFace = translated
|
|
const secondObject = first.updatedObject as DocumentObjectSnapshot
|
|
const secondDocument = { ...firstDocument, version: 2, objects: [secondObject] }
|
|
const second = await executor(secondObject, secondDocument, { documentId: secondDocument.id, documentVersion: 2, generation: 2, signal: new AbortController().signal })
|
|
assert.equal(second.status, 'success')
|
|
assert.equal(second.updatedObject?.topology?.generation, 2)
|
|
assert.equal(second.updatedObject?.topology?.migration.previousGeneration, 1)
|
|
assert.equal(second.updatedObject?.topology?.history.counts.modified, 1)
|
|
assert.equal(second.updatedObject?.topology?.entries[0].ref.persistentId, first.updatedObject?.topology?.entries[0].ref.persistentId)
|
|
assert.equal(shapes.get('box')?.id, 'box-2')
|
|
|
|
const persistence = createSqliteProjectPersistence()
|
|
await persistence.save({ ...secondDocument, objects: [second.updatedObject as DocumentObjectSnapshot] })
|
|
assert.deepEqual((await persistence.load(secondDocument.id))?.objects[0].topology, second.updatedObject?.topology)
|
|
await persistence.dispose()
|
|
|
|
const staleObject = second.updatedObject as DocumentObjectSnapshot
|
|
const staleDocument = { ...secondDocument, version: 3, objects: [staleObject] }
|
|
await assert.rejects(() => executor(staleObject, staleDocument, { documentId: staleDocument.id, documentVersion: 3, generation: 3, signal: new AbortController().signal, isCurrent: () => false }), /no longer current/)
|
|
assert.equal(shapes.get('box')?.id, 'box-2')
|
|
assert.ok(releases.includes('box-3'))
|
|
|
|
topologyShouldFail = true
|
|
const thirdObject = second.updatedObject as DocumentObjectSnapshot
|
|
const thirdDocument = { ...secondDocument, version: 4, objects: [thirdObject] }
|
|
const failed = await executor(thirdObject, thirdDocument, { documentId: thirdDocument.id, documentVersion: 4, generation: 4, signal: new AbortController().signal })
|
|
assert.equal(failed.status, 'failed')
|
|
assert.match(failed.errors?.[0].message ?? '', /topology capture failed/)
|
|
assert.equal(shapes.get('box')?.id, 'box-2')
|
|
assert.ok(releases.includes('box-4'))
|
|
})
|
|
|
|
test('generation topology migration updates LinkSub and external geometry without forcing ambiguous or deleted refs', () => {
|
|
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const previous = createSubshapeRefs('shape-old', 1, [face])
|
|
const current = createSubshapeRefs('shape-new', 2, [face, face])
|
|
const previousEntries = previous.refs.map((ref, index) => ({ ref, signature: previous.signatures[index] }))
|
|
const currentEntries = current.refs.map((ref, index) => ({ ref, signature: current.signatures[index] }))
|
|
const initialDuplicate = migrateTopoRefs('source', [], currentEntries, 1)
|
|
assert.equal(initialDuplicate.counts.ambiguous, 2)
|
|
const migration = migrateTopoRefs('source', previousEntries, currentEntries, 2)
|
|
const topology: ObjectTopologySnapshot = {
|
|
shapeId: 'shape-new', documentVersion: 2, generation: 2,
|
|
entries: currentEntries,
|
|
migration: { previousGeneration: 1, matches: migration.matches },
|
|
history: captureSignatureTopologyHistory('source:generation:2', [{ objectId: 'source', entries: previousEntries }], currentEntries),
|
|
}
|
|
const reference = createPersistedTopoRef('source', previous.refs[0], 1)
|
|
const sketch = createSketch('owner')
|
|
sketch.externalGeometry.push({ id: 'external', source: { ...reference }, projection: { id: 'projection', type: 'line', start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }, construction: true })
|
|
const document: DocumentSnapshot = {
|
|
...recomputeDocumentFixture(), id: 'doc-topology-migration', version: 2,
|
|
tree: [{ id: 'source', label: 'Source', type: 'feature' }, { id: 'owner', label: 'Owner', type: 'sketch' }],
|
|
objects: [
|
|
{ id: 'source', typeId: 'Part::Box', properties: [], topology },
|
|
{ id: 'owner', typeId: 'Sketcher::SketchObject', properties: [{ name: 'SupportFace', label: 'Support face', group: 'Attachment', scope: 'data', type: 'App::PropertyLinkSub', value: { ...reference } }], sketch },
|
|
], dependencies: [],
|
|
}
|
|
const ambiguous = migrateDocumentTopologyReferences(document, ['source'])
|
|
const property = document.objects[1].properties[0].value
|
|
const external = document.objects[1].sketch?.externalGeometry[0].source
|
|
assert.equal(property && typeof property === 'object' && !Array.isArray(property) && 'status' in property ? property.status : null, 'ambiguous')
|
|
assert.equal(external?.status, 'ambiguous')
|
|
assert.equal(external?.generation, 2)
|
|
assert.equal(external?.candidates?.length, 2)
|
|
assert.deepEqual(ambiguous.changedOwnerIds, ['owner'])
|
|
assert.deepEqual(ambiguous.issues.map((issue) => issue.referenceName), ['SupportFace', 'ExternalGeometry:external'])
|
|
|
|
const deletedMigration = migrateTopoRefs('source', previousEntries, [], 3)
|
|
document.objects[0].topology = {
|
|
shapeId: 'shape-empty', documentVersion: 3, generation: 3, entries: [],
|
|
migration: { previousGeneration: 1, matches: deletedMigration.matches },
|
|
history: captureSignatureTopologyHistory('source:generation:3', [{ objectId: 'source', entries: previousEntries }], []),
|
|
}
|
|
document.objects[1].properties[0].value = { ...reference }
|
|
if (document.objects[1].sketch) document.objects[1].sketch.externalGeometry[0].source = { ...reference }
|
|
const deleted = migrateDocumentTopologyReferences(document, ['source'])
|
|
assert.equal((document.objects[1].properties[0].value as typeof reference).status, 'deleted')
|
|
assert.equal(document.objects[1].sketch?.externalGeometry[0].source.status, 'deleted')
|
|
assert.ok(deleted.issues.every((issue) => issue.status === 'deleted'))
|
|
|
|
const currentSingle = createSubshapeRefs('shape-later', 4, [face])
|
|
const currentSingleEntries = currentSingle.refs.map((ref, index) => ({ ref: { ...ref, persistentId: reference.persistentId }, signature: currentSingle.signatures[index] }))
|
|
const skippedGeneration = migrateTopoRefs('source', [], currentSingleEntries, 4)
|
|
document.objects[0].topology = {
|
|
shapeId: 'shape-later', documentVersion: 4, generation: 4, entries: currentSingleEntries,
|
|
migration: { previousGeneration: 3, matches: skippedGeneration.matches },
|
|
history: captureSignatureTopologyHistory('source:generation:4', [], currentSingleEntries),
|
|
}
|
|
document.objects[1].properties[0].value = { ...reference }
|
|
const resolvedAcrossGap = migrateDocumentTopologyReferences(document, ['source'])
|
|
assert.equal((document.objects[1].properties[0].value as typeof reference).status, 'stable')
|
|
assert.equal((document.objects[1].properties[0].value as typeof reference).generation, 4)
|
|
assert.equal(resolvedAcrossGap.issues.length, 1)
|
|
})
|
|
|
|
test('facade async recompute commits only an accepted generation', async () => {
|
|
const facade = createMockFacade()
|
|
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 51 })
|
|
const result = await facade.app.document.recomputeAsync()
|
|
assert.equal(result.status, 'completed')
|
|
assert.equal(facade.app.document.getActive().recompute?.generation, result.generation)
|
|
assert.equal(facade.app.document.getActive().recompute?.dirtyObjects.length, 0)
|
|
})
|
|
|
|
test('feature suppression persists and unsuppress recomputes only its dependent closure', async () => {
|
|
const facade = createMockFacade()
|
|
facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'Suppressed', value: true })
|
|
const suppressed = facade.app.document.recompute()
|
|
assert.equal(suppressed.status, 'completed')
|
|
assert.deepEqual(suppressed.order, ['pocket', 'fillet', 'body'])
|
|
assert.deepEqual(new Set(suppressed.affected), new Set(['pocket', 'fillet', 'body']))
|
|
assert.equal(facade.app.document.getActive().recompute?.objectStates.pocket, 'suppressed')
|
|
assert.equal(facade.app.document.getActive().recompute?.objectStates.fillet, 'upstream-suppressed')
|
|
|
|
await facade.project.save()
|
|
assert.equal((await facade.project.load('doc-pump-housing'))?.objects.find((object) => object.id === 'pocket')?.properties.find((property) => property.name === 'Suppressed')?.value, true)
|
|
|
|
facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'Suppressed', value: false })
|
|
const restored = await facade.app.document.recomputeAsync()
|
|
assert.equal(restored.status, 'completed')
|
|
assert.deepEqual(restored.order, ['pocket', 'fillet', 'body'])
|
|
assert.deepEqual(new Set(restored.affected), new Set(['pocket', 'fillet', 'body']))
|
|
assert.equal(facade.app.document.getActive().recompute?.objectStates.pocket, 'up-to-date')
|
|
assert.equal(facade.app.document.getActive().recompute?.objectStates.fillet, 'up-to-date')
|
|
})
|
|
|
|
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('persistence write queue preserves all 1000 transaction slots under repeated failures', async () => {
|
|
const queue = new PersistenceWriteQueue()
|
|
const order: number[] = []
|
|
let inFlight = 0
|
|
let maximumInFlight = 0
|
|
const writes = Array.from({ length: 1000 }, (_, index) => queue.run(async () => {
|
|
inFlight += 1
|
|
maximumInFlight = Math.max(maximumInFlight, inFlight)
|
|
await Promise.resolve()
|
|
order.push(index)
|
|
inFlight -= 1
|
|
if (index % 137 === 0) throw new Error(`expected failure ${index}`)
|
|
return index
|
|
}))
|
|
const results = await Promise.allSettled(writes)
|
|
await queue.drain()
|
|
assert.equal(maximumInFlight, 1)
|
|
assert.deepEqual(order, Array.from({ length: 1000 }, (_, index) => index))
|
|
assert.equal(results.filter((result) => result.status === 'rejected').length, 8)
|
|
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 992)
|
|
})
|
|
|
|
test('project persistence broadcasts saved document versions across clients', async () => {
|
|
const writer = createSqliteProjectPersistence()
|
|
const observer = createSqliteProjectPersistence()
|
|
const notice = new Promise<{ documentId: string; documentVersion: number }>((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error('Expected cross-tab save notice.')), 250)
|
|
const unsubscribe = observer.subscribeExternalChanges((change) => {
|
|
clearTimeout(timer)
|
|
unsubscribe()
|
|
resolve(change)
|
|
})
|
|
})
|
|
const document = recomputeDocumentFixture()
|
|
const saved = await writer.save(document)
|
|
assert.equal(writer.capabilities().crossTabWriteLock, 'local-queue')
|
|
const received = await notice
|
|
assert.equal(received.documentId, saved.documentId)
|
|
assert.equal(received.documentVersion, saved.documentVersion)
|
|
await writer.dispose()
|
|
await observer.dispose()
|
|
})
|
|
|
|
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('manifest commands without a Bitbybit executor are explicitly unsupported', () => {
|
|
const facade = createMockFacade()
|
|
const before = facade.getState().document.version
|
|
const state = facade.gui.command.getState('mirrored')
|
|
assert.equal(state.status, 'disabled')
|
|
assert.match(state.reason || '', /business executor/)
|
|
facade.gui.command.execute({ commandId: 'mirrored' })
|
|
assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_UNIMPLEMENTED')
|
|
assert.equal(facade.getState().document.version, before)
|
|
})
|
|
|
|
test('implemented commands enforce their FreeCAD workbench preconditions', () => {
|
|
const facade = createMockFacade()
|
|
facade.gui.workbench.setActive('Sketcher')
|
|
assert.equal(facade.gui.command.getState('create-body').status, 'disabled')
|
|
assert.match(facade.gui.command.getState('create-body').reason || '', /Part Design/)
|
|
assert.equal(facade.gui.command.getState('new-sketch').status, 'enabled')
|
|
facade.gui.workbench.setActive('Part Design')
|
|
assert.equal(facade.gui.command.getState('new-sketch').status, 'disabled')
|
|
assert.equal(facade.gui.command.getState('pocket').status, 'enabled')
|
|
})
|
|
|
|
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('Placement properties are deeply cloned, validated, undoable and persisted', async () => {
|
|
const facade = createMockFacade()
|
|
const placement = { position: { x: 10, y: -2, z: 4 }, rotation: { axis: { x: 0, y: 1, z: 0 }, angle: 45 } }
|
|
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Placement', value: placement })
|
|
placement.position.x = 999
|
|
const stored = facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Placement')?.value
|
|
assert.deepEqual(stored, { position: { x: 10, y: -2, z: 4 }, rotation: { axis: { x: 0, y: 1, z: 0 }, angle: 45 } })
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Placement', value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 0 }, angle: 0 } } }), /axis cannot be zero/)
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Placement', value: { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 361 } } }), /between 0 and 360/)
|
|
facade.history.undo()
|
|
assert.deepEqual(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Placement')?.value, { position: { x: 0, y: 0, z: 0 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } })
|
|
facade.history.redo()
|
|
const persistence = createSqliteProjectPersistence()
|
|
await persistence.save(facade.app.document.getActive())
|
|
assert.deepEqual((await persistence.load('doc-pump-housing'))?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Placement')?.value, stored)
|
|
await persistence.dispose()
|
|
})
|
|
|
|
test('LinkSub properties preserve versioned TopoRefs and create topology dependencies', async () => {
|
|
const facade = createMockFacade()
|
|
const face = { vertexCoord: [0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0], normalCoord: [], triIndexes: [0, 1, 2, 0, 2, 3] }
|
|
const topology = createSubshapeRefs('pad-shape', 18, [face])
|
|
const topoRef = createPersistedTopoRef('pad', topology.refs[0], 4)
|
|
facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'UpToFace', value: topoRef })
|
|
const stored = facade.app.document.getObject('pocket')?.properties.find((property) => property.name === 'UpToFace')?.value
|
|
assert.deepEqual(stored, topoRef)
|
|
assert.notEqual(stored, topoRef)
|
|
const dependency = facade.app.document.getDependencies().find((edge) => edge.sourceId === 'pocket' && edge.relation === 'topo-ref')
|
|
assert.equal(dependency?.targetId, 'pad')
|
|
assert.equal(dependency?.reference, topoRef.persistentId)
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'UpToFace', value: { ...topoRef, faceIndex: 1 } as never }), /must not persist transient faceIndex/)
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'pocket', propertyName: 'UpToFace', value: { ...topoRef, objectId: 'missing' } }), /target does not exist/)
|
|
|
|
await facade.project.save()
|
|
const loaded = await facade.project.load('doc-pump-housing')
|
|
assert.deepEqual(loaded?.objects.find((object) => object.id === 'pocket')?.properties.find((property) => property.name === 'UpToFace')?.value, topoRef)
|
|
})
|
|
|
|
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('recompute diagnostics expose root causes and repair only the affected branch', async () => {
|
|
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 failed = facade.app.document.recompute()
|
|
assert.equal(failed.status, 'failed')
|
|
|
|
const diagnostics = facade.diagnostics.list().filter((diagnostic) => diagnostic.source === 'recompute')
|
|
const pocketRoot = diagnostics.find((diagnostic) => diagnostic.objectId === 'pocket' && diagnostic.code === 'DEPENDENCY_CYCLE')
|
|
const fillet = diagnostics.find((diagnostic) => diagnostic.objectId === 'fillet')
|
|
assert.ok(pocketRoot)
|
|
assert.equal(pocketRoot.generation, failed.generation)
|
|
assert.equal(pocketRoot.documentVersion, facade.app.document.getActive().version)
|
|
assert.equal(pocketRoot.repairActions?.find((action) => action.id === 'suppress-root')?.enabled, false)
|
|
assert.deepEqual(fillet?.dependencyPath, ['fillet', 'pocket'])
|
|
assert.equal(fillet?.rootCauseObjectId, 'pocket')
|
|
|
|
const tree = facade.diagnostics.tree()
|
|
assert.ok(tree.find((node) => node.diagnostic.id === pocketRoot.id)?.children.some((node) => node.diagnostic.objectId === 'fillet'))
|
|
const selected = await facade.diagnostics.repair(pocketRoot.id, 'select-object')
|
|
assert.equal(selected.status, 'completed')
|
|
assert.equal(facade.selection.getObjectId(), 'pocket')
|
|
|
|
facade.app.document.setExpression({ objectId: 'pad', propertyName: 'Length', expression: '25 mm' })
|
|
const repaired = await facade.diagnostics.repair(pocketRoot.id, 'recompute-root')
|
|
assert.equal(repaired.status, 'completed')
|
|
assert.equal(facade.diagnostics.list().some((diagnostic) => diagnostic.source === 'recompute'), false)
|
|
})
|
|
|
|
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 projects = await facade.project.list()
|
|
assert.equal(projects.length, 1)
|
|
const { updatedAt, ...summary } = projects[0]
|
|
assert.ok(updatedAt > 0)
|
|
assert.deepEqual(summary, { documentId: 'doc-pump-housing', label: 'Pump Housing', documentVersion: saved.documentVersion, objectCount: 7, dirty: true, readOnly: false })
|
|
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)
|
|
facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 61 })
|
|
const restored = await facade.app.document.load('doc-pump-housing')
|
|
assert.equal(restored?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value, 55)
|
|
assert.equal(restored?.recompute?.status, 'completed')
|
|
assert.equal(facade.selection.getObjectId(), '')
|
|
assert.equal(facade.task.getActive(), null)
|
|
assert.equal(facade.project.capabilities().mode, 'sqlite-memory')
|
|
const recovery = await facade.project.recovery('doc-pump-housing')
|
|
assert.equal(recovery.integrity, 'ok')
|
|
assert.equal(recovery.lastSavedVersion, loaded?.version)
|
|
})
|
|
|
|
test('recovery report identifies missing snapshots in the transient fallback store', async () => {
|
|
const facade = createMockFacade()
|
|
const recovery = await facade.project.recovery('missing-document')
|
|
assert.equal(recovery.integrity, 'unavailable')
|
|
assert.equal(recovery.lastSavedVersion, null)
|
|
assert.ok(recovery.warnings.some((warning) => /No saved snapshot/.test(warning)))
|
|
})
|
|
|
|
test('project recovery checkpoints retain the latest five document versions', async () => {
|
|
const persistence = createSqliteProjectPersistence()
|
|
const base = createMockFacade().app.document.getActive()
|
|
for (let version = 1; version <= 7; version += 1) await persistence.save({ ...base, version, label: `Checkpoint ${version}` })
|
|
const recovery = await persistence.recovery(base.id)
|
|
assert.deepEqual(recovery.checkpoints.map((checkpoint) => checkpoint.version), [7, 6, 5, 4, 3])
|
|
assert.equal((await persistence.loadCheckpoint(base.id, 4))?.label, 'Checkpoint 4')
|
|
assert.equal(await persistence.loadCheckpoint(base.id, 2), null)
|
|
assert.equal((await persistence.loadCheckpoint(base.id))?.version, 7)
|
|
const restored = await persistence.loadCheckpoint(base.id, 4)
|
|
if (restored) restored.label = 'mutated clone'
|
|
assert.equal((await persistence.loadCheckpoint(base.id, 4))?.label, 'Checkpoint 4')
|
|
await persistence.dispose()
|
|
})
|
|
|
|
test('FCStd inspection reports recognized, proxy, and Python-backed objects without executing code', () => {
|
|
const documentXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Document SchemaVersion="4">
|
|
<Properties Count="1"><Property name="Label" type="App::PropertyString"><String value="Imported assembly"/></Property></Properties>
|
|
<Objects Count="3">
|
|
<Object type="PartDesign::Body" name="Body"/>
|
|
<Object type="Vendor::CustomFeature" name="Custom"/>
|
|
<Object type="Part::FeaturePython" name="Scripted"/>
|
|
</Objects>
|
|
<ObjectData Count="3">
|
|
<Object name="Body"><Properties Count="1"><Property name="Label" type="App::PropertyString"><String value="Main body"/></Property></Properties></Object>
|
|
<Object name="Custom"><Properties Count="0"/></Object>
|
|
<Object name="Scripted"><Properties Count="0"/></Object>
|
|
</ObjectData>
|
|
</Document>`
|
|
const archive = zipSync({
|
|
'Document.xml': strToU8(documentXml),
|
|
'GuiDocument.xml': strToU8('<GuiDocument/>'),
|
|
'Macro/unsafe.py': strToU8('raise RuntimeError("must not run")'),
|
|
})
|
|
const inspection = inspectFcstdArchive(archive)
|
|
assert.equal(inspection.label, 'Imported assembly')
|
|
assert.equal(inspection.schemaVersion, '4')
|
|
assert.equal(inspection.objects.find((object) => object.name === 'Body')?.label, 'Main body')
|
|
assert.equal(inspection.compatibility.recognizedObjects, 1)
|
|
assert.equal(inspection.compatibility.proxyObjects, 1)
|
|
assert.equal(inspection.compatibility.blockedObjects, 1)
|
|
assert.equal(inspection.compatibility.level, 'blocked')
|
|
assert.equal(inspection.compatibility.codeExecutionBlocked, true)
|
|
assert.deepEqual(inspection.compatibility.unknownTypeIds, ['Vendor::CustomFeature'])
|
|
assert.ok(inspection.entries.some((entry) => entry.role === 'script'))
|
|
assert.equal(inspection.proxyDocument.readOnly, true)
|
|
assert.equal(inspection.proxyDocument.objects.length, 3)
|
|
assert.equal(inspection.proxyDocument.objects[1].properties.find((property) => property.name === 'ImportSupport')?.value, 'proxy')
|
|
})
|
|
|
|
test('FCStd inspection rejects missing metadata, traversal paths, and suspicious compression ratios', () => {
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ 'GuiDocument.xml': strToU8('<GuiDocument/>') })), /Document\.xml/)
|
|
assert.throws(() => inspectFcstdArchive(zipSync({ '../Document.xml': strToU8('<Document/>') })), /Unsafe FCStd entry path/)
|
|
const compressed = zipSync({ 'Document.xml': strToU8(`<Document>${' '.repeat(20_000)}</Document>`) })
|
|
assert.throws(() => inspectFcstdArchive(compressed, { maxCompressionRatio: 2 }), /compression ratio/)
|
|
})
|
|
|
|
test('FCStd inspection is available only through the facade project boundary', () => {
|
|
const facade = createMockFacade()
|
|
const archive = zipSync({ 'Document.xml': strToU8('<Document SchemaVersion="4"><Objects Count="0"/><ObjectData Count="0"/></Document>') })
|
|
const inspection = facade.project.fcstd.inspect(archive)
|
|
assert.equal(inspection.format, 'FCStd')
|
|
assert.equal(inspection.compatibility.level, 'metadata-compatible')
|
|
assert.equal(inspection.compatibility.readOnly, true)
|
|
})
|
|
|
|
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('resource quota policy reserves capacity and sweep planning is conservative', () => {
|
|
assert.deepEqual(assessResourceQuota({ usage: 600, quota: 1000 }, 300, 0.05), { allowed: true, known: true, usage: 600, quota: 1000, incomingBytes: 300, reservedBytes: 50, availableBytes: 350, reason: undefined })
|
|
const denied = assessResourceQuota({ usage: 600, quota: 1000 }, 351, 0.05)
|
|
assert.equal(denied.allowed, false)
|
|
assert.match(denied.reason || '', /only 350 bytes/)
|
|
assert.equal(assessResourceQuota({}, 10).known, false)
|
|
assert.deepEqual(planResourceSweep([
|
|
{ hash: 'active', byteLength: 10, refCount: 2 },
|
|
{ hash: 'zero', byteLength: 20, refCount: 0 },
|
|
{ hash: 'missing', byteLength: 30, refCount: 1 },
|
|
], ['active', 'zero', 'orphan']), { deleteFileHashes: ['orphan', 'zero'], deleteRecordHashes: ['zero'], missingFileHashes: ['missing'] })
|
|
})
|
|
|
|
test('resource sweep is exposed through the project facade in fallback mode', async () => {
|
|
const facade = createMockFacade()
|
|
await facade.project.resource.put(new Uint8Array([1, 2, 3]), 'application/octet-stream')
|
|
const report = await facade.project.resource.sweep()
|
|
assert.equal(report.inspectedRecords, 1)
|
|
assert.equal(report.deletedFiles, 0)
|
|
assert.match(report.warnings[0], /no separately enumerable orphan files/)
|
|
})
|
|
|
|
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: 55, reversed: true, midplane: true })
|
|
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)
|
|
assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Length')?.value, 55)
|
|
assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Reversed')?.value, true)
|
|
assert.equal(committed.objects.find((object) => object.id === 'pad001')?.properties.find((property) => property.name === 'Midplane')?.value, true)
|
|
assert.equal(committed.objects.find((object) => object.id === 'body')?.properties.find((property) => property.name === 'Tip')?.value, 'pad001')
|
|
assert.ok(committed.recompute?.dirtyObjects.includes('pad001'))
|
|
|
|
facade.history.undo()
|
|
assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), false)
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'fillet')
|
|
facade.history.redo()
|
|
assert.equal(facade.app.document.getActive().tree.some((item) => item.id === 'pad001'), true)
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'pad001')
|
|
|
|
facade.gui.command.execute({ commandId: 'revolution' })
|
|
assert.equal(facade.task.getActive()?.commandId, 'revolution')
|
|
facade.task.apply()
|
|
const revolution = facade.app.document.getObject('revolution')
|
|
assert.equal(revolution?.typeId, 'PartDesign::Revolution')
|
|
assert.equal(revolution?.properties.find((property) => property.name === 'Angle')?.value, 360)
|
|
facade.app.document.setProperty({ objectId: 'revolution', propertyName: 'Angle', value: 180 })
|
|
assert.equal(facade.app.document.getObject('revolution')?.properties.find((property) => property.name === 'Angle')?.value, 180)
|
|
|
|
facade.gui.command.execute({ commandId: 'linear-pattern' })
|
|
assert.equal(facade.task.getActive()?.commandId, 'linear-pattern')
|
|
facade.task.update({ occurrences: 4, length: 30, direction: 'Vertical' })
|
|
facade.task.apply()
|
|
const pattern = facade.app.document.getObject('linear-pattern')
|
|
assert.equal(pattern?.typeId, 'PartDesign::LinearPattern')
|
|
assert.equal(pattern?.properties.find((property) => property.name === 'Base')?.value, 'revolution')
|
|
assert.equal(pattern?.properties.find((property) => property.name === 'Occurrences')?.value, 4)
|
|
assert.equal(pattern?.properties.find((property) => property.name === 'Length')?.value, 30)
|
|
assert.equal(pattern?.properties.find((property) => property.name === 'Direction')?.value, 'Vertical')
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'linear-pattern')
|
|
assert.throws(() => facade.app.document.setProperty({ objectId: 'linear-pattern', propertyName: 'Occurrences', value: 1 }), /between 2 and 100/)
|
|
|
|
facade.gui.command.execute({ commandId: 'linear-pattern' })
|
|
facade.task.update({ occurrences: 1, length: 0 })
|
|
assert.throws(() => facade.task.apply(), /between 2 and 100/)
|
|
assert.equal(facade.app.document.getObject('linear-pattern001'), null)
|
|
facade.task.cancel()
|
|
|
|
facade.gui.command.execute({ commandId: 'polar-pattern' })
|
|
facade.task.update({ occurrences: 4, angle: 180, axis: 'Normal' })
|
|
facade.task.apply()
|
|
const polarPattern = facade.app.document.getObject('polar-pattern')
|
|
assert.equal(polarPattern?.typeId, 'PartDesign::PolarPattern')
|
|
assert.equal(polarPattern?.properties.find((property) => property.name === 'Base')?.value, 'linear-pattern')
|
|
assert.equal(polarPattern?.properties.find((property) => property.name === 'Occurrences')?.value, 4)
|
|
assert.equal(polarPattern?.properties.find((property) => property.name === 'Angle')?.value, 180)
|
|
assert.equal(polarPattern?.properties.find((property) => property.name === 'Axis')?.value, 'Normal')
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'polar-pattern')
|
|
|
|
facade.gui.command.execute({ commandId: 'hole' })
|
|
facade.task.update({ diameter: 6, depth: 12, type: 'Through all' })
|
|
facade.task.apply()
|
|
const hole = facade.app.document.getObject('hole')
|
|
assert.equal(hole?.typeId, 'PartDesign::Hole')
|
|
assert.equal(hole?.properties.find((property) => property.name === 'Base')?.value, 'polar-pattern')
|
|
assert.equal(hole?.properties.find((property) => property.name === 'Diameter')?.value, 6)
|
|
assert.equal(hole?.properties.find((property) => property.name === 'Depth')?.value, 12)
|
|
assert.equal(hole?.properties.find((property) => property.name === 'Type')?.value, 'Through all')
|
|
assert.equal(facade.app.document.getObject('body')?.properties.find((property) => property.name === 'Tip')?.value, 'hole')
|
|
})
|
|
|
|
test('Part workbench commands create primitive and boolean document objects', () => {
|
|
const facade = createMockFacade()
|
|
facade.gui.workbench.setActive('Part')
|
|
assert.equal(facade.gui.command.getState('primitive').status, 'enabled')
|
|
assert.equal(facade.gui.command.getState('check-shape').status, 'enabled')
|
|
facade.gui.command.execute({ commandId: 'check-shape' })
|
|
assert.equal(facade.getState().diagnostics.at(-1)?.code, 'SHAPE_NOT_RECOMPUTED')
|
|
facade.gui.command.execute({ commandId: 'primitive' })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('box')?.typeId, 'Part::Box')
|
|
facade.gui.command.execute({ commandId: 'primitive' })
|
|
facade.task.update({ primitiveType: 'Cylinder', radius: 3, height: 12, angle: 180 })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('cylinder')?.typeId, 'Part::Cylinder')
|
|
assert.equal(facade.app.document.getObject('cylinder')?.properties.find((property) => property.name === 'Radius')?.value, 3)
|
|
assert.equal(facade.app.document.getObject('cylinder')?.properties.find((property) => property.name === 'Height')?.value, 12)
|
|
assert.equal(facade.app.document.getObject('cylinder')?.properties.find((property) => property.name === 'Angle')?.value, 180)
|
|
|
|
assert.equal(facade.gui.command.getState('union').status, 'enabled')
|
|
facade.gui.command.execute({ commandId: 'union' })
|
|
facade.task.update({ base: 'box', tool: 'cylinder' })
|
|
facade.task.apply()
|
|
assert.equal(facade.app.document.getObject('union')?.typeId, 'Part::Fuse')
|
|
assert.equal(facade.app.document.getObject('union')?.properties.find((property) => property.name === 'Base')?.value, 'box')
|
|
assert.equal(facade.app.document.getObject('union')?.properties.find((property) => property.name === 'Tool')?.value, 'cylinder')
|
|
assert.deepEqual(facade.app.document.getDependencies().filter((edge) => edge.sourceId === 'union').map((edge) => edge.targetId).sort(), ['box', 'cylinder'])
|
|
facade.selection.select('origin')
|
|
assert.equal(facade.gui.command.getState('union').status, 'disabled')
|
|
assert.match(facade.gui.command.getState('union').reason || '', /solid or shape-producing/)
|
|
assert.equal(facade.gui.command.getState('check-shape').status, 'disabled')
|
|
})
|