// Tests of the design script: compilation, the checks, the helpers, the // evaluation on the grid, and the presets. import { test } from "node:test"; import assert from "node:assert/strict"; import { DesignError, HELPERS, compileDesign, evaluatePlate, noise2d, profileStats, removePlane } from "../src/design.ts"; import { PRESETS } from "../src/presets.ts"; import { countInside, insideNode, mountPatch, nodeWeight, nodeX, nodeY, thicknessRange } from "../src/params.ts"; const minimal = `return { material: { E: 170e9, nu: 0.29, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3 };`; test("a minimal script compiles with defaults filled in", () => { const d = compileDesign(minimal); assert.equal(d.name, "Untitled design"); assert.equal(d.inside, null); assert.equal(d.mount, null); assert.equal(d.thickness(0, 0, 0, 0), 1e-3); assert.equal(d.profile(0.1, 0.05, 0, 0), 0); assert.deepEqual(d.loss, { sigma0: 1.15, sigma1: 5e-4, sigmaIn: 20 }); }); test("the helpers are in scope and lengths are SI", () => { const d = compileDesign(`return { name: "x", material: { E: 1e11, nu: 0.3, rho: 8000 }, bounds: { width: 10 * inch, height: 100 * mm }, thickness: (x, y, r) => lerp(2 * mm, 1 * mm, clamp(r / 0.1, 0, 1)), profile: (x, y, r) => dome(r, 0.05, 0.01) + bump(r, 0.02, 0.001) + bow(x, 0.254, 0.002) };`); assert.ok(Math.abs(d.Lx - 0.254) < 1e-12 && Math.abs(d.Ly - 0.1) < 1e-12); assert.ok(Math.abs(d.thickness(0, 0, 0, 0) - 2e-3) < 1e-12); assert.ok(Math.abs(d.thickness(0, 0, 0.1, 0) - 1e-3) < 1e-12); assert.ok(Math.abs(d.profile(0, 0, 0, 0) - (0.01 + 0.001 + 0.002)) < 1e-12); assert.ok(Math.abs(d.profile(0, 0, 0.06, 0) - (0.002 + 0.001 * Math.exp(-9))) < 1e-15); assert.equal(HELPERS.smoothstep(0, 1, -1), 0); assert.equal(HELPERS.smoothstep(0, 1, 2), 1); assert.equal(HELPERS.smoothstep(0, 1, 0.5), 0.5); }); test("scripts that do not describe a plate are rejected with a message", () => { const bad: [string, RegExp][] = [ [`return 5;`, /must return an object/], [`this is not javascript`, /script error/], [`throw new Error("boom");`, /boom/], [`return { bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3 };`, /material/], [`return { material: { E: 170e9, nu: 0.6, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3 };`, /nu/], [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3 }, thickness: 1e-3 };`, /bounds.height/], [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 } };`, /thickness/], [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: "thin" };`, /thickness/], [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, inside: 5 };`, /inside/], [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, mount: { stiffness: 1e4 } };`, /mount.radius/], ]; for (const [src, re] of bad) assert.throws(() => compileDesign(src), (e: unknown) => e instanceof DesignError && re.test(e.message), src); }); test("evaluation rejects a thickness that is not positive and a profile that is not finite", () => { const zero = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: (x) => x };`); assert.throws(() => evaluatePlate(zero, 0.02), /thickness must be positive/); const nan = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, profile: (x, y) => Math.sqrt(x) };`); assert.throws(() => evaluatePlate(nan, 0.02), /profile returned NaN/); const thrower = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, inside: () => { throw new Error("no"); } };`); assert.throws(() => evaluatePlate(thrower, 0.02), /inside threw/); const empty = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, inside: () => false };`); assert.throws(() => evaluatePlate(empty, 0.02), /no grid cells/); }); test("a circle with a hole excludes the center and the corners", () => { const d = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.2, height: 0.2 }, thickness: 1e-3, inside: circle(0.2, 0.03) };`); const pl = evaluatePlate(d, 0.01); const g = pl.grid; assert.equal(g.nx, 21); const c = (g.nx - 1) / 2; assert.ok(!insideNode(g, c, c), "center node lies in the hole"); assert.ok(!insideNode(g, 0, 0), "corner node lies outside the disc"); assert.ok(insideNode(g, c, 3), "a node on the radius belongs to the disc"); let weight = 0; for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) weight += nodeWeight(g, i, j); const area = weight * g.hx * g.hy; const exact = Math.PI * (0.1 ** 2 - 0.015 ** 2); assert.ok(Math.abs(area / exact - 1) < 0.05, `the ${countInside(g)} nodes cover ${area} vs ${exact}`); assert.equal(thicknessRange(pl).max, 1e-3); }); test("the profile is made plane-free and the mount patch is found", () => { const d = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, profile: (x, y) => 0.01 + 0.05 * x - 0.02 * y + 0.001 * x * x, mount: { radius: 0.02, stiffness: 1e4, damping: 1 } };`); const pl = evaluatePlate(d, 0.01); const g = pl.grid; let s = 0, sx = 0, sy = 0; for (let j = 0; j < g.ny; j++) { for (let i = 0; i < g.nx; i++) { const wgt = nodeWeight(g, i, j), v = pl.w0[j * g.nx + i]; s += wgt * v; sx += wgt * nodeX(g, i) * v; sy += wgt * nodeY(g, j) * v; } } assert.ok(Math.abs(s) < 1e-12 && Math.abs(sx) < 1e-12 && Math.abs(sy) < 1e-12, "mean plane removed"); const stats = profileStats(pl); assert.ok(stats.peakToPeak > 0 && stats.peakToPeak < 1e-4, `peak to peak ${stats.peakToPeak}`); const mp = mountPatch(pl)!; assert.ok(mp.nodes.length >= 9 && mp.nodes.length <= 21, `mount patch has ${mp.nodes.length} nodes`); assert.ok(mp.weight > 0); }); test("removePlane leaves a plane-free field unchanged", () => { const g = { nx: 7, ny: 5, hx: 0.05, hy: 0.05, n: 35 }; const f = new Float64Array(g.n); for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) f[j * g.nx + i] = nodeX(g, i) ** 2 - 0.0125; const before = Float64Array.from(f); removePlane(f, g); // The parabola x^2 has a nonzero mean but no tilt; only the mean should move. let shift = f[0] - before[0]; for (let k = 0; k < g.n; k++) assert.ok(Math.abs(f[k] - before[k] - shift) < 1e-12); }); test("noise2d is deterministic with unit RMS", () => { const a = noise2d(7, 0.05), b = noise2d(7, 0.05), c = noise2d(8, 0.05); assert.equal(a(0.01, 0.02), b(0.01, 0.02)); assert.notEqual(a(0.01, 0.02), c(0.01, 0.02)); let s2 = 0, n = 0; for (let x = -0.5; x < 0.5; x += 0.01) for (let y = -0.5; y < 0.5; y += 0.01) { s2 += a(x, y) ** 2; n++; } const rms = Math.sqrt(s2 / n); assert.ok(Math.abs(rms - 1) < 0.2, `rms ${rms}`); }); test("every preset compiles and evaluates on the default grid", () => { for (const p of PRESETS) { const d = compileDesign(p.source); const pl = evaluatePlate(d, 0.008); const hr = thicknessRange(pl); assert.ok(hr.min > 0 && hr.max < 5e-3, `${p.id}: thickness ${hr.min}..${hr.max}`); assert.ok(countInside(pl.grid) > 100, `${p.id}: ${countInside(pl.grid)} nodes`); const stats = profileStats(pl); assert.ok(stats.peakToPeak < 0.05, `${p.id}: peak to peak ${stats.peakToPeak}`); if (pl.mount) { const mp = mountPatch(pl)!; assert.ok(mp.nodes.length > 1, `${p.id}: mount patch has ${mp.nodes.length} nodes`); } } });