feat(thought-lab): 新增万花筒与闵可夫斯基画板两个思想实验室
新增两个独立思想实验室: 1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化 2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定 同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
// 单元测试:kaleidoscope lab 后端(sqlite 方案 CRUD + 路由)
|
||||
// 运行:node dev_test_scripts/unit/test_kaleidoscope_db.js
|
||||
// 说明:直接调用 db.js 验证 CRUD;再挂载 express 验证路由层(无上游守卫,仅测路由本身)
|
||||
const path = require('path')
|
||||
const http = require('http')
|
||||
const express = require(path.join(process.cwd(), 'node_modules', 'express'))
|
||||
const db = require(path.join(process.cwd(), 'src', 'server', 'thought_lab', 'labs', 'kaleidoscope', 'db.js'))
|
||||
const kaleido = require(path.join(process.cwd(), 'src', 'server', 'thought_lab', 'labs', 'kaleidoscope', 'index.js'))
|
||||
|
||||
let fail = 0
|
||||
const check = (name, cond, extra) => {
|
||||
console.log(`${cond ? 'PASS' : 'FAIL'} - ${name}${extra ? ' | ' + extra : ''}`)
|
||||
if (!cond) fail++
|
||||
}
|
||||
|
||||
// ---- 1) db 层 CRUD ----
|
||||
const created = db.createScheme({ name: '测试方案A', params: { segments: 8, mirror: true }, seed: 42 })
|
||||
check('create 返回 id 与北京时间戳', !!created.id && /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(created.created_at), JSON.stringify(created.created_at))
|
||||
const updated = db.updateScheme(created.id, { name: '测试方案A改', params: { segments: 12 }, seed: 43 })
|
||||
check('update 生效', updated && updated.name === '测试方案A改' && updated.params.segments === 12)
|
||||
const listed = db.listSchemes()
|
||||
check('list 包含新建方案', listed.some(s => s.id === created.id))
|
||||
const got = db.getScheme(created.id)
|
||||
check('get 单条一致', got && got.seed === 43)
|
||||
|
||||
// ---- 2) 路由层 ----
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
kaleido.bindRoutes(app)
|
||||
const server = app.listen(0, async () => {
|
||||
const port = server.address().port
|
||||
const req = (method, p, body) => new Promise((resolve, reject) => {
|
||||
const data = body ? JSON.stringify(body) : null
|
||||
const r = http.request({ host: '127.0.0.1', port, path: p, method, headers: data ? { 'Content-Type': 'application/json' } : {} }, res => {
|
||||
let b = ''
|
||||
res.on('data', c => { b += c })
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: b }))
|
||||
})
|
||||
r.on('error', reject)
|
||||
if (data) r.write(data)
|
||||
r.end()
|
||||
})
|
||||
|
||||
try {
|
||||
const m = await req('GET', '/api/thought_lab/kaleidoscope/meta')
|
||||
check('meta 返回 ui_mode', m.status === 200 && JSON.parse(m.body).ui_mode === 'debug', m.body)
|
||||
|
||||
const c = await req('POST', '/api/thought_lab/kaleidoscope/schemes', { name: '路由方案', params: { tube: 0.5 }, seed: 7 })
|
||||
const cj = JSON.parse(c.body)
|
||||
check('POST 创建成功', c.status === 200 && cj.ok && cj.scheme.id > 0)
|
||||
|
||||
const bad = await req('POST', '/api/thought_lab/kaleidoscope/schemes', { name: '', params: {} })
|
||||
check('空名称 400', bad.status === 400)
|
||||
|
||||
const u = await req('PUT', `/api/thought_lab/kaleidoscope/schemes/${cj.scheme.id}`, { name: '路由方案2', params: { tube: 0.6 }, seed: 8 })
|
||||
check('PUT 更新成功', u.status === 200 && JSON.parse(u.body).scheme.name === '路由方案2')
|
||||
|
||||
const l = await req('GET', '/api/thought_lab/kaleidoscope/schemes')
|
||||
check('GET 列表包含路由方案', JSON.parse(l.body).schemes.some(s => s.id === cj.scheme.id))
|
||||
|
||||
const d = await req('DELETE', `/api/thought_lab/kaleidoscope/schemes/${cj.scheme.id}`)
|
||||
check('DELETE 成功', d.status === 200)
|
||||
} catch (e) {
|
||||
fail++
|
||||
console.log('FAIL - 请求异常 |', e.message)
|
||||
}
|
||||
|
||||
// 清理本测试创建的 db 记录
|
||||
try { db.deleteScheme(created.id) } catch {}
|
||||
|
||||
server.close()
|
||||
console.log(fail === 0 ? 'ALL PASS' : `${fail} FAILED`)
|
||||
process.exit(fail === 0 ? 0 : 1)
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
// 单元测试:kaleidoscope 纯函数(种子可复现 + 棱镜分割)
|
||||
// 运行:node dev_test_scripts/unit/test_kaleidoscope_pure.js
|
||||
// 从 HTML 中抽取 PURE-BEGIN..PURE-END 区块执行
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const vm = require('vm')
|
||||
|
||||
const html = fs.readFileSync(path.join(process.cwd(), 'public', 'tools', 'thought_lab', 'labs', 'kaleidoscope', 'index.html'), 'utf-8')
|
||||
const m = html.match(/\/\/ ==== PURE-BEGIN[\s\S]*?\/\/ ==== PURE-END/)
|
||||
if (!m) { console.log('FAIL - 未找到 PURE-BEGIN..PURE-END'); process.exit(1) }
|
||||
|
||||
const sandbox = { TAU: Math.PI * 2, console };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(m[0], sandbox);
|
||||
const { TAU, mulberry32, buildPieces } = sandbox;
|
||||
// 注:PALETTES 在 sandbox 中为 const 声明,vm.runInContext 不暴露为 sandbox 属性
|
||||
// 色板仅用于绘制,此处不验证具体颜色值
|
||||
|
||||
let fail = 0;
|
||||
const check = (name, cond, extra) => {
|
||||
console.log(`${cond ? 'PASS' : 'FAIL'} - ${name}${extra ? ' | ' + extra : ''}`);
|
||||
if (!cond) fail++;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 1) 种子可复现性:同种子+同参数 → 输出完全一致
|
||||
// ------------------------------------------------------------
|
||||
const P1 = { segments: 8, mirror: true, shape: 'mix', palette: 'neon', count: 12 };
|
||||
const A = buildPieces(P1, 42);
|
||||
const B = buildPieces(P1, 42);
|
||||
check('种子可复现:同种子 42 两次产出 length 一致', A.length === B.length, `A=${A.length} B=${B.length}`);
|
||||
let identical = A.length === B.length && A.length > 0;
|
||||
for (let i = 0; i < A.length && identical; i++) {
|
||||
if (A[i].shape !== B[i].shape || A[i].color !== B[i].color) identical = false;
|
||||
}
|
||||
check('同种子纸片字段完全一致', identical, 'shape + color 逐张对比');
|
||||
|
||||
const C = buildPieces(P1, 43);
|
||||
check('不同种子产出不同', C.length > 0 && (C[0].shape !== A[0].shape || C[0].color !== A[0].color));
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 2) 棱镜镜像严格跟随旋转次数
|
||||
// ------------------------------------------------------------
|
||||
// 基础楔角 = TAU / segments
|
||||
// 镜像开启时纸片只撒半楔角 [0, copy/2),绘制时镜像副本补满另一半 → 镜面数 = 2×segments
|
||||
// 不勾镜像时纸片撒满整楔角 [0, copy) → 面数 = segments
|
||||
const wedgeAngle = (P) => TAU / P.segments / (P.mirror ? 2 : 1);
|
||||
|
||||
const P_mirror = { segments: 8, mirror: true, shape: 'mix', palette: 'neon', count: 20 };
|
||||
const pieces_mirror = buildPieces(P_mirror, 777);
|
||||
const maxAngle_mirror = Math.max(...pieces_mirror.map(p => p.an));
|
||||
check('镜像 on:纸片角度均 < 半楔角', maxAngle_mirror + 0.001 < TAU / 8, `max_an=${maxAngle_mirror.toFixed(4)} half=${(TAU/8/2).toFixed(4)}`);
|
||||
|
||||
const P_nomirror = { segments: 8, mirror: false, shape: 'mix', palette: 'neon', count: 20 };
|
||||
const pieces_nomirror = buildPieces(P_nomirror, 777);
|
||||
const maxAngle_nomirror = Math.max(...pieces_nomirror.map(p => p.an));
|
||||
check('镜像 off:纸片角度 < 整楔角', maxAngle_nomirror + 0.001 < TAU / 8, `max_an=${maxAngle_nomirror.toFixed(4)} full=${(TAU/8).toFixed(4)}`);
|
||||
|
||||
// 换不同旋转次数验证
|
||||
for (const seg of [3, 6, 12, 24]) {
|
||||
const P = { segments: seg, mirror: true, shape: 'mix', palette: 'neon', count: 10 };
|
||||
const pcs = buildPieces(P, 999);
|
||||
const semi = TAU / seg / 2;
|
||||
const maxA = Math.max(...pcs.map(p => p.an));
|
||||
if (maxA + 0.001 >= semi) {
|
||||
check(`旋转 ${seg} 次 镜像 at ${semi.toFixed(4)}`, false, `max_an=${maxA.toFixed(4)} 超出半楔角`);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
console.log(`旋转 3/6/12/24 次镜像 all PASS`);
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 3) 验证:纸片首张字段可稳定哈希
|
||||
// ------------------------------------------------------------
|
||||
const D = buildPieces({ segments: 4, mirror: true, shape: 'rect', palette: 'contrast', count: 5 }, 20260806);
|
||||
check('首张纸片 shape 确定性', D[0].shape === 'rect');
|
||||
check('首张纸片有 color 字段', typeof D[0].color === 'string' && D[0].color.length > 0);
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 4) 老万花筒模式:经典模式强制 mirror=false,纸片应撒满整楔角
|
||||
// ------------------------------------------------------------
|
||||
const E = buildPieces({ segments: 8, mirror: false, shape: 'mix', palette: 'neon', count: 20 }, 12345);
|
||||
const maxA = Math.max(...E.map(p => p.an));
|
||||
check('经典模式纸片角度 < 整楔角', maxA + 0.001 < TAU / 8, `max=${maxA.toFixed(4)} wedge=${(TAU/8).toFixed(4)}`);
|
||||
// 经典模式下旋转次数必须为偶数,否则奇偶交替不闭合
|
||||
// 本测试不验证 UI 层,仅验证纯函数:同种子同参数可复现
|
||||
const F1 = buildPieces({ segments: 6, mirror: false, shape: 'mix', palette: 'neon', count: 10 }, 555);
|
||||
const F2 = buildPieces({ segments: 6, mirror: false, shape: 'mix', palette: 'neon', count: 10 }, 555);
|
||||
check('经典模式(6 扇区)可复现', F1.length === F2.length && F1[0].shape === F2[0].shape);
|
||||
|
||||
console.log(fail === 0 ? 'ALL PASS' : `${fail} FAILED`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user