1// GPU-versus-CPU test: serves the app with Vite's dev server, opens it in a
2// headless Chromium with WebGPU through Playwright, and reads the result of the
3// in-page self-test (src/selftest.ts), which compares one step of the WGSL
4// kernel with the float64 reference on several presets. Then runs short
5// strikes on a free plate and on a mounted cymbal to completion.
7import { test } from "node:test";
8import assert from "node:assert/strict";
9import { fileURLToPath } from "node:url";
10import { dirname, join } from "node:path";
11import { createServer } from "vite";
12import { chromium, type Page } from "playwright";
14const root = join(dirname(fileURLToPath(import.meta.url)), "..");
16interface SelfTestCase {
17 name: string;
18 accError: { u: number; v: number; w: number };
19 positionError: number;
20 nodes: number;
21 dt: number;
22}
23interface SelfTestResult { done: boolean; passed?: boolean; cases?: SelfTestCase[]; error?: string }
25interface Status {
26 phase: string;
27 t: number;
28 totalSteps: number;
29 wmax: number;
30 peakPressure: number;
31 samples: number;
32 frames: number;
33 stepsPerSec: number;
34 dt: number;
35 nx: number;
36 ny: number;
37 stick: { maxForce: number };
38 design: { name: string | null; nodes: number; mounted: boolean; error: string | null };
39}
41const ARGS = ["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-angle=vulkan", "--ignore-gpu-blocklist", "--no-sandbox"];
43/** Serves the app and opens `query` in a headless Chromium; `body` gets the page and the list of page errors. */
44async function withPage(query: string, body: (page: Page, errors: string[]) => Promise<void>): Promise<void> {
45 const server = await createServer({ root, logLevel: "silent", server: { port: 0, strictPort: false } });
46 await server.listen();
47 const url = server.resolvedUrls?.local[0];
48 assert.ok(url, "dev server URL");
49 const browser = await chromium.launch({ headless: true, args: ARGS });
50 try {
51 const page = await browser.newPage();
52 const errors: string[] = [];
53 page.on("pageerror", (e) => errors.push(e.message));
54 page.on("console", (msg) => { if (msg.type() === "error" || msg.type() === "warning") errors.push(`console.${msg.type()}: ${msg.text()}`); });
55 await page.goto(`${url}?${query}`);
56 await body(page, errors);
57 } finally {
58 await browser.close();
59 await server.close();
60 }
61}
63test("one GPU step matches the float64 reference on flat, bowed, tapered, mounted, and elliptical plates", { timeout: 180_000 }, async () => {
64 await withPage("selftest=1", async (page, errors) => {
65 let result: SelfTestResult = { done: false };
66 for (let i = 0; i < 240 && !result.done; i++) {
67 await page.waitForTimeout(500);
68 result = await page.evaluate<SelfTestResult>("window.steelsim ? window.steelsim.selftest() : { done: false }");
69 }
70 assert.deepEqual(errors, [], "page errors");
71 assert.ok(result.done, "self-test did not finish");
72 assert.equal(result.error, undefined, `self-test error: ${result.error}`);
73 for (const c of result.cases ?? []) {
74 console.log(` ${c.name}: ${c.nodes} nodes, dt ${(c.dt * 1e6).toFixed(2)} us, acc errors ${c.accError.u.toExponential(1)} / ${c.accError.v.toExponential(1)} / ${c.accError.w.toExponential(1)}, position ${c.positionError.toExponential(1)}`);
75 }
76 assert.ok(result.passed, `self-test failed: ${JSON.stringify(result.cases)}`);
77 });
78});
80/** Runs a short simulation to completion and returns its final status. */
81async function runToCompletion(query: string): Promise<Status> {
82 let st: Status | null = null;
83 await withPage(`autostart=1&${query}`, async (page, errors) => {
84 for (let i = 0; i < 600; i++) {
85 await page.waitForTimeout(250);
86 st = await page.evaluate<Status | null>("window.steelsim && window.steelsim.status() ? { ...window.steelsim.status(), design: window.steelsim.design() } : null");
87 const noGpu = await page.evaluate<string | null>("window.steelsim.noGpu()");
88 assert.equal(noGpu, null, `no WebGPU: ${noGpu}`);
89 if (st && st.phase === "done") break;
90 }
91 assert.deepEqual(errors, [], "page errors");
92 });
93 assert.ok(st, "no status");
94 return st!;
95}
97test("a short strike on the free bowed rectangle completes with finite output", { timeout: 180_000 }, async () => {
98 const st = await runToCompletion("preset=bowed-rectangle&spacing=0.012&duration=0.2&rigidProjection=1");
99 console.log(` ${st.design.name}: ${st.nx}x${st.ny}, dt ${(st.dt * 1e6).toFixed(2)} us, ${(st.stepsPerSec / 1e3).toFixed(0)} k steps/s, peak ${st.peakPressure.toFixed(3)} Pa, max |w| ${(st.wmax * 1e3).toFixed(2)} mm, max contact force ${st.stick.maxForce.toFixed(1)} N`);
100 assert.equal(st.design.name, "Bowed steel rectangle", "the preset was read from the URL");
101 assert.equal(st.design.mounted, false);
102 assert.equal(st.phase, "done");
103 assert.ok(st.samples >= 0.2 * 48000 - 1, "recorded the requested duration");
104 assert.ok(isFinite(st.wmax) && st.wmax < 0.05, "displacement stays bounded");
105 assert.ok(st.peakPressure > 1e-3 && isFinite(st.peakPressure), "the plate radiates");
106 assert.ok(st.stick.maxForce > 1, "the stick hit the plate");
107});
109test("a short strike on the mounted, tapered 16 inch crash completes with finite output", { timeout: 180_000 }, async () => {
110 const st = await runToCompletion("preset=crash-16&spacing=0.012&duration=0.2");
111 console.log(` ${st.design.name}: ${st.nx}x${st.ny}, ${st.design.nodes} nodes, ${(st.stepsPerSec / 1e3).toFixed(0)} k steps/s, peak ${st.peakPressure.toFixed(3)} Pa, max |w| ${(st.wmax * 1e3).toFixed(2)} mm, max contact force ${st.stick.maxForce.toFixed(1)} N`);
112 assert.equal(st.design.name, "16 inch crash");
113 assert.equal(st.design.mounted, true);
114 assert.equal(st.phase, "done");
115 assert.ok(isFinite(st.wmax) && st.wmax < 0.05, "displacement stays bounded");
116 assert.ok(st.peakPressure > 1e-3, "the cymbal radiates");
117 assert.ok(st.stick.maxForce > 1, "the stick hit the cymbal");
118});
120test("a script with an error is reported and does not start", { timeout: 120_000 }, async () => {
121 await withPage("", async (page, errors) => {
122 for (let i = 0; i < 40; i++) {
123 await page.waitForTimeout(250);
124 const ready = await page.evaluate<boolean>("!!window.steelsim");
125 if (ready) break;
126 }
127 await page.evaluate("window.steelsim.setScript('return { material: { E: 1e11, nu: 0.3, rho: 8000 }, bounds: { width: 0.3, height: 0.2 }, thickness: -1 };')");
128 await page.waitForTimeout(200);
129 const d = await page.evaluate<Status["design"]>("window.steelsim.design()");
130 assert.equal(d.name, null);
131 assert.match(d.error ?? "", /thickness must be positive/);
132 await page.evaluate("window.steelsim.start()");
133 await page.waitForTimeout(500);
134 assert.equal(await page.evaluate("window.steelsim.status()"), null, "nothing started");
135 assert.deepEqual(errors, [], "page errors");
136 });
137});