45 lines
3.6 KiB
JavaScript
45 lines
3.6 KiB
JavaScript
import { build } from 'esbuild'
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import assert from 'node:assert/strict'
|
|
|
|
const temp = await mkdtemp(join(tmpdir(), 'reformer-model-'))
|
|
try {
|
|
for (const name of ['model', 'geometry']) await build({ entryPoints: [`education/reformer/src/${name}.ts`], bundle: true, platform: 'node', format: 'esm', outfile: join(temp, `${name}.mjs`) })
|
|
const { positionAt, forceAt, springCalibration } = await import(pathToFileURL(join(temp, 'model.mjs')).href)
|
|
const { drawing, springEndpoints, springPaths, hookPath, ropePaths, ropeRoutes } = await import(pathToFileURL(join(temp, 'geometry.mjs')).href)
|
|
assert.equal(positionAt(0), 0); assert.equal(positionAt(0.5), 1); assert.equal(positionAt(1), 0)
|
|
assert.equal(forceAt(0.1, ['r1'], springCalibration), null, 'unmeasured spring must not produce a force')
|
|
// Synthetic, test-only records. These values never ship as equipment parameters.
|
|
const record = { source: 'test fixture', equipment: 'test rig', coach: 'test reviewer', reviewedAt: 'test-only', samples: [{ positionM: 0, forceN: 4 }, { positionM: 0.2, forceN: 8 }] }
|
|
assert.equal(forceAt(0.1, ['a'], { a: record }), 6)
|
|
assert.equal(forceAt(0.1, ['a', 'b'], { a: record, b: record }), 12)
|
|
for (const pos of [-1, 0.3, NaN, Infinity]) assert.equal(forceAt(pos, ['a'], { a: record }), null, 'no extrapolation or invalid positions')
|
|
assert.equal(forceAt(0.1, ['a', 'a'], { a: record }), null, 'no duplicate springs')
|
|
assert.equal(forceAt(0.1, ['a', 'missing'], { a: record }), null, 'partial calibration must not produce a force')
|
|
for (const field of ['coach', 'reviewedAt', 'source', 'equipment']) assert.equal(forceAt(0.1, ['a'], { a: { ...record, [field]: '' } }), null)
|
|
assert.equal(forceAt(0.1, ['a'], { a: { ...record, samples: [...record.samples].reverse() } }), null)
|
|
assert.equal(forceAt(0.1, ['a'], { a: { ...record, samples: [{ positionM: 0, forceN: -4 }, { positionM: 0.2, forceN: 8 }] } }), null)
|
|
// Sample all connected/stowed spring paths over the full travel and intermediate explode poses.
|
|
for (let step = 0; step <= 100; step++) {
|
|
const pos = step / 100
|
|
const active = springEndpoints(pos, true), stowed = springEndpoints(pos, false)
|
|
assert.equal(active.start, drawing.anchorX, 'fixed springbar endpoint must stay fixed')
|
|
assert.equal(active.end, drawing.carriageAnchorX + pos * drawing.travel, 'bed endpoint must move with the bed')
|
|
assert.ok(Math.abs(stowed.end - stowed.start - 70) < 1e-8, 'disconnected spring must not stretch')
|
|
assert.ok(active.end > active.start, 'spring must not invert')
|
|
for (const explode of [0, 0.25, 0.5, 0.75, 1]) for (let i = 0; i < 5; i++) for (const connected of [true, false]) {
|
|
const coil = springPaths(pos, i, connected, explode), hook = hookPath(pos, i, connected, explode)
|
|
assert.ok(!/NaN|Infinity|undefined/.test(coil.front + coil.back + hook))
|
|
}
|
|
for (const explode of [0, 0.5, 1]) assert.ok(!/NaN|Infinity|undefined/.test(ropePaths(pos, explode).join('')))
|
|
for (const route of ropeRoutes(pos)) {
|
|
const length = route.slice(1).reduce((sum, p, i) => sum + Math.hypot(...p.map((v, j) => v - route[i][j])), 0)
|
|
assert.ok(Math.abs(length - 430) < 0.2, 'parked rope must conserve its drawing length across the full stroke')
|
|
}
|
|
}
|
|
console.log('Model passed: complete motion cycle, fixed and moving endpoints, stowed springs, finite geometry, calibration refusal and no extrapolation.')
|
|
} finally { await rm(temp, { recursive: true, force: true }) }
|