// The tutorial's pages render without a DOM: every step is rendered with an // empty context, with a compiled plate, and with the plate's modes (on a // coarse grid), and the text must contain no undefined or NaN value and // balanced math delimiters. The actions are exercised against a small // in-process host that runs the real design, assembly and mode code. import { test } from "node:test"; import assert from "node:assert/strict"; import { compileDesign, evaluatePlate } from "../src/design.ts"; import { presetById } from "../src/presets.ts"; import { defaultParams, type SimParams } from "../src/params.ts"; import { assemble } from "../src/model/assemble.ts"; import { computeModes, localRunner } from "../src/modal/modes.ts"; import { modalModel, synthesize } from "../src/modal/synth.ts"; import { computeDamping, dampingPlate } from "../src/modal/damping.ts"; import { STEPS, chapters, emptyContext, rectangleScript, stepIndex, type TutorialContext, type TutorialHost } from "../src/tutorial/steps.ts"; function check(html: string, where: string): void { assert.ok(!/undefined|NaN|\?\s*(Hz|N|m|s)\b/.test(html), `${where}: ${html.match(/.{0,40}(undefined|NaN).{0,40}/)?.[0]}`); const dollars = (html.match(/\$/g) ?? []).length; assert.equal(dollars % 2, 0, `${where}: unbalanced math delimiters`); assert.ok(html.length > 200, `${where}: too short`); } /** A host that runs the design and the modes in-process, for the actions. */ class TestHost implements TutorialHost { params: SimParams = { ...defaultParams, spacing: 0.02, modeMaxFreq: 4000 }; c: TutorialContext = emptyContext(this.params); runMode: "linear" | "full" = "linear"; shown: (number | null)[] = []; played = 0; private async build(source: string): Promise { const d = compileDesign(source); const pl = evaluatePlate(d, this.params.spacing); this.c = { ...this.c, design: d, plate: pl, params: { ...this.params }, spacing: this.params.spacing, sys: null, modes: null, model: null, lastContact: null }; } ctx(): TutorialContext { return { ...this.c, params: { ...this.params }, runMode: this.runMode }; } async loadPreset(id: string): Promise { await this.build(presetById(id)!.source); } async loadScript(source: string): Promise { await this.build(source); } setParam(key: K, value: SimParams[K]): void { this.params[key] = value; if (key === "spacing" && this.c.design) void this.build(this.c.design.source); } async computeModes(): Promise { const pl = this.c.plate; if (!pl || this.c.modes) return; const sys = assemble(pl); const modes = await computeModes(sys, this.params.modeMaxFreq, localRunner(sys)); const model = modalModel(pl, sys, modes); computeDamping(model, dampingPlate(pl)); this.c = { ...this.c, sys, modes, model }; } showMode(m: number | null): void { this.shown.push(m); } async synthesize(): Promise { await this.computeModes(); const pl = this.c.plate!, model = this.c.model!; const r = synthesize(model, pl, { ...this.params, duration: 0.05 }, false); this.c = { ...this.c, lastContact: r.contact, recording: { source: "linear", seconds: 0.05 } }; } play(): void { this.played++; } setRunMode(mode: "linear" | "full"): void { this.runMode = mode; } async runFull(): Promise { throw new Error("no GPU in the test host"); } setSpectrogram(o: { window?: number; logFreq?: boolean }): void { this.c.spectrogram = { ...this.c.spectrogram, ...o }; } setViewOptions(): void {} } test("the chapters partition the steps and the ids are unique", () => { const ids = new Set(STEPS.map((s) => s.id)); assert.equal(ids.size, STEPS.length); const covered = chapters().flatMap((c) => c.steps); assert.deepEqual(covered, STEPS.map((_, i) => i)); assert.equal(stepIndex("modes"), STEPS.findIndex((s) => s.id === "modes")); }); test("every step renders with an empty context, with a plate, and with its modes", async () => { const empty = emptyContext(defaultParams); for (const s of STEPS) check(s.body(empty), `${s.id} (empty)`); const host = new TestHost(); await host.loadPreset("flat-rectangle"); for (const s of STEPS) check(s.body(host.ctx()), `${s.id} (plate)`); await host.computeModes(); await host.synthesize(); const c = host.ctx(); assert.ok(c.modes && c.modes.count > 10); for (const s of STEPS) check(s.body(c), `${s.id} (modes)`); }); test("the actions run against an in-process host", async () => { const host = new TestHost(); const skip = new Set(["Run the full model"]); const slow = /4 mm|6 mm|12 mm|Half the size|Twice as thick|Set up|crash|ride|Thunder|Reference/i; for (const s of STEPS) { for (const a of s.actions ?? []) { if (skip.has(a.label) || slow.test(a.label)) continue; await a.run(host); check(s.body(host.ctx()), `${s.id} after '${a.label}'`); } } assert.ok(host.shown.length > 5); assert.ok(host.played > 0); }); test("the scaling actions remember mode lists that compare as predicted", async () => { const host = new TestHost(); const scaling = STEPS[stepIndex("scaling")]; const by = (re: RegExp) => scaling.actions!.find((a) => re.test(a.label))!; await by(/Reference/).run(host); await by(/Twice as thick/).run(host); await by(/Half the size, 4 mm/).run(host); const c = host.ctx(); const ref = c.memo.ref!, thick = c.memo.thick!, half = c.memo.half4!; assert.ok(ref.freqs.length > 5); for (let m = 0; m < 5; m++) { assert.ok(Math.abs(thick.freqs[m] / ref.freqs[m] - 2) < 1e-6, `thickness ratio ${thick.freqs[m] / ref.freqs[m]}`); assert.ok(Math.abs(half.freqs[m] / ref.freqs[m] - 4) < 1e-6, `size ratio ${half.freqs[m] / ref.freqs[m]}`); } const html = scaling.body(c); check(html, "scaling"); assert.match(html, /expected 2/); assert.match(html, /expected 4/); }); test("the rectangle script compiles with each option", () => { for (const o of [{}, { thicknessMm: 1.58 }, { widthIn: 6, heightIn: 9 }, { eta: 1e-3 }, { thermal: false }]) { const d = compileDesign(rectangleScript(o)); assert.ok(d.Lx > 0); } });