P7/P4: harden recompute and FCStd boundaries
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
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 } from '../src/facade/projectSchema'
|
||||
import { PersistenceWriteQueue, ProjectAutosaveScheduler } from '../src/facade/projectStore'
|
||||
@@ -7,8 +8,36 @@ import { DependencyGraph } from '../src/facade/dependencyGraph'
|
||||
import { evaluateQuantityExpression, quantityFromNumber } from '../src/facade/units'
|
||||
import { createSubshapeRefs, matchSubshapes, signatureForFace } from '../src/facade/topologyNaming'
|
||||
import { createSketch, solveSketch } from '../src/facade/sketcher'
|
||||
import { RecomputeCoordinator } 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 { ShapeHandle } from '../src/facade/types'
|
||||
import type { DocumentSnapshot, ShapeHandle } 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()
|
||||
@@ -174,6 +203,66 @@ test('dependency graph propagates dirty state and orders dependencies', () => {
|
||||
assert.deepEqual(graph.findCycles(new Set(['pad', 'pocket', 'fillet', 'body'])), [['pad', 'pocket', 'fillet', 'body']])
|
||||
})
|
||||
|
||||
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 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('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('persistence writes are serialized and continue after a failed write', async () => {
|
||||
const queue = new PersistenceWriteQueue()
|
||||
let inFlight = 0
|
||||
@@ -248,6 +337,17 @@ test('disabled commands return a reason instead of mutating the document', () =>
|
||||
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('linear-pattern')
|
||||
assert.equal(state.status, 'disabled')
|
||||
assert.match(state.reason || '', /business executor/)
|
||||
facade.gui.command.execute({ commandId: 'linear-pattern' })
|
||||
assert.equal(facade.getState().diagnostics.at(-1)?.code, 'COMMAND_UNIMPLEMENTED')
|
||||
assert.equal(facade.getState().document.version, before)
|
||||
})
|
||||
|
||||
test('command events carry a document-scoped request context', () => {
|
||||
const facade = createMockFacade()
|
||||
const events: string[] = []
|
||||
@@ -334,6 +434,55 @@ test('project persistence remains behind the facade contract', async () => {
|
||||
assert.equal(facade.project.capabilities().mode, 'sqlite-memory')
|
||||
})
|
||||
|
||||
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'))
|
||||
})
|
||||
|
||||
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])
|
||||
|
||||
Reference in New Issue
Block a user