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