feat: establish reproducible FreeCAD web compatibility baseline
This commit is contained in:
16
tests/addonGovernance.test.ts
Normal file
16
tests/addonGovernance.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAddonManager, createSignedAddonPackage, type AddonManifest } from '../src/facade/addonGovernance'
|
||||
|
||||
const manifest = (version: string, permissions: AddonManifest['permissions'] = ['geometry.read']): AddonManifest => ({ id: 'measure-addon', version, name: 'Measure', entrypoint: 'facade.measure', permissions, dependencies: [] })
|
||||
describe('addon governance', () => {
|
||||
it('verifies, installs, updates and rolls back signed Facade-only packages', () => {
|
||||
const manager = createAddonManager({ trustedKeys: { release: 'test-secret' } }); const first = createSignedAddonPackage(manifest('1.0.0'), 'commands:v1', 'release', 'test-secret'); const second = createSignedAddonPackage(manifest('1.1.0'), 'commands:v2', 'release', 'test-secret')
|
||||
assert.equal(manager.verify(first).valid, true); assert.equal(manager.install(first).revision, 1); assert.equal(manager.update(second).revision, 2); assert.equal(manager.rollback('measure-addon').manifest.version, '1.0.0'); assert.equal(manager.snapshot().catalog[0].manifest.version, '1.0.0'); manager.remove('measure-addon'); assert.equal(manager.snapshot().catalog.length, 0)
|
||||
})
|
||||
it('rejects forged signatures, disallowed permissions and tampered payloads', () => {
|
||||
const manager = createAddonManager({ trustedKeys: { release: 'test-secret' } }); const unsafe = createSignedAddonPackage(manifest('1.0.0', ['network']), 'commands', 'release', 'test-secret'); assert.throws(() => manager.install(unsafe), /permission exceeds policy/)
|
||||
const forged = { ...createSignedAddonPackage(manifest('1.0.0'), 'commands', 'release', 'wrong'), payload: 'tampered' }; assert.throws(() => manager.install(forged), /digest mismatch/)
|
||||
const unknown = createSignedAddonPackage(manifest('1.0.0'), 'commands', 'unknown', 'test-secret'); assert.throws(() => manager.install(unknown), /untrusted or invalid signature/)
|
||||
})
|
||||
})
|
||||
75
tests/assembly.test.ts
Normal file
75
tests/assembly.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAssembly } from '../src/facade/assembly'
|
||||
|
||||
const connector = (id: string, componentId: string, x = 0) => ({ id, componentId, origin: { x, y: 0, z: 0 }, axis: { x: 1, y: 0, z: 0 } })
|
||||
|
||||
test('assembly solves grounded coincident and distance joints deterministically', () => {
|
||||
const assembly = createAssembly('motor', 'Motor assembly')
|
||||
assembly.addComponent({ id: 'base', sourceObjectId: 'box-base', label: 'Base', grounded: true, placement: { x: 0 } })
|
||||
assembly.addComponent({ id: 'shaft', sourceObjectId: 'box-shaft', label: 'Shaft', grounded: false, placement: { x: 9 } })
|
||||
assembly.addConnector(connector('base-origin', 'base'))
|
||||
assembly.addConnector(connector('shaft-origin', 'shaft'))
|
||||
assembly.addJoint({ id: 'coincident', kind: 'coincident', first: 'base-origin', second: 'shaft-origin' })
|
||||
assembly.addJoint({ id: 'distance', kind: 'distance', first: 'base-origin', second: 'shaft-origin', value: 5 })
|
||||
const solved = assembly.solve()
|
||||
assert.equal(solved.solver.status, 'solved')
|
||||
assert.equal(solved.joints.every((joint) => joint.status === 'solved'), true)
|
||||
assert.equal(solved.components.find((component) => component.id === 'shaft')?.placement.x, 5)
|
||||
assert.equal(solved.solver.iterations, 2)
|
||||
})
|
||||
|
||||
test('assembly rejects invalid connectors and reports grounded conflicts', () => {
|
||||
const assembly = createAssembly()
|
||||
assembly.addComponent({ id: 'a', sourceObjectId: 'shape-a', label: 'A', grounded: true })
|
||||
assembly.addComponent({ id: 'b', sourceObjectId: 'shape-b', label: 'B', grounded: true, placement: { x: 2 } })
|
||||
assert.throws(() => assembly.addConnector({ id: 'bad', componentId: 'a', origin: { x: 0, y: 0, z: 0 }, axis: { x: 0, y: 0, z: 0 } }), /must be non-zero/)
|
||||
assembly.addConnector(connector('a-origin', 'a'))
|
||||
assembly.addConnector(connector('b-origin', 'b'))
|
||||
assembly.addJoint({ id: 'fixed', kind: 'fixed', first: 'a-origin', second: 'b-origin' })
|
||||
const solved = assembly.solve()
|
||||
assert.equal(solved.solver.status, 'conflicting')
|
||||
assert.equal(solved.diagnostics[0]?.code, 'GROUND_CONFLICT')
|
||||
})
|
||||
|
||||
test('assembly angle joint validates its range and snapshots are isolated', () => {
|
||||
const assembly = createAssembly()
|
||||
assembly.addComponent({ id: 'a', sourceObjectId: 'shape-a', label: 'A', grounded: true })
|
||||
assembly.addComponent({ id: 'b', sourceObjectId: 'shape-b', label: 'B', grounded: true })
|
||||
assembly.addConnector(connector('a-origin', 'a'))
|
||||
assembly.addConnector(connector('b-origin', 'b'))
|
||||
assert.throws(() => assembly.addJoint({ id: 'invalid', kind: 'angle', first: 'a-origin', second: 'b-origin', value: Math.PI * 2 }), /outside the supported range/)
|
||||
assembly.addJoint({ id: 'angle', kind: 'angle', first: 'a-origin', second: 'b-origin', value: 0 })
|
||||
const snapshot = assembly.snapshot()
|
||||
snapshot.components[0].placement.x = 99
|
||||
assert.equal(assembly.snapshot().components[0]?.placement.x, 0)
|
||||
})
|
||||
|
||||
test('assembly tools provide deterministic BOM, collision, exploded and variant views', () => {
|
||||
const assembly = createAssembly()
|
||||
assembly.addComponent({ id: 'a', sourceObjectId: 'plate', label: 'Plate', placement: { x: 0 }, bounds: { min: { x: -1, y: -1, z: -1 }, max: { x: 1, y: 1, z: 1 } } })
|
||||
assembly.addComponent({ id: 'b', sourceObjectId: 'plate', label: 'Plate', placement: { x: 0.5 } })
|
||||
assembly.addComponent({ id: 'c', sourceObjectId: 'bolt', label: 'Bolt', placement: { x: 10 } })
|
||||
assert.deepEqual(assembly.bom().map((row) => [row.sourceObjectId, row.quantity]), [['bolt', 1], ['plate', 2]])
|
||||
assert.equal(assembly.collisions().length, 1)
|
||||
assert.ok(assembly.exploded(2).some((component) => component.placement.x !== assembly.snapshot().components.find((entry) => entry.id === component.id)?.placement.x))
|
||||
assert.equal(assembly.variant('open', { c: { x: 12 } }).placements.find((component) => component.id === 'c')?.placement.x, 12)
|
||||
const motion = assembly.motion('c', { x: 14, yaw: Math.PI / 2 }, 4)
|
||||
assert.equal(motion.length, 5)
|
||||
assert.equal(motion[0].placement.x, 10)
|
||||
assert.equal(motion.at(-1)?.placement.x, 14)
|
||||
assert.equal(motion.at(-1)?.placement.yaw, Math.PI / 2)
|
||||
assert.throws(() => assembly.motion('c', { x: 1 }, 0), /between 1 and 10000/)
|
||||
})
|
||||
|
||||
test('assembly solves a movable angle joint', () => {
|
||||
const assembly = createAssembly()
|
||||
assembly.addComponent({ id: 'base', sourceObjectId: 'base-shape', label: 'Base', grounded: true })
|
||||
assembly.addComponent({ id: 'arm', sourceObjectId: 'arm-shape', label: 'Arm', grounded: false })
|
||||
assembly.addConnector(connector('base-axis', 'base'))
|
||||
assembly.addConnector(connector('arm-axis', 'arm'))
|
||||
assembly.addJoint({ id: 'right-angle', kind: 'angle', first: 'base-axis', second: 'arm-axis', value: Math.PI / 2 })
|
||||
const solved = assembly.solve()
|
||||
assert.equal(solved.solver.status, 'solved')
|
||||
assert.equal(solved.components.find((component) => component.id === 'arm')?.placement.yaw, Math.PI / 2)
|
||||
})
|
||||
11
tests/bim.test.ts
Normal file
11
tests/bim.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createBimModel } from '../src/facade/bim'
|
||||
|
||||
test('BIM hierarchy, material, property and quantity schedule', () => {
|
||||
const bim = createBimModel(); bim.addSite({ id: 'site', label: 'Campus', latitude: 40, longitude: -74 }); bim.addBuilding({ id: 'building', label: 'Plant', siteId: 'site' }); bim.addLevel({ id: 'L1', label: 'Ground', elevation: 0, buildingId: 'building' }); bim.addSpace({ id: 'S1', label: 'Plant room', levelId: 'L1', area: 24 }); bim.addMaterial({ id: 'concrete', name: 'Concrete', category: 'structural' }); bim.addElement({ id: 'wall-1', label: 'Wall 1', type: 'wall', shapeId: 'shape-1', levelId: 'L1', spaceId: 'S1', properties: {}, quantity: { length: 5, area: 12, volume: 1.2 } }); bim.assignMaterial('wall-1', 'concrete'); bim.setClassification('wall-1', { system: 'OmniClass', code: '21-02' }); bim.setElementProperty('wall-1', 'FireRating', 'A1'); assert.equal(bim.schedule()[0]?.quantity.volume, 1.2); assert.match(bim.exportIfc(), /IFCBUILDINGELEMENTPROXY/); const ifc = bim.exportIfc('IFC4'); assert.equal(bim.importIfc(ifc).schema, 'IFC4'); assert.equal(bim.importIfc(bim.exportIfc('IFC2X3')).schema, 'IFC2X3')
|
||||
})
|
||||
|
||||
test('BIM rejects elements with missing hierarchy links', () => {
|
||||
const bim = createBimModel(); assert.throws(() => bim.addSpace({ id: 'space', label: 'Space', levelId: 'missing' }), /level does not exist/); bim.addLevel({ id: 'L1', label: 'L1', elevation: 0 }); assert.throws(() => bim.addElement({ id: 'wall', label: 'Wall', type: 'wall', shapeId: 'shape', levelId: 'L1', spaceId: 'missing', properties: {}, quantity: {} }), /space does not exist/)
|
||||
})
|
||||
291
tests/cam.test.ts
Normal file
291
tests/cam.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createCamJob } from '../src/facade/cam'
|
||||
import { menuDefinitions, workbenchDefinitions } from '../src/freecadManifest'
|
||||
|
||||
test('CAM job generates a FreeCAD-style Profile path, simulates and posts deterministic G-code', () => {
|
||||
const cam = createCamJob('job', 'Job', { min: [0, 0, 0], max: [10, 8, 6], mode: 'from-base-bound-box' })
|
||||
cam.addTool({ id: 'T1', name: '6mm end mill', shape: 'endmill', diameter: 6, length: 30 })
|
||||
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 2, feed: 120 })
|
||||
const operation = cam.generateRectangularPath('profile')
|
||||
const simulation = cam.simulate()
|
||||
assert.equal(operation.path.length, 5)
|
||||
assert.equal(simulation.status, 'pass')
|
||||
assert.deepEqual(simulation.collisions, [])
|
||||
assert.equal(simulation.activeOperations, 1)
|
||||
assert.equal(simulation.pathPoints, 5)
|
||||
assert.deepEqual(cam.postprocessors(), ['grbl', 'linuxcnc', 'mach3-mach4', 'centroid', 'marlin', 'masso-g3', 'snapmaker'])
|
||||
const first = cam.exportGcode('linuxcnc')
|
||||
assert.match(first, /post:linuxcnc/)
|
||||
assert.match(first, /T1 M6/)
|
||||
assert.equal(first, cam.exportGcode('linuxcnc'))
|
||||
assert.throws(() => cam.exportGcode('unsafe' as never), /not allowed/)
|
||||
})
|
||||
|
||||
test('CAM SetupSheet, Tool Controller, Pocket and drilling operations retain native manufacturing parameters', () => {
|
||||
const cam = createCamJob('job', 'Machining Job', { min: [0, 0, 0], max: [20, 12, 8] })
|
||||
cam.updateSetupSheet({ safeHeightOffset: 2, clearanceHeightOffset: 4, coolantMode: 'flood' })
|
||||
cam.addTool({ id: 'T2', name: '4mm ball end', shape: 'ballend', diameter: 4, length: 40, cuttingEdgeHeight: 12, shankDiameter: 4 })
|
||||
cam.addToolController({ id: 'TC2', label: 'Ball End Controller', toolId: 'T2', toolNumber: 2, spindleSpeed: 18000, spindleDirection: 'forward', horizontalFeed: 480, verticalFeed: 120, horizontalRapid: 2500, verticalRapid: 1000 })
|
||||
cam.addOperation({ id: 'pocket', label: 'Pocket Shape', kind: 'pocket', toolId: 'T2', controllerId: 'TC2', depth: 4, feed: 480, verticalFeed: 120, stepDown: 2, stepOver: 50, direction: 'climb', coolantMode: 'flood' })
|
||||
const pocket = cam.generatePath('pocket')
|
||||
cam.addOperation({ id: 'drill', kind: 'drilling', toolId: 'T2', controllerId: 'TC2', depth: 3, feed: 120, locations: [[4, 4, 8], [16, 8, 8]] })
|
||||
const drilling = cam.generatePath('drill')
|
||||
assert.ok(pocket.path.length > 10)
|
||||
assert.equal(drilling.path.length, 6)
|
||||
assert.equal(cam.simulate().status, 'pass')
|
||||
assert.match(cam.exportGcode('grbl'), /M8/)
|
||||
const snapshot = cam.snapshot()
|
||||
assert.equal(snapshot.setupSheet.clearanceHeightOffset, 4)
|
||||
assert.equal(snapshot.toolControllers[0].spindleSpeed, 18000)
|
||||
assert.equal(snapshot.operations[0].stepDown, 2)
|
||||
})
|
||||
|
||||
test('CAM dress-ups, operation lifecycle and Sanity Check remain deterministic', () => {
|
||||
const cam = createCamJob('job', 'Dress-up Job')
|
||||
cam.addTool({ id: 'T1', name: 'Tool', diameter: 2, length: 20 })
|
||||
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
|
||||
cam.generatePath('profile')
|
||||
cam.applyDressup('profile', { kind: 'lead-in-out', parameters: { length: 1 } })
|
||||
const dressed = cam.generatePath('profile')
|
||||
const copied = cam.copyOperation('profile', 'profile-copy')
|
||||
assert.equal(dressed.path.length, 7)
|
||||
assert.equal(copied.path.length, 7)
|
||||
assert.equal(cam.sanityCheck().status, 'pass')
|
||||
cam.toggleOperation('profile', false)
|
||||
assert.equal(cam.sanityCheck().status, 'warning')
|
||||
assert.equal(cam.simulate().activeOperations, 1)
|
||||
cam.removeOperation('profile')
|
||||
assert.equal(cam.snapshot().operations.length, 1)
|
||||
})
|
||||
|
||||
test('CAM rejects invalid setup, depth, duplicate tools and G-code on collision', () => {
|
||||
const cam = createCamJob('job', 'Job')
|
||||
assert.throws(() => cam.updateSetupSheet({ safeHeightOffset: 6, clearanceHeightOffset: 5 }), /cannot be below/)
|
||||
cam.addTool({ id: 'T1', name: 'Tool', diameter: 2, length: 10 })
|
||||
assert.throws(() => cam.addTool({ id: 'T1', name: 'Duplicate', diameter: 2, length: 10 }), /already exists/)
|
||||
assert.throws(() => cam.addOperation({ id: 'bad', kind: 'pocket', toolId: 'T1', depth: 11, feed: 10 }), /outside stock/)
|
||||
cam.addOperation({ id: 'draft', kind: 'contour', toolId: 'T1', depth: 1, feed: 10 })
|
||||
const simulation = cam.simulate()
|
||||
assert.equal(simulation.status, 'collision')
|
||||
assert.equal(simulation.diagnostics[0].type, 'missing-path')
|
||||
assert.throws(() => cam.exportGcode(), /collisions/)
|
||||
})
|
||||
|
||||
test('CAM ToolBit assets, controller edits and dependency-safe removal are deterministic', () => {
|
||||
const source = createCamJob('source')
|
||||
source.addTool({ id: 'T1', name: '3 mm Drill', shape: 'drill', diameter: 3, length: 35, cuttingEdgeHeight: 18, shankDiameter: 3 })
|
||||
const asset = source.exportToolBit('T1')
|
||||
assert.equal(asset, source.exportToolBit('T1'))
|
||||
|
||||
const cam = createCamJob('target')
|
||||
const loaded = cam.importToolBit(asset, 'T7')
|
||||
assert.equal(loaded.id, 'T7')
|
||||
assert.equal(loaded.shape, 'drill')
|
||||
cam.addToolController({ id: 'TC7', label: 'Drill Controller', toolId: 'T7', toolNumber: 7, spindleSpeed: 9000, spindleDirection: 'forward', horizontalFeed: 250, verticalFeed: 80, horizontalRapid: 1000, verticalRapid: 500 })
|
||||
assert.equal(cam.updateToolController('TC7', { spindleSpeed: 10000 }).spindleSpeed, 10000)
|
||||
assert.throws(() => cam.removeTool('T7'), /used by a Tool Controller/)
|
||||
cam.removeToolController('TC7')
|
||||
cam.removeTool('T7')
|
||||
assert.equal(cam.snapshot().tools.length, 0)
|
||||
assert.throws(() => cam.importToolBit('{bad json'), /valid JSON/)
|
||||
})
|
||||
|
||||
test('CAM extended operations, Drag Knife, Dogbone and start-point editing generate stable paths', () => {
|
||||
const cam = createCamJob('extended', 'Extended CAM', { min: [0, 0, 0], max: [12, 10, 6] })
|
||||
cam.addTool({ id: 'T1', name: '2 mm Tool', diameter: 2, length: 30 })
|
||||
const kinds = ['area', 'area-workplane', 'custom', 'shape', 'path-shape-tool-controller'] as const
|
||||
for (const [index, kind] of kinds.entries()) {
|
||||
const id = `${kind}-${index}`
|
||||
cam.addOperation({ id, kind, toolId: 'T1', depth: 1, feed: 100 })
|
||||
assert.ok(cam.generatePath(id).path.length >= 2)
|
||||
}
|
||||
cam.applyDressup('shape-3', { kind: 'dogbone', parameters: { radius: 0.4 } })
|
||||
const dogbone = cam.generatePath('shape-3')
|
||||
assert.ok(dogbone.path.length > 5)
|
||||
cam.applyDressup('area-0', { kind: 'drag-knife', parameters: { offset: 0.3 } })
|
||||
const dragged = cam.generatePath('area-0')
|
||||
assert.ok(dragged.path.length >= 5)
|
||||
const originalStart = cam.snapshot().operations.find((operation) => operation.id === 'area-workplane-1')?.path[0]
|
||||
const shifted = cam.setStartPoint('area-workplane-1', 1)
|
||||
assert.notDeepEqual(shifted.path[0], originalStart)
|
||||
assert.deepEqual(shifted.path[0], shifted.path.at(-1))
|
||||
})
|
||||
|
||||
test('CAM material removal timeline and fixture holder collision are deterministic', () => {
|
||||
const cam = createCamJob('simulation', 'Simulation', { min: [0, 0, 0], max: [10, 10, 5] })
|
||||
cam.addTool({ id: 'T1', name: 'Short edge tool', diameter: 2, length: 20, cuttingEdgeHeight: 1, shankDiameter: 2, holderDiameter: 4, holderLength: 20 })
|
||||
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
|
||||
cam.generatePath('profile')
|
||||
const first = cam.simulate()
|
||||
assert.equal(first.status, 'pass')
|
||||
assert.equal(first.materialRemoval.enabled, true)
|
||||
assert.equal(first.materialRemoval.stockVolume, 500)
|
||||
assert.ok(first.materialRemoval.removedVolume > 0)
|
||||
assert.equal(first.materialRemoval.timeline.length, first.pathPoints)
|
||||
assert.equal(first.materialRemoval.remainingVolume, first.materialRemoval.stockVolume - first.materialRemoval.removedVolume)
|
||||
cam.setMaterialRemovalEnabled(false)
|
||||
const disabled = cam.simulate()
|
||||
assert.equal(disabled.materialRemoval.enabled, false)
|
||||
assert.equal(disabled.materialRemoval.removedVolume, 0)
|
||||
assert.equal(disabled.materialRemoval.timeline.length, 0)
|
||||
cam.setMaterialRemovalEnabled(true)
|
||||
cam.updateCollisionFixtures([{ id: 'clamp', kind: 'fixture', min: [-1, -1, 5], max: [1, 1, 7] }])
|
||||
const collision = cam.simulate()
|
||||
assert.equal(collision.status, 'collision')
|
||||
assert.ok(collision.diagnostics.some((diagnostic) => diagnostic.type === 'fixture' && diagnostic.message.includes('clamp')))
|
||||
assert.throws(() => cam.updateCollisionFixtures([{ id: 'clamp', min: [0, 0, 0], max: [1, 1, 1] }, { id: 'clamp', min: [2, 2, 2], max: [3, 3, 3] }]), /unique/)
|
||||
})
|
||||
|
||||
test('CAM browser raster timeline records feed time and does not remove overlapping stock twice', () => {
|
||||
const cam = createCamJob('raster', 'Raster simulation', { min: [0, 0, 0], max: [12, 6, 4] })
|
||||
cam.addTool({ id: 'T1', name: '2 mm End Mill', diameter: 2, length: 20, cuttingEdgeHeight: 10, holderDiameter: 5, holderLength: 20 })
|
||||
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 2, feed: 60 })
|
||||
const profile = cam.generatePath('profile')
|
||||
const first = cam.materialRemovalTimeline({ resolution: 0.5, maxCells: 4096 })
|
||||
|
||||
assert.equal(first.status, 'pass')
|
||||
assert.equal(first.backend, 'browser-raster')
|
||||
assert.equal(first.model, '2d-column-raster')
|
||||
assert.equal(first.nativeEquivalent, false)
|
||||
assert.equal(first.events.length, profile.path.length)
|
||||
assert.ok(first.removedVolume > 0 && first.removedVolume <= first.initialVolume)
|
||||
assert.equal(first.remainingVolume, first.initialVolume - first.removedVolume)
|
||||
assert.equal(first.events.at(-1)?.cumulativeRemovedVolume, first.removedVolume)
|
||||
assert.equal(first.events.at(-1)?.endTimeMinutes, first.durationMinutes)
|
||||
assert.ok(first.events.every((event, index) => index === 0 || event.startTimeMinutes === first.events[index - 1].endTimeMinutes))
|
||||
assert.deepEqual(cam.materialRemovalTimeline({ resolution: 0.5, maxCells: 4096 }), first)
|
||||
|
||||
cam.copyOperation('profile', 'profile-copy')
|
||||
const repeated = cam.materialRemovalTimeline({ resolution: 0.5, maxCells: 4096 })
|
||||
assert.equal(repeated.removedVolume, first.removedVolume)
|
||||
assert.ok(Math.abs(repeated.durationMinutes - first.durationMinutes * 2) < 1e-12)
|
||||
assert.equal(repeated.events.length, first.events.length * 2)
|
||||
assert.throws(() => cam.materialRemovalTimeline({ resolution: 0 }), /positive/)
|
||||
assert.throws(() => cam.materialRemovalTimeline({ maxCells: 1.5 }), /integer/)
|
||||
})
|
||||
|
||||
test('CAM collision report distinguishes holder-stock and swept cutter-fixture collisions', () => {
|
||||
const holderCam = createCamJob('holder', 'Holder collision', { min: [0, 0, 0], max: [10, 10, 5] })
|
||||
holderCam.addTool({ id: 'T1', name: 'Short flute', diameter: 2, length: 20, cuttingEdgeHeight: 0.5, holderDiameter: 4, holderLength: 12 })
|
||||
holderCam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
|
||||
holderCam.generatePath('profile')
|
||||
const holderReport = holderCam.collisionReport()
|
||||
assert.equal(holderReport.status, 'collision')
|
||||
assert.ok(holderReport.holderCollisions > 0)
|
||||
assert.ok(holderReport.collisions.some((collision) => collision.kind === 'holder-stock' && collision.penetration > 0 && collision.clearance < 0))
|
||||
assert.ok(holderCam.simulate().diagnostics.some((diagnostic) => diagnostic.type === 'tool-holder'))
|
||||
|
||||
const fixtureCam = createCamJob('fixture', 'Swept fixture collision', { min: [0, 0, 0], max: [10, 10, 5] })
|
||||
fixtureCam.addTool({ id: 'T1', name: '1 mm End Mill', diameter: 1, length: 20, cuttingEdgeHeight: 10, holderDiameter: 4, holderLength: 20 })
|
||||
fixtureCam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
|
||||
fixtureCam.generatePath('profile')
|
||||
fixtureCam.updateCollisionFixtures([{ id: 'mid-span-clamp', min: [4.8, -0.2, 3.5], max: [5.2, 0.2, 4.5] }])
|
||||
const fixtureReport = fixtureCam.collisionReport()
|
||||
const swept = fixtureReport.collisions.find((collision) => collision.kind === 'cutter-fixture' && collision.fixtureId === 'mid-span-clamp')
|
||||
assert.equal(fixtureReport.status, 'collision')
|
||||
assert.ok(fixtureReport.fixtureCollisions > 0)
|
||||
assert.equal(swept?.pathIndex, 1)
|
||||
assert.ok((swept?.point[0] ?? 0) > 4 && (swept?.point[0] ?? 0) < 6)
|
||||
const simulation = fixtureCam.simulate()
|
||||
assert.equal(simulation.status, 'collision')
|
||||
assert.deepEqual(simulation.collisionReport, fixtureReport)
|
||||
assert.ok(simulation.diagnostics.some((diagnostic) => diagnostic.type === 'fixture' && diagnostic.message.includes('mid-span-clamp')))
|
||||
})
|
||||
|
||||
test('CAM Job assets and Undo/Redo preserve the complete browser manufacturing graph', () => {
|
||||
const cam = createCamJob('asset-job', 'Asset Job', { min: [0, 0, 0], max: [8, 6, 4], mode: 'existing-solid' })
|
||||
cam.updateSetupSheet({ safeHeightOffset: 1, clearanceHeightOffset: 2, coolantMode: 'mist' })
|
||||
cam.addTool({ id: 'T1', name: '2 mm End Mill', diameter: 2, length: 20, cuttingEdgeHeight: 8, holderDiameter: 5, holderLength: 20 })
|
||||
cam.addToolController({ id: 'TC1', label: 'Controller', toolId: 'T1', toolNumber: 1, spindleSpeed: 12000, spindleDirection: 'forward', horizontalFeed: 100, verticalFeed: 50, horizontalRapid: 500, verticalRapid: 300 })
|
||||
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', controllerId: 'TC1', depth: 1, feed: 100 })
|
||||
cam.applyDressup('profile', { kind: 'dogbone', parameters: { radius: 0.25 } })
|
||||
cam.generatePath('profile')
|
||||
cam.updateCollisionFixtures([{ id: 'clamp', min: [3, -1, 3.5], max: [4, 0.2, 5] }])
|
||||
const asset = cam.exportJob()
|
||||
assert.equal(asset, cam.exportJob())
|
||||
|
||||
cam.toggleOperation('profile', false)
|
||||
assert.equal(cam.snapshot().operations[0].active, false)
|
||||
assert.equal(cam.canUndo(), true)
|
||||
assert.equal(cam.undo().operations[0].active, true)
|
||||
assert.equal(cam.canRedo(), true)
|
||||
assert.equal(cam.redo().operations[0].active, false)
|
||||
|
||||
const reopened = createCamJob('asset-job', 'Asset Job')
|
||||
const loaded = reopened.importJob(asset)
|
||||
assert.equal(reopened.exportJob(), asset)
|
||||
assert.equal(loaded.operations[0].dressups[0].kind, 'dogbone')
|
||||
assert.equal(loaded.collisionFixtures[0].id, 'clamp')
|
||||
assert.equal(reopened.canUndo(), true)
|
||||
assert.equal(reopened.undo().operations.length, 0)
|
||||
assert.equal(reopened.redo().operations.length, 1)
|
||||
assert.throws(() => reopened.importJob('{bad json'), /valid JSON/)
|
||||
assert.throws(() => createCamJob('other', 'Other').importJob(asset), /identity/)
|
||||
const invalid = JSON.parse(asset)
|
||||
invalid.job.operations[0].path[0][0] = null
|
||||
assert.throws(() => reopened.importJob(JSON.stringify(invalid)), /finite coordinates/)
|
||||
})
|
||||
|
||||
test('CAM 4-axis and 5-axis kinematics enforce limits and emit controlled G-code', () => {
|
||||
const cam = createCamJob('multi-axis', 'Multi Axis', { min: [0, 0, 0], max: [10, 8, 5] })
|
||||
cam.addTool({ id: 'T1', name: '2 mm End Mill', diameter: 2, length: 30, cuttingEdgeHeight: 30 })
|
||||
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
|
||||
const operation = cam.generatePath('profile')
|
||||
const fourAxis = cam.kinematics('profile', { configuration: '4-axis', rotary: { axis: 'A', startDegrees: 0, endDegrees: 90 } })
|
||||
assert.equal(fourAxis.status, 'pass')
|
||||
assert.equal(fourAxis.nativeEquivalent, false)
|
||||
assert.equal(fourAxis.poses.length, operation.path.length)
|
||||
assert.equal(fourAxis.poses[0].a, 0)
|
||||
assert.equal(fourAxis.poses.at(-1)?.a, 90)
|
||||
const fiveAxis = cam.kinematics('profile', { configuration: '5-axis', rotary: { axis: 'A', startDegrees: -30, endDegrees: 30 }, tilt: { axis: 'B', startDegrees: 0, endDegrees: 45 } })
|
||||
assert.equal(fiveAxis.status, 'pass')
|
||||
assert.equal(fiveAxis.poses.at(-1)?.b, 45)
|
||||
const limited = cam.kinematics('profile', { configuration: '5-axis', rotary: { axis: 'A', startDegrees: 0, endDegrees: 20 }, tilt: { axis: 'B', startDegrees: 0, endDegrees: 150 } })
|
||||
assert.equal(limited.status, 'limit')
|
||||
assert.ok(limited.violations.some((violation) => violation.axis === 'B'))
|
||||
const gcode = cam.exportMultiAxisGcode('profile', 'linuxcnc', { configuration: '5-axis', rotary: { axis: 'C', startDegrees: -30, endDegrees: 30 }, tilt: { axis: 'B', startDegrees: 0, endDegrees: 45 } })
|
||||
assert.match(gcode, /post:linuxcnc xyzbc-rtcp/)
|
||||
assert.match(gcode, /B45\.000 C30\.000/)
|
||||
assert.match(gcode, /M428/)
|
||||
assert.match(gcode, /G93/)
|
||||
assert.match(gcode, /G94\nM429/)
|
||||
assert.equal(gcode, cam.exportMultiAxisGcode('profile', 'linuxcnc', { configuration: '5-axis', rotary: { axis: 'C', startDegrees: -30, endDegrees: 30 }, tilt: { axis: 'B', startDegrees: 0, endDegrees: 45 } }))
|
||||
assert.throws(() => cam.exportMultiAxisGcode('profile', 'linuxcnc', { configuration: '5-axis', rotary: { axis: 'A', startDegrees: -30, endDegrees: 30 }, tilt: { axis: 'B', startDegrees: 0, endDegrees: 45 } }), /requires distinct B and C/)
|
||||
assert.throws(() => cam.exportMultiAxisGcode('profile', 'grbl', { configuration: '4-axis', rotary: { axis: 'A', startDegrees: 0, endDegrees: 90 } }), /does not support/)
|
||||
assert.throws(() => cam.kinematics('profile', { configuration: '5-axis', rotary: { axis: 'A', startDegrees: 0, endDegrees: 10 }, tilt: { axis: 'A', startDegrees: 0, endDegrees: 10 } }), /different/)
|
||||
})
|
||||
|
||||
test('CAM Job comments, typed properties and operation compounds round-trip with undo/redo', () => {
|
||||
const cam = createCamJob('metadata', 'Metadata Job')
|
||||
cam.addTool({ id: 'T1', name: '2 mm End Mill', diameter: 2, length: 20 })
|
||||
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
|
||||
cam.addOperation({ id: 'pocket', kind: 'pocket', toolId: 'T1', depth: 1, feed: 100 })
|
||||
assert.equal(cam.addComment(' Setup note '), 'Setup note')
|
||||
cam.setPropertyBagValue('Material', 'Al6061')
|
||||
cam.setPropertyBagValue('BatchSize', 12)
|
||||
cam.setPropertyBagValue('DryRun', true)
|
||||
const compound = cam.createCompound(['profile', 'pocket'], 'roughing', 'Roughing Group')
|
||||
assert.deepEqual(compound, { id: 'roughing', label: 'Roughing Group', operationIds: ['profile', 'pocket'] })
|
||||
assert.deepEqual(cam.snapshot().metadata, { comments: ['Setup note'], propertyBag: { Material: 'Al6061', BatchSize: 12, DryRun: true }, compounds: [compound] })
|
||||
const asset = cam.exportJob()
|
||||
const reopened = createCamJob('metadata', 'Metadata Job')
|
||||
assert.deepEqual(reopened.importJob(asset).metadata, cam.snapshot().metadata)
|
||||
reopened.removeOperation('pocket')
|
||||
assert.deepEqual(reopened.snapshot().metadata.compounds, [])
|
||||
assert.deepEqual(reopened.undo().metadata.compounds, [compound])
|
||||
assert.deepEqual(reopened.redo().metadata.compounds, [])
|
||||
assert.throws(() => cam.setPropertyBagValue('bad name', 'value'), /property name/)
|
||||
assert.throws(() => cam.createCompound(['profile', 'missing']), /existing, unique operations/)
|
||||
})
|
||||
|
||||
test('CAM manifest exposes every actionable FreeCAD CAM command exactly once', () => {
|
||||
const toolbar = workbenchDefinitions.CAM.groups.flatMap((group) => group.commands)
|
||||
const menu = menuDefinitions.CAM
|
||||
assert.equal(workbenchDefinitions.CAM.groups.length, 4)
|
||||
assert.equal(toolbar.length, 60)
|
||||
assert.equal(menu.length, 60)
|
||||
assert.equal(new Set(toolbar.map((command) => command.id)).size, 60)
|
||||
assert.deepEqual(new Set(menu.map((command) => command.command)), new Set(toolbar.map((command) => command.id)))
|
||||
assert.ok(!toolbar.some((command) => command.id === 'CAM_%s'))
|
||||
})
|
||||
73
tests/camNativeSimulation.test.ts
Normal file
73
tests/camNativeSimulation.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
CAMOTICS_SOURCE_REVISION,
|
||||
OPEN_CAM_LIB_ARTIFACT_SHA256,
|
||||
generateCamoticsSourceStageGcode,
|
||||
nativeCamCapabilities,
|
||||
runOpenCamLibDropCutter,
|
||||
sampleFlatEndPath,
|
||||
} from '../src/facade/camNativeSimulation'
|
||||
|
||||
test('native CAM capability report distinguishes workspace CAMotics CLI from browser execution', () => {
|
||||
const capabilities = nativeCamCapabilities()
|
||||
assert.equal(capabilities.openCamLib.backend, 'upstream-opencamlib-wasm')
|
||||
assert.equal(capabilities.openCamLib.artifactSha256, OPEN_CAM_LIB_ARTIFACT_SHA256)
|
||||
assert.deepEqual(capabilities.openCamLib.algorithms, ['path-drop-cutter', 'adaptive-path-drop-cutter', 'waterline', 'adaptive-waterline'])
|
||||
assert.equal(capabilities.solidRemoval.backend, 'bitbybit-occt-wasm')
|
||||
assert.equal(capabilities.solidRemoval.nativeSolid, true)
|
||||
assert.equal(capabilities.solidRemoval.freeCadNativeEquivalent, false)
|
||||
assert.deepEqual(capabilities.solidRemoval.sweepModes, ['sampled-flat-end', 'continuous-segment'])
|
||||
assert.equal(capabilities.camotics.status, 'native-host-available')
|
||||
assert.equal(capabilities.camotics.backend, 'camotics-native-host-qt5-tpl')
|
||||
assert.equal(capabilities.camotics.workspaceNativeExecutable, true)
|
||||
assert.equal(capabilities.camotics.nativeGuiPath, 'CAMotics/camotics')
|
||||
assert.equal(capabilities.camotics.nativeTplPath, 'CAMotics/tplang')
|
||||
assert.equal(capabilities.camotics.nativeArtifactEvidence, 'config/camotics-native-artifact.json')
|
||||
assert.equal(capabilities.camotics.browserExecutable, false)
|
||||
assert.equal(capabilities.camotics.browserWasmKernelExecutable, true)
|
||||
assert.equal(capabilities.camotics.nativeCliPath, 'CAMotics/camsim')
|
||||
assert.equal(capabilities.camotics.wasmKernel.backend, 'camotics-upstream-sweep-wasm')
|
||||
assert.equal(capabilities.camotics.wasmKernel.fullCamoticsProgram, false)
|
||||
assert.equal(capabilities.camotics.wasmKernel.gcodeParserIncluded, false)
|
||||
assert.equal(capabilities.camotics.sourceRevision, CAMOTICS_SOURCE_REVISION)
|
||||
assert.equal(capabilities.camotics.generationAuthority, 'camotics-source-stage')
|
||||
assert.equal(capabilities.camotics.parserAuthority, 'linuxcnc-wasm')
|
||||
assert.equal(capabilities.camotics.camoticsParsesCanonicalGcode, false)
|
||||
assert.match(capabilities.camotics.reason, /workspace-verified/)
|
||||
})
|
||||
|
||||
test('CAMotics source stage only generates canonical G-code for LinuxCNC WASM', () => {
|
||||
const result = generateCamoticsSourceStageGcode([
|
||||
{ type: 'rapid', point: [0, 0, 5] },
|
||||
{ type: 'cut', point: [1.25, 2, 3.5] },
|
||||
{ type: 'cut', point: [2, 2, 3.5] },
|
||||
], { toolNumber: 2, feed: 120 })
|
||||
assert.equal(result.backend, 'camotics-source-stage-contract')
|
||||
assert.equal(result.nativeExecutable, false)
|
||||
assert.equal(result.parserAuthority, 'linuxcnc-wasm')
|
||||
assert.equal(result.camoticsParsesCanonicalGcode, false)
|
||||
assert.equal(result.lineCount, 7)
|
||||
assert.equal(result.gcode, 'G21G90\nM6 T2\nG0 X0. Y0. Z5.\nF120.\nG1 X1.25 Y2. Z3.5\nG1 X2.\nM2\n')
|
||||
assert.throws(() => generateCamoticsSourceStageGcode([], { feed: 120 }), /must not be empty/)
|
||||
assert.throws(() => generateCamoticsSourceStageGcode([{ type: 'cut', point: [0, 0, 0] }], { feed: 0 }), /greater than zero/)
|
||||
})
|
||||
|
||||
test('flat-end solid-removal sampling has a deterministic chord bound', () => {
|
||||
const samples = sampleFlatEndPath([[0, 0, 1], [3, 4, 1]], 1)
|
||||
assert.equal(samples.length, 6)
|
||||
assert.deepEqual(samples[0], [0, 0, 1])
|
||||
assert.deepEqual(samples.at(-1), [3, 4, 1])
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
assert.ok(Math.hypot(...samples[index].map((value, axis) => value - samples[index - 1][axis])) <= 1 + 1e-12)
|
||||
}
|
||||
assert.deepEqual(sampleFlatEndPath([[1, 2, 3]], 0.5), [[1, 2, 3]])
|
||||
assert.throws(() => sampleFlatEndPath([], 1), /must not be empty/)
|
||||
assert.throws(() => sampleFlatEndPath([[0, 0, 0]], 0), /greater than zero/)
|
||||
})
|
||||
|
||||
test('OpenCAMLib adapter validates unsafe input before requesting the browser WASM runtime', async () => {
|
||||
await assert.rejects(() => runOpenCamLibDropCutter({ triangles: [], path: [[0, 0, 0], [1, 0, 0]], cutter: { shape: 'endmill', diameter: 1, length: 10 }, sampling: 0.1 }), /at least one surface triangle/)
|
||||
await assert.rejects(() => runOpenCamLibDropCutter({ triangles: [[[0, 0, 0], [1, 0, 0], [0, 1, 0]]], path: [[0, 0, 0]], cutter: { shape: 'endmill', diameter: 1, length: 10 }, sampling: 0.1 }), /at least two points/)
|
||||
await assert.rejects(() => runOpenCamLibDropCutter({ triangles: [[[0, 0, 0], [1, 0, 0], [0, 1, 0]]], path: [[0, 0, 0], [1, 0, 0]], cutter: { shape: 'endmill', diameter: 0, length: 10 }, sampling: 0.1 }), /greater than zero/)
|
||||
})
|
||||
57
tests/camPipeline.test.ts
Normal file
57
tests/camPipeline.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createCamJob } from '../src/facade/cam'
|
||||
import { CAM_PIPELINE_ORDER, camoticsSourceEvidence, prepareCamPipeline, submitCamPipelineToLinuxcnc } from '../src/facade/camPipeline'
|
||||
import { OPEN_CAM_LIB_ARTIFACT_SHA256, OPEN_CAM_LIB_SOURCE_REVISION, type OpenCamLibDropCutterInput } from '../src/facade/camNativeSimulation'
|
||||
|
||||
const job = () => {
|
||||
const cam = createCamJob('pipeline', 'CAD/OCL/CAMotics/LinuxCNC')
|
||||
cam.addTool({ id: 'T1', name: 'end mill', diameter: 2, length: 20 })
|
||||
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
|
||||
cam.generatePath('profile')
|
||||
return cam
|
||||
}
|
||||
|
||||
const openCamLibRunner = async (input: OpenCamLibDropCutterInput) => ({
|
||||
backend: 'upstream-opencamlib-wasm' as const,
|
||||
sourceRevision: OPEN_CAM_LIB_SOURCE_REVISION,
|
||||
artifactSha256: OPEN_CAM_LIB_ARTIFACT_SHA256,
|
||||
algorithm: 'path-drop-cutter' as const,
|
||||
triangleCount: input.triangles.length,
|
||||
points: input.path.map((point) => [...point] as [number, number, number]),
|
||||
})
|
||||
|
||||
test('pipeline evidence pins CAMotics source and canonical LinuxCNC policy', () => {
|
||||
assert.deepEqual(CAM_PIPELINE_ORDER, ['CAD', 'OCL', 'CAMotics', 'GCODE', 'LinuxCNC WASM'])
|
||||
const evidence = camoticsSourceEvidence()
|
||||
assert.equal(evidence.backend, 'camotics-source')
|
||||
assert.equal(evidence.generator, 'camotics-source-stage')
|
||||
assert.equal(evidence.workspaceNativeCli, 'verified-sidecar')
|
||||
assert.equal(evidence.browserNativeExecutable, false)
|
||||
assert.equal(evidence.parserAuthority, 'linuxcnc-wasm')
|
||||
assert.equal(evidence.camoticsParsesCanonicalGcode, false)
|
||||
assert.equal(evidence.sourceRevision.length, 40)
|
||||
})
|
||||
|
||||
test('LinuxCNC WASM cannot be called before the OCL and G-code stages are prepared', async () => {
|
||||
const prepared = await prepareCamPipeline(job(), 'profile', { openCamLib: { runner: openCamLibRunner } })
|
||||
assert.equal(prepared.stage, 'GCODE')
|
||||
assert.equal(prepared.openCamLib.backend, 'upstream-opencamlib-wasm')
|
||||
assert.equal(prepared.openCamLib.inputPoints, 5)
|
||||
assert.equal(prepared.openCamLib.outputPoints, 5)
|
||||
const submitted = await submitCamPipelineToLinuxcnc(prepared, {
|
||||
submit: async (program) => ({ backend: 'linuxcnc-wasm', status: 'dry-run', programSha256: String(program.length) }),
|
||||
})
|
||||
assert.equal(submitted.stage, 'LinuxCNC WASM')
|
||||
assert.equal(submitted.linuxcnc?.status, 'dry-run')
|
||||
assert.match(submitted.gcode, /G90/)
|
||||
assert.match(submitted.camoticsMotionGcode, /G1 X/)
|
||||
})
|
||||
|
||||
test('pipeline rejects non-LinuxCNC posts before any machine submission', async () => {
|
||||
await assert.rejects(() => prepareCamPipeline(job(), 'profile', { postprocessor: 'grbl', openCamLib: { runner: openCamLibRunner } }), /requires the linuxcnc postprocessor/)
|
||||
})
|
||||
|
||||
test('pipeline rejects an unpinned OCL result instead of bypassing the native stage', async () => {
|
||||
await assert.rejects(() => prepareCamPipeline(job(), 'profile', { openCamLib: { runner: async (input) => ({ ...await openCamLibRunner(input), sourceRevision: '0'.repeat(40) }) } }), /pinned upstream WASM identity/)
|
||||
})
|
||||
35
tests/camoticsWasm.test.ts
Normal file
35
tests/camoticsWasm.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { CAMOTICS_WASM_ARTIFACT_SHA256, CAMOTICS_WASM_SOURCE_REVISION, createCamoticsSweepKernel } from '../src/facade/camoticsWasm'
|
||||
|
||||
const calls: number[][] = []
|
||||
const kernel = createCamoticsSweepKernel({
|
||||
memory: {} as WebAssembly.Memory,
|
||||
camotics_sweep_abi_version: () => 1,
|
||||
camotics_conic_depth: (...values: number[]) => { calls.push(values); return 1 },
|
||||
camotics_spheroid_depth: (...values: number[]) => { calls.push(values); return -1 },
|
||||
camotics_conic_bbox_count: (...values: number[]) => { calls.push(values); return 3 },
|
||||
} as never)
|
||||
|
||||
test('CAMotics sweep adapter preserves upstream WASM identity and parser boundary', () => {
|
||||
assert.equal(kernel.backend, 'camotics-upstream-sweep-wasm')
|
||||
assert.equal(kernel.sourceRevision, CAMOTICS_WASM_SOURCE_REVISION)
|
||||
assert.equal(kernel.artifactSha256, CAMOTICS_WASM_ARTIFACT_SHA256)
|
||||
assert.equal(kernel.abiVersion, 1)
|
||||
assert.equal(kernel.fullCamoticsProgram, false)
|
||||
assert.equal(kernel.gcodeParserIncluded, false)
|
||||
})
|
||||
|
||||
test('CAMotics sweep adapter validates and forwards native arguments', () => {
|
||||
assert.equal(kernel.conicDepth({ length: 5, topRadius: 2, start: [0, 0, 0], end: [10, 0, 0], point: [5, 0, 0] }), 1)
|
||||
assert.equal(kernel.spheroidDepth({ radius: 2, start: [0, 0, 0], end: [10, 0, 0], point: [5, 4, 0] }), -1)
|
||||
assert.equal(kernel.conicBoundingBoxCount({ length: 5, topRadius: 2, start: [0, 0, 0], end: [100, 0, 0] }), 3)
|
||||
assert.equal(calls.length, 3)
|
||||
assert.throws(() => kernel.conicDepth({ length: 0, topRadius: 2, start: [0, 0, 0], end: [1, 0, 0], point: [0, 0, 0] }), /greater than zero/)
|
||||
assert.throws(() => kernel.spheroidDepth({ radius: 2, start: [0, 0, 0], end: [1, Number.NaN, 0], point: [0, 0, 0] }), /must be finite/)
|
||||
assert.throws(() => kernel.conicBoundingBoxCount({ length: 5, topRadius: 2, start: [0, 0, 0], end: [1, 0, 0], tolerance: -1 }), /must not be negative/)
|
||||
})
|
||||
|
||||
test('CAMotics sweep adapter rejects an unknown ABI', () => {
|
||||
assert.throws(() => createCamoticsSweepKernel({ camotics_sweep_abi_version: () => 2 } as never), /Unsupported CAMotics sweep WASM ABI/)
|
||||
})
|
||||
108
tests/dataModules.test.ts
Normal file
108
tests/dataModules.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createDataModules } from '../src/facade/dataModules'
|
||||
|
||||
const bytes = (value: string) => new TextEncoder().encode(value)
|
||||
const close = (actual: number, expected: number, tolerance = 1e-9) => assert.ok(Math.abs(actual - expected) <= tolerance, `${actual} != ${expected}`)
|
||||
|
||||
test('data adapters expose specialist module coverage and proxy records', () => {
|
||||
const data = createDataModules()
|
||||
assert.equal(data.descriptors().length, 7)
|
||||
assert.equal(data.descriptors().find((entry) => entry.module === 'Points')?.status, 'supported')
|
||||
assert.equal(data.descriptors().find((entry) => entry.module === 'ReverseEngineering')?.status, 'supported')
|
||||
const record = data.importRecord({ id: 'openscad', module: 'OpenSCAD', format: 'SCAD', bytes: bytes('cube(1);'), metadata: { units: 'mm' } })
|
||||
assert.equal(record.proxy, true)
|
||||
assert.equal(record.format, 'scad')
|
||||
assert.match(data.exportManifest(), /"module": "OpenSCAD"/)
|
||||
})
|
||||
|
||||
test('Points imports, exports, translates, crops, downsamples, structures and merges point clouds', () => {
|
||||
const data = createDataModules()
|
||||
const cloud = data.importPointCloud({
|
||||
id: 'grid',
|
||||
format: 'csv',
|
||||
bytes: bytes('x,y,z\n0,0,0\n1,0,1\n2,0,2\n0,1,3\n1,1,4\n2,1,5\n'),
|
||||
})
|
||||
assert.equal(cloud.points.length, 6)
|
||||
assert.deepEqual(cloud.bounds, { min: { x: 0, y: 0, z: 0 }, max: { x: 2, y: 1, z: 5 } })
|
||||
assert.deepEqual(cloud.centroid, { x: 1, y: 0.5, z: 2.5 })
|
||||
|
||||
const structured = data.structurePointCloud({ id: 'structured', sourceId: 'grid' })
|
||||
assert.deepEqual(structured.structured, { width: 3, height: 2, grid: [0, 1, 2, 3, 4, 5] })
|
||||
const translated = data.translatePointCloud({ id: 'centered', sourceId: 'grid', offset: { x: -1, y: -0.5, z: -2.5 } })
|
||||
assert.deepEqual(translated.centroid, { x: 0, y: 0, z: 0 })
|
||||
const cropped = data.cropPointCloud({ id: 'cropped', sourceId: 'grid', bounds: { min: { x: 1, y: 0, z: 0 }, max: { x: 2, y: 1, z: 5 } } })
|
||||
assert.equal(cropped.points.length, 4)
|
||||
const polygonCropped = data.polygonCropPointCloud({ id: 'polygon-cropped', sourceId: 'grid', polygon: [{ x: 0, y: 0 }, { x: 1.1, y: 0 }, { x: 1.1, y: 1.1 }, { x: 0, y: 1.1 }] })
|
||||
assert.equal(polygonCropped.points.length, 4)
|
||||
const reduced = data.voxelDownsample({ id: 'reduced', sourceId: 'grid', size: 10 })
|
||||
assert.equal(reduced.points.length, 1)
|
||||
assert.deepEqual(reduced.points[0], cloud.centroid)
|
||||
const merged = data.mergePointClouds({ id: 'merged', sourceIds: ['grid', 'centered'] })
|
||||
assert.equal(merged.points.length, 12)
|
||||
|
||||
const xyz = new TextDecoder().decode(data.exportPointCloud('grid', 'xyz'))
|
||||
assert.equal(xyz, '0 0 0\n1 0 1\n2 0 2\n0 1 3\n1 1 4\n2 1 5\n')
|
||||
const roundTrip = createDataModules().importPointCloud({ id: 'roundtrip', format: 'xyz', bytes: bytes(xyz) })
|
||||
assert.deepEqual(roundTrip.points, cloud.points)
|
||||
})
|
||||
|
||||
test('Points parses PTS, ASCII PCD and ASCII PLY with explicit format errors', () => {
|
||||
const data = createDataModules()
|
||||
assert.equal(data.importPointCloud({ id: 'pts', format: 'pts', bytes: bytes('2\n1 2 3\n4 5 6\n') }).points.length, 2)
|
||||
assert.equal(data.importPointCloud({ id: 'pcd', format: 'pcd', bytes: bytes('VERSION 0.7\nFIELDS x y z\nWIDTH 2\nHEIGHT 1\nPOINTS 2\nDATA ascii\n1 2 3\n4 5 6\n') }).points.length, 2)
|
||||
assert.equal(data.importPointCloud({ id: 'ply', format: 'ply', bytes: bytes('ply\nformat ascii 1.0\nelement vertex 2\nproperty float x\nproperty float y\nproperty float z\nend_header\n1 2 3\n4 5 6\n') }).points.length, 2)
|
||||
assert.throws(() => data.importPointCloud({ id: 'short', format: 'pts', bytes: bytes('3\n1 2 3\n4 5 6\n') }), /declared 3 points/)
|
||||
assert.throws(() => data.importPointCloud({ id: 'binary', format: 'ply', bytes: bytes('ply\nformat binary_little_endian 1.0\nend_header\n') }), /Only ASCII PLY/)
|
||||
assert.throws(() => data.importPointCloud({ id: 'empty', format: 'xyz', bytes: bytes('# no points\n') }), /at least one point/)
|
||||
})
|
||||
|
||||
test('ReverseEngineering fits FreeCAD-style plane, sphere and cylinder primitives', () => {
|
||||
const data = createDataModules()
|
||||
const planeRows: string[] = []
|
||||
for (const x of [-2, 0, 2]) for (const y of [-1, 1]) planeRows.push(`${x},${y},${2 * x + 3 * y + 4}`)
|
||||
data.importPointCloud({ id: 'plane-points', format: 'csv', bytes: bytes(`x,y,z\n${planeRows.join('\n')}\n`) })
|
||||
const plane = data.fitPlane({ id: 'plane-fit', sourceId: 'plane-points', referenceNormal: { x: -2, y: -3, z: 1 } })
|
||||
close(plane.rms, 0)
|
||||
close(plane.normal.x, -2 / Math.sqrt(14))
|
||||
close(plane.normal.y, -3 / Math.sqrt(14))
|
||||
close(plane.normal.z, 1 / Math.sqrt(14))
|
||||
|
||||
const spherePoints = [
|
||||
[3, 2, 3], [-1, 2, 3], [1, 4, 3], [1, 0, 3], [1, 2, 5], [1, 2, 1],
|
||||
[1 + Math.sqrt(2), 2 + Math.sqrt(2), 3], [1 - Math.sqrt(2), 2 - Math.sqrt(2), 3],
|
||||
].map((entry) => entry.join(' ')).join('\n')
|
||||
data.importPointCloud({ id: 'sphere-points', format: 'xyz', bytes: bytes(`${spherePoints}\n`) })
|
||||
const sphere = data.fitSphere({ id: 'sphere-fit', sourceId: 'sphere-points' })
|
||||
close(sphere.center.x, 1); close(sphere.center.y, 2); close(sphere.center.z, 3); close(sphere.radius, 2); close(sphere.rms, 0)
|
||||
|
||||
const cylinderRows: string[] = []
|
||||
for (const z of [-4, 0, 4]) for (let index = 0; index < 8; index += 1) { const angle = index * Math.PI / 4; cylinderRows.push(`${2 + 3 * Math.cos(angle)} ${-1 + 3 * Math.sin(angle)} ${z}`) }
|
||||
data.importPointCloud({ id: 'cylinder-points', format: 'xyz', bytes: bytes(`${cylinderRows.join('\n')}\n`) })
|
||||
const cylinder = data.fitCylinder({ id: 'cylinder-fit', sourceId: 'cylinder-points' })
|
||||
close(cylinder.radius, 3); close(cylinder.height, 8); close(cylinder.rms, 0, 1e-8); close(Math.abs(cylinder.axis.z), 1)
|
||||
|
||||
const polynomialRows: string[] = []
|
||||
for (const x of [-1, 0, 1]) for (const y of [-1, 0, 1]) polynomialRows.push(`${x} ${y} ${x * x + 2 * x * y + 3 * y * y + 4 * x + 5 * y + 6}`)
|
||||
data.importPointCloud({ id: 'polynomial-points', format: 'xyz', bytes: bytes(`${polynomialRows.join('\n')}\n`) })
|
||||
const polynomial = data.fitPolynomialSurface({ id: 'polynomial-fit', sourceId: 'polynomial-points' })
|
||||
assert.deepEqual(polynomial.coefficients.map((value) => Number(value.toFixed(8))), [1, 2, 3, 4, 5, 6])
|
||||
close(polynomial.rms, 0)
|
||||
|
||||
data.importPointCloud({ id: 'segmentation-points', format: 'xyz', bytes: bytes('0 0 0\n0 0.1 0\n10 0 0\n10 0.1 0\n') })
|
||||
const segments = data.segmentPointCloud({ id: 'segments', sourceId: 'segmentation-points', radius: 0.3 })
|
||||
assert.deepEqual(segments.clusters.map((cluster) => cluster.pointIndices.length), [2, 2])
|
||||
assert.equal(data.snapshot().reverseEngineering.length, 4)
|
||||
assert.equal(data.snapshot().segments.length, 1)
|
||||
})
|
||||
|
||||
test('data adapters reject unknown formats, degenerate fits and oversized records', () => {
|
||||
const data = createDataModules()
|
||||
assert.throws(() => data.importRecord({ id: 'bad', module: 'JtReader', format: 'step', bytes: new Uint8Array([1]) }), /does not accept/)
|
||||
assert.throws(() => data.importRecord({ id: 'large', module: 'Import', format: 'step', bytes: { length: 8 * 1024 * 1024 + 1 } }), /outside the supported limit/)
|
||||
data.importPointCloud({ id: 'line', format: 'xyz', bytes: bytes('0 0 0\n1 0 0\n2 0 0\n3 0 0\n') })
|
||||
assert.throws(() => data.fitPlane({ id: 'bad-plane', sourceId: 'line' }), /degenerate|non-zero/)
|
||||
assert.throws(() => data.cropPointCloud({ id: 'empty-crop', sourceId: 'line', bounds: { min: { x: 9, y: 9, z: 9 }, max: { x: 10, y: 10, z: 10 } } }), /removed every point/)
|
||||
assert.throws(() => data.polygonCropPointCloud({ id: 'bad-polygon', sourceId: 'line', polygon: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }), /non-degenerate/)
|
||||
assert.throws(() => data.segmentPointCloud({ id: 'bad-segment', sourceId: 'line', radius: 0 }), /positive/)
|
||||
})
|
||||
46
tests/draft.test.ts
Normal file
46
tests/draft.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createDraftDocument } from '../src/facade/draft'
|
||||
|
||||
test('Draft working plane, grid and parametric geometry are deterministic', () => {
|
||||
const draft = createDraftDocument()
|
||||
draft.setWorkingPlane({ origin: { x: 10, y: 20, z: 30 }, xAxis: { x: 0, y: 1, z: 0 }, yAxis: { x: 0, y: 0, z: 1 } })
|
||||
draft.setGrid(0.5)
|
||||
assert.deepEqual(draft.snap({ x: 1.24, y: 2.26 }), { x: 1, y: 2.5 })
|
||||
assert.deepEqual(draft.mapToWorld({ x: 2, y: 3 }), { x: 10, y: 22, z: 33 })
|
||||
draft.createLine('line', { x: 0, y: 0 }, { x: 4, y: 0 })
|
||||
draft.createWire('wire', [{ x: 0, y: 0 }, { x: 2, y: 0 }, { x: 2, y: 2 }])
|
||||
draft.createCircle('circle', { x: 1, y: 1 }, 2)
|
||||
assert.equal(draft.snapshot().objects.length, 3)
|
||||
})
|
||||
|
||||
test('Draft operations preserve clone and array source dependencies', () => {
|
||||
const draft = createDraftDocument()
|
||||
draft.addLayer({ id: 'construction', label: 'Construction', visible: true, color: '#007f86' })
|
||||
draft.createLine('line', { x: 0, y: 0 }, { x: 4, y: 0 })
|
||||
draft.move('line', { x: 1, y: 2 })
|
||||
draft.rotate('line', 90, { x: 1, y: 2 })
|
||||
draft.scale('line', 2, { x: 1, y: 2 })
|
||||
const offset = draft.offsetLine('line', 1, 'offset')
|
||||
assert.equal(Math.round(Math.hypot(offset.start.x - 1, offset.start.y - 2)), 1)
|
||||
const trimmed = draft.trimLine('line', 0.25, 0.75)
|
||||
assert.equal(Math.hypot(trimmed.end.x - trimmed.start.x, trimmed.end.y - trimmed.start.y), 4)
|
||||
const clone = draft.clone('line', 'clone', { x: 10, y: 0 })
|
||||
const array = draft.array('line', { columns: 2, rows: 2, columnSpacing: 5, rowSpacing: 7, idPrefix: 'array' })
|
||||
assert.equal(clone.sourceId, 'line')
|
||||
assert.equal(array.length, 4)
|
||||
assert.equal(array.every((entry) => entry.sourceId === 'line'), true)
|
||||
assert.equal(draft.assignLayer('clone', 'construction').layerId, 'construction')
|
||||
})
|
||||
|
||||
test('Draft rejects invalid planes, geometry, layers and arrays', () => {
|
||||
const draft = createDraftDocument()
|
||||
assert.throws(() => draft.setWorkingPlane({ origin: { x: 0, y: 0, z: 0 }, xAxis: { x: 2, y: 0, z: 0 }, yAxis: { x: 0, y: 1, z: 0 } }), /normalized/)
|
||||
assert.throws(() => draft.createLine('line', { x: 0, y: 0 }, { x: 0, y: 0 }), /non-zero/)
|
||||
draft.createCircle('circle', { x: 0, y: 0 }, 1)
|
||||
assert.throws(() => draft.offsetLine('circle', 1, 'offset'), /line objects only/)
|
||||
assert.throws(() => draft.trimLine('circle', 0.25, 0.75), /line objects only/)
|
||||
draft.createLine('line', { x: 0, y: 0 }, { x: 4, y: 0 })
|
||||
assert.throws(() => draft.trimLine('line', 0.75, 0.25), /0 <= start < end <= 1/)
|
||||
assert.throws(() => draft.array('circle', { columns: 1001, rows: 1, columnSpacing: 1, rowSpacing: 1, idPrefix: 'many' }), /between 1 and 1000/)
|
||||
})
|
||||
6
tests/engineeringProject.test.ts
Normal file
6
tests/engineeringProject.test.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createEngineeringProject } from '../src/facade/engineeringProject'
|
||||
|
||||
test('engineering project closes mixed Assembly BIM Mesh Surface artifacts', () => { const project = createEngineeringProject(); project.add({ id: 'assembly', kind: 'Assembly', sourceRefs: [], payload: { components: 2 } }); project.add({ id: 'bim', kind: 'BIM', sourceRefs: ['assembly'], payload: { elements: 1 } }); project.add({ id: 'mesh', kind: 'Mesh', sourceRefs: ['bim'], payload: { triangles: 12 } }); project.add({ id: 'surface', kind: 'Surface', sourceRefs: ['mesh'], payload: { patches: 2 } }); project.update('mesh', { payload: { triangles: 24 } }); assert.equal(project.snapshot().artifacts.find((artifact) => artifact.id === 'mesh')?.version, 2); assert.deepEqual(project.integrity(), { complete: true, kinds: ['Assembly', 'BIM', 'Mesh', 'Surface'], missingKinds: [], missingRefs: [], artifacts: 4 }); const reopened = createEngineeringProject(); assert.equal(reopened.load(project.save()).artifacts.length, 4) })
|
||||
test('engineering project reports missing source references', () => { const project = createEngineeringProject(); project.add({ id: 'mesh', kind: 'Mesh', sourceRefs: ['missing'], payload: {} }); assert.equal(project.integrity().complete, false); assert.deepEqual(project.integrity().missingKinds, ['Assembly', 'BIM', 'Surface']); assert.deepEqual(project.integrity().missingRefs, ['mesh->missing']) })
|
||||
3890
tests/facade.test.ts
3890
tests/facade.test.ts
File diff suppressed because it is too large
Load Diff
6
tests/fcstdRoundTrip.test.ts
Normal file
6
tests/fcstdRoundTrip.test.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { compareFcstdRoundTrip, fingerprintRoundTrip } from '../src/facade/fcstdRoundTrip'
|
||||
|
||||
test('FCStd round-trip fingerprint is stable across object key order', () => { assert.equal(fingerprintRoundTrip({ b: 2, a: { d: 4, c: 3 } }), fingerprintRoundTrip({ a: { c: 3, d: 4 }, b: 2 })) })
|
||||
test('FCStd round-trip comparison classifies unknown differences', () => { const pass = compareFcstdRoundTrip('box', 'web-freecad-web', { typeId: 'Part::Box', volume: 24 }, { volume: 24, typeId: 'Part::Box' }, ['shape', 'properties']); assert.equal(pass.status, 'pass'); const fail = compareFcstdRoundTrip('box', 'freecad-web-freecad', { typeId: 'Part::Box', volume: 24 }, { typeId: 'Part::Box', volume: 25 }, ['shape']); assert.equal(fail.status, 'fail-unknown-difference'); assert.equal(fail.differences[0].classification, 'unknown') })
|
||||
6
tests/fem.test.ts
Normal file
6
tests/fem.test.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createFemAnalysis } from '../src/facade/fem'
|
||||
|
||||
test('FEM reference bar solve produces deterministic displacement/stress results', () => { const fem = createFemAnalysis(); fem.setMaterial({ id: 'steel', youngsModulus: 200000, poissonRatio: 0.3 }); fem.setMesh([{ id: 1, position: [0, 0, 0] }, { id: 2, position: [10, 0, 0] }]); fem.addNodeSet({ id: 'ends', nodeIds: [2, 1], role: 'result' }); fem.fixNode(1); fem.addLoad({ nodeId: 2, force: 100 }); const result = fem.solve({ length: 10, area: 5 }); assert.equal(result.status, 'solved'); assert.equal(result.solverStrategy, 'local-reference'); assert.equal(result.nodeSets[0]?.nodeIds.join(','), '1,2'); assert.equal(result.resultFields.length, 2); assert.equal(result.results[1]?.displacement, 0.001); assert.equal(result.results[1]?.stress, 20); assert.equal(fem.exportResultsCsv().trim().split('\n').length, 3) })
|
||||
test('FEM rejects invalid material and reports unsolved analysis', () => { const fem = createFemAnalysis(); assert.throws(() => fem.setMaterial({ id: 'bad', youngsModulus: 0, poissonRatio: 0.3 }), /Young modulus/); assert.equal(fem.solve({ length: 1, area: 1 }).status, 'invalid') })
|
||||
107
tests/freecadCamPathOracle.test.mjs
Normal file
107
tests/freecadCamPathOracle.test.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
const report = JSON.parse(await readFile(new URL('../config/freecad-cam-path-oracle.json', import.meta.url), 'utf8'))
|
||||
|
||||
const commandGcode = (object) => object.commands.map((command) => command.gcode)
|
||||
const objectByName = (objects, name) => objects.find((object) => object.name === name)
|
||||
|
||||
test('native Profile oracle is a locked FreeCAD algorithm result', () => {
|
||||
assert.equal(report.profileAlgorithm.operationTypeId, 'Path::FeaturePython')
|
||||
assert.equal(report.profileAlgorithm.pathPropertyType, 'Path::PropertyPath')
|
||||
assert.equal(report.profileAlgorithm.commandCount, 32)
|
||||
assert.equal(report.profileAlgorithm.cuttingCommandCount, 20)
|
||||
assert.equal(report.profileAlgorithm.normalizationToleranceMm, 0.01)
|
||||
assert.equal(report.profileAlgorithm.commandSha256, '5fcc36e3fc3c022599096f4110c890401f285b5878030de7c481786470a7a455')
|
||||
assert.deepEqual(report.profileAlgorithm.variants.map((variant) => ({ id: variant.id, useComp: variant.useComp, hash: variant.commandSha256 })), [
|
||||
{ id: 'outside-cw-tool-comp', useComp: true, hash: '5fcc36e3fc3c022599096f4110c890401f285b5878030de7c481786470a7a455' },
|
||||
{ id: 'outside-cw-no-comp', useComp: false, hash: 'c4c8bc58c4b21bc7be8796ff5d074fbe02656e31a26cc91ee65a67537a7d240a' },
|
||||
])
|
||||
})
|
||||
|
||||
test('native Helix oracle locks all four cut-direction combinations', () => {
|
||||
assert.equal(report.helixAlgorithm.fixture, 'Mod/CAM/CAMTests/test_holes00.fcstd')
|
||||
assert.equal(report.helixAlgorithm.baseSubElementCount, 9)
|
||||
assert.equal(report.helixAlgorithm.toolDiameterMm, 0.9)
|
||||
assert.deepEqual(report.helixAlgorithm.scenarios.map((scenario) => ({
|
||||
id: scenario.id,
|
||||
direction: scenario.direction,
|
||||
arcs: scenario.arcNames,
|
||||
arcCount: scenario.arcCommandCount,
|
||||
hash: scenario.commandSha256,
|
||||
})), [
|
||||
{ id: 'inside-conventional', direction: 'CW', arcs: ['G2'], arcCount: 1260, hash: '6499583c26dbf01694abc0a98fdbeb9d240cce0f73d32634284ddfa132030e0b' },
|
||||
{ id: 'outside-climb', direction: 'CW', arcs: ['G2'], arcCount: 1260, hash: '37ee85fe360bfa82235f1b7aefce31cb7150900bb8d6f08751741fce6df18626' },
|
||||
{ id: 'inside-climb', direction: 'CCW', arcs: ['G3'], arcCount: 1260, hash: 'f2fd26ca11be5efb037f809fb65949d24956f6ce330f87d152d2674770a27bd8' },
|
||||
{ id: 'outside-conventional', direction: 'CCW', arcs: ['G3'], arcCount: 1260, hash: '0220f90245a74a50c1a54cb501439d14e7370d9d9d34fcc89dccdaff345688b7' },
|
||||
])
|
||||
})
|
||||
|
||||
test('Path Feature and FeaturePython survive native save-open-mutate-save-open', () => {
|
||||
for (const name of ['NativePath', 'PythonPath']) {
|
||||
const before = objectByName(report.fcstdRoundTrip.sourceBeforeClose, name)
|
||||
const reopened = objectByName(report.fcstdRoundTrip.sourceReopened, name)
|
||||
const mutated = objectByName(report.fcstdRoundTrip.mutatedReopened, name)
|
||||
assert.deepEqual(commandGcode(reopened), commandGcode(before))
|
||||
assert.deepEqual(commandGcode(mutated).slice(0, 5), commandGcode(before))
|
||||
assert.equal(mutated.commandCount, 6)
|
||||
assert.equal(mutated.commands.at(-1).name, name === 'NativePath' ? 'M5' : 'M2')
|
||||
}
|
||||
})
|
||||
|
||||
test('Web byte-transparent proxy reopens natively without changing Path commands', () => {
|
||||
assert.equal(report.fcstdRoundTrip.webTransparentPreservation.byteExact, true)
|
||||
assert.equal(report.fcstdRoundTrip.webTransparentPreservation.nativeReopenVerified, true)
|
||||
for (const name of ['NativePath', 'PythonPath']) {
|
||||
const native = objectByName(report.fcstdRoundTrip.sourceReopened, name)
|
||||
const webPreserved = objectByName(report.fcstdRoundTrip.webTransparentPreservation.objects, name)
|
||||
assert.deepEqual(commandGcode(webPreserved), commandGcode(native))
|
||||
}
|
||||
assert.equal(report.claims.webEditablePathPropertyCodec, 'verified-native-path-feature')
|
||||
assert.equal(report.boundary.exactParityClaim, false)
|
||||
})
|
||||
|
||||
test('Web Path::PropertyPath edit is reopened by FreeCAD while FeaturePython stays blocked', () => {
|
||||
const editable = report.fcstdRoundTrip.webEditablePathProperty
|
||||
assert.equal(editable.objectName, 'NativePath')
|
||||
assert.equal(editable.sourceCommandCount, 5)
|
||||
assert.equal(editable.editedCommandCount, 6)
|
||||
assert.deepEqual(editable.lastCommand, { name: 'M3', parameters: { S: 12000 } })
|
||||
assert.equal(editable.nativeReopenVerified, true)
|
||||
assert.equal(editable.featurePythonExecutionBlocked, true)
|
||||
})
|
||||
|
||||
test('FeaturePython Path::PropertyPath resource editing is explicit and non-executing', () => {
|
||||
const editable = report.fcstdRoundTrip.webEditableFeaturePythonPathProperty
|
||||
assert.equal(editable.objectName, 'PythonPath')
|
||||
assert.equal(editable.sourceCommandCount, 5)
|
||||
assert.equal(editable.editedCommandCount, 6)
|
||||
assert.deepEqual(editable.lastCommand, { name: 'M2', parameters: {} })
|
||||
assert.equal(editable.nativeReopenVerified, true)
|
||||
assert.equal(editable.scriptExecution, 'blocked')
|
||||
assert.equal(editable.optInRequired, true)
|
||||
assert.equal(report.claims.webEditableFeaturePythonPathResourceCodec, 'verified-safe-resource-only-opt-in')
|
||||
})
|
||||
|
||||
test('Qt oracle records live CAM actions from the active workbench', () => {
|
||||
assert.equal(report.qtDynamicOracle.activeWorkbench, 'CAMWorkbench')
|
||||
assert.ok(report.qtDynamicOracle.camCommandCount >= 50)
|
||||
assert.ok(report.qtDynamicOracle.requiredCommands.every((command) => (
|
||||
command.selectedModel.registered
|
||||
&& command.selectedModel.actionCount >= 1
|
||||
&& command.selectedModel.text.length > 0
|
||||
&& command.selectedModel.iconPresent
|
||||
)))
|
||||
assert.ok(report.qtDynamicOracle.requiredCommands.some((command) => command.selectionChangesEnabledState))
|
||||
})
|
||||
|
||||
test('Qt oracle records CAM Task panel open, focus fields, and OK/Cancel close semantics', () => {
|
||||
const lifecycles = report.qtDynamicOracle.taskLifecycles
|
||||
assert.equal(report.qtDynamicOracle.taskLifecycleVerified, true)
|
||||
assert.ok(lifecycles.length >= 3)
|
||||
assert.ok(lifecycles.every((lifecycle) => lifecycle.dialogOpened && lifecycle.dialogClosed && lifecycle.buttonClicked))
|
||||
assert.ok(lifecycles.every((lifecycle) => lifecycle.active.buttonBoxCount === 1 && lifecycle.active.fieldCount >= 10))
|
||||
assert.ok(lifecycles.every((lifecycle) => lifecycle.active.buttonTexts.some((button) => button.text === 'OK') && lifecycle.active.buttonTexts.some((button) => button.text === 'Cancel')))
|
||||
assert.deepEqual(new Set(lifecycles.map((lifecycle) => lifecycle.action)), new Set(['accept', 'cancel']))
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { compareGoldenResult, loadGoldenManifest, validateGoldenOperation } from '../scripts/freecad-golden-contract.mjs'
|
||||
|
||||
@@ -8,9 +9,31 @@ const manifestFile = resolve('fixtures/freecad-golden/manifest.json')
|
||||
test('FreeCAD golden manifest loads unique validated scenarios', async () => {
|
||||
const manifest = await loadGoldenManifest(manifestFile)
|
||||
assert.equal(manifest.baselineId, 'freecad-1.1.1')
|
||||
assert.deepEqual(manifest.scenarios.map((entry) => entry.id), ['part-box', 'part-cylinder', 'part-sphere', 'part-cone', 'part-cut-through-hole'])
|
||||
assert.equal(manifest.scenarios.at(-1).scenario.expected.shapeType, 'Compound')
|
||||
assert.equal(manifest.scenarios.at(-1).scenario.expected.solids, 1)
|
||||
assert.equal(manifest.scenarios.length, 100)
|
||||
assert.deepEqual(manifest.scenarios.slice(0, 5).map((entry) => entry.id), ['part-box', 'part-cylinder', 'part-sphere', 'part-cone', 'part-cut-through-hole'])
|
||||
assert.equal(manifest.scenarios.find((entry) => entry.id === 'part-cut-through-hole').scenario.expected.shapeType, 'Compound')
|
||||
assert.equal(manifest.scenarios.find((entry) => entry.id === 'part-cut-through-hole').scenario.expected.solids, 1)
|
||||
})
|
||||
|
||||
test('FreeCAD failure corpus includes an invalid Common operand', async () => {
|
||||
const failureManifest = JSON.parse(await readFile(resolve('fixtures/freecad-golden/failures/manifest.json'), 'utf8'))
|
||||
assert.equal(failureManifest.failures.length, 51)
|
||||
const common = failureManifest.failures.find((entry) => entry.id === 'invalid-common-001')
|
||||
assert.ok(common)
|
||||
const fixture = JSON.parse(await readFile(resolve('fixtures/freecad-golden/failures', common.file), 'utf8'))
|
||||
assert.throws(() => validateGoldenOperation(fixture.operation, fixture.id), /must have a type/)
|
||||
})
|
||||
|
||||
test('supplemental Part family corpus validates native solid primitives and failures', async () => {
|
||||
const supplemental = await loadGoldenManifest(resolve('fixtures/freecad-golden/feature-families/manifest.json'))
|
||||
assert.equal(supplemental.scenarios.length, 5)
|
||||
assert.deepEqual(supplemental.scenarios.map((entry) => entry.scenario.operation.type), ['ellipsoid', 'torus', 'prism', 'wedge', 'helix'])
|
||||
const failures = JSON.parse(await readFile(resolve('fixtures/freecad-golden/feature-families/failures/manifest.json'), 'utf8'))
|
||||
assert.equal(failures.failures.length, 5)
|
||||
for (const entry of failures.failures) {
|
||||
const fixture = JSON.parse(await readFile(resolve('fixtures/freecad-golden/feature-families/failures', entry.file), 'utf8'))
|
||||
assert.throws(() => validateGoldenOperation(fixture.operation, fixture.id))
|
||||
}
|
||||
})
|
||||
|
||||
test('golden comparator applies linear and scalar tolerances only to declared oracle fields', async () => {
|
||||
|
||||
27
tests/inspection.test.ts
Normal file
27
tests/inspection.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createInspection } from '../src/facade/inspection'
|
||||
|
||||
const shape = { id: 'box', label: 'Box', volume: 120, area: 148, structuralValid: true, bounds: { min: [0, 0, 0] as [number, number, number], max: [4, 5, 6] as [number, number, number] }, topoRefs: [{ persistentId: 'Face1', kind: 'face' as const, status: 'stable' as const }] }
|
||||
|
||||
test('inspection measures points, vectors and registered shape values', () => {
|
||||
const inspection = createInspection()
|
||||
inspection.registerShape(shape)
|
||||
assert.equal(inspection.measureDistance('distance', [0, 0, 0], [3, 4, 0]).value, 5)
|
||||
assert.equal(inspection.measureAngle('angle', [1, 0, 0], [0, 1, 0]).value, 90)
|
||||
assert.equal(inspection.measureShape('volume', 'box', 'volume').value, 120)
|
||||
assert.equal(inspection.measureShape('area', 'box', 'area').unit, 'mm2')
|
||||
assert.equal(inspection.measureDeviation('deviation', 10, 10.01, 0.02).status, 'resolved')
|
||||
assert.equal(inspection.section('box', 3).area, 20)
|
||||
assert.deepEqual(inspection.topoRefReport('box'), { total: 1, stable: 1, unresolved: [] })
|
||||
assert.equal(inspection.exportCsv().trim().split('\n').length, 6)
|
||||
})
|
||||
|
||||
test('inspection reports missing dependencies and isolates snapshots', () => {
|
||||
const inspection = createInspection()
|
||||
inspection.registerShape({ ...shape, id: 'child', dependencies: ['missing'] })
|
||||
assert.deepEqual(inspection.checkDependencies(), ['child->missing'])
|
||||
const snapshot = inspection.snapshot(); snapshot.shapes[0].bounds.min[0] = 99
|
||||
assert.equal(inspection.snapshot().shapes[0]?.bounds.min[0], 0)
|
||||
assert.equal(inspection.measureAngle('invalid', [0, 0, 0], [1, 0, 0]).status, 'invalid')
|
||||
})
|
||||
6
tests/locale.test.ts
Normal file
6
tests/locale.test.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { checkAccessibilitySemantics, formatLength, localeSnapshot, supportedLocales, translate } from '../src/facade/locale'
|
||||
|
||||
test('locale catalog formats units and preserves long labels', () => { assert.deepEqual(supportedLocales(), ['en-US', 'zh-CN', 'de-DE']); assert.equal(translate('zh-CN', 'workspace'), '工作区'); assert.match(formatLength(12.3456, 'en-US'), /^12\.346 mm$/); assert.ok(localeSnapshot('de-DE').messages.longLabel.length > 20) })
|
||||
test('accessibility semantic checker rejects unnamed or duplicate visible controls', () => { assert.equal(checkAccessibilitySemantics([{ id: 'save', role: 'button', accessibleName: 'Save', focusable: true }, { id: 'status', role: 'status', accessibleName: 'Ready' }]).pass, true); const report = checkAccessibilitySemantics([{ id: 'save', role: 'button', focusable: true }, { id: 'save', role: 'button', accessibleName: 'Save' }]); assert.equal(report.pass, false); assert.deepEqual(report.duplicateIds, ['save']); assert.deepEqual(report.unnamedFocusable, ['save']) })
|
||||
33
tests/mesh.test.ts
Normal file
33
tests/mesh.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createMeshDocument } from '../src/facade/mesh'
|
||||
|
||||
test('mesh weld, quality analysis, transform and deterministic OBJ export', () => {
|
||||
const mesh = createMeshDocument('triangle', 'Triangle', { positions: [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0], indices: [0, 1, 2, 0, 3, 1] })
|
||||
assert.equal(mesh.analyze().triangles, 2)
|
||||
mesh.weldVertices()
|
||||
assert.equal(mesh.analyze().vertices, 3)
|
||||
assert.equal(mesh.analyze().boundaryEdges, 3)
|
||||
assert.equal(mesh.analyze().selfIntersections, 0)
|
||||
mesh.removeDegenerate()
|
||||
mesh.transform({ translation: [1, 2, 3], scale: 2 })
|
||||
assert.equal(mesh.analyze().bounds.min[0], 1)
|
||||
assert.match(mesh.exportObj(), /v 1\.000000 2\.000000 3\.000000/)
|
||||
assert.equal(mesh.exportObj(), mesh.exportObj())
|
||||
assert.match(mesh.exportPly(), /^ply/m)
|
||||
assert.match(mesh.exportStl(), /^solid bitbybit/m)
|
||||
assert.equal(mesh.lod(1).triangles, 1)
|
||||
})
|
||||
|
||||
test('mesh detects crossing triangles without shared vertices', () => {
|
||||
const mesh = createMeshDocument('cross', 'Cross', { positions: [-1, -1, 0, 1, 1, 0, -1, 1, 0, -1, 1, 0, 1, -1, 0, 1, 1, 0], indices: [0, 1, 2, 3, 4, 5] })
|
||||
assert.equal(mesh.detectSelfIntersections(), 1)
|
||||
assert.equal(mesh.analyze().selfIntersections, 1)
|
||||
})
|
||||
|
||||
test('mesh repair rejects non-boundary edges and accepts a minimal triangle hole fill', () => {
|
||||
const mesh = createMeshDocument('hole', 'Hole', { positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], indices: [] })
|
||||
mesh.fillBoundaryTriangle([0, 1], 2)
|
||||
assert.equal(mesh.analyze().triangles, 1)
|
||||
assert.throws(() => mesh.fillBoundaryTriangle([0, 1], 2), /not available/)
|
||||
})
|
||||
5
tests/performanceBudget.test.ts
Normal file
5
tests/performanceBudget.test.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { runPerformanceBudget } from '../src/facade/performanceBudget'
|
||||
|
||||
test('performance budget executes deterministic object, triangle and table workloads', () => { const report = runPerformanceBudget({ objectCount: 100, triangleCount: 10_000, tableCells: 1_000, maxObjectMs: 10_000, maxTriangleMs: 10_000, maxTableMs: 10_000 }); assert.equal(report.objects.count, 100); assert.equal(report.triangles.count, 10_000); assert.equal(report.table.cells, 1_000); assert.equal(typeof report.triangles.checksum, 'number'); assert.equal(report.pass, true) })
|
||||
35
tests/plot.test.ts
Normal file
35
tests/plot.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createPlot } from '../src/facade/plot'
|
||||
|
||||
test('Plot updates bound Spreadsheet series and exports deterministic SVG/CSV', () => {
|
||||
const plot = createPlot('plot', 'Length history')
|
||||
plot.setAxes({ x: { label: 'Revision', scale: 'linear', minimum: 0, maximum: 2 }, y: { label: 'Length (mm)', scale: 'linear', minimum: 0, maximum: 20 } })
|
||||
plot.setSeries({ id: 'length', label: 'Length', points: [{ x: 0, y: 6 }, { x: 1, y: 12 }], style: { color: '#007f86', lineWidth: 2 } })
|
||||
plot.bindSeries('length', { sourceId: 'Spreadsheet', xRange: 'A1:A3', yRange: 'B1:B3' })
|
||||
plot.updateBoundSeries('length', [{ x: 0, y: 6 }, { x: 1, y: 12 }, { x: 2, y: 16 }])
|
||||
assert.equal(plot.snapshot().series[0].points[2].y, 16)
|
||||
assert.match(plot.exportSvg(), /data-series="length"/)
|
||||
assert.match(plot.exportSvg(), /Length \(mm\)/)
|
||||
assert.equal(plot.exportCsv().split('\n').length, 4)
|
||||
})
|
||||
|
||||
test('Plot validates series, axes, bindings and output dimensions', () => {
|
||||
const plot = createPlot()
|
||||
assert.throws(() => plot.setAxes({ x: { label: 'X', scale: 'linear', minimum: 2, maximum: 1 }, y: { label: 'Y', scale: 'linear' } }), /less than maximum/)
|
||||
assert.throws(() => plot.setSeries({ id: 'bad', label: 'Bad', points: [{ x: 0, y: 0 }], style: { color: '#000000', lineWidth: 1 } }), /at least two/)
|
||||
plot.setSeries({ id: 'good', label: 'Good', points: [{ x: 0, y: 0 }, { x: 1, y: 1 }], style: { color: '#007f86', lineWidth: 1, dash: [4, 2] } })
|
||||
assert.throws(() => plot.updateBoundSeries('good', [{ x: 0, y: 0 }, { x: 1, y: 1 }]), /not bound/)
|
||||
assert.throws(() => plot.exportSvg(100, 100), /outside the supported range/)
|
||||
})
|
||||
|
||||
test('Plot supports positive logarithmic axes with deterministic log-space SVG mapping', () => {
|
||||
const plot = createPlot('log-plot', 'Log history')
|
||||
plot.setAxes({ x: { label: 'X', scale: 'log', minimum: 1, maximum: 100 }, y: { label: 'Y', scale: 'log', minimum: 1, maximum: 1000 } })
|
||||
plot.setSeries({ id: 'log', label: 'Log', points: [{ x: 1, y: 1 }, { x: 10, y: 10 }, { x: 100, y: 1000 }], style: { color: '#007f86', lineWidth: 1 } })
|
||||
const svg = plot.exportSvg()
|
||||
assert.match(svg, /data-x-scale="log" data-y-scale="log"/)
|
||||
assert.match(svg, /M56\.000 312\.000/)
|
||||
assert.throws(() => plot.setSeries({ id: 'bad-log', label: 'Bad', points: [{ x: 0, y: 1 }, { x: 1, y: 2 }], style: { color: '#000000', lineWidth: 1 } }), /positive series values/)
|
||||
assert.throws(() => plot.setAxes({ x: { label: 'X', scale: 'log', minimum: 0, maximum: 10 }, y: { label: 'Y', scale: 'linear' } }), /bounds must be greater than zero/)
|
||||
})
|
||||
6
tests/productionDocument.test.ts
Normal file
6
tests/productionDocument.test.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createProductionDocument } from '../src/facade/productionDocument'
|
||||
|
||||
test('production document closes the four document workbench dependency graph', () => { const document = createProductionDocument(); document.add({ id: 'sheet', kind: 'Spreadsheet', dependencies: [], payload: { cells: 4 } }); document.add({ id: 'draft', kind: 'Draft', dependencies: [], payload: { objects: 2 } }); document.add({ id: 'page', kind: 'TechDraw', dependencies: ['draft'], payload: { views: 1 } }); document.add({ id: 'plot', kind: 'Plot', dependencies: ['sheet'], payload: { series: 1 } }); assert.equal(document.closure().complete, true); const saved = document.save(); const reopened = createProductionDocument(); assert.equal(reopened.load(saved).artifacts.length, 4); assert.equal(reopened.closure().complete, true) })
|
||||
test('production document reports missing dependencies and cycles', () => { const document = createProductionDocument(); document.add({ id: 'sheet', kind: 'Spreadsheet', dependencies: ['missing'], payload: {} }); document.add({ id: 'plot', kind: 'Plot', dependencies: ['sheet'], payload: {} }); assert.equal(document.closure().complete, false); assert.deepEqual(document.closure().missingKinds, ['TechDraw', 'Draft']); assert.deepEqual(document.closure().missingDependencies, ['sheet->missing']) })
|
||||
6
tests/robot.test.ts
Normal file
6
tests/robot.test.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRobot } from '../src/facade/robot'
|
||||
|
||||
test('robot generates a bounded linear trajectory, kinematics and controller export', () => { const robot = createRobot(); robot.addJoint({ id: 'J1', label: 'Base', minimum: -180, maximum: 180, linkLength: 2 }); robot.addJoint({ id: 'J2', label: 'Arm', minimum: -90, maximum: 90, linkLength: 1 }); robot.addWaypoint({ id: 'home', values: [0, 0] }); robot.addWaypoint({ id: 'pick', values: [90, 45] }); const snapshot = robot.generateLinearTrajectory(4); assert.equal(snapshot.status, 'generated'); assert.equal(snapshot.trajectory.length, 5); assert.equal(robot.validateTrajectory().valid, true); assert.deepEqual(robot.forwardKinematics([0, 0]), { position: [3, 0, 0], maxReach: 3 }); assert.deepEqual(robot.workspace(), { maxReach: 3, jointCount: 2 }); assert.ok(robot.collisions([{ id: 'home-obstacle', min: [2.9, -0.1, -0.1], max: [3.1, 0.1, 0.1] }]).length > 0); assert.match(robot.exportController(), /"trajectory"/); assert.equal(robot.exportCsv().trim().split('\n').length, 6) })
|
||||
test('robot rejects waypoints outside joint limits', () => { const robot = createRobot(); robot.addJoint({ id: 'J1', label: 'Base', minimum: -1, maximum: 1 }); assert.throws(() => robot.addWaypoint({ id: 'bad', values: [2] }), /joint limit/) })
|
||||
12
tests/scriptSandbox.test.ts
Normal file
12
tests/scriptSandbox.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createScriptSandbox } from '../src/facade/scriptSandbox'
|
||||
|
||||
describe('script sandbox', () => {
|
||||
it('records and replays only approved Facade commands', () => {
|
||||
const sandbox = createScriptSandbox({ allowedCommands: ['project.list', 'geometry.createBox'], maxCommands: 3 }); sandbox.record('project.list'); sandbox.record('geometry.createBox', { width: 2, height: 3 }); const macro = sandbox.exportMacro(); assert.equal(sandbox.importMacro(macro).length, 2); assert.deepEqual(sandbox.replay(), { status: 'replayed', executed: 2, rejected: [] }); assert.equal(sandbox.snapshot().policy.maxCommands, 3); for (const capability of ['file', 'network', 'time', 'resource'] as const) assert.equal(sandbox.requestCapability(capability).granted, false)
|
||||
})
|
||||
it('rejects disallowed commands and enforces argument and command quotas', () => {
|
||||
const sandbox = createScriptSandbox({ allowedCommands: ['project.list'], maxCommands: 1, maxArgumentBytes: 10 }); assert.throws(() => sandbox.record('geometry.boolean'), /not allowed/); assert.throws(() => sandbox.record('project.list', 'this argument is too long'), /quota/); sandbox.record('project.list'); assert.throws(() => sandbox.record('project.list'), /quota/); assert.equal(sandbox.replay([{ sequence: 1, command: 'geometry.boolean', arguments: null }]).status, 'rejected')
|
||||
})
|
||||
})
|
||||
31
tests/secondaryFormats.test.ts
Normal file
31
tests/secondaryFormats.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createSecondaryFormats, type SecondaryFormat } from '../src/facade/secondaryFormats'
|
||||
|
||||
const formats: SecondaryFormat[] = ['DXF', 'SVG', 'OBJ', 'PLY', 'STL', 'PDF', 'IFC', 'CSV']
|
||||
|
||||
test('secondary format adapters cover 2D, mesh, BIM and data with byte-exact proxy round-trips', () => {
|
||||
const api = createSecondaryFormats()
|
||||
for (const format of formats) {
|
||||
const bytes = api.exportBytes(format, { label: 'Fixture' })
|
||||
assert.equal(api.detect(format, bytes), true)
|
||||
const record = api.importBytes({ id: format, format, bytes })
|
||||
assert.equal(record.detected, true)
|
||||
assert.equal(record.byteLength, bytes.byteLength)
|
||||
assert.deepEqual(api.exportRecord(format), bytes)
|
||||
}
|
||||
assert.deepEqual(new Set(api.descriptors().map((entry) => entry.category)), new Set(['2d', 'mesh', 'bim', 'data']))
|
||||
assert.equal(api.snapshot().records.length, formats.length)
|
||||
assert.deepEqual(api.exportBytes('SVG'), api.exportBytes('SVG'))
|
||||
})
|
||||
|
||||
test('every secondary format rejects an invalid signature and records remain isolated', () => {
|
||||
const api = createSecondaryFormats()
|
||||
const invalid = new TextEncoder().encode('invalid secondary payload')
|
||||
for (const format of formats) assert.throws(() => api.importBytes({ id: `bad-${format}`, format, bytes: invalid }), new RegExp(`${format} payload signature`))
|
||||
const bytes = api.exportBytes('PDF')
|
||||
api.importBytes({ id: 'pdf', format: 'PDF', bytes })
|
||||
assert.throws(() => api.importBytes({ id: 'pdf', format: 'PDF', bytes }), /already exists/)
|
||||
api.remove('pdf')
|
||||
assert.throws(() => api.exportRecord('pdf'), /does not exist/)
|
||||
})
|
||||
6
tests/securityPreflight.test.ts
Normal file
6
tests/securityPreflight.test.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createSecurityPreflight } from '../src/facade/securityPreflight'
|
||||
|
||||
test('security preflight accepts bounded paths, archives, XML and read permissions', () => { const security = createSecurityPreflight(); assert.equal(security.path('projects/model.FCStd'), 'projects/model.FCStd'); security.archive([{ path: 'Document.xml', compressedBytes: 100, uncompressedBytes: 500 }]); security.xml('<Document><Object id="box"/></Document>'); security.permissions(['geometry.read']); assert.equal(security.report().pass, true) })
|
||||
test('security preflight rejects traversal, bombs, XML entities and privileged permissions', () => { const security = createSecurityPreflight(); assert.throws(() => security.path('../outside'), /Unsafe/); assert.throws(() => security.archive([{ path: 'bomb', compressedBytes: 1, uncompressedBytes: 101 }]), /compression ratio/); assert.throws(() => security.xml('<!DOCTYPE x SYSTEM "file:///etc/passwd">'), /forbidden/); assert.throws(() => security.permissions(['network']), /allowlist/); assert.equal(security.report().pass, false) })
|
||||
58
tests/spreadsheet.test.ts
Normal file
58
tests/spreadsheet.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createSpreadsheet } from '../src/facade/spreadsheet'
|
||||
|
||||
test('Spreadsheet evaluates unit formulas and aliases deterministically', () => {
|
||||
const sheet = createSpreadsheet('sheet', 'Parameters')
|
||||
sheet.setCell('A1', '6 mm', { alias: 'Width' })
|
||||
sheet.setCell('A2', '=Width * 2', { alias: 'Length' })
|
||||
sheet.setCell('B1', '=max(A1, 4 mm)')
|
||||
const evaluation = sheet.evaluate()
|
||||
assert.equal(evaluation.errors.length, 0)
|
||||
assert.deepEqual(evaluation.cells.A1.value, { value: 6, dimension: 'length' })
|
||||
assert.deepEqual(evaluation.cells.A2.value, { value: 12, dimension: 'length' })
|
||||
assert.deepEqual(evaluation.cells.B1.value, { value: 6, dimension: 'length' })
|
||||
assert.deepEqual(evaluation.dependencies.map(({ source, target, reference }) => `${source}:${target}:${reference}`).sort(), ['A2:A1:Width', 'B1:A1:A1'])
|
||||
})
|
||||
|
||||
test('Spreadsheet rejects duplicate aliases and reports dependency cycles', () => {
|
||||
const sheet = createSpreadsheet()
|
||||
sheet.setCell('A1', 1, { alias: 'Count' })
|
||||
assert.throws(() => sheet.setCell('A2', 2, { alias: 'Count' }), /already used/)
|
||||
sheet.setCell('B1', '=C1 + 1')
|
||||
sheet.setCell('C1', '=B1 + 1')
|
||||
const evaluation = sheet.evaluate()
|
||||
assert.equal(evaluation.cycles.length > 0, true)
|
||||
assert.equal(evaluation.cells.B1.error?.code, 'DEPENDENCY_CYCLE')
|
||||
assert.equal(evaluation.cells.C1.error?.code, 'DEPENDENCY_CYCLE')
|
||||
})
|
||||
|
||||
test('Spreadsheet preserves binding metadata and exports escaped CSV', () => {
|
||||
const sheet = createSpreadsheet()
|
||||
sheet.setCell('$A$1', '6 mm')
|
||||
sheet.setAlias('A1', 'Width')
|
||||
sheet.setCell('B1', '=Width * 2')
|
||||
const binding = sheet.bindProperty('Width', 'pad', 'Length')
|
||||
assert.deepEqual(binding, { alias: 'Width', objectId: 'pad', propertyName: 'Length' })
|
||||
assert.deepEqual(sheet.snapshot().bindings, [binding])
|
||||
assert.match(sheet.exportCsv(), /^6 length,12 length$/)
|
||||
assert.throws(() => sheet.setCell('1A', 1), /Invalid spreadsheet cell address/)
|
||||
assert.throws(() => sheet.bindProperty('Missing', 'pad', 'Length'), /does not exist/)
|
||||
})
|
||||
|
||||
test('Spreadsheet preserves styles, merged ranges, named ranges and address shifts', () => {
|
||||
const sheet = createSpreadsheet()
|
||||
sheet.setCell('A1', 1)
|
||||
sheet.setCell('A2', '=A1 + 1')
|
||||
sheet.setStyle('A1:B2', { background: '#e6f4f1', bold: true, numberFormat: '0.00' })
|
||||
sheet.mergeCells('B1:A2')
|
||||
sheet.setNamedRange('Inputs', 'A1:A2')
|
||||
sheet.insertRows(2)
|
||||
sheet.insertColumns(2)
|
||||
const snapshot = sheet.snapshot()
|
||||
assert.equal(snapshot.cells.find((cell) => cell.address === 'A1')?.style?.bold, true)
|
||||
assert.deepEqual(snapshot.namedRanges, [{ name: 'Inputs', range: 'A1:A3' }])
|
||||
assert.deepEqual(snapshot.mergedRanges, ['A1:C3'])
|
||||
assert.equal(snapshot.cells.find((cell) => cell.address === 'A3')?.input, '=A1 + 1')
|
||||
assert.throws(() => sheet.setStyle('A1', { color: 'red' }), /invalid color/)
|
||||
})
|
||||
7
tests/surface.test.ts
Normal file
7
tests/surface.test.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createSurfaceDocument } from '../src/facade/surface'
|
||||
|
||||
test('surface creates Bezier/BSpline/loft/fill/offset patches, trims and sews C0 edges', () => { const surface = createSurfaceDocument(); surface.addBezier('bezier', [[[0, 0, 0], [0, 1, 0]], [[1, 0, 0], [1, 1, 0]]]); surface.addBspline('spline', [[[1, 0, 0], [1, 1, 0]], [[2, 0, 0], [2, 1, 0]]], { degreeU: 1, degreeV: 1 }); surface.trim('spline', [0, 0.8], [0, 1]); assert.deepEqual(surface.continuity('bezier', 'spline').relation, 'C0'); assert.deepEqual(surface.sew(['bezier', 'spline']), ['bezier', 'spline']); const loft = surface.loft('loft', [[[0, 0, 0], [1, 0, 0]], [[0, 0, 1], [1, 0, 1]]]); const fill = surface.fill('fill', [[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]]); const offset = surface.offset('fill', 'offset', 2); assert.equal(loft.kind, 'loft'); assert.equal(fill.kind, 'bezier'); assert.equal(offset.poles[0][0][2], 2); assert.equal(surface.topologyRefs('fill').length, 4); assert.equal(surface.analyze().patches, 5); assert.match(surface.exportObj(), /^v /m) })
|
||||
|
||||
test('surface rejects invalid grids and trim bounds', () => { const surface = createSurfaceDocument(); assert.throws(() => surface.addBezier('bad', [[[0, 0, 0]]]), /2x2/); surface.addBezier('ok', [[[0, 0, 0], [0, 1, 0]], [[1, 0, 0], [1, 1, 0]]]); assert.throws(() => surface.trim('ok', [0.8, 0.2], [0, 1]), /trim bounds/) })
|
||||
35
tests/techDraw.test.ts
Normal file
35
tests/techDraw.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createTechDrawPage } from '../src/facade/techDraw'
|
||||
import type { TopoRefValue } from '../src/facade/types'
|
||||
|
||||
const ref = (id: string, version: number): TopoRefValue => ({ schemaVersion: 1, objectId: 'box', kind: 'vertex', persistentId: id, topologyVersion: version, generation: version, status: 'stable', signature: id })
|
||||
const source = (revision: number) => ({ id: 'box', revision, points: [{ id: 'a', ref: ref('Vertex1', revision), point: { x: 0, y: 0, z: 0 } }, { id: 'b', ref: ref('Vertex2', revision), point: { x: 4, y: 0, z: 0 } }, { id: 'c', ref: ref('Vertex3', revision), point: { x: 4, y: 3, z: 0 } }], edges: [{ id: 'edge', start: 'a', end: 'b' }, { id: 'edge2', start: 'b', end: 'c' }] })
|
||||
|
||||
test('TechDraw projects stable TopoRefs and updates dimensions on source revision', () => {
|
||||
const page = createTechDrawPage()
|
||||
page.addSource(source(1))
|
||||
page.addView({ id: 'front', sourceId: 'box', direction: { x: 0, y: 0, z: 1 }, position: { x: 80, y: 80 } })
|
||||
page.addView({ id: 'section', sourceId: 'box', kind: 'section', direction: { x: 0, y: 0, z: 1 }, position: { x: 160, y: 80 }, section: { normal: { x: 1, y: 0, z: 0 }, offset: 2 } })
|
||||
const dimension = page.addDimension({ id: 'length', sourceId: 'box', first: ref('Vertex1', 1), second: ref('Vertex2', 1), label: 'Length' })
|
||||
page.addAnnotation({ id: 'note', text: 'Box', position: { x: 20, y: 20 }, style: 'callout' })
|
||||
page.addGeometricTolerance({ id: 'flatness', sourceId: 'box', reference: ref('Vertex3', 1), characteristic: 'flatness', value: 0.05, datum: 'A' })
|
||||
assert.equal(dimension.value, 4)
|
||||
page.updateSource({ ...source(2), points: source(2).points.map((point) => point.id === 'b' ? { ...point, point: { x: 6, y: 0, z: 0 } } : point) })
|
||||
assert.equal(page.snapshot().dimensions[0].value, 6)
|
||||
assert.equal(page.snapshot().views[0].sourceRevision, 2)
|
||||
assert.match(page.exportSvg(), /data-view="section"/)
|
||||
assert.match(page.exportSvg(), /data-tolerance="flatness"/)
|
||||
assert.match(new TextDecoder().decode(page.exportPdf()), /^%PDF-1\.4[\s\S]+xref[\s\S]+%%EOF\n$/)
|
||||
})
|
||||
|
||||
test('TechDraw marks deleted TopoRefs unresolved and rejects invalid input', () => {
|
||||
const page = createTechDrawPage()
|
||||
page.addSource(source(1))
|
||||
page.addDimension({ id: 'missing', sourceId: 'box', first: ref('Vertex1', 1), second: ref('Missing', 1) })
|
||||
page.addGeometricTolerance({ id: 'position', sourceId: 'box', reference: ref('Missing', 1), characteristic: 'position', value: 0.1 })
|
||||
assert.equal(page.snapshot().dimensions[0].status, 'unresolved')
|
||||
assert.equal(page.snapshot().tolerances[0].status, 'unresolved')
|
||||
assert.throws(() => page.addView({ id: 'bad', sourceId: 'box', direction: { x: 0, y: 0, z: 0 }, position: { x: 0, y: 0 } }), /non-zero/)
|
||||
assert.throws(() => page.addSource(source(1)), /already exists/)
|
||||
})
|
||||
Reference in New Issue
Block a user