feat: 新增3个思想实验室项目,完善私有剪贴板授权与上传功能,添加配套测试脚本

1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目
2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理
3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景
4. 补全穿越三体项目的完整HTML页面资源
This commit is contained in:
yangxiangyuan
2026-08-07 17:49:24 +08:00
parent 0f8fe2c2e2
commit 4492b4106a
22 changed files with 6935 additions and 34 deletions
@@ -0,0 +1,83 @@
// ============================================================
// test_four_dim_polytopes.js - 四维空间实验室数学块校验
// 从 public/tools/thought_lab/labs/four_dim_space/index.html 抽取
// POLYTOPE_MATH_BEGIN/END 之间的纯数学代码执行校验,保证测试对象 = 上线代码。
// 校验:5 个凸正多胞体的顶点数/边数(严格已知值)、外接半径全等、
// 旋转保范、投影 w=0 时缩放为 1、偶排列数 = 12。
// 运行:node dev_test_scripts/unit/test_four_dim_polytopes.js
// ============================================================
'use strict'
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const HTML = path.resolve(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'four_dim_space', 'index.html')
const src = fs.readFileSync(HTML, 'utf8')
const m = src.match(/\/\/ ==== POLYTOPE_MATH_BEGIN ====\n([\s\S]*?)\/\/ ==== POLYTOPE_MATH_END ====/)
if (!m) { console.error('FAIL - 未找到 POLYTOPE_MATH 标记块'); process.exit(1) }
const api = vm.runInNewContext(m[1] + `
;({ POLYTOPES, edgesByMinDist, rotate4, project4to3, project3to2, evenPerms4, perms4, PHI })
`)
let pass = 0, fail = 0
const check = (name, cond) => {
if (cond) { pass++; console.log('PASS -', name) }
else { fail++; console.log('FAIL -', name) }
}
const norm = v => Math.sqrt(v.reduce((s, x) => s + x * x, 0))
// 排列数
check('4 元素全排列 = 24', api.perms4.length === 24)
check('偶排列 = 12', api.evenPerms4.length === 12)
// 各多胞体:顶点数 / 边数 / 外接半径全等
for (const key of Object.keys(api.POLYTOPES)) {
const P = api.POLYTOPES[key]
const verts = P.verts()
const edges = api.edgesByMinDist(verts)
check(`${key} 顶点数 = ${P.expect.v}`, verts.length === P.expect.v)
check(`${key} 边数 = ${P.expect.e}`, edges.length === P.expect.e)
const rs = verts.map(norm)
const rMin = Math.min(...rs), rMax = Math.max(...rs)
check(`${key} 外接半径全等(${rMin.toFixed(6)})`, rMax - rMin < 1e-9)
}
// 600-胞体最小边长应 = 1/φ(单位外接半径)
{
const verts = api.POLYTOPES.p600.verts()
let min = Infinity
for (let i = 0; i < verts.length; i++)
for (let j = i + 1; j < verts.length; j++) {
let s = 0
for (let k = 0; k < 4; k++) { const d = verts[i][k] - verts[j][k]; s += d * d }
min = Math.min(min, s)
}
check('600-胞体边长 = 1/φ', Math.abs(Math.sqrt(min) - 1 / api.PHI) < 1e-9)
}
// 旋转保范:六个平面各转 0.7 rad
{
const v = [1, 2, 3, 4]
const planes = [[0, 1], [0, 2], [1, 2], [0, 3], [1, 3], [2, 3]]
let ok = true
for (const [i, j] of planes) {
const q = api.rotate4(v, i, j, 0.7)
if (Math.abs(norm(q) - norm(v)) > 1e-12) ok = false
}
check('六平面旋转均保范', ok)
}
// 投影:w=0 时 4D→3D 缩放为 1;w>0 放大
{
const a = api.project4to3([1, 1, 1, 0], 3)
const b = api.project4to3([1, 1, 1, 0.9], 3)
check('project4to3 w=0 缩放=1', Math.abs(a[0] - 1) < 1e-12)
check('project4to3 w>0 放大', b[0] > 1)
const c = api.project3to2([1, 1, 0], 6)
check('project3to2 z=0 缩放=1', Math.abs(c[0] - 1) < 1e-12)
}
console.log(fail === 0 ? `ALL PASS (${pass})` : `FAILED: ${fail} / ${pass + fail}`)
process.exit(fail === 0 ? 0 : 1)
@@ -0,0 +1,152 @@
// ============================================================
// 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);
@@ -0,0 +1,53 @@
// ============================================================
// test_three_body_integrator.js - 穿越三体 · 三体积分器校验
// 从 public/tools/thought_lab/labs/three_body_journey/index.html 抽取
// THREEBODY_MATH_BEGIN/END 之间的纯物理代码执行校验(测试对象 = 上线代码)。
// 校验:RK4 积分动量守恒、能量漂移极小、加速度满足牛顿第三定律对称性。
// 运行:node dev_test_scripts/unit/test_three_body_integrator.js
// ============================================================
'use strict'
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const HTML = path.resolve(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'three_body_journey', 'index.html')
const src = fs.readFileSync(HTML, 'utf8')
const m = src.match(/\/\/ ==== THREEBODY_MATH_BEGIN ====\n([\s\S]*?)\/\/ ==== THREEBODY_MATH_END ====/)
if (!m) { console.error('FAIL - 未找到 THREEBODY_MATH 标记块'); process.exit(1) }
const api = vm.runInNewContext(m[1] + ';({ tbAccel, tbStep, tbEnergy, tbMomentum })')
let pass = 0, fail = 0
const check = (name, cond) => {
if (cond) { pass++; console.log('PASS -', name) }
else { fail++; console.log('FAIL -', name) }
}
const masses = [1, 1, 1]
const eps2 = 0.02
let pos = [1, 0, -0.5, Math.sqrt(3) / 2, -0.5, -Math.sqrt(3) / 2]
let vel = [0, 0.42, -0.38, -0.2, 0.38, -0.22]
// 牛顿第三定律:Σ mᵢaᵢ = 0(作用力反作用力,任意位形)
{
const ms = [1, 2, 0.5]
const a = api.tbAccel([0, 0, 1, 0.3, -0.7, 1.2], ms, 0)
let sx = 0, sy = 0
for (let i = 0; i < 3; i++) { sx += ms[i] * a[2 * i]; sy += ms[i] * a[2 * i + 1] }
check('Σ mᵢaᵢ = 0(作用反作用)', Math.abs(sx) < 1e-12 && Math.abs(sy) < 1e-12)
}
// 动量守恒:跑 5000 步
const p0 = api.tbMomentum(vel, masses)
const E0 = api.tbEnergy(pos, vel, masses, eps2)
for (let i = 0; i < 5000; i++) [pos, vel] = api.tbStep(pos, vel, masses, 0.002, eps2)
const p1 = api.tbMomentum(vel, masses)
const E1 = api.tbEnergy(pos, vel, masses, eps2)
check('动量守恒(5000 步,|Δp|<1e-9)', Math.abs(p1[0] - p0[0]) < 1e-9 && Math.abs(p1[1] - p0[1]) < 1e-9)
check(`能量漂移小(|ΔE/E0|<1e-3,实际 ${Math.abs((E1 - E0) / E0).toExponential(2)})`, Math.abs((E1 - E0) / E0) < 1e-3)
// 有限性:5000 步后位置/速度均为有限数(软化避免奇点崩溃)
check('积分稳定(全部有限)', [...pos, ...vel].every(Number.isFinite))
console.log(fail === 0 ? `ALL PASS (${pass})` : `FAILED: ${fail} / ${pass + fail}`)
process.exit(fail === 0 ? 0 : 1)