// ============================================================ // dev_test_scripts/unit/test_retro_pinball_physics.js // 怀旧弹珠台 · 物理核心单元测试 // 从 public/tools/thought_lab/labs/retro_pinball/index.html 中 // 提取 ==PHYSICS_CORE_START== / ==PHYSICS_CORE_END== 之间的纯函数核心, // 在 node vm 中运行并断言物理正确性。 // 运行:node dev_test_scripts/unit/test_retro_pinball_physics.js // ============================================================ 'use strict'; const fs = require('fs'); const path = require('path'); const vm = require('vm'); const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html'); function loadCore() { const html = fs.readFileSync(HTML_PATH, 'utf-8'); const s = html.indexOf('/* ==PHYSICS_CORE_START== */'); const e = html.indexOf('/* ==PHYSICS_CORE_END== */'); if (s < 0 || e < 0 || e <= s) throw new Error('未找到物理核心标记块'); const code = html.slice(s, e); const sandbox = { Math, console }; vm.createContext(sandbox); vm.runInContext(code, sandbox); if (!sandbox.PhysicsCore) throw new Error('PhysicsCore 未导出'); return sandbox.PhysicsCore; } const PC = loadCore(); let passed = 0, failed = 0; function check(name, cond, detail) { if (cond) { passed++; console.log(` [PASS] ${name}`); } else { failed++; console.error(` [FAIL] ${name}${detail ? ' -> ' + detail : ''}`); } } const DT = 1 / 360; function baseParams(over) { return Object.assign({ g: 2000, e: 0.55, mu: 0.12, airK: 0, rollR: 0, restV: 1, iterations: 8, slop: 0.3, correction: 0.8, maxV: 6000 }, over || {}); } const FLOOR = { type: 'seg', x1: -500, y1: 500, x2: 500, y2: 500 }; // ---------- 1. 恢复系数:回弹高度 ≈ e²·h ---------- (function testRestitution() { console.log('1) 恢复系数回弹高度'); const e = 0.5, h = 300; const p = baseParams({ e, mu: 0, restV: 1 }); const b = PC.createBall(0, 500 - 11 - h, 11); const statics = [FLOOR]; let bounced = false, apex = 1e9, done = false, t = 0; while (t < 3 && !done) { PC.stepWorld([b], statics, p, DT); t += DT; if (!bounced && b.vy < 0) bounced = true; // 回弹开始(y 向下,vy<0 为向上) if (bounced) { apex = Math.min(apex, b.y); if (b.vy > 0) done = true; // 到达顶点 } } const rebound = (500 - 11) - apex; const expect = e * e * h; check(`回弹高度 ${rebound.toFixed(1)} ≈ e²h=${expect.toFixed(1)} (±10%)`, Math.abs(rebound - expect) <= expect * 0.1, `got=${rebound}`); })(); // ---------- 2. 能量不增 ---------- (function testEnergy() { console.log('2) 机械能不自发增加'); const p = baseParams({ e: 0.5, mu: 0 }); const b = PC.createBall(0, 100, 11); const statics = [FLOOR]; const E0 = 0.5 * b.m * (b.vx * b.vx + b.vy * b.vy) + b.m * p.g * (500 - b.y); let maxE = 0; for (let t = 0; t < 2; t += DT) { PC.stepWorld([b], statics, p, DT); const E = 0.5 * b.m * (b.vx * b.vx + b.vy * b.vy) + b.m * p.g * (500 - b.y); maxE = Math.max(maxE, E); } check(`maxE=${maxE.toFixed(0)} <= E0*1.02=${(E0 * 1.02).toFixed(0)}`, maxE <= E0 * 1.02); })(); // ---------- 3. 静止稳定:不沉陷、不抖动 ---------- (function testRest() { console.log('3) 水平面静止稳定性'); const p = baseParams({ e: 0.55, mu: 0.2 }); const b = PC.createBall(0, 200, 11); const statics = [FLOOR]; for (let t = 0; t < 3; t += DT) PC.stepWorld([b], statics, p, DT); const sp = Math.hypot(b.vx, b.vy); check(`静止速度 ${sp.toFixed(2)} < 20`, sp < 20); check(`不沉陷 y=${b.y.toFixed(2)} <= 489+1`, b.y <= 500 - 11 + 1); })(); // ---------- 4. 等质量弹性碰撞速度交换 ---------- (function testMomentum() { console.log('4) 等质量 e=1 正碰速度交换'); const p = baseParams({ g: 0, e: 1, mu: 0 }); const a = PC.createBall(0, 0, 11); a.vx = 100; const b = PC.createBall(30, 0, 11); for (let t = 0; t < 0.5; t += DT) PC.stepWorld([a, b], [], p, DT); check(`a.vx=${a.vx.toFixed(1)}≈0`, Math.abs(a.vx) < 3, `got=${a.vx}`); check(`b.vx=${b.vx.toFixed(1)}≈100`, Math.abs(b.vx - 100) < 3, `got=${b.vx}`); })(); // ---------- 5. 高速不穿透 ---------- (function testNoTunnel() { console.log('5) 3000px/s 不穿透薄壁'); const p = baseParams({ g: 0, e: 0.5, mu: 0 }); const wall = { type: 'seg', x1: 100, y1: -1000, x2: 100, y2: 1000 }; const b = PC.createBall(0, 0, 11); b.vx = 3000; let maxX = -1e9; for (let t = 0; t < 0.5; t += DT) { PC.stepWorld([b], [wall], p, DT); maxX = Math.max(maxX, b.x); } check(`maxX=${maxX.toFixed(1)} <= 100`, maxX <= 100 + 0.5); })(); // ---------- 6. 摩擦减速 + 滚动建立 ---------- (function testFrictionRoll() { console.log('6) 摩擦使滑动减速并建立滚动'); const mk = (mu) => { const p = baseParams({ e: 0.3, mu, rollR: 0 }); const b = PC.createBall(0, 500 - 11, 11); b.vx = 500; for (let t = 0; t < 0.5; t += DT) PC.stepWorld([b], [FLOOR], p, DT); return b; }; const noF = mk(0), withF = mk(0.4); check(`无摩擦保持滑动 |vx|=${noF.vx.toFixed(0)} > 400`, noF.vx > 400); check(`有摩擦减速 vx=${withF.vx.toFixed(0)} < ${noF.vx.toFixed(0)}`, withF.vx < noF.vx - 50); check(`滚动建立 omega=${withF.omega.toFixed(1)} > 5`, withF.omega > 5); })(); // ---------- 7. 斜坡:无摩擦加速下滑 ---------- (function testSlope() { console.log('7) 斜坡下滑加速度 ≈ g·sinθ'); const theta = Math.PI / 6; // 30° const p = baseParams({ g: 2000, e: 0, mu: 0 }); const slope = { type: 'seg', x1: 0, y1: 0, x2: 300, y2: 300 * Math.tan(theta) }; const b = PC.createBall(50, 50 * Math.tan(theta) - 11, 11); const v0 = Math.hypot(b.vx, b.vy); for (let t = 0; t < 0.3; t += DT) PC.stepWorld([b], [slope], p, DT); const v1 = Math.hypot(b.vx, b.vy); const dv = v1 - v0; const expect = p.g * Math.sin(theta) * 0.3; check(`dv=${dv.toFixed(0)} ≈ g·sinθ·t=${expect.toFixed(0)} (±8%)`, Math.abs(dv - expect) <= expect * 0.08); })(); console.log(`\n结果: ${passed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0);