/ proof-of-concept / cymbalsim
Sign in
proof-of-concept / cymbalsim
cymbalsim / tests / tutorial.test.ts
136 lines · 6.2 KBCodeBlameHistory
351af76Interactive tutorial on the physics and the mathematics of the simulatorJeremy Magland 1// The tutorial's pages render without a DOM: every step is rendered with an
2// empty context, with a compiled plate, and with the plate's modes (on a
3// coarse grid), and the text must contain no undefined or NaN value and
4// balanced math delimiters. The actions are exercised against a small
5// in-process host that runs the real design, assembly and mode code.
7import { test } from "node:test";
8import assert from "node:assert/strict";
9import { compileDesign, evaluatePlate } from "../src/design.ts";
10import { presetById } from "../src/presets.ts";
11import { defaultParams, type SimParams } from "../src/params.ts";
12import { assemble } from "../src/model/assemble.ts";
13import { computeModes, localRunner } from "../src/modal/modes.ts";
14import { modalModel, synthesize } from "../src/modal/synth.ts";
15import { STEPS, chapters, emptyContext, rectangleScript, stepIndex, type TutorialContext, type TutorialHost } from "../src/tutorial/steps.ts";
17function check(html: string, where: string): void {
18 assert.ok(!/undefined|NaN|\?\s*(Hz|N|m|s)\b/.test(html), `${where}: ${html.match(/.{0,40}(undefined|NaN).{0,40}/)?.[0]}`);
19 const dollars = (html.match(/\$/g) ?? []).length;
20 assert.equal(dollars % 2, 0, `${where}: unbalanced math delimiters`);
21 assert.ok(html.length > 200, `${where}: too short`);
24/** A host that runs the design and the modes in-process, for the actions. */
25class TestHost implements TutorialHost {
26 params: SimParams = { ...defaultParams, spacing: 0.02, modeMaxFreq: 4000 };
27 c: TutorialContext = emptyContext(this.params);
28 runMode: "linear" | "full" = "linear";
29 shown: (number | null)[] = [];
30 played = 0;
31 private async build(source: string): Promise<void> {
32 const d = compileDesign(source);
33 const pl = evaluatePlate(d, this.params.spacing);
34 this.c = { ...this.c, design: d, plate: pl, params: { ...this.params }, spacing: this.params.spacing, sys: null, modes: null, model: null, lastContact: null };
35 }
36 ctx(): TutorialContext { return { ...this.c, params: { ...this.params }, runMode: this.runMode }; }
37 async loadPreset(id: string): Promise<void> { await this.build(presetById(id)!.source); }
38 async loadScript(source: string): Promise<void> { await this.build(source); }
39 setParam<K extends keyof SimParams>(key: K, value: SimParams[K]): void {
40 this.params[key] = value;
41 if (key === "spacing" && this.c.design) void this.build(this.c.design.source);
42 }
43 async computeModes(): Promise<void> {
44 const pl = this.c.plate;
45 if (!pl || this.c.modes) return;
46 const sys = assemble(pl);
47 const modes = await computeModes(sys, this.params.modeMaxFreq, localRunner(sys));
48 this.c = { ...this.c, sys, modes, model: modalModel(pl, sys, modes) };
49 }
50 showMode(m: number | null): void { this.shown.push(m); }
51 async synthesize(): Promise<void> {
52 await this.computeModes();
53 const pl = this.c.plate!, model = this.c.model!;
54 const r = synthesize(model, pl, pl.loss, { ...this.params, duration: 0.05 }, false);
55 this.c = { ...this.c, lastContact: r.contact, recording: { source: "linear", seconds: 0.05 } };
56 }
57 play(): void { this.played++; }
58 setRunMode(mode: "linear" | "full"): void { this.runMode = mode; }
59 async runFull(): Promise<void> { throw new Error("no GPU in the test host"); }
60 setSpectrogram(o: { window?: number; logFreq?: boolean }): void { this.c.spectrogram = { ...this.c.spectrogram, ...o }; }
61 setViewOptions(): void {}
64test("the chapters partition the steps and the ids are unique", () => {
65 const ids = new Set(STEPS.map((s) => s.id));
66 assert.equal(ids.size, STEPS.length);
67 const covered = chapters().flatMap((c) => c.steps);
68 assert.deepEqual(covered, STEPS.map((_, i) => i));
69 assert.equal(stepIndex("modes"), STEPS.findIndex((s) => s.id === "modes"));
70});
72test("every step renders with an empty context, with a plate, and with its modes", async () => {
73 const empty = emptyContext(defaultParams);
74 for (const s of STEPS) check(s.body(empty), `${s.id} (empty)`);
76 const host = new TestHost();
77 await host.loadPreset("flat-rectangle");
78 for (const s of STEPS) check(s.body(host.ctx()), `${s.id} (plate)`);
80 await host.computeModes();
81 await host.synthesize();
82 const c = host.ctx();
83 assert.ok(c.modes && c.modes.count > 10);
84 for (const s of STEPS) check(s.body(c), `${s.id} (modes)`);
86 // The stand pages with a plate on a stand.
87 await host.loadPreset("flat-disc");
88 await host.computeModes();
89 const cs = host.ctx();
90 const stand = STEPS[stepIndex("stand")].body(cs);
91 check(stand, "stand (disc)");
92 assert.match(stand, /bounce/);
93 assert.match(stand, /rock/);
94});
96test("the actions run against an in-process host", async () => {
97 const host = new TestHost();
98 const skip = new Set(["Run the full model"]);
99 const slow = /4 mm|6 mm|12 mm|Half the size|Twice as thick|Set up|crash|ride|Thunder|Reference/i;
100 for (const s of STEPS) {
101 for (const a of s.actions ?? []) {
102 if (skip.has(a.label) || slow.test(a.label)) continue;
103 await a.run(host);
104 check(s.body(host.ctx()), `${s.id} after '${a.label}'`);
105 }
106 }
107 assert.ok(host.shown.length > 5);
108 assert.ok(host.played > 0);
109});
111test("the scaling actions remember mode lists that compare as predicted", async () => {
112 const host = new TestHost();
113 const scaling = STEPS[stepIndex("scaling")];
114 const by = (re: RegExp) => scaling.actions!.find((a) => re.test(a.label))!;
115 await by(/Reference/).run(host);
116 await by(/Twice as thick/).run(host);
117 await by(/Half the size, 4 mm/).run(host);
118 const c = host.ctx();
119 const ref = c.memo.ref!, thick = c.memo.thick!, half = c.memo.half4!;
120 assert.ok(ref.freqs.length > 5);
121 for (let m = 0; m < 5; m++) {
122 assert.ok(Math.abs(thick.freqs[m] / ref.freqs[m] - 2) < 1e-6, `thickness ratio ${thick.freqs[m] / ref.freqs[m]}`);
123 assert.ok(Math.abs(half.freqs[m] / ref.freqs[m] - 4) < 1e-6, `size ratio ${half.freqs[m] / ref.freqs[m]}`);
124 }
125 const html = scaling.body(c);
126 check(html, "scaling");
127 assert.match(html, /expected 2/);
128 assert.match(html, /expected 4/);
129});
131test("the rectangle script compiles with each option", () => {
132 for (const o of [{}, { thicknessMm: 1.58 }, { widthIn: 6, heightIn: 9 }, { t60: "1" }, { t60: "[100 5; 1000 2]" }]) {
133 const d = compileDesign(rectangleScript(o));
134 assert.ok(d.Lx > 0);
135 }
136});
↑↓ move↵openescclose