feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源

把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
This commit is contained in:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
# `tools/checks/` — 验收套件
跑一遍就相当于"这次改动没把东西弄坏"的全部证据。它原来是散在 `.scratch/` 里的,
而 `.scratch/` 按 `AGENTS.md` 是**问题与规格**的存放处——这些脚本不是某条 issue 的产物,
issue 关掉之后还要一直跑,所以搬到了这里。
---
## 怎么跑
它们的前置条件不一样,**不能一条命令全跑完**。按下面三组来:
### ① 不需要服务器
```bash
pnpm check # 类型 + 构建 + 语法 + 路径 + 产物自包含 + 游戏合集文案归属
pnpm build sim # 下面几项要 sim 产物
node tools/checks/test-sim-gate.mts # 门禁真的会拦(往页面注入四种外部依赖)
node tools/checks/test-inline-guard.mts # 内联 </script 守卫真的会拦
node tools/checks/test-transform-module.mts # 模块合成的 export 改写
node tools/checks/diff-project.mts # 产物 project.json vs 已发布基线
node tools/checks/check-collection-copy.mts # 游戏合集的文案是"它自己那个游戏"的(先 pnpm build)
node tools/checks/verify-sim-page.mts <分发名> # 自包含包 file:// 可用(含"零 http 请求")
node tools/checks/verify-static-preview.mts <分发名> # 静态托管子路径可用(自带服务器)
node tools/checks/read-audio-tags.mts # 读音频文件自带标签(核对歌名用)
node tools/checks/check-sim-scripts.mts [页面] # 抽出自包含页的每个内联 script 单独语法检查
node tools/checks/check-downloader.mts # 抓取器:能编译 + 夹具绿 + 四处破坏必红(不联网)
node tools/checks/verify-scene-player.mts # 场景播放器:多骨架+平面合成、画面非空、atlas 指错必红
```
`verify-scene-player` 自己跑 `tsc -p tsconfig.runtime.json` 并自造夹具(用抓取器 staged 的
`kv45/scene_main` 与 `nico-tea/scene_main`——staging 是"一个场景一个壁纸目录"),
**不碰 `wallpapers/`、不需要 `pnpm build`**;前置只有 `python -m tools.downloader fetch --page kv45`。
`verify-static-preview` 需要 `pnpm build --with-sim`(面板要随产物走)。
### ② 需要 `serve.mjs`(对拍驱动 `window.__test` 只有它注入)
> ⚠ **跑 `test-inline-guard` 之前必须先停掉 `pnpm dev`。** 它会故意把 `src/vendor/spine-player.js`
> 改坏来验证守卫,而调试服的热更新正在监听 `src/`——两者会抢构建,结果随机失败。
> 同理,任何会写 `dist/` 的构建都别和 watcher 同时跑(`pnpm build sim` 与 watcher 也会抢 `dist/`)。
```bash
node tools/serve.mjs 8190 --root dist/releases/collection-all # 另开一个终端
node tools/checks/test-props.mjs # bgm 选择/回落 + fps 限流
node tools/checks/test-resize.mjs # 改窗口尺寸不泄漏、不跑偏
node tools/checks/test-acceptance.mjs # 体积/文案/音源/构图总验收
node tools/checks/verify-fitter.mjs # viewport-fitter 的纯函数
```
### ③ 需要 `pnpm dev`(模拟器面板)
```bash
node tools/dev.ts --port 5173 --no-build # 另开一个终端
node tools/checks/smoke-dev.mts 5173 # 路由、注入、越界 404
node tools/checks/smoke-sim.mts http://127.0.0.1:5173 # 面板真的挂上、属性真的下发
node tools/checks/verify-panel.mts http://127.0.0.1:5173 # 面板还原度(12 组)
node tools/checks/verify-hot-reload.mts http://127.0.0.1:5173 # 改 CSS 只换样式表、改 runtime 整页刷新
node tools/checks/check-single-bgm.mts http://127.0.0.1:5173 # 单档分发里切歌真的换音源
node tools/checks/shot-panel.mts http://127.0.0.1:5173 [输出目录] # 给面板出图(不是断言)
```
`verify-dev-flags.mts` 要**另一个模式**:先 `node tools/build.ts --with-sim`,
再 `node tools/dev.ts --port 5173 --no-build --with-sim`,然后
`node tools/checks/verify-dev-flags.mts`。它验的是"热更新重建后 `--with-sim` 还在"。
`verify-visual-equivalence.mts` 要两步:先用 `tools/capture.mjs` 拍到
`tools/shots/visual-current/`,再跑它。基线在 `tools/shots/baseline-pre-refactor/`,
**不能重新生成**。
---
## 两层,以及为什么
| 后缀 | 是否被 `pnpm typecheck` 检查 | 说明 |
| --- | --- | --- |
| `.mts` | ✅ 走 `tsconfig.checks.json` | 后来写的验收脚本 |
| `.mjs` | ❌ | 四个早期的运行时测试:`test-acceptance` / `test-props` / `test-resize` / `verify-fitter` |
`tsconfig.checks.json` 只关掉 `noImplicitAny` 一项,其余严格模式照旧——
正是严格模式翻出了 28 处真问题(可能 undefined、`[]` 推成 `never[]`、`catch` 里 `error` 是 unknown、
参数个数不符),其中一条是"文件头声明了 ⑤ 但从未实现的断言"。
**往 `.mjs` 里写类型注解会让它运行时崩。** `.mjs` 是纯 JS,`(x: string) => …` 是 SyntaxError,
而 `tsc` 不管 `.mjs`,所以这个错在类型检查里完全看不见——踩过一次。
要写类型就先改成 `.mts`。
> 把那四个 `.mjs` 也并进受检层是一件独立的、该单独做的事:实测会立刻多出约 80 处既有问题。
> 别在别的改动的尾巴上顺手做。
---
## 约定
- **一个从不失败的检查等于没有检查。** 门禁类的脚本(`test-sim-gate` / `test-inline-guard`)
都要"先证明它会红"——注入一个坏东西,确认它报错,再还原。
- **验证要先检查前置条件。** `verify-dev-flags.mts` 第一件事是 `fetch("/")` 断言服务活着;
否则"服务没起来"会被当成"验证通过"——这是真实发生过的一次空验证。
- **区分"选项在"和"能生效"。** `verify-panel.mts` 断言面板上有那个下拉;
`check-single-bgm.mts` 断言选了之后 `<audio>.src` 真的变了。两者缺一不可。
+75
View File
@@ -0,0 +1,75 @@
// 游戏合集的文案必须是**它自己那个游戏**的——产物里不许出现别家的标题。
//
// 这条门禁来自一个真实缺陷:addGame 曾经把 title 取全局 meta(那一层是给「全部合集」写的),
// 于是 collection-ys 顶着《崩坏:星穹铁道》昔涟。三条断言都只看产物,不看实现:
// ① 前置条件:dist/releases 必须存在。否则"一个分发都没有"会被当成通过(空验证比不验证更危险)。
// ② 游戏合集的 title 必须提到本游戏的显示名,且逐字等于 wallpapers/<游戏id>/meta.json 的 title。
// ③ 全部合集 collection-all 的 title 必须来自 wallpapers/meta.json。
//
// 前置:先 `pnpm build`(只用产物与源数据,不需要服务器)。
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const RELEASES = "dist/releases";
if (!existsSync(RELEASES)) {
console.error(`前置条件不满足:${RELEASES} 不存在。先跑 pnpm build。`);
process.exit(1);
}
let failures = 0;
const check = (ok: boolean, label: string, detail?: string): void => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${detail}` : ""}`);
};
const readJson = (path: string): Record<string, unknown> =>
JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
const str = (value: unknown): string => (typeof value === "string" ? value : "");
const globalMeta = readJson("wallpapers/meta.json");
const releases = readdirSync(RELEASES).filter((name) => name.startsWith("collection-"));
let checkedGames = 0;
for (const dir of releases) {
const projectPath = join(RELEASES, dir, "project.json");
if (!existsSync(projectPath)) {
check(false, `${dir} 缺少 project.json`);
continue;
}
const project = readJson(projectPath);
const gameId = dir.slice("collection-".length);
if (gameId === "all") {
check(str(project.title) === str(globalMeta.title), "collection-all 的 title 来自全局 meta", str(project.title));
continue;
}
const gameMetaPath = `wallpapers/${gameId}/meta.json`;
if (!existsSync(gameMetaPath)) {
check(false, `${dir} 找不到对应的 ${gameMetaPath}`);
continue;
}
const gameMeta = readJson(gameMetaPath);
checkedGames++;
check(
str(project.title) === str(gameMeta.title),
`${dir} 的 title 逐字等于它自己游戏的 title`,
`产物 ${str(project.title) || "(空)"} / 源 ${str(gameMeta.title) || "(未声明)"}`,
);
check(
str(project.title).includes(str(gameMeta.name)),
`${dir} 的 title 提到了本游戏「${str(gameMeta.name)}」`,
str(project.title),
);
if (gameMeta.description !== undefined) {
check(str(project.description) === str(gameMeta.description), `${dir} 的 description 用的是游戏级声明`);
}
}
// 数量断言:目录命名一变(比如以后换成别的前缀),上面整个循环会静默变成空验证。
check(checkedGames > 0, "至少检查到一个游戏合集", `实际 ${checkedGames} 个`);
console.log(failures === 0 ? "\n游戏合集文案归属:通过" : `\n游戏合集文案归属:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+194
View File
@@ -0,0 +1,194 @@
// 抓取器(tools/downloader/)的门:**先证明它会红**。
//
// 四件事,缺一不可:
// 1. 语法门:整个包能编译(Python 不在 `pnpm check` 的任何一道门里,这是它唯一的机会)
// 2. 绿:自造一个形状正确的产物夹具 → `verify` 必须退出 0
// 3. 红:把夹具弄坏几处 → `verify` 必须退出非 0(点名的必须是**刚弄坏的那处**)
// 4. 还原:必须重新变绿(证明红是那处改动引起的,不是夹具本身坏的)
//
// 夹具是自造的,不依赖 `_out/`(那是 gitignore 的 staging,别人机器上不一定有),也不联网。
// 形状 = 「一个场景一个可直接搬走的壁纸目录」(见 tools/downloader/layout.py)。
import { spawnSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const PY = process.env.PYTHON ?? "python";
const FIXTURE = join(ROOT, "tools", ".cache", "check-downloader");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
/** 跑一条 python 命令,返回 { code, out }。 */
function run(args: string[], cwd = ROOT): { code: number; out: string } {
const result = spawnSync(PY, args, { cwd, encoding: "utf8", env: { ...process.env, PYTHONIOENCODING: "utf-8" } });
if (result.error) {
throw new Error(`跑不动 ${PY}:${result.error.message}(设 PYTHON 环境变量可指定解释器)`);
}
return { code: result.status ?? -1, out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() };
}
const PAGE = () => join(FIXTURE, "ys", "fixture-page");
const SCENE = () => join(PAGE(), "scene_main");
const sceneJson = () => join(SCENE(), "scene.json");
const presetJson = () => join(SCENE(), "preset.template.json");
const metaJson = () => join(SCENE(), "meta.json");
const heroPng = () => join(SCENE(), "spines", "hero", "hero.png");
/** 造一个形状正确的产物夹具:一页、一个场景目录、一具骨架、一块贴图平面。 */
function writeFixture(dir: string): void {
rmSync(dir, { recursive: true, force: true });
const scene = SCENE();
const spine = join(scene, "spines", "hero");
mkdirSync(spine, { recursive: true });
mkdirSync(join(scene, "scene"), { recursive: true });
mkdirSync(join(scene, "audios"), { recursive: true });
writeFileSync(
join(PAGE(), "page.json"),
JSON.stringify({
id: "fixture-page",
game: "ys",
url: "https://example.invalid/",
scenes: [{ id: "scene_main", dir: "scene_main", spines: 1, images: 1, solids: 1 }],
chosenScene: "scene_main",
chosenDir: "scene_main",
sceneDirs: [{ dir: "scene_main", scene: "scene_main", spines: 1, images: 1, solids: 1, parts: 2, bytes: 0 }],
skippedScenes: [],
totalBytes: 0,
}),
);
writeFileSync(
sceneJson(),
JSON.stringify({
version: 1,
id: "scene_main",
parts: [
{ kind: "spine", id: "hero", order: 0, position: [0, 0, 0], scale: [1, 1, 1] },
{ kind: "image", id: "sky", order: 1, position: [0, 0, 0], scale: [1, 1, 1], geometrySize: [100, 50] },
{ kind: "solid", id: "DEFAULT", order: 2, position: [0, 0, 0], scale: [1, 1, 1] },
],
}),
);
writeFileSync(
presetJson(),
JSON.stringify({
backgroundImage: "./scene/sky.png",
sceneConfig: {
ui: [100, 100],
parts: [
{
kind: "spine",
id: "hero",
order: 0,
position: [0, 0, 0],
scale: [1, 1, 1],
jsonUrl: "./spines/hero/hero.json",
atlasUrl: "./spines/hero/hero.atlas",
},
{
kind: "image",
id: "sky",
order: 1,
position: [0, 0, 0],
scale: [1, 1, 1],
width: 100,
height: 50,
image: "./scene/sky.png",
},
],
},
}),
);
writeFileSync(
metaJson(),
JSON.stringify({
id: "scene_main",
name: "夹具页(scene_main)",
title: "夹具页(scene_main)",
description: "夹具",
game: "ys",
page: "fixture-page",
scene: "scene_main",
source: "https://example.invalid/",
audio: { choices: [] },
}),
);
writeFileSync(
join(spine, "hero.json"),
JSON.stringify({ skeleton: { spine: "4.2.43", images: "" }, bones: [], slots: [] }),
);
writeFileSync(join(spine, "hero.atlas"), "hero.png\nsize:8,8\nfilter:Linear,Linear\nbody\nbounds:0,0,4,4\n");
writeFileSync(heroPng(), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
writeFileSync(join(spine, "meta.json"), JSON.stringify({ name: "hero", spine: "4.2.43", pages: ["hero.png"] }));
writeFileSync(join(scene, "scene", "sky.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
}
console.log("抓取器门:\n");
// ① 前置条件:解释器与包目录都在
const pre = run(["-c", "import sys; print(sys.version.split()[0])"]);
check(pre.code === 0, `${PY} 可用`, pre.out.split("\n")[0] || pre.out);
if (pre.code !== 0) {
console.log("\n前置条件不满足,无法继续。");
process.exit(1);
}
// ② 语法门
const compile = run(["-m", "compileall", "-q", "tools/downloader"]);
check(compile.code === 0, "tools/downloader 全部能编译", compile.out.split("\n").slice(-2).join(" | ") || "无输出");
// ③ 绿:形状正确的夹具必须通过
writeFixture(FIXTURE);
const good = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(good.code === 0, "形状正确的夹具 → verify 退出 0", good.code === 0 ? good.out.split("\n").pop() : good.out);
// ④ 红之一:贴图页缺文件(atlas 声明的页名必须逐字落盘)
const savedPng = readFileSync(heroPng());
rmSync(heroPng());
const missingPage = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(missingPage.code !== 0, "删掉贴图页 → verify 报红", missingPage.out.split("\n")[0]);
check(/hero\.png/.test(missingPage.out), "报错里点名了缺的那张页", missingPage.out.split("\n")[0]);
writeFileSync(heroPng(), savedPng);
// ⑤ 红之二:侧车引用一具不存在的骨架
const savedScene = readFileSync(sceneJson(), "utf8");
const scene = JSON.parse(savedScene) as { parts: { kind: string; id: string; order?: number }[] };
scene.parts.push({ kind: "spine", id: "ghost", order: 3 });
writeFileSync(sceneJson(), JSON.stringify(scene));
const ghost = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(ghost.code !== 0, "侧车引用不存在的骨架 → verify 报红", ghost.out.split("\n")[0]);
check(/ghost/.test(ghost.out), "报错里点名了 ghost", ghost.out.split("\n")[0]);
writeFileSync(sceneJson(), savedScene);
// ⑥ 红之三:预设里有一条 ./… 指向不存在的文件(构建期才会炸的那类)
const savedPreset = readFileSync(presetJson(), "utf8");
const preset = JSON.parse(savedPreset) as { backgroundImage: string };
preset.backgroundImage = "./scene/nope.png";
writeFileSync(presetJson(), JSON.stringify(preset));
const badPath = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(badPath.code !== 0, "预设路径指向不存在的文件 → verify 报红", badPath.out.split("\n")[0]);
check(/nope\.png/.test(badPath.out), "报错里点名了那条路径", badPath.out.split("\n")[0]);
writeFileSync(presetJson(), savedPreset);
// ⑦ 红之四:meta.json 的 id 与场景目录名不一致(搬进 wallpapers/ 会直接构建报错)
const savedMeta = readFileSync(metaJson(), "utf8");
const meta = JSON.parse(savedMeta) as { id: string };
meta.id = "other-id";
writeFileSync(metaJson(), JSON.stringify(meta));
const badId = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(badId.code !== 0, "meta.json 的 id 与目录名不一致 → verify 报红", badId.out.split("\n")[0]);
check(/other-id/.test(badId.out), "报错里点名了那个 id", badId.out.split("\n")[0]);
writeFileSync(metaJson(), savedMeta);
// ⑧ 还原后必须重新变绿(证明红是那处改动引起的,不是夹具本身坏的)
const restored = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(restored.code === 0, "还原四处破坏 → 重新变绿", restored.code === 0 ? restored.out.split("\n").pop() : restored.out);
rmSync(FIXTURE, { recursive: true, force: true });
console.log(failures === 0 ? "\n抓取器门:通过" : `\n抓取器门:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+26
View File
@@ -0,0 +1,26 @@
// 把自包含 HTML 里每个内联 <script> 抽出来单独语法检查,定位 "Unexpected token '{'"。
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
const page = process.argv[2] ?? "dist/releases/single-hsr-kv37/sim/index.html";
const html = readFileSync(page, "utf8");
const outDir = "tools/checks/.sim-scripts";
mkdirSync(outDir, { recursive: true });
const scripts = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)].map((m) => m[1] ?? "");
console.log(`共 ${scripts.length} 个内联 script,页面 ${(html.length / 1024 / 1024).toFixed(1)} MB\n`);
scripts.forEach((code, i) => {
const file = join(outDir, `script-${i}.js`);
writeFileSync(file, code);
const kb = (code.length / 1024).toFixed(0);
try {
execFileSync(process.execPath, ["--check", file], { stdio: "pipe" });
console.log(` ✓ script-${i} ${kb} KB`);
} catch (error) {
const stderr = String((error as { stderr?: string }).stderr ?? "");
const first = stderr.split("\n").filter((l) => l.trim()).slice(0, 6).join("\n ");
console.log(` ✗ script-${i} ${kb} KB\n ${first}`);
}
});
+41
View File
@@ -0,0 +1,41 @@
// 单档分发里真的能切歌吗——选项在 ≠ 切得动。
//
// 这是用户报的那件事的终局判据:single-hsr-xilian 有两首,面板上给了下拉之后,
// 选第二首必须真的换掉 <audio> 的源。
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const name = (src: unknown) => (src ? decodeURIComponent(String(src).split("/").pop() ?? "") : "(空)");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
await withPage({ width: 1280, height: 720, profile: "tools/.cache/edge-single-bgm" }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/single-hsr-xilian/` });
await sleep(3500);
const src = () => evaluate(`(function(){ var a = document.getElementById("background-music"); return a ? a.src : null; })()`);
const bgm = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ bgm: { value: ${JSON.stringify(v)} } })`);
const initial = await src();
check(name(initial) === "zaiduheni.flac", "默认是第一首「再度和你」", name(initial));
// 「昔涟」在自增 id 里是 "3"(kv37 的「版本 PV」占 "1",xilian 两首是 "2"/"3")
await bgm("3");
await sleep(500);
const afterSwitch = await src();
check(name(afterSwitch) === "xilian-src.flac", "选「昔涟」→ 音源真的换了", name(afterSwitch));
await bgm("2");
await sleep(500);
const back = await src();
check(name(back) === "zaiduheni.flac", "再选回「再度和你」→ 换回来", name(back));
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 2).join(" | ") || "无");
});
console.log(failures === 0 ? "\n单档切歌:通过" : `\n单档切歌:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+35
View File
@@ -0,0 +1,35 @@
// 逐键比对:本次构建的 collection-all/project.json vs **已发布**的基线。
// 只报**具体哪个键**不同,而不是一串字符串差异。
import { readFileSync } from "node:fs";
// 基线只保留这一个文件:原来读的是 .scratch/dist-baseline/(96.9 MB 的整份旧 dist 拷贝,已按 .gitignore 的约定删除),
// 而这里只用到 project.json。重构等价性验证结束后,那份拷贝已按 .gitignore 的约定删除。
const live = JSON.parse(readFileSync("tools/checks/published-project.json", "utf8"));
const built = JSON.parse(readFileSync("dist/releases/collection-all/project.json", "utf8"));
function diff(a: any, b: any, path = ""): string[] {
const out: string[] = [];
const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
for (const key of keys) {
const p = path ? `${path}.${key}` : key;
const va = a?.[key];
const vb = b?.[key];
if (va === undefined) out.push(`+ ${p}(基线没有)`);
else if (vb === undefined) out.push(`- ${p}(本次没有)`);
else if (typeof va === "object" && va !== null && !Array.isArray(va)) out.push(...diff(va, vb, p));
else if (JSON.stringify(va) !== JSON.stringify(vb)) out.push(`≠ ${p}\n 基线: ${JSON.stringify(va)}\n 本次: ${JSON.stringify(vb)}`);
}
return out;
}
console.log("=== 顶层键序 ===");
console.log("基线:", Object.keys(live).join(" "));
console.log("本次:", Object.keys(built).join(" "));
console.log("");
const differences = diff(live, built);
if (differences.length === 0) console.log("✓ 字段值完全一致");
else {
console.log(`=== ${differences.length} 处差异 ===`);
for (const d of differences) console.log(" " + d);
}
+26
View File
@@ -0,0 +1,26 @@
$ErrorActionPreference = "Stop"
Set-Location D:\Workspace\Temp\SpineWallpaper
$sizes = @(
@(3440, 1440, "219"),
@(1920, 1080, "169"),
@(1080, 1920, "916")
)
$props = @{
"xilian" = ""
"kv37" = "&__props=%7B%22preset%22%3A%22kv37%22%7D"
}
New-Item -ItemType Directory -Force -Path tools\shots\fit | Out-Null
foreach ($p in @("xilian", "kv37")) {
foreach ($s in $sizes) {
$w = $s[0]; $h = $s[1]; $tag = $s[2]
$url = "http://127.0.0.1:8190/?__freeze=1" + $props[$p]
Write-Host "=== $p $tag ($w x $h) ==="
node tools/shot.mjs "tools/shots/fit/${p}_${tag}.png" $url $w $h 2>&1 | Select-Object -First 1
node tools/shot.mjs "tools/shots/fit/${p}_${tag}_ref.png" "$url&__hide=canvas" $w $h 2>&1 | Select-Object -First 1
node tools/subject.mjs "tools/shots/fit/${p}_${tag}.png" "tools/shots/fit/${p}_${tag}_ref.png"
Write-Host ""
}
}
+139
View File
@@ -0,0 +1,139 @@
{
"contentrating" : "Everyone",
"description" : "[b]特色功能[/b]\r\n[list]\r\n [*]支持切换预设。\r\n [olist]\r\n [*]官方昔涟动态立绘。\r\n [*]官方三点七版本「成为昨日的明天」专题展示页。\r\n [/olist]\r\n [*]支持音源切换。\r\n [olist]\r\n [*]可切换各预设自带的背景音乐。\r\n [*]也可自行填写音频 URL。\r\n [*][quote][b]注意:[/b]目前仅支持 URL 格式。[/quote]\r\n [/olist]\r\n [*]自适应窗口比例,不同显示器下构图保持一致。\r\n[/list]",
"file" : "index.html",
"general" :
{
"properties" :
{
"audio_file" :
{
"index" : 6,
"order" : 106,
"text" : "🎵音频文件路径",
"type" : "textinput",
"value" : ""
},
"audio_file_note" :
{
"index" : 7,
"order" : 107,
"text" : "<small>更换背景音乐来源:<ul><li>🚨注意:目前仅支持 URL 链接。</li><li>当音源不可用时会切换至上一音源。</li></ul></small><br />",
"type" : "text"
},
"audio_volume" :
{
"fraction" : true,
"index" : 4,
"max" : 1,
"min" : 0,
"order" : 104,
"precision" : 1,
"step" : 0.1,
"text" : "🔊音频音量调整",
"type" : "slider",
"value" : 1
},
"audio_volume_note" :
{
"index" : 5,
"order" : 105,
"text" : "<small>调整背景音乐音量:<ul><li>为零时暂停。</li><li>为一时最大。</li></ul></small><br />",
"type" : "text"
},
"author_info" :
{
"condition" : "show_author_info.value == true",
"index" : 1,
"order" : 101,
"text" : "<small>作者信息:<ul><li>作者:品毅</li><li>QQ:2463253700</li><li>哔哩哔哩:<a href=\"https://space.bilibili.com/72266376\">品毅的个人空间</a></li></ul></small><br />",
"type" : "text"
},
"bgm" :
{
"index" : 8,
"options" :
[
{
"label" : "随预设",
"value" : "auto"
},
{
"label" : "「再度和你」",
"value" : "zaiduheni"
},
{
"label" : "昔涟",
"value" : "xilian"
},
{
"label" : "版本 PV",
"value" : "pv37"
}
],
"order" : 108,
"text" : "🎵背景音乐选择",
"type" : "combo",
"value" : "auto"
},
"bgm_note" :
{
"index" : 9,
"order" : 109,
"text" : "<small>选择各预设自带的背景音乐:<ul><li>默认「随预设」跟随壁纸切换。</li><li>所选项不属于当前壁纸时,自动回落该壁纸的默认音乐,选择仍会保留。</li></ul></small><br />",
"type" : "text"
},
"preset" :
{
"index" : 2,
"options" :
[
{
"label" : "昔涟立绘",
"value" : "xilian"
},
{
"label" : "「成为昨日的明天」",
"value" : "kv37"
}
],
"order" : 102,
"text" : "⚙️壁纸预设切换<br />",
"type" : "combo",
"value" : "xilian"
},
"preset_note" :
{
"index" : 3,
"order" : 103,
"text" : "<small>切换壁纸预设(包括背景、音乐、动画):<ul><li>当其它选项存在更改,不会覆盖其它选项。</li></ul></small><br />",
"type" : "text"
},
"schemecolor" :
{
"order" : 0,
"text" : "ui_browse_properties_scheme_color",
"type" : "color",
"value" : "0.38823529411764707 0.14901960784313725 0.6196078431372549"
},
"show_author_info" :
{
"index" : 0,
"order" : 100,
"text" : "📇显示作者信息",
"type" : "bool",
"value" : true
}
}
},
"preview" : "preview.gif",
"ratingsex" : "none",
"ratingviolence" : "none",
"tags" : [ "Anime" ],
"title" : "【崩坏:星穹铁道】昔涟",
"type" : "Web",
"version" : 3,
"visibility" : "public",
"workshopid" : "3604974793",
"workshopurl" : "steam://url/CommunityFilePage/3604974793"
}
+75
View File
@@ -0,0 +1,75 @@
// 读音频文件自带的元数据标签(ID3v2 / Vorbis comment)。
//
// 为什么值得读:bgm 下拉里的名字是 meta.json **手写**的,build 不做任何推导,
// 也没有任何校验能发现写错。文件自带的标签是唯一的"外部真相",可以对一下。
import { readFileSync } from "node:fs";
/** ID3v2.3/2.4:读 TIT2/TPE1/TALB 等文本帧。 */
function readId3(buf) {
if (buf.toString("latin1", 0, 3) !== "ID3") return null;
const version = buf[3];
const size = ((buf[6] & 0x7f) << 21) | ((buf[7] & 0x7f) << 14) | ((buf[8] & 0x7f) << 7) | (buf[9] & 0x7f);
const out = {};
let at = 10;
const end = Math.min(10 + size, buf.length);
while (at + 10 <= end) {
const id = buf.toString("latin1", at, at + 4);
if (!/^[A-Z0-9]{4}$/.test(id)) break;
const frameSize =
version === 4
? ((buf[at + 4] & 0x7f) << 21) | ((buf[at + 5] & 0x7f) << 14) | ((buf[at + 6] & 0x7f) << 7) | (buf[at + 7] & 0x7f)
: buf.readUInt32BE(at + 4);
const body = buf.subarray(at + 10, at + 10 + frameSize);
// 首字节是编码:0=latin1, 1=utf16, 3=utf8
const enc = body[0];
const text = enc === 1 ? body.subarray(1).toString("utf16le") : body.subarray(1).toString(enc === 3 ? "utf8" : "latin1");
out[id] = text.replace(/\0/g, "").trim();
at += 10 + frameSize;
}
return out;
}
/** FLAC:遍历 metadata block,取 type 4 = VORBIS_COMMENT。 */
function readFlac(buf) {
if (buf.toString("latin1", 0, 4) !== "fLaC") return null;
let at = 4;
const out = {};
while (at + 4 <= buf.length) {
const header = buf[at];
const last = (header & 0x80) !== 0;
const type = header & 0x7f;
const size = buf.readUIntBE(at + 1, 3);
if (type === 4) {
const body = buf.subarray(at + 4, at + 4 + size);
const count = body.readUInt32LE(0);
let p = 4;
for (let i = 0; i < count && p + 4 <= body.length; i++) {
const len = body.readUInt32LE(p);
const entry = body.subarray(p + 4, p + 4 + len).toString("utf8");
const eq = entry.indexOf("=");
if (eq > 0) out[entry.slice(0, eq).toUpperCase()] = entry.slice(eq + 1);
p += 4 + len;
}
}
at += 4 + size;
if (last) break;
}
return out;
}
const files = [
"wallpapers/hsr/kv37/audios/pv37.mp3",
"wallpapers/hsr/xilian/audios/zaiduheni.flac",
"wallpapers/hsr/xilian/audios/xilian-src.flac",
];
for (const file of files) {
const buf = readFileSync(file);
const tags = readId3(buf) ?? readFlac(buf);
console.log(`\n=== ${file} ===`);
if (!tags || Object.keys(tags).length === 0) {
console.log(" (没有可读的标签)");
continue;
}
for (const [k, v] of Object.entries(tags)) console.log(` ${k.padEnd(12)} ${v}`);
}
+51
View File
@@ -0,0 +1,51 @@
// 给"还原后的面板"拍图。用真实 CDP:把指针移到右边缘让面板滑出,等过渡走完再截。
// 出两张:面板本体、以及点色块弹出的取色器。
// 用法:node tools/checks/shot-panel.mts [base] [outDir]
import { mkdir, writeFile } from "node:fs/promises";
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const OUT_DIR = (process.argv[3] ?? ".scratch/shots").replace(/[\\/]+$/, "");
const W = 1280;
const H = 900;
await mkdir(OUT_DIR, { recursive: true });
await withPage({ width: W, height: H, profile: "tools/.cache/edge-panel-shot" }, async ({ send, evaluate }) => {
await send("Page.navigate", { url: `${BASE}/release/collection-all/` });
await sleep(3500);
const shoot = async (name) => {
const shot = await send("Page.captureScreenshot", { format: "png" });
const path = `${OUT_DIR}/${name}.png`;
await writeFile(path, Buffer.from(shot.data, "base64"));
console.log(` 已保存 ${path}`);
};
// 指针移到右边缘 → 面板滑出(这是它唯一的打开方式)
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: W - 8, y: H / 2, button: "none", buttons: 0 });
await sleep(500);
console.log(` 面板状态 data-open=${await evaluate(`document.getElementById("wesim").dataset.open`)}`);
await shoot("panel-official-look");
// 点色块 → 取色器
await evaluate(`document.querySelector("#wesim .swatch").click()`);
await sleep(400);
console.log(` 取色器已打开:${await evaluate(`!!document.getElementById("wesim-picker")`)}`);
await shoot("panel-color-picker");
// 收起取色器,再展开「壁纸预设切换」的下拉
await evaluate(`document.querySelector("#wesim-picker .pk-foot button:not(.primary)").click()`);
await sleep(200);
await evaluate(`(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "壁纸预设切换") { rows[i].querySelector(".combo-btn").click(); return true; }
}
return false;
})()`);
await sleep(300);
console.log(` 下拉已展开:${await evaluate(`!!document.getElementById("wesim-combo")`)}`);
await shoot("panel-combo-open");
});
+72
View File
@@ -0,0 +1,72 @@
// 调试服冒烟:验证路由、注入、以及"所有分发内引用都能取到 200"。
// 用法:node tools/checks/smoke-dev.mts [port]
//
// 这里有一条**回归断言**:模拟器本体必须能从调试服取到 200。
// 它曾经是 404 —— 驱动脚本从 `/release/<dir>/scripts/wallpaper-engine.js` 取模拟器,
// 而该文件只有 `pnpm build --with-sim` 才会放进分发,默认构建下必然缺失。
// 症状是浏览器里"WE 模拟面板没出来",而 404 只留在控制台里。
// 所以断言必须打在**模拟器本体的可获取性**上,不能只断言"HTML 里提到了这个字符串"。
const port = Number(process.argv[2] ?? 5173);
const base = `http://127.0.0.1:${port}`;
const SIMULATOR_URL = "/simulator/wallpaper-engine.js";
let failures = 0;
function ok(condition: boolean, label: string, detail = ""): void {
if (condition) {
console.log(` ✓ ${label}`);
} else {
failures += 1;
console.log(` ✗ ${label}${detail ? ` —— ${detail}` : ""}`);
}
}
const indexRes = await fetch(`${base}/`);
const indexHtml = await indexRes.text();
console.log(`索引页 ${indexRes.status}`);
ok(indexRes.status === 200, "GET / 返回 200");
ok(indexHtml.includes("single-hsr-xilian") && indexHtml.includes("collection-all"), "索引页列出全部分发");
const map = await (await fetch(`${base}/dist-map.json`).catch(() => ({ status: 0, text: async () => "" }))).text?.();
void map;
for (const dir of ["single-hsr-xilian", "single-hsr-kv37", "collection-hsr", "collection-all"]) {
console.log(`\n${dir}`);
const res = await fetch(`${base}/release/${dir}/`);
const html = await res.text();
ok(res.status === 200, "GET /release/<dir>/ 返回 200");
ok(html.includes("wallpaper-engine.js"), "已注入模拟器脚本"); ok(html.includes("__weProperties"), "已注入 project.json 属性表");
ok(html.includes("mountWallpaperEngineSimulator"), "已注入模拟器驱动");
ok(html.indexOf("wallpaper-engine.js") < html.indexOf("scripts/index.js"), "模拟器在壁纸模块之前");
// 模拟器脚本本体可访问(**回归断言**:曾经 404,导致面板静默消失)
const simRes = await fetch(`${base}${SIMULATOR_URL}`);
ok(simRes.status === 200, `模拟器本体 ${SIMULATOR_URL} 返回 200`, `status=${simRes.status}`);
const simText = simRes.status === 200 ? await simRes.text() : "";
ok(
simText.includes("mountWallpaperEngineSimulator"),
"模拟器本体确实导出了 mountWallpaperEngineSimulator",
`${(simText.length / 1024).toFixed(0)} KB`,
);
// ?nojs=1 剥脚本
const nojs = await (await fetch(`${base}/release/${dir}/?nojs=1`)).text();
ok(!nojs.includes("scripts/index.js") && nojs.includes("TESTSTATE"), "?nojs=1 剥掉脚本并留状态标记", nojs.slice(0, 80));
// ?sim=0 不注入模拟器
const nosim = await (await fetch(`${base}/release/${dir}/?sim=0`)).text();
ok(!nosim.includes('type="module" src="/release/'), "?sim=0 不注入模拟器 script 标签");
// 越界引用必须 404(目录自包含性)
const escape = await fetch(`${base}/release/${dir}/../${dir}/index.html`);
ok(escape.status === 200 || escape.status === 404, "越界路径被规范化(不崩溃)");
}
console.log("\n越权路由");
const other = await fetch(`${base}/release/not-a-release/`);
ok(other.status === 404, "未知分发返回 404");
const stray = await fetch(`${base}/whatever.txt`);
ok(stray.status === 404, "非 /release 路由返回 404");
console.log(failures === 0 ? "\n调试服冒烟通过" : `\n调试服冒烟失败:${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+148
View File
@@ -0,0 +1,148 @@
// 浏览器端冒烟:在真实无头 Edge 里验证
// ① 分发能正常渲染(spine canvas 出现、无异常)
// ② WE 模拟器确实生效(window.__weSim、面板、替身横幅)
// ③ 属性下发链路真的打通(经模拟器改 preset → 背景与骨架跟着换)
// ④ ?propsAt=dom 这条"load 之前下发"的竞态路径不会把配置丢掉
// ⑤ 单档分发(属性表里没有 preset/bgm)不白屏
//
// 判定只用页面**真实可观察**的量:canvas 是否出现、body 背景、播放器 config。
// 不要用 window.__player —— 那是 tools/serve.mjs 的对拍驱动注入的探针,不是产品行为。
//
// 用法:node tools/checks/smoke-sim.mts [base]
// 前提:调试服在跑(node tools/dev.ts --no-build)。
// 不需要 `--with-sim`:模拟器由调试服自己从 build/scripts/wallpaper-engine.js 提供
// (它默认不进发布产物,见 ADR 0006 §1 与 dev.ts 的路由说明)。
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const RELEASE = "collection-all";
/** 就绪判定:spine-container 里出现 canvas(= SpinePlayer 真的创建成功)。 */
async function waitCanvas(evaluate, tag) {
const deadline = Date.now() + 40000;
while (Date.now() < deadline) {
const state = await evaluate(`(function(){
var c = document.getElementById("spine-container");
return { canvas: c ? c.querySelectorAll("canvas").length : 0, children: c ? c.children.length : -1 };
})()`);
if (state && state.canvas > 0) return { ok: true, ...state };
await sleep(200);
}
return { ok: false, canvas: 0, error: `${tag} 超时` };
}
function assertClean(tag, exceptions, consoleLines) {
check(exceptions.length === 0, `${tag} 0 未捕获异常`, exceptions.slice(0, 2).join(" | ") || "无");
const noisy = consoleLines.filter((l) => /error|uncaught/i.test(l));
check(noisy.length === 0, `${tag} 0 控制台报错`, noisy.slice(0, 3).join(" | ") || "干净");
}
// ── ① 正常路径 ──
console.log("=== ① 正常路径:模拟器生效 + 属性下发 ===");
const normal = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
const ready = await waitCanvas(evaluate, "normal");
await sleep(600);
const sim = await evaluate(`(function(){
var w = window.__weSim;
if (!w) return null;
return { isSimulated: w.__isSimulated, substituted: w.substituted.length,
release: w.release, deliveries: w.deliveries.map(function(d){ return d.type; }),
panel: !!document.getElementById("wesim"),
panels: document.querySelectorAll("#wesim").length,
edges: document.querySelectorAll("#wesim-edge").length,
banner: document.getElementById("wesim") ? document.querySelector("#wesim .banner").textContent : null,
hasEngineGlobal: typeof window.wallpaperEngine,
hasRegisterListener: typeof window.wallpaperRegisterListener };
})()`);
const view = await evaluate(`(function(){
return { bg: document.body.style.backgroundImage,
canvas: document.querySelectorAll("canvas").length,
canvasSize: (function(){ var c = document.querySelector("canvas"); return c ? c.clientWidth + "x" + c.clientHeight : null; })() };
})()`);
return { ready, sim, view, consoleLines, exceptions };
});
check(normal.ready.ok, "Spine 画布已创建(渲染就绪)", normal.ready.error ?? `canvas=${normal.ready.canvas}`);
check(normal.sim?.isSimulated === true, "window.__weSim.__isSimulated = true");
check((normal.sim?.substituted ?? 0) > 0, "替身清单非空(做不到的 API 被显式标记)", `${normal.sim?.substituted} 项`);
check(normal.sim?.panel === true, "模拟器面板已挂载(#wesim)");
// 回归断言:`--with-sim` 构建的分发自带驱动,调试服又会注入一次。
// 没有幂等闸的话模拟器会被 mount 两遍,页面上出现两个面板。
check(normal.sim?.panels === 1, "面板恰好一个(驱动幂等,没有被注入两次)", `${normal.sim?.panels} 个`);
check(normal.sim?.edges === 1, "右边缘热区恰好一个", `${normal.sim?.edges} 个`);
check(/模拟环境/.test(normal.sim?.banner ?? ""), "面板有常驻的「模拟环境」横幅", normal.sim?.banner?.trim());
check(normal.sim?.deliveries?.includes("applyUserProperties"), "已下发 applyUserProperties", normal.sim?.deliveries?.join(", "));
check(normal.sim?.deliveries?.includes("applyGeneralProperties"), "已下发 applyGeneralProperties", normal.sim?.deliveries?.join(", "));
check(/ava\.jpg/.test(normal.view.bg ?? ""), "默认预设(xilian)的背景已应用", normal.view.bg?.slice(-32));
// 严格还原 WE:这两个 API 在真实 WE 里都不存在,模拟器绝不能凭空提供
check(normal.sim?.hasEngineGlobal === "undefined", "未凭空暴露 window.wallpaperEngine(WE 没有这个全局)", normal.sim?.hasEngineGlobal);
check(normal.sim?.hasRegisterListener === "undefined", "未暴露 window.wallpaperRegisterListener(WE 没有这个 API)", normal.sim?.hasRegisterListener);
assertClean("正常路径", normal.exceptions, normal.consoleLines);
// ── ② 属性下发真的改变了画面 ──
console.log("\n=== ② 经模拟器改 preset → 背景与骨架跟着换 ===");
const switched = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
await waitCanvas(evaluate, "switch");
await sleep(600);
const bgBefore = await evaluate("document.body.style.backgroundImage");
// setProperties 收的是**值**,不是 WE 的 { value } 信封(信封由模拟器自己套)
await evaluate('window.__weSim.setProperties({ preset: "kv37" })');
await sleep(2000);
const after = await evaluate(`(function(){
return { bg: document.body.style.backgroundImage,
canvas: document.querySelectorAll("canvas").length,
// 骨架换了就意味着播放器被重建过:容器里应当只有一张新 canvas
children: document.getElementById("spine-container").children.length };
})()`);
return { bgBefore, ...after, exceptions };
});
check(switched.bgBefore !== switched.bg, "背景图已更换", `${switched.bgBefore?.slice(-22)} → ${switched.bg?.slice(-22)}`);
check(/kv37_xilian/.test(switched.bg ?? ""), "换成了 kv37 的背景", switched.bg?.slice(-30));
check(switched.canvas === 1, "换骨架后只剩一张 canvas(旧播放器已 dispose)", `canvas=${switched.canvas}`);
check(switched.children === 1, "容器里没有残留节点(dispose 生效)", `children=${switched.children}`);
check(switched.exceptions.length === 0, "切换过程 0 异常", switched.exceptions.slice(0, 2).join(" | ") || "无");
// ── ③ load 之前的属性下发 ──
console.log("\n=== ③ ?__propsAt=dom:load 之前下发不丢配置 ===");
const race = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, exceptions }) => {
const props = encodeURIComponent(JSON.stringify({ preset: "kv37" }));
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/?__propsAt=dom&__props=${props}` });
const ready = await waitCanvas(evaluate, "race");
await sleep(800);
const state = await evaluate(`(function(){
return { bg: document.body.style.backgroundImage, canvas: document.querySelectorAll("canvas").length,
propsAt: window.__weSim ? window.__weSim.deliveries[0] && window.__weSim.deliveries[0].at : null };
})()`);
return { ready, ...state, exceptions };
});
check(race.ready.ok, "播放器仍被创建(配置没被丢弃)", race.ready.error ?? "ok");
check(/kv37_xilian/.test(race.bg ?? ""), "load 前下发的 preset 已生效", race.bg?.slice(-30));
check(race.canvas === 1, "只有一张 canvas", `canvas=${race.canvas}`);
check(race.exceptions.length === 0, "0 异常", race.exceptions.slice(0, 2).join(" | ") || "无");
// ── ④ 单档分发 ──
console.log("\n=== ④ 单档分发(属性表里没有 preset/bgm) ===");
const single = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/single-hsr-kv37/` });
const ready = await waitCanvas(evaluate, "single");
await sleep(800);
const bg = await evaluate("document.body.style.backgroundImage");
const props = await evaluate("JSON.stringify(Object.keys(window.__weProperties))");
return { ready, bg, props, consoleLines, exceptions };
});
check(single.ready.ok, "单档分发渲染就绪(未白屏)", single.ready.error ?? "ok");
check(/kv37_xilian/.test(single.bg ?? ""), "单档分发背景已应用", single.bg?.slice(-30));
check(!/"preset"/.test(single.props ?? ""), "单档分发确实没有 preset 属性", single.props);
check(!/"bgm"/.test(single.props ?? ""), "单档分发确实没有 bgm 属性", single.props);
assertClean("单档分发", single.exceptions, single.consoleLines);
console.log(failures === 0 ? "\n模拟器浏览器冒烟通过" : `\n模拟器浏览器冒烟失败:${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+176
View File
@@ -0,0 +1,176 @@
// Issue 06 全量验收。
// ① dist 体积与死文件;② project.json 与代码一致;③ 三种比例 0 控制台报错;
// ④ 预设往返不丢用户级音源/音量设置。
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { basename, extname, join } from "node:path";
import { withPage, sleep } from "../cdp.mjs";
let failures = 0;
const check = (ok, msg, detail) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
// ── ① 体积与死文件 ──
console.log("=== ① dist 体积与死文件 ===");
const walk = (dir) =>
readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = join(dir, e.name);
return e.isDirectory() ? walk(p) : [p];
});
const RELEASE = "dist/releases/collection-all";
const files = walk(RELEASE);
const total = files.reduce((s, f) => s + statSync(f).size, 0);
// 用户已明确豁免体积(音频走无损、壁纸在本地),基准改为"不超过已通过审核的 v2"。
// 这是真正有意义的界:v2 以 104.98 MB 发布过,所以这个体积不会带来新的上传风险。
const V2_MB = 104.98;
check(total <= V2_MB * 1024 * 1024, `总体积不超过已发布的 v2(${V2_MB} MB)`, `${(total / 1048576).toFixed(2)} MB / ${files.length} 个文件`);
const TEXT = new Set([".html", ".js", ".json", ".txt", ".css", ".atlas"]);
const texts = files.filter((f) => TEXT.has(extname(f)));
const contents = new Map(texts.map((f) => [f, readFileSync(f, "utf8")]));
// 这四个是 WE 的元数据/入口/说明,本来就不被页面引用
const ALLOWED = new Set(["project.json", "README.txt", "index.html", "preview.gif"]);
// 关键:比对时要把文件**自身**的内容排除掉。否则一个文件只要在自己开头写上自己的名字
// (比如注释里的 "// wallpaper-engine.js")就会被判为"被引用",死文件因此漏网。
const dead = files.filter((f) => {
const n = basename(f);
if (ALLOWED.has(n)) return false;
const others = texts
.filter((t) => t !== f)
.map((t) => contents.get(t))
.join("\n");
return !others.includes(n);
});
check(dead.length === 0, "0 个死文件", dead.length ? dead.join(", ") : "无孤立资源");
// ── ② project.json ──
console.log("\n=== ② project.json ===");
const project = JSON.parse(readFileSync(join(RELEASE, "project.json"), "utf8"));
check(project.version === 3, "version = 3", String(project.version));
check(project.preview === "preview.gif", "preview 指向 preview.gif");
check(!!project.general.properties.bgm, "新增 bgm combo");
check(project.general.properties.preset.value === "xilian", "preset 默认值未变");
const presetValues = project.general.properties.preset.options.map((o) => o.value).sort();
check(
JSON.stringify(presetValues) === JSON.stringify(["kv37", "xilian"]),
"preset 的 combo 取值未变(用户已保存的设置依赖它)",
presetValues.join(", "),
);
const bgmValues = project.general.properties.bgm.options.map((o) => o.value);
check(bgmValues.includes("auto"), "bgm 含「随预设」档", bgmValues.join(", "));
const gifSize = statSync(join(RELEASE, "preview.gif")).size;
check(gifSize < 500 * 1024, "preview.gif < 500 KB", `${(gifSize / 1024).toFixed(0)} KB`);
// ── ③ 三种比例 0 报错 ──
console.log("\n=== ③ 21:9 / 16:9 / 9:16 控制台与异常 ===");
for (const [w, h, tag] of [
[3440, 1440, "21:9"],
[1920, 1080, "16:9"],
[1080, 1920, "9:16"],
]) {
const r = await withPage({ width: w, height: h }, async ({ send, evaluate, consoleLines, exceptions }) => {
await send("Page.navigate", { url: "http://127.0.0.1:8190/?__freeze=1" });
const deadline = Date.now() + 30000;
let ready = false;
while (Date.now() < deadline) {
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) {
ready = true;
break;
}
await sleep(150);
}
await sleep(500);
const state = await evaluate(`(function(){
var p = window.__player, v = p.currentViewport;
return { w: p.canvas.clientWidth, h: p.canvas.clientHeight,
vw: +v.width.toFixed(1), vh: +v.height.toFixed(1),
err: window.__test.error || null };
})()`);
return { ready, state, consoleLines, exceptions };
});
const noisy = r.consoleLines.filter((l) => /error|uncaught|fail/i.test(l));
const aspectOk = r.state && Math.abs(r.state.vw / r.state.vh - r.state.w / r.state.h) < 0.002;
check(r.ready && !r.state?.err, `${tag} 播放器就绪`, r.state?.err ?? "无错");
check(aspectOk, `${tag} 可见矩形比例与画布一致`, r.state ? `${r.state.vw}×${r.state.vh}` : "—");
check(noisy.length === 0 && r.exceptions.length === 0, `${tag} 0 控制台报错 / 0 异常`, noisy.concat(r.exceptions).slice(0, 3).join(" | ") || "干净");
}
// ── ④ 预设往返不丢音频设置 ──
console.log("\n=== ④ 预设往返 10 次,音频设置不得丢失 ===");
const rt = await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: "http://127.0.0.1:8190/?__freeze=1" });
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
await sleep(150);
}
await sleep(500);
// 「使用自定义音乐」必须一起打开:audio_file 只在它为 true 时才生效。
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({
use_custom_audio: { value: true },
audio_file: { value: "https://example.com/custom-track.mp3" },
audio_volume: { value: 0.2 }
})`);
await sleep(300);
const rows = [];
for (let i = 0; i < 10; i++) {
const id = i % 2 === 0 ? "kv37" : "xilian";
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: "${id}" } })`);
await sleep(320);
rows.push(
await evaluate(`(function(){
var a = document.getElementById("background-music");
return { id: "${id}", src: a.src.split("/").pop(), vol: a.volume,
draws: window.__test.drawCalls };
})()`),
);
}
return { rows, exceptions };
});
const srcOk = rt.rows.every((r) => r.src === "custom-track.mp3");
const volOk = rt.rows.every((r) => Math.abs(r.vol - 0.2) < 0.001);
check(srcOk, "10 次切换后自定义音源仍生效", [...new Set(rt.rows.map((r) => r.src))].join(", "));
check(volOk, "10 次切换后音量仍为 0.2", [...new Set(rt.rows.map((r) => r.vol))].join(", "));
check(rt.rows[rt.rows.length - 1].draws > rt.rows[0].draws, "切换后仍在持续绘制", `${rt.rows[0].draws} → ${rt.rows[rt.rows.length - 1].draws}`);
check(rt.exceptions.length === 0, "往返过程 0 未捕获异常", rt.exceptions.join(" | ") || "无");
// ── ⑤ 音频格式:xilian 两个音源必须是无损 ──
console.log("\n=== ⑤ 音频格式与可达性 ===");
const audioDirs = [
[join(RELEASE, "audios", "xilian"), "flac", "昔涟立绘(无损)"],
[join(RELEASE, "audios", "kv37"), "mp3", "「成为昨日的明天」"],
];
const codecOf = (f) =>
execFileSync("ffprobe", ["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "default=nw=1:nk=1", f])
.toString()
.trim();
for (const [dir, want, label] of audioDirs) {
const fs2 = readdirSync(dir).filter((f) => extname(f) !== ".json");
const codecs = fs2.map((f) => `${f}=${codecOf(join(dir, f))}`);
check(
fs2.length > 0 && fs2.every((f) => codecOf(join(dir, f)) === want),
`${label} 音源全为 ${want}`,
codecs.join(", "),
);
}
// 有损的 .ogg 必须已经清干净,否则就是残留
const stale = files.filter((f) => /\.(ogg|opus)$/.test(f));
check(stale.length === 0, "已无残留的有损音源(.ogg/.opus)", stale.length ? stale.join(", ") : "干净");
// preset.js 引用的每个音源都必须真实存在——引用错一个字符就是 404 + 立即静音
const presetFiles = files.filter((f) => basename(f) === "preset.js");
let broken = [];
for (const pf of presetFiles) {
const text = readFileSync(pf, "utf8");
for (const m of text.matchAll(/source: asset\("((?:\.\.\/)*\.\/audios\/[^"]+)"\)/g)) {
const target = join(pf, "..", m[1].replace(/^\.\//, ""));
if (!existsSync(target)) broken.push(`${pf} → ${m[1]}`);
}
}
check(broken.length === 0, "preset.js 引用的音源文件全部存在", broken.length ? broken.join(" | ") : `${presetFiles.length} 份预设,路径全部可达`);
console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
process.exit(failures === 0 ? 0 : 1);
+52
View File
@@ -0,0 +1,52 @@
// 验证 bundle 的 "</script" 内联守卫**真的会拦**。
// 一个从不失败的检查等于没有检查——所以临时把源码弄坏,确认构建报错,再还原。
//
// **收尾必须全量重建**:这里用 `--single kv37` 触发构建,而构建默认会删掉"本次未构建"的
// 分发目录。测完只留 single-hsr-kv37,其它三档就没了,后续的基线比对会直接 ENOENT。
// 所以 finally 里跑一次全量 `sim` 构建把 dist 恢复成完整状态。
import { readFileSync, writeFileSync, copyFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
const target = "src/vendor/spine-player.js";
const backup = `${target}.bak`;
const original = readFileSync(target, "utf8");
function run(args: string[]) {
try {
return { ok: true, out: execFileSync("node", ["tools/build.ts", ...args], { encoding: "utf8" }) };
} catch (error) {
const e = error as { stdout?: string; stderr?: string };
return { ok: false, out: String(e.stdout ?? "") + String(e.stderr ?? "") };
}
}
let failed = 0;
copyFileSync(target, backup);
try {
console.log("=== 干净源码应当构建成功 ===");
const clean = run(["sim", "--single", "kv37"]);
console.log(` ${clean.ok ? "✓" : "✗"} 构建成功`);
if (!clean.ok) failed++;
// 追加一个含 </script 的字面量:不破坏语法,只触发守卫。
writeFileSync(target, original + '\nvar __guardProbe = "</script>";\n');
console.log("\n=== 注入 </script 后应当构建失败 ===");
const broken = run(["sim", "--single", "kv37"]);
const caught = !broken.ok && broken.out.includes("截断页面");
console.log(` ${caught ? "✓" : "✗"} 守卫拦住了`);
if (!caught) {
failed++;
console.log(` 输出:${broken.out.split("\n").slice(0, 4).join(" | ")}`);
}
} finally {
writeFileSync(target, original);
}
console.log("\n=== 还原后全量重建 ===");
const restored = run(["sim"]);
console.log(` ${restored.ok ? "✓" : "✗"} 构建恢复成功(四档都在)`);
if (!restored.ok) failed++;
console.log(failed === 0 ? "\n内联守卫:有效" : `\n内联守卫:${failed} 处不符合预期`);
process.exit(failed === 0 ? 0 : 1);
+181
View File
@@ -0,0 +1,181 @@
// Issue 05 验收:bgm 音源 combo 与 fps 限流。
//
// A 段验音源优先级:用户自填 URL > bgm 选择(仅当本壁纸声明过)> 本壁纸默认音源;
// 且 bgm 选择跨壁纸时"音频回落、选择保留"。
// B 段验 fps:WE 不替壁纸限流,得自己门控。关键是限流只该丢帧,
// 不该改变动画相位推进速度——用同一段真实时间里 trackTime 前进了多少来判定。
import { withPage, sleep } from "../cdp.mjs";
// 服务地址:默认 8190(调试服常用端口),可用 SIM_BASE 覆盖。
// 写死端口会在换了端口或换成分发目录时静默测错东西——它只是"连不上",
// 看起来像功能坏了,实际是脚本连错了地方。
const BASE = process.env.SIM_BASE ?? "http://127.0.0.1:8190/";
const name = (src) => (src ? decodeURIComponent(String(src).split("/").pop()) : "(空)");
let failures = 0;
const check = (ok, msg, detail) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
// ───────────────────────── A 段:音源 ─────────────────────────
console.log("=== A 段:bgm 音源选择与回落 ===");
await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: BASE + "?__freeze=1" });
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
await sleep(150);
}
await sleep(600);
const src = () => evaluate('document.getElementById("background-music").src');
const preset = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: ${JSON.stringify(v)} } })`);
const bgm = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ bgm: { value: ${JSON.stringify(v)} } })`);
const file = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ audio_file: { value: ${JSON.stringify(v)} } })`);
// 「使用自定义音乐」是 audio_file 的总开关:关着时填了 URL 也不该生效。
const customAudio = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ use_custom_audio: { value: ${JSON.stringify(v)} } })`);
const seen = [];
const step = async (label, action, expected) => {
await action();
await sleep(350);
const got = name(await src());
seen.push(`${label}=${got}`);
check(got === expected, label, `期望 ${expected},实得 ${got}`);
};
// 载入即 xilian,默认音源应是「再度和你」
check(name(await src()) === "zaiduheni.flac", "初始默认音源 = 昔涟立绘默认", name(await src()));
// 无损是否真的可用,取决于 CEF 能不能解 FLAC——不能只看路径对不对。
// readyState>=1(HAVE_METADATA) 说明容器与编码被接受,duration>0 说明解析出了正确时长。
const healthProbe = `(function(){
var a = document.getElementById("background-music");
return { rs: a.readyState, dur: a.duration, net: a.networkState,
err: a.error ? a.error.code : null, flac: a.canPlayType("audio/flac") };
})()`;
let h = null;
for (let i = 0; i < 40; i++) {
h = await evaluate(healthProbe);
if (h.rs >= 1 || h.err !== null) break;
await sleep(250);
}
check(h.flac === "probably" || h.flac === "maybe", "CEF 支持 audio/flac", h.flac || "(不支持)");
check(h.err === null, "音频无解码错误", h.err === null ? "无" : `MediaError.code=${h.err}`);
check(h.rs >= 1, "FLAC 已解析出元数据(readyState≥1)", `readyState=${h.rs}`);
check(h.dur > 100 && h.dur < 300, "解析出的时长符合源文件", `${h.dur?.toFixed(1)} 秒`);
// 元数据能解析 ≠ 真的在放。等一会儿看播放头有没有前进——这才是"无损可用"的终局证据。
await sleep(1500);
const pb = await evaluate(`(function(){
var a = document.getElementById("background-music");
return { paused: a.paused, t: a.currentTime, loop: a.loop, vol: a.volume };
})()`);
check(!pb.paused, "音频处于播放状态", pb.paused ? "仍暂停" : "播放中");
check(pb.t > 0.5, "播放头已前进(确实在解码播放)", `currentTime=${Number(pb.t).toFixed(2)} s`);
check(pb.loop === true, "循环播放已开启", String(pb.loop));
// 音源 id 是 build 自增分配的(不再是 zaiduheni / xilian / pv37 这种手写名):
// 分配顺序 = 游戏 → 共享音频 → 各壁纸(按 id 排序),所以 kv37 的「版本 PV」拿到 "1",
// xilian 的「「再度和你」」是 "2"、「昔涟」是 "3"。顺序变了这些数字就会变——这正是
// 自增 id 的代价,改音源清单时要回来同步这里。
await step("bgm 选「昔涟」", () => bgm("3"), "xilian-src.flac");
await step("切到 kv37(选择不属于它)→ 回落", () => preset("kv37"), "pv37.mp3");
await step("bgm 选「版本 PV」(属于 kv37)", () => bgm("1"), "pv37.mp3");
await step("切回 xilian(pv37 不属于它)→ 回落", () => preset("xilian"), "zaiduheni.flac");
await step("bgm 选「「再度和你」」", () => bgm("2"), "zaiduheni.flac");
await step("bgm 回到「随预设」", () => bgm("auto"), "zaiduheni.flac");
// 用户自填 URL 优先级最高——但要先打开「使用自定义音乐」这个总开关。
await step("开关关着时填 URL 不生效(回落预设默认)", async () => {
await customAudio(false);
await file("https://example.com/ignored.mp3");
}, "zaiduheni.flac");
await step("打开开关后 URL 生效", async () => {
await customAudio(true);
await file("https://example.com/custom-track.mp3");
}, "custom-track.mp3");
await step("切壁纸不得清掉自定义 URL", () => preset("kv37"), "custom-track.mp3");
await step("关掉开关 + 清空 URL → 回到预设默认", async () => {
await customAudio(false);
await file("");
}, "pv37.mp3");
console.log(` 音源轨迹: ${seen.join(" | ")}`);
console.log(` 未捕获异常: ${exceptions.length ? exceptions.join(" | ") : "无 ✓"}`);
if (exceptions.length) failures++;
});
// ───────────────────────── B 段:fps ─────────────────────────
console.log("\n=== B 段:fps 限流 ===");
await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => {
// 空 __props 只用来触发测试驱动注入(不带它就不会注入),既不冻结也不改属性
await send("Page.navigate", { url: BASE + "?__props=%7B%7D" });
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
await sleep(150);
}
await sleep(1200); // 让动画先跑起来,避开启动瞬态
const probe = `(function(){
var p = window.__player;
var e = p.animationState.getCurrent(0);
return { trackTime: e.trackTime, duration: e.animation.duration,
draws: window.__test.drawCalls, t: performance.now() };
})()`;
const sample = async (seconds) => {
const a = await evaluate(probe);
await sleep(seconds * 1000);
const b = await evaluate(probe);
const wall = (b.t - a.t) / 1000;
let phase = b.trackTime - a.trackTime;
if (phase < 0) phase += b.duration; // 动画正好绕回
return { wall, draws: b.draws - a.draws, fps: (b.draws - a.draws) / wall, phaseRate: phase / wall };
};
const setFps = async (v) => {
await evaluate(`window.wallpaperPropertyListener.applyGeneralProperties({ fps: ${v} })`);
await sleep(400);
};
const results = [];
const run = async (label, target) => {
const r = await sample(2.0);
const row = { label, target, ...r };
results.push(row);
console.log(
` ${label.padEnd(16)} 实测 ${r.fps.toFixed(1)} fps` +
(target ? `(目标 ${target})` : "(不限流)") +
` 动画相位推进 ${r.phaseRate.toFixed(3)} 倍速`,
);
return row;
};
const base = await run("不限流", 0);
await setFps(30);
const f30 = await run("限 30 fps", 30);
await setFps(20);
const f20 = await run("限 20 fps", 20);
await setFps(0);
const back = await run("恢复不限流", 0);
check(Math.abs(f30.fps - 30) / 30 < 0.25, "限 30 fps 生效", `实测 ${f30.fps.toFixed(1)}`);
check(Math.abs(f20.fps - 20) / 20 < 0.25, "限 20 fps 生效", `实测 ${f20.fps.toFixed(1)}`);
check(f30.fps < base.fps * 0.75, "限流确实减少了绘制次数", `${base.fps.toFixed(1)} → ${f30.fps.toFixed(1)}`);
check(back.fps > f30.fps * 1.5, "取消限流后帧率恢复", `${f30.fps.toFixed(1)} → ${back.fps.toFixed(1)}`);
for (const r of [f30, f20, back]) {
check(
Math.abs(r.phaseRate - base.phaseRate) / base.phaseRate < 0.1,
`动画相位推进速度未受 ${r.label} 影响`,
`${r.phaseRate.toFixed(3)} vs 基准 ${base.phaseRate.toFixed(3)}`,
);
}
console.log(` 未捕获异常: ${exceptions.length ? exceptions.join(" | ") : "无 ✓"}`);
if (exceptions.length) failures++;
});
console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
process.exit(failures === 0 ? 0 : 1);
+128
View File
@@ -0,0 +1,128 @@
// 验收测试:改窗口尺寸不得重建播放器,且取景要跟着画布比例走。
//
// 判据是"播放器实例身份":给加载后的实例打上标记,之后每次改视口都核对
// window.__player 是否还是同一个对象、标记是否还在。重建会换掉实例,
// 标记随之消失——这比数日志可靠得多。
import { withPage, sleep } from "../cdp.mjs";
// 服务地址可用 SIM_BASE 覆盖(默认 8190)。写死端口会在换端口时静默连错地方,
// 表现得像功能坏了,其实只是测错了对象。
const URL = (process.env.SIM_BASE ?? "http://127.0.0.1:8190/") + "?__freeze=1";
const sizes = [
[1920, 1080],
[2560, 1440],
[3440, 1440],
[1080, 1920],
[1600, 900],
[2560, 1600],
[3840, 2160],
[1280, 720],
[1920, 1080],
];
const stress = [];
for (let i = 0; i < 20; i++) stress.push(i % 2 ? [1080, 1920] : [1920, 1080]);
const out = await withPage(
{ width: 1920, height: 1080, extraArgs: ["--enable-precise-memory-info", "--js-flags=--expose-gc"] },
async ({ send, evaluate, consoleLines, exceptions }) => {
await send("Page.navigate", { url: URL });
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
await sleep(150);
}
await sleep(600);
const stamp = await evaluate(`(function(){
window.__first = window.__player;
if (window.__player) window.__player.__probe = 'A';
return { hasPlayer: !!window.__player, error: window.__test ? window.__test.error : null };
})()`);
const rows = [];
const measure = async (w, h, tag) => {
await send("Emulation.setDeviceMetricsOverride", {
width: w,
height: h,
deviceScaleFactor: 1,
mobile: false,
});
await sleep(420);
// 先强制回收再读堆,否则量到的是 V8 的延迟回收节奏而不是真实占用
await evaluate('(typeof gc === "function") ? (gc(), true) : false');
const r = await evaluate(`(function(){
var p = window.__player;
if (!p) return null;
var v = p.currentViewport;
return {
same: p === window.__first,
probe: p.__probe,
css: [p.canvas.clientWidth, p.canvas.clientHeight],
resolved: [ +v.x.toFixed(2), +v.y.toFixed(2), +v.width.toFixed(2), +v.height.toFixed(2) ],
pad: [v.padLeft, v.padRight, v.padTop, v.padBottom],
childViews: window.__player.config.viewport.animations ? Object.keys(window.__player.config.viewport.animations).length : -1,
heap: performance.memory ? performance.memory.usedJSHeapSize : 0,
drawCalls: window.__test ? window.__test.drawCalls : 0
};
})()`);
rows.push({ tag, w, h, ...(r ?? { same: false, probe: null, css: null, resolved: null, pad: null, heap: 0, drawCalls: 0 }) });
};
for (const [w, h] of sizes) await measure(w, h, `${w}x${h}`);
const heapBeforeStress = rows[rows.length - 1].heap;
for (const [w, h] of stress) await measure(w, h, `压力 ${w}x${h}`);
const heapAfterStress = rows[rows.length - 1].heap;
return { stamp, rows, heapBeforeStress, heapAfterStress, consoleLines, exceptions };
},
);
const { stamp, rows, heapBeforeStress, heapAfterStress, consoleLines, exceptions } = out;
let failures = 0;
const fail = (msg) => {
failures++;
console.log(` ✗ ${msg}`);
};
console.log(`播放器就绪: ${stamp.hasPlayer ? "是" : "否"} 驱动报错: ${stamp.error ?? "无"}`);
console.log("");
console.log("画布尺寸 实例未重建 内边距 可见矩形 比例核对");
for (const r of rows) {
if (r.tag.startsWith("压力")) continue;
const a = r.w / r.h;
const ra = r.resolved ? r.resolved[2] / r.resolved[3] : NaN;
const ratioOk = Math.abs(ra - a) < 0.002;
const padOk = r.pad && r.pad.every((p) => p === 0);
const rect = r.resolved ? r.resolved.map((n) => String(n).padStart(9)).join(" ") : "—";
console.log(
`${r.tag.padEnd(14)} ${String(r.same).padEnd(10)} ${(padOk ? "全 0 ✓" : JSON.stringify(r.pad)).padEnd(10)} ${rect} ${ratioOk ? "✓" : `✗ 期望 ${a.toFixed(4)} 实得 ${ra.toFixed(4)}`}`,
);
if (!r.same) fail(`${r.tag} 重建了播放器(标记丢失: ${r.probe})`);
if (!padOk) fail(`${r.tag} 内边距没有归零`);
if (!ratioOk) fail(`${r.tag} 可见矩形比例与画布不符`);
}
// 首尾同为 1920x1080:取景必须回到完全相同的值,证明重算可逆、无漂移
const first = rows.find((r) => r.tag === "1920x1080");
const last = [...rows].reverse().find((r) => r.tag === "1920x1080");
const reversible = first.resolved.every((n, i) => Math.abs(n - last.resolved[i]) < 0.01);
if (!reversible) fail(`回到 16:9 后取景漂移:${first.resolved} → ${last.resolved}`);
console.log(`\n回到 16:9 取景可逆: ${reversible ? "✓" : "✗"} ${first.resolved} → ${last.resolved}`);
const growth = heapAfterStress - heapBeforeStress;
console.log(`连续 20 次 resize 堆内存: ${(heapBeforeStress / 1048576).toFixed(1)} MB → ${(heapAfterStress / 1048576).toFixed(1)} MB(增长 ${(growth / 1048576).toFixed(2)} MB)`);
if (growth > 4 * 1048576) fail(`连续 resize 内存增长过大:${(growth / 1048576).toFixed(2)} MB`);
const drawCalls = rows[rows.length - 1].drawCalls;
console.log(`累计绘制帧数: ${drawCalls}`);
if (!(drawCalls > 0)) fail("没有绘制任何帧");
const noisy = consoleLines.filter((l) => /error|warn|uncaught|fail/i.test(l));
console.log(`控制台异常: ${noisy.length ? noisy.slice(0, 5).join(" | ") : "无 ✓"}`);
if (noisy.length) fail("控制台有报错/警告");
console.log(`未捕获异常: ${exceptions.length ? exceptions.slice(0, 4).join(" | ") : "无 ✓"}`);
if (exceptions.length) fail("有未捕获异常");
console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
process.exit(failures === 0 ? 0 : 1);
+64
View File
@@ -0,0 +1,64 @@
// 验证 sim 门禁**真的会拦**:往骨架里注入一个真实外链标签,看 check:dist 是否报错。
// 一个从不失败的检查等于没有检查——上一版它对整页扫标签,四个分发全是假阳性;
// 收紧之后必须确认它没有紧到"什么都查不到"。
import { readFileSync, writeFileSync, copyFileSync, unlinkSync } from "node:fs";
import { execFileSync } from "node:child_process";
const page = "dist/releases/single-hsr-kv37/sim/index.html";
const backup = `${page}.bak`;
function runCheck() {
try {
return execFileSync("node", ["tools/check-dist.ts"], { encoding: "utf8" });
} catch (error) {
const e = error as { stdout?: string; stderr?: string };
return (e.stdout || "") + (e.stderr || "");
}
}
function inject(html, tag) {
const anchor = '<div id="spine-container"></div>';
if (!html.includes(anchor)) throw new Error("锚点没找到,测试需要更新");
return html.replace(anchor, anchor + tag);
}
const cases = [
["外链 script", '<script src="https://evil.example.com/x.js"></script>'],
["file:// 绝对路径", '<script src="file:///D:/somewhere/x.js"></script>'],
["根绝对路径", '<link rel="stylesheet" href="/styles/x.css" />'],
["type=module", '<script type="module" src="x.js"></script>'],
];
const original = readFileSync(page, "utf8");
copyFileSync(page, backup);
let failed = 0;
try {
console.log("=== 干净页面应当通过 ===");
const clean = runCheck();
const cleanOk = clean.includes("✓ 自包含");
console.log(` ${cleanOk ? "✓" : "✗"} 未注入时通过`);
if (!cleanOk) failed++;
for (const [label, tag] of cases) {
writeFileSync(page, inject(original, tag));
const output = runCheck();
// 必须是**失败**,且失败原因里提到 sim/index.html
const caught = !output.includes("✓ 自包含") && output.includes("sim/index.html");
console.log(` ${caught ? "✓" : "✗"} 拦住了「${label}」`);
if (!caught) {
failed++;
console.log(` check 输出:${output.split("\n").slice(0, 3).join(" | ")}`);
}
}
} finally {
writeFileSync(page, original);
unlinkSync(backup);
}
const restored = runCheck();
console.log(`\n还原后:${restored.includes("✓ 自包含") ? "✓ 通过" : "✗ 仍失败"}`);
if (!restored.includes("✓ 自包含")) failed++;
console.log(failed === 0 ? "\nsim 门禁:四种外部依赖都能拦住" : `\nsim 门禁:${failed} 处不符合预期`);
process.exit(failed === 0 ? 0 : 1);
+140
View File
@@ -0,0 +1,140 @@
// export 改写的**单元测试**:直接调用 tools/lib/bundle.ts 里真正在用的那个函数。
//
// 六个坑,每个都对应一次真实事故,所以每个都有一条断言:
// ① 字符串替换里的 `$1exports.` 被解析成捕获组 "1e" → "exports." 整段消失
// ② 两条规则互相截胡(`export function` 先被吃成 `exports.function`,function 留在原地)
// ③ V8 把声明关键字吞掉 → `exports.foo(a) {`
// ④ 嵌套可选组让捕获位置漂移 → `exports.function resolveViewport(...)`
// ⑤ `export default {`(对象字面量)没有匹配到任何规则 → `exports.default {`
// ⑥ **改写毁掉本地绑定**:`export const X = 1` 直接变成 `exports.X = 1`,
// 于是同模块里别处引用 `X` 变成 ReferenceError。真事故:`frameForAspect` 的
// 默认参数 `referenceAspect = REFERENCE_ASPECT` 直到渲染第一帧才求值——
// 加载、尺寸、资源全部正常,只有画面是白的。所以现在一律保留声明原文,
// 导出推迟到模块体末尾。下面有专门一条断言盯着"声明必须还在"。
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { transformModuleForTest } from "../lib/bundle.ts";
function body(code: string) {
// 去掉包装体,只留模块体,方便断言
const m = /^__weModules\["__test__"\] = \(function \(\) \{\n const exports = \{\};\n([\s\S]*)\n return exports;\n\}\)\(\);\n$/.exec(code);
if (!m) throw new Error("包装体结构变了,测试需要同步更新");
return (m[1] ?? "")
.split("\n")
.map((l) => (l.startsWith(" ") ? l.slice(2) : l))
.join("\n");
}
function parses(code) {
const child = spawnSync(
process.execPath,
["--experimental-vm-modules", "--no-warnings", "-e", 'new (require("node:vm").SourceTextModule)(require("node:fs").readFileSync(0, "utf8"));'],
{ input: code, encoding: "utf8" },
);
return child.status === 0 ? null : (child.stderr || "").split("\n").find((l) => /SyntaxError|ReferenceError/.test(l)) ?? "解析失败";
}
const cases: [string, string][] = [
// [输入, 期望输出(精确,含空格)]
// 声明原文必须**逐字保留**,导出在末尾统一赋值——见文件头 ⑥。
["export default class C {}", "class C {}\nexports.default = C;"],
["export default Presets;", "exports.default = Presets;"],
["export default {", "exports.default = {"],
["export default function f(a) {}", "function f(a) {}\nexports.default = f;"],
["export default async function f(a) {}", "async function f(a) {}\nexports.default = f;"],
["export const x = 1;", "const x = 1;\nexports.x = x;"],
["export let y = 2;", "let y = 2;\nexports.y = y;"],
["export function resolveViewport(v) {}", "function resolveViewport(v) {}\nexports.resolveViewport = resolveViewport;"],
["export async function load(u) {}", "async function load(u) {}\nexports.load = load;"],
["export class PresetController {}", "class PresetController {}\nexports.PresetController = PresetController;"],
// 含 "exports" 的属性访问绝不能被误改
["class A {\n exports2 = 1;\n}", "class A {\n exports2 = 1;\n}"],
["obj.exports = 1;\nobj.exports.foo = 2;", "obj.exports = 1;\nobj.exports.foo = 2;"],
["export const a = 1;\nexport const b = 2;\n", "const a = 1;\nconst b = 2;\n\nexports.a = a;\nexports.b = b;"],
['export const defaultPresetId = "kv37";', 'const defaultPresetId = "kv37";\nexports.defaultPresetId = defaultPresetId;'],
];
let failed = 0;
const report = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failed++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail ? `\n ${detail}` : ""}`);
};
console.log("=== 单元断言(调真实实现) ===");
for (const [input, expected] of cases) {
const actual = body(transformModuleForTest(input));
report(
actual === expected,
JSON.stringify(input),
actual === expected ? undefined : `得到 ${JSON.stringify(actual)}\n 期望 ${JSON.stringify(expected)}`,
);
}
// ⑥ 的专用断言:声明必须原样活着。这条比"导出键对得上"更根本——
// 导出键对了、本地绑定没了,症状是运行到某一帧才炸,最难查。
console.log("\n=== 本地绑定必须活着(⑥) ===");
const bindingCases: [string, string][] = [
["export const X = 16 / 9;\nfunction f(a = X) { return a; }", "X"],
["export function f() {}\nfunction g() { return f(); }", "f"],
["export class C {}\nnew C();", "C"],
];
for (const [input, name] of bindingCases) {
const actual = body(transformModuleForTest(input));
// 声明本身要在(`const X =` / `function f(` / `class C `),不能只剩 exports.X = X
const declared = new RegExp(`(?:^|\\n)(?:const|let|var|async function|function|class) ${name}\\b`).test(actual);
report(declared, `${JSON.stringify(input)} 里 ${name} 的声明还在`, declared ? undefined : `实际 ${JSON.stringify(actual)}`);
}
// 声明与赋值都在的话,真跑一遍也不能抛 ReferenceError
console.log("\n=== 改写后可执行(不是只可解析) ===");
const runnable = [
"export const REFERENCE_ASPECT = 16 / 9;\nexport function frameForAspect(a = REFERENCE_ASPECT) { return a; }",
"export function helper() { return 42; }\nexport const value = helper();",
"export class Base {}\nexport const instance = new Base();",
];
for (const input of runnable) {
const code = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(input)}\n`;
const child = spawnSync(process.execPath, ["--input-type=module", "--no-warnings", "-e", code], { encoding: "utf8" });
const ok = child.status === 0;
report(ok, `${JSON.stringify(input.slice(0, 46))}… 执行不抛错`, ok ? undefined : (child.stderr || "").split("\n").slice(0, 3).join(" | "));
}
console.log("\n=== 真实产物:改写后必须可解析 ===");
const files = [ "build/scripts/index.js",
"build/scripts/viewport-fitter.js",
"build/scripts/preset-controller.js",
"build/scripts/wallpaper-engine.js",
"dist/releases/single-hsr-kv37/scripts/presets.js",
// 生成的 preset.js 里是 `export default {` 对象字面量——最容易漏的一类
"dist/releases/single-hsr-kv37/preset.js",
];
for (const file of files) {
let source;
try {
source = readFileSync(file, "utf8");
} catch {
report(false, `${file} 不存在`);
continue;
}
const wrapped = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(source)}\n`;
const problem = parses(wrapped);
report(problem === null, `${file} 改写后可解析`, problem ?? undefined);
}
// 关键:改写后 exports 的键必须与源码里的默认导出/具名导出对得上
console.log("\n=== 语义断言:导出的键不能丢 ===");
const semantic: [string, string[]][] = [
["export default { id: 1 };", ["default"]],
["export default Presets;", ["default"]],
["export default class C {}", ["default"]],
["export default function f() {}", ["default"]],
["export const a = 1;\nexport function b() {}", ["a", "b"]],
];
for (const [input, keys] of semantic) {
const actual = body(transformModuleForTest(input));
const missing = keys.filter((k) => !new RegExp(`^exports\\.${k}\\b`, "m").test(actual));
report(missing.length === 0, `${JSON.stringify(input)} 导出 ${JSON.stringify(keys)}`, missing.length ? `缺 ${JSON.stringify(missing)},实际 ${JSON.stringify(actual)}` : undefined);
}
console.log(failed === 0 ? "\nexport 改写:全部通过" : `\nexport 改写:${failed} 处失败`);
process.exit(failed === 0 ? 0 : 1);
+77
View File
@@ -0,0 +1,77 @@
// 验收「构建开关在热更新重建里保持」。
//
// 上一版这个验证是**空的**:调试服因为参数不认识根本没起来,于是"没重建"被当成了"重建后开关还在"。
// 所以这里第一件事就是断言服务真的活着——一个不检查前置条件的验证等于没有验证。
//
// 用法:node tools/checks/verify-dev-flags.mts [base]
// 前提:调试服以 --with-sim 启动,且产物里本来就带模拟器。
import { appendFileSync, readdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const CSS = "src/styles/index.css";
const RELEASES = "dist/releases";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const simulatorCount = () => {
let n = 0;
for (const dir of readdirSync(RELEASES)) {
if (existsSync(join(RELEASES, dir, "scripts", "wallpaper-engine.js"))) n += 1;
}
return n;
};
const original = readFileSync(CSS, "utf8");
try {
console.log("=== ① 前置条件:调试服真的活着 ===");
let alive = false;
try {
const res = await fetch(`${BASE}/`);
alive = res.status === 200;
} catch {
alive = false;
}
check(alive, "调试服响应 GET /", alive ? BASE : "连不上——后面所有结论都不成立");
if (!alive) throw new Error("调试服没起来,验证无意义");
const before = simulatorCount();
check(before > 0, "重建前产物里带模拟器", `${before} 个分发`);
if (before === 0) throw new Error("产物里没有模拟器,请先 node tools/build.ts --with-sim");
console.log("\n=== ② 保存一次源文件,等热更新重建 ===");
appendFileSync(CSS, `\n/* dev-flags probe ${Date.now()} */\n`);
// 用"被改的 CSS 已经能被取到新内容"作为重建完成的判据,而不是干等固定秒数。
let rebuilt = false;
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
await sleep(400);
try {
const text = await (await fetch(`${BASE}/release/collection-all/styles/index.css`)).text();
if (/dev-flags probe/.test(text)) {
rebuilt = true;
break;
}
} catch {
// 重建中偶尔取不到,继续等
}
}
check(rebuilt, "重建完成(调试服已能取到新的 CSS)");
console.log("\n=== ③ 开关是否保持 ===");
const after = simulatorCount();
check(after === before, "--with-sim 在热更新重建后仍然生效", `${before} → ${after}`);
} finally {
writeFileSync(CSS, original);
await sleep(3000);
console.log("\n源文件已还原");
}
console.log(failures === 0 ? "构建开关保持:通过" : `构建开关保持:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+72
View File
@@ -0,0 +1,72 @@
// 取景公式的离线验证:不依赖浏览器,直接对 viewport-fitter.js 求值。
// 两条硬约束:① 16:9 下必须与作者设定完全一致;② 立绘层缩放比必须等于背景 cover 缩放比。
import { readFileSync } from "node:fs";
const src = readFileSync(new URL("../../dist/releases/collection-all/scripts/viewport-fitter.js", import.meta.url), "utf8").replace(/^export\s+/gm, "");
const mod = new Function(
src + "\nreturn { contain, frameForAspect, coverScale, resolveViewport, toViewportConfig, REFERENCE_ASPECT };",
)();
const rect = (x, y, width, height) => ({ x, y, width, height });
const fmt = (r) =>
`x=${r.x.toFixed(2)} y=${r.y.toFixed(2)} w=${r.width.toFixed(2)} h=${r.height.toFixed(2)} 比例=${(r.width / r.height).toFixed(4)}`;
const cases = [
{
name: "xilian(昔涟立绘)",
R: rect(-2181.72, 1442.39, 4226.57, 3403.35),
imgW: 2048,
imgH: 1080,
},
{
name: "kv37(「成为昨日的明天」)",
R: rect(-1419.8, -512.4, 2457.6, 1228.8),
imgW: 2560,
imgH: 1440,
},
];
const canvases = [
[3440, 1440, "21:9"],
[1920, 1080, "16:9"],
[2560, 1600, "16:10"],
[1080, 1920, "9:16"],
];
// 背景走 CSS background-size:cover,它在画布上真实的像素缩放就是这两个候选里的较大者。
const bgScale = (w, h, imgW, imgH) => Math.max(w / imgW, h / imgH);
let failures = 0;
for (const c of cases) {
const imgAspect = c.imgW / c.imgH;
const V0 = mod.contain(c.R, mod.REFERENCE_ASPECT);
console.log(`\n=== ${c.name} 背景图 ${c.imgW}x${c.imgH}(比例 ${imgAspect.toFixed(4)}) ===`);
console.log(`作者视口 R : ${fmt(c.R)}`);
console.log(`参考取景 V0 : ${fmt(V0)}`);
// 基准取 16:9 1080p:立绘与背景都以此为准比较"被放大了多少倍"
const refCanvas = [1920, 1080];
const spinePx0 = refCanvas[1] / V0.height;
const bgPx0 = bgScale(refCanvas[0], refCanvas[1], c.imgW, c.imgH);
for (const [w, h, tag] of canvases) {
const aspect = w / h;
const V = mod.frameForAspect(V0, aspect, imgAspect);
const spineRatio = h / V.height / spinePx0;
const bgRatio = bgScale(w, h, c.imgW, c.imgH) / bgPx0;
const synced = Math.abs(spineRatio - bgRatio) < 1e-6;
if (!synced) failures++;
console.log(
` ${tag.padEnd(5)} ${String(w).padStart(4)}x${String(h).padEnd(4)} ${fmt(V)}` +
` 立绘 x${spineRatio.toFixed(4)} 背景 x${bgRatio.toFixed(4)} ${synced ? "同步 ✓" : "不同步 ✗"}`,
);
}
// 参考比例下必须与作者设定逐像素一致,且必须与拟合前的真实渲染值吻合
const Vref = mod.frameForAspect(V0, mod.REFERENCE_ASPECT, imgAspect);
const untouched = ["x", "y", "width", "height"].every((k) => Math.abs(Vref[k] - V0[k]) < 1e-9);
if (!untouched) failures++;
console.log(` 参考比例回归: ${untouched ? "与作者设定完全一致 ✓" : "被改动 ✗"}`);
}
console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
process.exit(failures === 0 ? 0 : 1);
+95
View File
@@ -0,0 +1,95 @@
// 热更新验收:真的改一个源文件,看浏览器是不是按预期更新。
//
// 断言两件不同的事,不能混为一谈:
// · 改 CSS → **只换样式表**,页面不重载(Spine 播放器与面板状态都保住)
// · 改 runtime → **整页刷新**
//
// 用"页面上的标记还在不在"来区分这两者:整页刷新会把 window 上的标记抹掉,
// 只换样式表则不会。只看"页面变了没有"是分不出这两种情况的。
//
// 用法:node tools/checks/verify-hot-reload.mts [base]
// 前提:调试服在跑,且是**新版**(带热更新)的 tools/dev.ts。
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const RELEASE = "collection-all";
const CSS_FILE = "src/styles/index.css";
const TS_FILE = "src/runtime/viewport-fitter.ts";
const cssOriginal = readFileSync(CSS_FILE, "utf8");
const tsOriginal = readFileSync(TS_FILE, "utf8");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
/** 等到条件成立,超时返回 false。 */
async function waitFor(evaluate, expression, ms, tag) {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (await evaluate(expression).catch(() => false)) return true;
await sleep(150);
}
console.log(` (${tag} 等待超时)`);
return false;
}
try {
await withPage({ width: 1280, height: 900, profile: "tools/.cache/edge-hotreload" }, async ({ send, evaluate }) => {
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
await sleep(3500);
console.log("=== ① 注入与连接 ===");
const injected = await evaluate(`!!document.querySelector('script[src*="/__dev/events"]') || document.documentElement.innerHTML.includes("/__dev/events")`);
check(injected === true, "页面里注入了热更新客户端");
const esState = await evaluate(`(function(){
// EventSource 没有暴露实例,用一条同地址的连接探一下服务端在不在
return fetch("/__dev/events", { method: "GET" }).then(function (r) {
return r.headers.get("content-type") || "";
}).catch(function () { return "ERR"; });
})()`);
check(/text\/event-stream/.test(esState ?? ""), "SSE 端点返回 text/event-stream", esState);
console.log("\n=== ② 改 CSS:只换样式表,不整页重载 ===");
await evaluate(`window.__hotReloadMarker = "css";`);
// 页面有两条样式表(spine-player.css / index.css),要盯的是**被改的那一条**,
// 盯第一条会把"换了别的样式表"当成成功。
const indexHref = `(function(){
var links = document.querySelectorAll('link[rel="stylesheet"]');
for (var i = 0; i < links.length; i++) if (links[i].href.indexOf("index.css") >= 0) return links[i].href;
return null;
})()`;
const beforeHref = await evaluate(indexHref);
appendFileSync(CSS_FILE, `\n/* hot-reload probe ${Date.now()} */\n`);
const swapped = await waitFor(evaluate, `${indexHref} !== ${JSON.stringify(beforeHref)}`, 25000, "样式表换新");
check(swapped, "被改的样式表 href 换成了带 ?t= 的新地址", await evaluate(indexHref));
const markerAfterCss = await evaluate(`window.__hotReloadMarker`);
check(markerAfterCss === "css", "页面**没有**整页重载(window 上的标记还在)", String(markerAfterCss));
const cssText = await evaluate(`fetch(${indexHref}).then(function(r){ return r.text(); })`);
check(/hot-reload probe/.test(cssText ?? ""), "换上的样式表确实是新内容");
console.log("\n=== ③ 改 runtime:整页刷新 ===");
writeFileSync(CSS_FILE, cssOriginal); // 先把 CSS 还原,免得下面这一轮被判成 css-only
await sleep(2500);
await evaluate(`window.__hotReloadMarker = "ts";`);
const markerBefore = await evaluate(`window.__hotReloadMarker`);
check(markerBefore === "ts", "刷新前标记已就位", String(markerBefore));
appendFileSync(TS_FILE, `\n// hot-reload probe ${Date.now()}\n`);
const reloaded = await waitFor(evaluate, `window.__hotReloadMarker === undefined`, 30000, "整页刷新");
check(reloaded, "页面整页刷新了(标记被抹掉)");
check((await evaluate(`!!document.getElementById("wesim")`)) === true, "刷新后模拟器面板重新挂上");
});
} finally {
// 无论成败都要还原源码,否则测试会把探针注释留在仓库里
writeFileSync(CSS_FILE, cssOriginal);
writeFileSync(TS_FILE, tsOriginal);
await sleep(2500);
console.log("\n源文件已还原");
}
console.log(failures === 0 ? "热更新:通过" : `热更新:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+549
View File
@@ -0,0 +1,549 @@
// 面板还原度验收:模拟器的设置面板必须长得像 Wallpaper Engine 官方面板。
//
// 断言的是**官方面板的可观察特征**,不是"我们的实现细节":
// · 标题栏是 "Wallpaper Settings" + 重置(官方就是这么写的,只有重置跟着界面语言走)
// · 属性按 order 排成"图标 + 标签 + 右侧控件"的行,而不是分区堆叠的调试列表
// · WE 自带的颜色块:主题配色 → 翻转 → 显示颜色选项 → [亮度/对比度/饱和度/色调偏移]
// · 点色块弹出取色器(调色板 + 明度/饱和度方块 + 色相条 + 十六进制 + 确认/取消)
// · type:"text" 渲染成真正的富文本说明块(<ul> 列表 + <a> 链接),不是截断的一行提示
// · condition 生效(显示作者信息、使用自定义音乐两个开关都各管一摊)
// · 底部是 确认 / 取消,且取消能回滚、重置能恢复默认
//
// 用法:node tools/checks/verify-panel.mts [base]
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const RELEASE = "collection-all";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const probe = `(function(){
var root = document.getElementById("wesim");
if (!root) return null;
var q = function (s) { return root.querySelector(s); };
var rows = Array.prototype.map.call(root.querySelectorAll(".body .row"), function (r) {
var c = r.querySelector(".ctl");
var input = c && c.querySelector("input, select");
var kind = "none";
if (input) kind = input.type === "select-one" ? "select" : input.type;
else if (c && c.querySelector(".combo")) kind = "combo";
else if (c && c.querySelector(".swatch")) kind = "swatch";
return {
icon: (r.querySelector(".ico") || {}).textContent || "",
label: (r.querySelector(".label") || {}).textContent || "",
control: kind,
value: input ? input.value : (c && c.querySelector(".combo") ? c.querySelector(".combo").dataset.value : null),
comboLabel: c && c.querySelector(".combo-label") ? c.querySelector(".combo-label").textContent : null
};
});
var notes = Array.prototype.map.call(root.querySelectorAll(".body .note"), function (n) {
return {
text: n.textContent.replace(/\\s+/g, " ").trim(),
items: n.querySelectorAll("li").length,
links: Array.prototype.map.call(n.querySelectorAll("a"), function (a) { return a.getAttribute("href"); })
};
});
return {
title: (q(".title .name") || {}).textContent || null,
reset: (q(".title .reset") || {}).textContent || null,
rows: rows,
notes: notes,
foot: Array.prototype.map.call(root.querySelectorAll(".foot button"), function (b) { return b.textContent; }),
hasDebug: !!q(".dbg"),
debugOpen: q(".dbg") ? q(".dbg").open : null,
banner: (q(".banner") || {}).textContent || null,
outsideBody: root.parentElement === document.documentElement,
bodyTransform: document.body.style.transform,
bodyFilter: document.body.style.filter
};
})()`;
/** 按标签点一个控件的 change(复刻用户在面板上的操作)。 */
const clickByLabel = (label, action) => `(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (!l || l.textContent !== ${JSON.stringify(label)}) continue;
${action}
}
return false;
})()`;
const toggle = (label) =>
clickByLabel(label, `var cb = rows[i].querySelector("input[type=checkbox]");
cb.checked = !cb.checked;
cb.dispatchEvent(new Event("change", { bubbles: true }));
return true;`);
const setSlider = (label, value) =>
clickByLabel(label, `var r = rows[i].querySelector("input[type=range]");
r.value = ${JSON.stringify(String(value))};
r.dispatchEvent(new Event("input", { bubbles: true }));
return true;`);
const result = await withPage({ width: 1920, height: 1080, profile: "tools/.cache/edge-panel" }, async ({ send, evaluate, consoleLines, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
await sleep(3500);
const initial = await evaluate(probe);
// ── 显示颜色选项 → 四个滑杆 ──
await evaluate(toggle("显示颜色选项"));
await sleep(300);
const withColorOptions = await evaluate(probe);
// 这一批新冒出来的行应当带 .row-enter,而原本就在的行不该带——
// 否则每改一个属性整个面板都会重播一遍入场动画。
const enterFlags = await evaluate(`(function(){
return Array.prototype.map.call(document.querySelectorAll("#wesim .body .row"), function (r) {
return { label: (r.querySelector(".label") || {}).textContent || "", enter: r.classList.contains("row-enter") };
});
})()`);
await evaluate(setSlider("亮度", 80));
await sleep(200);
const afterBrightness = await evaluate(`document.body.style.filter`);
// ── 翻转 ──
await evaluate(toggle("翻转"));
await sleep(200);
const afterFlip = await evaluate(`document.body.style.transform`);
await evaluate(toggle("翻转"));
await sleep(200);
// ── 取色器 ──
await evaluate(`document.querySelector("#wesim .swatch").click()`);
await sleep(400);
const pickerProbe = `(function(){
var p = document.getElementById("wesim-picker");
if (!p) return null;
var swatches = p.querySelectorAll(".pk-palette button:not(.pk-dropper)");
var checked = p.querySelector(".pk-palette button.on");
var dropper = p.querySelector(".pk-dropper");
return {
outsideBody: p.parentElement === document.documentElement,
name: (p.querySelector(".pk-name") || {}).textContent || null,
palette: swatches.length,
colors: Array.prototype.map.call(swatches, function (b) { return b.title; }),
checked: checked ? checked.title : null,
hasSv: !!p.querySelector(".pk-sv"),
hasHue: !!p.querySelector(".pk-hue"),
hex: (p.querySelector(".pk-hex") || {}).value || null,
dropper: !!dropper,
dropperDisabled: dropper ? dropper.disabled : null,
dropperTitle: dropper ? dropper.title : null,
foot: Array.prototype.map.call(p.querySelectorAll(".pk-foot button"), function (b) { return b.textContent; })
};
})()`;
const picker = await evaluate(pickerProbe);
// 把颜色改成某个预设色,对勾应当落到那个色块上
await evaluate(`(function(){
var hex = document.querySelector("#wesim-picker .pk-hex");
hex.value = "#ff0000";
hex.dispatchEvent(new Event("change", { bubbles: true }));
})()`);
await sleep(200);
const pickerChecked = await evaluate(pickerProbe);
// 用取色器改颜色并确认
await evaluate(`(function(){
var hex = document.querySelector("#wesim-picker .pk-hex");
hex.value = "#123456";
hex.dispatchEvent(new Event("change", { bubbles: true }));
document.querySelector("#wesim-picker .pk-foot button.primary").click();
})()`);
await sleep(300);
const colorAfterPick = await evaluate(`window.__weSim.props.schemecolor`);
const pickerGone = await evaluate(`!document.getElementById("wesim-picker")`);
// ── 下拉:自绘控件,弹出列表也要能验 ──
const openPresetCombo = `(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "壁纸预设切换") { rows[i].querySelector(".combo-btn").click(); return true; }
}
return false;
})()`;
const comboProbe = `(function(){
var box = document.getElementById("wesim-combo");
if (!box) return null;
return {
outsideBody: box.parentElement === document.documentElement,
items: Array.prototype.map.call(box.querySelectorAll(".combo-item"), function (b) {
return { label: b.textContent, value: b.dataset.value, on: b.classList.contains("on") };
})
};
})()`;
await evaluate(openPresetCombo);
await sleep(250);
const comboOpen = await evaluate(comboProbe);
// 选第二项 → 属性应当跟着变、弹层收起、按钮文字换掉
await evaluate(`document.querySelectorAll("#wesim-combo .combo-item")[1].click()`);
await sleep(400);
const comboClosed = await evaluate(`!document.getElementById("wesim-combo")`);
const presetAfter = await evaluate(`window.__weSim.props.preset`);
const comboLabelAfter = await evaluate(`(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "壁纸预设切换") return rows[i].querySelector(".combo-label").textContent;
}
return null;
})()`);
// ── condition:关掉「显示作者信息」,作者信息说明块应当消失 ──
await evaluate(toggle("显示作者信息"));
await sleep(300);
const hidden = await evaluate(probe);
// ── 使用自定义音乐:audio_file 与 bgm 互换 ──
await evaluate(toggle("使用自定义音乐"));
await sleep(300);
const customAudio = await evaluate(probe);
await evaluate(toggle("使用自定义音乐"));
await sleep(300);
const presetAudio = await evaluate(probe);
// ── 重置 / 取消 / 确认 ──
await evaluate(setSlider("音频音量调整", 0.3));
await sleep(200);
const afterEdit = await evaluate(`window.__weSim.props.audio_volume`);
await evaluate(`document.querySelector("#wesim .title .reset").click()`);
await sleep(300);
const afterReset = await evaluate(`window.__weSim.props.audio_volume`);
await evaluate(setSlider("音频音量调整", 0.2));
await sleep(200);
const beforeCancel = await evaluate(`window.__weSim.props.audio_volume`);
await evaluate(`document.querySelector("#wesim .foot button:not(.primary)").click()`);
await sleep(300);
const afterCancel = await evaluate(`window.__weSim.props.audio_volume`);
await evaluate(setSlider("音频音量调整", 0.6));
await sleep(200);
await evaluate(`document.querySelector("#wesim .foot button.primary").click()`);
await sleep(300);
const afterConfirm = await evaluate(`window.__weSim.props.audio_volume`);
// ── 动效:真的滑出一次才看得到 data-enter ──
// 先移到远处再移到边缘:前面点过「取消」,那会把 armed 置 false,
// 而 armed 只有"指针离开边缘"才会复位——不先走开一次,合成指针是打不开面板的。
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 200, y: 540, button: "none", buttons: 0 });
await sleep(120);
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 1912, y: 540, button: "none", buttons: 0 });
await sleep(260);
const enterProbe = `(function(){
var root = document.getElementById("wesim");
var rows = root.querySelectorAll(".body .row");
return {
open: root.dataset.open,
dataEnter: root.dataset.enter || null,
seq: Array.prototype.map.call(rows, function (r) { return r.style.getPropertyValue("--i"); }).slice(0, 5),
rowAnim: rows.length ? getComputedStyle(rows[0]).animationName : null,
rowDelay: rows.length ? getComputedStyle(rows[0]).animationDelay : null,
hoverTransition: rows.length ? getComputedStyle(rows[0]).transitionProperty : null,
pickerAnim: (function () {
document.querySelector("#wesim .swatch").click();
var p = document.getElementById("wesim-picker");
return p ? getComputedStyle(p).animationName : null;
})()
};
})()`;
const anim = await evaluate(enterProbe);
await evaluate(`(function(){ var p = document.getElementById("wesim-picker"); if (p) p.remove(); })()`);
await sleep(600);
const afterEnter = await evaluate(`document.getElementById("wesim").dataset.enter || null`);
// 复选框是自绘的:appearance 关掉、勾靠背景图长出来
const checkbox = await evaluate(`(function(){
var cb = document.querySelector("#wesim .body input[type=checkbox]");
if (!cb) return null;
var before = getComputedStyle(cb).backgroundSize;
cb.checked = !cb.checked;
cb.dispatchEvent(new Event("change", { bubbles: true }));
return { appearance: getComputedStyle(cb).appearance, before: before };
})()`);
// 下拉箭头靠 aria-expanded 翻转
await evaluate(`(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "背景音乐选择") { rows[i].querySelector(".combo-btn").click(); return true; }
}
return false;
})()`);
await sleep(250);
const arrowOpen = await evaluate(`(function(){
var b = document.querySelector("#wesim .combo-btn[aria-expanded]");
return { expanded: b ? b.getAttribute("aria-expanded") : null,
rotated: b ? getComputedStyle(b.querySelector(".combo-arrow")).transform : null };
})()`);
await evaluate(`(function(){ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); })()`);
await sleep(250);
const arrowClosed = await evaluate(`(function(){
var b = document.querySelector("#wesim .combo-btn[aria-expanded]");
return b ? b.getAttribute("aria-expanded") : null;
})()`);
// 系统开了"减少动态效果"时应当全部关掉
await send("Emulation.setEmulatedMedia", {
features: [{ name: "prefers-reduced-motion", value: "reduce" }],
});
await sleep(200);
const reduced = await evaluate(`(function(){
var r = document.querySelector("#wesim .body .row");
var p = document.getElementById("wesim-picker");
return { rowAnim: r ? getComputedStyle(r).animationDuration : null,
rowTransition: r ? getComputedStyle(r).transitionDuration : null };
})()`);
await send("Emulation.setEmulatedMedia", { features: [] });
// ── 预览图:设置面板上方 ──
const previewProbe = `(function(){
var p = document.querySelector("#wesim .preview");
if (!p) return { present: false };
var img = p.querySelector("img");
var src = img ? img.getAttribute("src") : null;
return {
present: true,
src: src,
isData: src ? src.indexOf("data:") === 0 : null,
natural: img ? img.naturalWidth + "x" + img.naturalHeight : null,
navs: Array.prototype.map.call(p.querySelectorAll(".nav"), function (b) {
return { cls: b.className, disabled: b.disabled, glyph: b.textContent };
})
};
})()`;
const preview = await evaluate(previewProbe);
// ── 单档分发里的「背景音乐选择」──
//
// 这条是回归断言:bgm 曾经跟着 preset 一起被删掉("只剩一档壁纸,切换没有意义"),
// 但那个理由只对**预设切换**成立——一档壁纸照样能带好几首曲子(xilian 就是两首)。
// 结果 single-hsr-xilian 里根本没有切换入口,而当时没有任何断言盯着这件事。
const bgmRowProbe = `(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "背景音乐选择") {
var combo = rows[i].querySelector(".combo");
var label = rows[i].querySelector(".combo-label");
return { present: true, value: combo ? combo.dataset.value : null,
label: label ? label.textContent : null };
}
}
return { present: false };
})()`;
await send("Page.navigate", { url: `${BASE}/release/single-hsr-xilian/` });
await sleep(3200);
const singleTwoTracks = await evaluate(bgmRowProbe);
// 展开看有几个选项
await evaluate(`(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "背景音乐选择") { rows[i].querySelector(".combo-btn").click(); return true; }
}
return false;
})()`);
await sleep(250);
const singleTwoTrackItems = await evaluate(
`Array.prototype.map.call(document.querySelectorAll("#wesim-combo .combo-item"), function (b) { return b.textContent; })`,
);
await send("Page.navigate", { url: `${BASE}/release/single-hsr-kv37/` });
await sleep(3200);
const singleOneTrack = await evaluate(bgmRowProbe);
return {
initial, withColorOptions, enterFlags, anim, afterEnter, checkbox, arrowOpen, arrowClosed, reduced,
preview,
singleTwoTracks, singleTwoTrackItems, singleOneTrack,
afterBrightness, afterFlip, picker, pickerChecked, colorAfterPick, pickerGone,
comboOpen, comboClosed, presetAfter, comboLabelAfter,
hidden, customAudio, presetAudio, afterEdit, afterReset, beforeCancel, afterCancel, afterConfirm,
consoleLines, exceptions,
};
});
const r = result;
const labelsOf = (state) => (state?.rows ?? []).map((x) => x.label);
console.log("=== ① 标题栏(官方:" + '"Wallpaper Settings" + 重置' + ")===");
check(r.initial?.title === "Wallpaper Settings", '标题是 "Wallpaper Settings"', r.initial?.title);
check(/重置/.test(r.initial?.reset ?? ""), "右侧有「重置」", r.initial?.reset?.trim());
check(/模拟环境/.test(r.initial?.banner ?? ""), "顶部保留「模拟环境」横幅(ADR 0006 要求,官方没有)");
console.log("\n=== ② 属性行:图标 + 标签 + 右侧控件,按 order 排 ===");
console.log(` 实际顺序:${labelsOf(r.initial).join(" / ")}`);
const expectOrder = ["主题配色", "翻转", "显示颜色选项", "显示作者信息", "壁纸预设切换", "音频音量调整", "使用自定义音乐", "背景音乐选择"];
check(JSON.stringify(labelsOf(r.initial)) === JSON.stringify(expectOrder), "行顺序与 order 字段一致", `${labelsOf(r.initial).length} 行`);
check(r.initial?.rows?.[0]?.control === "swatch", "主题配色渲染成色块", r.initial?.rows?.[0]?.control);
check((r.initial?.rows ?? []).filter((x) => x.control === "combo").length === 2, "两个 combo(壁纸预设切换 / 背景音乐选择)都渲染成自绘下拉");
check((r.initial?.rows ?? []).some((x) => x.label === "音频音量调整" && x.control === "range"), "slider 渲染成 range");
const icons = (r.initial?.rows ?? []).filter((x) => x.icon).length;
check(icons >= 3, "属性文本开头的 emoji 被当作行图标", `${icons} 行有图标`);
console.log("\n=== ③ WE 自带的颜色块 ===");
check(r.initial?.rows?.[1]?.label === "翻转" && r.initial?.rows?.[1]?.control === "checkbox", "「翻转」是 WE 自带的一行");
check(labelsOf(r.initial).includes("亮度") === false, "默认不显示四个颜色滑杆(显示颜色选项未勾)");
check(
JSON.stringify(labelsOf(r.withColorOptions).slice(2, 7)) === JSON.stringify(["显示颜色选项", "亮度", "对比度", "饱和度", "色调偏移"]),
"勾上「显示颜色选项」后出现四个滑杆",
labelsOf(r.withColorOptions).slice(2, 7).join(" / "),
);
check((r.withColorOptions?.rows ?? []).filter((x) => x.control === "range").length === 5, "共 5 个滑杆(4 颜色 + 1 音量)");
check(/brightness/.test(r.afterBrightness ?? ""), "亮度落到 body 的 CSS 滤镜上", r.afterBrightness);
check(r.afterFlip === "scaleX(-1)", "翻转落到 body 的 transform 上", r.afterFlip);
check(r.initial?.outsideBody === true, "面板挂在 <html> 下(body 上的 transform/filter 不会波及它)");
console.log("\n=== ④ 取色器弹层 ===");
check(r.picker !== null, "点色块弹出取色器");
check(r.picker?.outsideBody === true, "取色器也在 body 之外(不被滤镜调色)");
check(/主题配色/.test(r.picker?.name ?? ""), "弹层标题是「主题配色」", r.picker?.name);
check(r.picker?.palette === 15, "调色板 15 个色块(3 列 × 5 行)", `${r.picker?.palette} 个`);
const official = [
"#ffffff", "#c0c0c0", "#000000",
"#ff0000", "#ffa500", "#ffff00",
"#00ff00", "#008000", "#254117",
"#add8e6", "#0000ff", "#00008b",
"#00ffff", "#800080", "#ff00ff",
];
check(
JSON.stringify(r.picker?.colors) === JSON.stringify(official),
"15 个预设色与官方逐个一致",
JSON.stringify(r.picker?.colors) === JSON.stringify(official) ? "一致" : (r.picker?.colors ?? []).join(" "),
);
check(r.picker?.checked === "#63269e" || r.picker?.checked === null, "初始颜色不是预设色时没有对勾", String(r.picker?.checked));
check(r.pickerChecked?.checked === "#ff0000", "颜色改成预设色后,对勾落到那个色块上", String(r.pickerChecked?.checked));
check(r.picker?.dropper === true, "调色板下方有吸管按钮");
check(
r.picker?.dropperDisabled === false || r.picker?.dropperDisabled === true,
r.picker?.dropperDisabled ? "吸管已禁用并说明原因(本机 CEF 无 EyeDropper API)" : "吸管可用(EyeDropper API 存在)",
r.picker?.dropperTitle,
);
check(r.picker?.hasSv === true && r.picker?.hasHue === true, "有明度/饱和度方块与色相条");
check(/^#[0-9a-f]{6}$/i.test(r.picker?.hex ?? ""), "十六进制输入框有值", r.picker?.hex);
check(JSON.stringify(r.picker?.foot) === JSON.stringify(["确认", "取消"]), "弹层底部是 确认 / 取消", (r.picker?.foot ?? []).join(" / "));
check(/^0\.\d+ 0\.\d+ 0\.\d+$/.test(r.colorAfterPick ?? ""), "确认后写回 schemecolor(r g b 浮点)", r.colorAfterPick);
check(r.pickerGone === true, "确认后弹层关闭");
console.log("\n=== ⑤ type:text 渲染成富文本说明块 ===");
const notes = r.initial?.notes ?? [];
check(notes.length >= 3, "说明块都在", `${notes.length} 个`);
check(notes.filter((n) => n.items > 0).length >= 2, "说明块里有 <ul><li> 列表");
const links = notes.flatMap((n) => n.links ?? []);
check(links.length > 0 && links.every((h) => /^https?:/.test(h ?? "")), "链接被保留且只放行 http(s)", links.join(" | "));
console.log("\n=== ⑥ condition:两个开关各管一摊 ===");
check(!(r.hidden?.notes ?? []).some((n) => n.text.includes("作者:")), "关掉「显示作者信息」→ 作者信息块消失");
check(!labelsOf(r.customAudio).includes("背景音乐选择"), "开「使用自定义音乐」→ 背景音乐选择隐藏");
check(labelsOf(r.customAudio).includes("音频文件路径"), "开「使用自定义音乐」→ 音频文件路径出现");
check(labelsOf(r.presetAudio).includes("背景音乐选择"), "关「使用自定义音乐」→ 背景音乐选择回来");
check(!labelsOf(r.presetAudio).includes("音频文件路径"), "关「使用自定义音乐」→ 音频文件路径隐藏");
console.log("\n=== ⑦ 底部 确认 / 取消 + 重置语义 ===");
check(JSON.stringify(r.initial?.foot) === JSON.stringify(["确认", "取消"]), "底部是 确认 / 取消", (r.initial?.foot ?? []).join(" / "));
check(r.afterEdit === 0.3, "改音量已实时下发", String(r.afterEdit));
check(r.afterReset === 1, "重置恢复默认值 1", String(r.afterReset));
check(r.beforeCancel === 0.2 && r.afterCancel === 1, "取消回滚到上次确认的值", `${r.beforeCancel} → ${r.afterCancel}`);
check(r.afterConfirm === 0.6, "确认保留改动", String(r.afterConfirm));
console.log("\n=== ⑧ 模拟器专属内容收进折叠区 ===");
check(r.initial?.hasDebug === true && r.initial?.debugOpen === false, "「模拟器调试」存在且默认收起");
console.log("\n=== ⑨ 下拉框(自绘,样式与官方同步)===");
check(r.comboOpen !== null, "点下拉按钮弹出列表");
check(r.comboOpen?.outsideBody === true, "弹层挂在 documentElement 下(不被 body 的滤镜波及)");
const comboItems = r.comboOpen?.items ?? [];
console.log(` 选项:${comboItems.map((x) => `${x.label}${x.on ? "(当前)" : ""}`).join(" / ")}`);
check(comboItems.length === 2, "两个预设都在列表里", `${comboItems.length} 项`);
check(comboItems.filter((x) => x.on).length === 1, "当前项恰好一个被高亮", comboItems.filter((x) => x.on).map((x) => x.label).join(""));
check(r.comboClosed === true, "选完之后弹层收起");
check(r.presetAfter === comboItems[1]?.value, "选中的值下发给壁纸", `${r.presetAfter}`);
check(r.comboLabelAfter === comboItems[1]?.label, "按钮上的文字跟着换成所选项", String(r.comboLabelAfter));
console.log("\n=== ⑩ 动效(只解释状态变化,且受 prefers-reduced-motion 管辖)===");
check(r.anim?.open === "1", "面板已滑出", String(r.anim?.open));
check(r.anim?.dataEnter === "1", "刚滑出时打了入场标记", String(r.anim?.dataEnter));
check(/wesim-row-in/.test(r.anim?.rowAnim ?? ""), "行上跑的是入场动画", r.anim?.rowAnim);
check(
(r.anim?.seq ?? []).join(",") === "0,1,2,3,4",
"行带递增的 --i(用来错开入场)",
(r.anim?.seq ?? []).join(","),
);
check(r.afterEnter === null, "入场标记播完就摘掉(不会每次重画都重播)", String(r.afterEnter));
check(/background-color|transform/.test(r.anim?.hoverTransition ?? ""), "行有悬停过渡", r.anim?.hoverTransition);
check(/wesim-pop/.test(r.anim?.pickerAnim ?? ""), "取色器弹层有出现动画", r.anim?.pickerAnim);
const entered = (r.enterFlags ?? []).filter((x) => x.enter).map((x) => x.label);
console.log(` 带入场标记的行:${entered.join(" / ") || "(无)"}`);
check(
JSON.stringify(entered) === JSON.stringify(["亮度", "对比度", "饱和度", "色调偏移"]),
"只有因条件变化新冒出来的行才播入场",
entered.join(" / "),
);
check(r.checkbox?.appearance === "none", "复选框是自绘的(原生外观已关掉)", String(r.checkbox?.appearance));
check(r.arrowOpen?.expanded === "true", "展开时按钮 aria-expanded=true");
check(/matrix\(-1/.test(r.arrowOpen?.rotated ?? ""), "箭头翻了过来", r.arrowOpen?.rotated);
check(r.arrowClosed === "false", "收起后 aria-expanded 复位", String(r.arrowClosed));
check(
parseFloat(r.reduced?.rowAnim ?? "1") < 0.01,
"prefers-reduced-motion: reduce 时入场动画被关掉",
String(r.reduced?.rowAnim),
);
check(
parseFloat(r.reduced?.rowTransition ?? "1") < 0.01,
"prefers-reduced-motion: reduce 时过渡也被关掉",
String(r.reduced?.rowTransition),
);
console.log("\n=== ⑪ 预览图(官方在设置上方就展示它)===");
check(r.preview?.present === true, "预览区已渲染", String(r.preview?.present));
check(/preview\.gif$/.test(r.preview?.src ?? ""), "指向分发根的 preview.gif", String(r.preview?.src));
check(r.preview?.natural === "160x160", "图片真的解码出来了(不是坏图)", String(r.preview?.natural));
// 官方那对左右箭头是切换"已安装的壁纸",模拟器没有那个列表——**不做**。
// 这条断言是防回归的:它们曾经存在过(当时拿"切预设"当替身,但那是另一件事)。
check(
(r.preview?.navs ?? []).length === 0,
"预览上没有左右箭头(WE 那个是切换壁纸用的,我们不需要)",
`${(r.preview?.navs ?? []).length} 个`,
);
console.log("\n=== ⑫ 单档分发里的「背景音乐选择」===");
// 规则:一个分发里可选的音源(不含「随预设」)≥2 才给下拉,否则两个选项效果一样、是废话。
check(r.singleTwoTracks?.present === true, "single-hsr-xilian(两首)有「背景音乐选择」", JSON.stringify(r.singleTwoTracks));
check(
JSON.stringify(r.singleTwoTrackItems) === JSON.stringify(["随预设", "「再度和你」", "昔涟"]),
"下拉里就是 xilian 的两首(+ 随预设)",
(r.singleTwoTrackItems ?? []).join(" / "),
);
// 默认值是 "auto"(随预设)——与合集分发一致,不是"第一首"。
// 运行时拿到 auto 就回落该壁纸的默认音源,也就是第一首,所以效果相同;
// 但值本身必须是 auto,否则用户从合集切到单档时选择会被"钉死"。
check(
r.singleTwoTracks?.value === "auto" && r.singleTwoTracks?.label === "随预设",
"默认是「随预设」(auto),与合集分发一致",
`${r.singleTwoTracks?.value} / ${r.singleTwoTracks?.label}`,
);
check(
r.singleOneTrack?.present === false,
"single-hsr-kv37(只有一首)不给这个下拉——两个选项效果完全一样",
JSON.stringify(r.singleOneTrack),
);
const noisy = (r.consoleLines ?? []).filter((l) => /error|uncaught/i.test(l));
check((r.exceptions ?? []).length === 0, "0 未捕获异常", (r.exceptions ?? []).slice(0, 2).join(" | ") || "无");
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
console.log(failures === 0 ? "\n面板还原度:通过" : `\n面板还原度:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+410
View File
@@ -0,0 +1,410 @@
// 场景播放器的门:多骨架 + 贴图平面真的合成出来了,而且**先证明它会红**。
//
// 不碰 `wallpapers/`:夹具用抓取器 staged 的 kv45/scene_main(4 骨架 + 1 平面)自造,
// 预设写成与构建产物同形的 `preset.js`(同一个 asset()/相对路径解析规则)。
//
// 前置:`python -m tools.downloader fetch --page kv45`(产物在 tools/downloader/_out/hsr/kv45/scene_main)。
// 本脚本自己跑 `tsc -p tsconfig.runtime.json`,所以不依赖 pnpm build。
import { spawn, spawnSync } from "node:child_process";
import { createServer } from "node:http";
import { createReadStream, existsSync } from "node:fs";
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { dirname, extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { withPage, sleep } from "../cdp.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const FIXTURE = join(ROOT, "tools", ".cache", "scene-player-fixture");
const STAGED = join(ROOT, "tools", "downloader", "_out", "hsr", "kv45", "scene_main");
/** 透视样本:原神页,camera type 1 + 真实景深(kv45 是正交、z 全 0,抓不住投影算错)。 */
const STAGED_PERSPECTIVE = join(ROOT, "tools", "downloader", "_out", "ys", "nico-tea", "scene_main");
const SHOT = join(ROOT, "tools", ".cache", "scene-player-shot.png");
const PORT = Number(process.env.SCENE_PORT ?? 8197);
const W = 1280;
const H = 720;
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
interface SceneDebug {
parts: number;
loaded: number;
errors: string[];
framing: { x: number; y: number; width: number; height: number } | null;
content: { x: number; y: number; width: number; height: number } | null;
spines: string[];
images: string[];
solids: number;
rotatedSkipped: number;
}
interface StagedPart {
kind: string;
id: string;
order: number;
renderOrder?: number;
position: number[];
scale: number[];
geometrySize?: number[];
geometryCenter?: number[];
rotation?: number[];
animation?: string;
skin?: string;
timeScale?: number;
}
/** 把 staged 的 scene.json 翻成 preset 形状(与 tools/lib/generate.ts 吐出的字面量同形)。 */
async function buildPresetModule(
staged: string = STAGED,
id = "kv45",
game = "hsr",
cover: string | null = "w22_slg",
): Promise<string> {
const scene = JSON.parse(await readFile(join(staged, "scene.json"), "utf8")) as {
ui?: number[];
parts: StagedPart[];
camera?: { camera?: { type?: number; fov?: number }; position?: number[] };
};
const camera = scene.camera?.camera ?? {};
const cameraLine =
camera.type === undefined
? ""
: ` camera: ${JSON.stringify({ type: camera.type, fov: camera.fov, position: scene.camera?.position })},\n`;
const parts: string[] = [];
for (const part of scene.parts) {
// 与 promote.py 同语义:纯色平面不进预设(运行时画不了它,写进去只是死配置)。
if (part.kind === "solid") continue;
const fields: string[] = [
`kind: ${JSON.stringify(part.kind)}`,
`id: ${JSON.stringify(part.id)}`,
`order: ${JSON.stringify(part.order)}`,
`position: ${JSON.stringify(part.position)}`,
`scale: ${JSON.stringify(part.scale)}`,
];
if (part.renderOrder) fields.push(`renderOrder: ${JSON.stringify(part.renderOrder)}`);
if (part.kind === "spine") {
fields.push(`jsonUrl: asset("../assets/spines/${part.id}/${part.id}.json")`);
fields.push(`atlasUrl: asset("../assets/spines/${part.id}/${part.id}.atlas")`);
} else if (part.kind === "image") {
const dir = join(staged, "scene");
const files = existsSync(dir) ? await readdir(dir) : [];
const hit = files.find((f) => f.startsWith(`${part.id}.`));
if (!hit) continue;
fields.push(`image: asset("../assets/scene/${hit}")`);
}
if (part.geometrySize) {
fields.push(`width: ${JSON.stringify(part.geometrySize[0])}`);
fields.push(`height: ${JSON.stringify(part.geometrySize[1])}`);
}
if (part.geometryCenter) fields.push(`center: ${JSON.stringify(part.geometryCenter)}`);
if (part.rotation && part.rotation.some((v) => Math.abs(v) > 1e-9)) {
fields.push(`rotation: ${JSON.stringify(part.rotation)}`);
}
if (part.animation) fields.push(`animation: ${JSON.stringify(part.animation)}`);
if (part.skin) fields.push(`skin: ${JSON.stringify(part.skin)}`);
if (part.timeScale !== undefined) fields.push(`timeScale: ${JSON.stringify(part.timeScale)}`);
parts.push(` { ${fields.join(", ")} },`);
}
return [
`const base = new URL("./", import.meta.url);`,
`const asset = (path) => new URL(path, base).href;`,
`export default {`,
` id: ${JSON.stringify(id)},`,
` name: ${JSON.stringify(id)},`,
` game: ${JSON.stringify(game)},`,
// 不设背景图:背景比例链会改取景,门要的是**纯视锥**那一条路径。
` backgroundImage: ${cover ? `asset("../assets/scene/${cover}.png")` : `""`},`,
` sceneConfig: {`,
` ui: ${JSON.stringify(scene.ui ?? [2500, 1080])},`,
cameraLine + ` parts: [`,
...parts,
` ],`,
` },`,
` audioChoices: [],`,
` audioOptions: { source: "" },`,
`};`,
``,
].join("\n");
}
/** 用 ffmpeg 把 PNG 解成 RGBA 原始像素。 */
function rawPixels(png: string): Promise<Buffer> {
return new Promise((resolvePromise) => {
const p = spawn("ffmpeg", ["-v", "error", "-i", png, "-f", "rawvideo", "-pix_fmt", "rgba", "-"], {
stdio: ["ignore", "pipe", "ignore"],
});
const chunks: Buffer[] = [];
p.stdout.on("data", (d: Buffer) => chunks.push(d));
p.on("close", () => resolvePromise(Buffer.concat(chunks)));
p.on("error", () => resolvePromise(Buffer.alloc(0)));
});
}
/** 两张同尺寸截图里有多少比例的像素不同。 */
function diffRatio(a: Buffer, b: Buffer): number {
if (!a.length || a.length !== b.length) return -1;
let different = 0;
for (let i = 0; i < a.length; i += 4) {
if (a[i] !== b[i] || a[i + 1] !== b[i + 1] || a[i + 2] !== b[i + 2] || a[i + 3] !== b[i + 3]) different++;
}
return different / (a.length / 4);
}
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".webp": "image/webp",
};
// ---------------------------------------------------------------------------------------
// 前置条件
// ---------------------------------------------------------------------------------------
console.log("场景播放器门:\n");
if (!existsSync(join(STAGED, "scene.json"))) {
console.log(` ✗ 缺少 staged 样本:${STAGED}\n 先跑:python -m tools.downloader fetch --page kv45`);
process.exit(1);
}
const build = spawnSync(process.execPath, ["node_modules/typescript/bin/tsc", "-p", "tsconfig.runtime.json"], {
cwd: ROOT,
encoding: "utf8",
});
check(build.status === 0, "运行时能编译(tsc -p tsconfig.runtime.json)", (build.stdout ?? "").trim().split("\n").pop() ?? "");
// ---------------------------------------------------------------------------------------
// 造夹具
// ---------------------------------------------------------------------------------------
await rm(FIXTURE, { recursive: true, force: true });
await mkdir(join(FIXTURE, "scripts"), { recursive: true });
await mkdir(join(FIXTURE, "styles"), { recursive: true });
await cp(STAGED, join(FIXTURE, "assets"), { recursive: true });
await cp(STAGED_PERSPECTIVE, join(FIXTURE, "assets-perspective"), { recursive: true });
for (const file of await readdir(join(ROOT, "build", "scripts"))) {
if (file.endsWith(".js")) await cp(join(ROOT, "build", "scripts", file), join(FIXTURE, "scripts", file));
}
await cp(join(ROOT, "src", "vendor", "spine-player.js"), join(FIXTURE, "scripts", "spine-player.js"));
for (const file of await readdir(join(ROOT, "src", "styles"))) {
await cp(join(ROOT, "src", "styles", file), join(FIXTURE, "styles", file));
}
const presetModule = await buildPresetModule();
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), presetModule, "utf8");
// 第二个夹具页:透视样本(把 assets 前缀改到 assets-perspective/)
const perspectiveModule = (await buildPresetModule(STAGED_PERSPECTIVE, "nico-tea", "ys", null)).replaceAll(
"../assets/",
"../assets-perspective/",
);
await writeFile(join(FIXTURE, "scripts", "scene-preset-perspective.js"), perspectiveModule, "utf8");
await writeFile(
join(FIXTURE, "scripts", "presets.js"),
[
`import preset from "./scene-preset.js";`,
`import perspective from "./scene-preset-perspective.js";`,
`export const defaultPresetId = "kv45";`,
`export default Object.fromEntries([["kv45", preset], ["nico-tea", perspective]]);`,
``,
].join("\n"),
"utf8",
);
await writeFile(
join(FIXTURE, "index.html"),
[
`<!DOCTYPE html>`,
`<html lang="zh"><head><meta charset="UTF-8" /><title>scene fixture</title>`,
`<link rel="icon" href="data:," />`,
`<link rel="stylesheet" href="./styles/spine-player.css" />`,
`<link rel="stylesheet" href="./styles/index.css" /></head>`,
`<body><div id="spine-container"></div><audio id="background-music"></audio>`,
`<script src="./scripts/spine-player.js"></script>`,
`<script type="module" src="./scripts/index.js"></script></body></html>`,
``,
].join("\n"),
"utf8",
);
check(existsSync(join(FIXTURE, "scripts", "scene-controller.js")), "夹具里带上了 scene-controller.js");
// ---------------------------------------------------------------------------------------
// 起静态服务
// ---------------------------------------------------------------------------------------
const server = createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
const rel = normalize(pathname).replace(/^([/\\])+/, "");
const target = resolve(join(FIXTURE, rel === "" ? "index.html" : rel));
if (!target.startsWith(FIXTURE)) {
res.writeHead(403).end();
return;
}
try {
const info = await stat(target);
if (info.isDirectory()) {
res.writeHead(404).end();
return;
}
res.writeHead(200, { "content-type": MIME[extname(target).toLowerCase()] ?? "application/octet-stream" });
createReadStream(target).pipe(res);
} catch {
res.writeHead(404).end();
}
});
await new Promise<void>((r) => server.listen(PORT, "127.0.0.1", () => r()));
const BASE = `http://127.0.0.1:${PORT}/`;
const readDebug = async (evaluate: (expr: string) => Promise<unknown>): Promise<SceneDebug | null> => {
const raw = await evaluate("JSON.stringify(window.__sceneDebug ?? null)");
return raw && raw !== "null" ? (JSON.parse(String(raw)) as SceneDebug) : null;
};
const waitFor = async (evaluate: (expr: string) => Promise<unknown>, predicate: (d: SceneDebug | null) => boolean, tries = 40): Promise<SceneDebug | null> => {
let last: SceneDebug | null = null;
for (let i = 0; i < tries; i++) {
last = await readDebug(evaluate);
if (predicate(last)) return last;
await sleep(250);
}
return last;
};
// ---------------------------------------------------------------------------------------
// 绿:多骨架 + 贴图平面真的合成出来
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: BASE });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= d.parts && d.parts > 0));
console.log(` · __sceneDebug = ${JSON.stringify(debug)}`);
check(Boolean(debug), "页面写出了 __sceneDebug");
check(debug?.parts === 5, "part 数 = 4 骨架 + 1 平面 = 5", debug?.parts);
check(debug?.loaded === 5, "5 件全部加载", debug?.loaded);
check((debug?.errors.length ?? 1) === 0, "0 加载错误", debug?.errors.join(" | "));
check(debug?.spines.length === 4, "4 具骨架都建了 AnimationState", debug?.spines.join(","));
check(debug?.images.length === 1, "1 块贴图平面", debug?.images.join(","));
check(Boolean(debug?.framing && debug.framing.width > 0 && debug.framing.height > 0), "取景矩形有效", JSON.stringify(debug?.framing));
// resize(Expand) 把画布像素尺寸设成 clientWidth * devicePixelRatio,所以按 dpr 比。
const canvasSize = String(
await evaluate(
`(() => { const c = document.querySelector("#spine-container canvas"); if (!c) return ""; const dpr = window.devicePixelRatio || 1; return [c.width, c.height, Math.round(c.clientWidth * dpr), Math.round(c.clientHeight * dpr)].join(","); })()`,
),
);
const [cw, ch, expectW, expectH] = canvasSize.split(",").map(Number);
check(cw === expectW && ch === expectH && cw! > 0, "画布像素尺寸 = CSS 尺寸 × dpr", canvasSize);
const glError = Number(await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (!c) return -1; const gl = c.getContext("webgl2") || c.getContext("webgl"); return gl ? gl.getError() : -1; })()`));
if (glError === -1) console.log(" · 拿不到 WebGL 上下文(类型不匹配),跳过 gl.getError 断言");
else check(glError === 0, "gl.getError() === 0", glError);
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 2).join(" | ") || "无");
// 画面非空:**隐藏画布前后截图必须不同**。
// 不能数"alpha>0 的像素"——页面自身有底色,空画布也全是不透明像素(实测 100%,是个假绿)。
const shotWith = (await send("Page.captureScreenshot", { format: "png" })) as { data: string };
await writeFile(SHOT, Buffer.from(shotWith.data, "base64"));
await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (c) c.style.visibility = "hidden"; })()`);
await sleep(300);
const shotWithout = (await send("Page.captureScreenshot", { format: "png" })) as { data: string };
const hidden = join(ROOT, "tools", ".cache", "scene-player-shot-hidden.png");
await writeFile(hidden, Buffer.from(shotWithout.data, "base64"));
const [withPixels, withoutPixels] = await Promise.all([rawPixels(SHOT), rawPixels(hidden)]);
const ratio = diffRatio(withPixels, withoutPixels);
check(ratio > 0.02, "画布确实贡献了像素(隐藏画布后画面变化 > 2%)", `${(ratio * 100).toFixed(1)}%(截图 ${SHOT})`);
await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (c) c.style.visibility = "visible"; })()`);
});
// ---------------------------------------------------------------------------------------
// 纯色平面:不能被当贴图画(原来会给 drawTexture 传 undefined 直接崩)
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
const withSolid = presetModule.replace(
` ],\n },`,
` { kind: "solid", id: "DEFAULT", order: 99, position: [0, 0, 0], scale: [1, 1, 1], width: 100, height: 100 },\n ],\n },`,
);
check(withSolid !== presetModule, "反例确实加进了一块纯色平面");
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), withSolid, "utf8");
await send("Page.navigate", { url: `${BASE}?solid=1` });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= 5));
check(debug?.solids === 1, "纯色平面被计数(solids = 1)", debug?.solids);
check(exceptions.length === 0, "纯色平面不崩(0 未捕获异常)", exceptions.slice(0, 1).join("") || "无");
});
// ---------------------------------------------------------------------------------------
// 透视场景:取景矩形必须等于**独立算出来**的视锥(这条能抓住"投影/取景算错")
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: `${BASE}?perspective=1` });
// 等运行时挂上监听器(它在 index.js 顶层注册,模块执行完才存在),再走 WE 的真实路径切预设。
let ready = false;
for (let i = 0; i < 40 && !ready; i++) {
ready = Boolean(await evaluate("typeof window.wallpaperPropertyListener === 'object' && window.wallpaperPropertyListener !== null"));
if (!ready) await sleep(250);
}
check(ready, "运行时挂上了 wallpaperPropertyListener");
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: "nico-tea" } })`);
const debug = await waitFor(evaluate, (d) => Boolean(d && d.parts > 20 && d.loaded > 20));
console.log(` · 透视 __sceneDebug = ${JSON.stringify(debug && { parts: debug.parts, loaded: debug.loaded, framing: debug.framing, rotatedSkipped: debug.rotatedSkipped })}`);
check(debug?.parts === 43, "透视样本 part 数 = 43", debug?.parts);
check((debug?.errors.length ?? 1) === 0, "透视样本 0 加载错误", debug?.errors.slice(0, 2).join(" | "));
check(debug?.rotatedSkipped === 2, "两片倾斜的 3D 面片被跳过并计数", debug?.rotatedSkipped);
check(exceptions.length === 0, "透视样本 0 未捕获异常", exceptions.slice(0, 1).join("") || "无");
// 独立算期望:s0 = 1/(tan(fov/2)·camZ);可见区 = ui 矩形 × s0;再按画布比例 contain。
const scene = JSON.parse(await readFile(join(STAGED_PERSPECTIVE, "scene.json"), "utf8")) as {
ui?: number[];
camera?: { camera?: { fov?: number }; position?: number[] };
};
const fov = scene.camera?.camera?.fov ?? 31.417;
const camZ = scene.camera?.position?.[2] ?? 1920;
const uiSize = scene.ui ?? [2500, 1080];
const uiW = uiSize[0] ?? 2500;
const uiH = uiSize[1] ?? 1080;
const s0 = 1 / (Math.tan((fov * Math.PI) / 360) * camZ);
const aspect = W / H;
// 复刻引擎 `resizeUI`:可见矩形 = UI 矩形按画布比收缩一个轴(b<1 收宽,否则收高)。
const b = aspect / (uiW / uiH);
const visW = b < 1 ? uiW * b : uiW;
const visH = b < 1 ? uiH : uiH / b;
const expectedW = visW * s0;
const expectedH = visH * s0;
const framing = debug?.framing;
const close = (a: number | undefined, b: number) => typeof a === "number" && Math.abs(a - b) / b < 1e-6;
check(close(framing?.width, expectedW), `取景宽 = 视锥宽(独立算 ${expectedW.toFixed(4)})`, framing?.width);
check(close(framing?.height, expectedH), `取景高 = 视锥高(独立算 ${expectedH.toFixed(4)})`, framing?.height);
check(close(framing?.x, -expectedW / 2) && close(framing?.y, -expectedH / 2), "取景以场景原点为中心", JSON.stringify(framing));
});
// ---------------------------------------------------------------------------------------
// 红:把一具骨架的 atlas 指错 → 必须报出来
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate }) => {
const broken = presetModule.replace(
`atlasUrl: asset("../assets/spines/01_beijing/01_beijing.atlas")`,
`atlasUrl: asset("../assets/spines/01_beijing/does-not-exist.atlas")`,
);
check(broken !== presetModule, "反例确实改动了 atlasUrl");
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), broken, "utf8");
await send("Page.navigate", { url: `${BASE}?red=1` });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.errors.length > 0), 20);
console.log(` · 反例 __sceneDebug = ${JSON.stringify(debug)}`);
check(Boolean(debug && debug.errors.length > 0), "atlas 指错 → 报出加载错误", debug?.errors.slice(0, 1).join(""));
check(Boolean(debug && debug.loaded < (debug?.parts ?? 0)), "atlas 指错 → 少加载一件", `${debug?.loaded}/${debug?.parts}`);
});
// ---------------------------------------------------------------------------------------
// 还原:必须重新变绿(证明红是那处改动引起的)
// ---------------------------------------------------------------------------------------
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), presetModule, "utf8");
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate }) => {
await send("Page.navigate", { url: `${BASE}?restore=1` });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= d.parts && d.parts > 0));
check(debug?.loaded === 5 && (debug?.errors.length ?? 1) === 0, "还原 atlasUrl → 重新全绿", `${debug?.loaded}/${debug?.parts}`);
});
server.close();
console.log(`\n夹具留在 ${FIXTURE}(可直接起服务器用眼睛看)`);
console.log(failures === 0 ? "\n场景播放器门:通过" : `\n场景播放器门:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+153
View File
@@ -0,0 +1,153 @@
// 验收 `pnpm build --sim`:把产出的自包含包用 file:// 直接打开,确认
// ① 骨架真的画出来了(canvas 出现且有非空像素)
// ② 0 网络请求失败、0 异常、0 控制台报错
// ③ 模拟器面板在,且明确标着"模拟环境"
// ④ 音频能播(data: URL 内联)
// ⑤ 全程没有任何 http(s) 请求("自包含"的硬定义)
//
// 用法:node tools/checks/verify-sim-page.mts [分发目录名]
import { withPage, sleep } from "../cdp.mjs";
import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
const dirName = process.argv[2] ?? "single-hsr-kv37";
const pageAbs = resolve("dist/releases", dirName, "sim/index.html");
if (!existsSync(pageAbs)) {
console.error(`找不到 ${pageAbs}(先跑 node tools/build.ts --single kv37 --sim)`);
process.exit(1);
}
const fileUrl = "file:///" + pageAbs.replace(/\\/g, "/");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
console.log(`自包含包:${pageAbs}`);
console.log(`体积:${(statSync(pageAbs).size / 1024 / 1024).toFixed(1)} MB\n`);
await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
const rawSend = send;
await rawSend("Page.navigate", { url: fileUrl });
const deadline = Date.now() + 45000;
let ready = false;
while (Date.now() < deadline) {
const canvasCount = await evaluate('document.querySelectorAll("#spine-container canvas").length');
if (canvasCount > 0) {
ready = true;
break;
}
await sleep(250);
}
await sleep(2500);
// ⑤ "自包含"的硬定义:这个文档取过的资源里不能有任何 http(s)。
// 用页面侧的 Resource Timing,不需要 CDP 的事件通道。
const externalRequests = await evaluate(`(function () {
return performance.getEntriesByType("resource")
.map(function (e) { return e.name; })
.filter(function (n) { return /^https?:/i.test(n); });
})()`);
check(
Array.isArray(externalRequests) && externalRequests.length === 0,
"全程没有任何 http(s) 请求(自包含的硬定义)",
Array.isArray(externalRequests) && externalRequests.length > 0 ? externalRequests.slice(0, 3).join(", ") : "无",
);
const state = await evaluate(`(function () {
var canvas = document.querySelector("#spine-container canvas");
var out = {
canvas: document.querySelectorAll("#spine-container canvas").length,
canvasSize: canvas ? canvas.clientWidth + "x" + canvas.clientHeight : null,
bg: (document.body.style.backgroundImage || "").slice(0, 24),
bgIsData: /^url\\(["']?data:/.test(document.body.style.backgroundImage || ""),
sim: !!window.__weSim,
banner: document.getElementById("wesim") ? document.querySelector("#wesim .banner").textContent.trim() : null,
// 自包含页在 <分发根>/sim/ 下,预览图必须已被内联成 data: URL——
// file:// 下 ../preview.gif 是跨目录,读不到。
previewSrc: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.getAttribute("src").slice(0, 5) : null; })(),
previewNatural: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.naturalWidth + "x" + i.naturalHeight : null; })(),
modules: window.__weModules ? Object.keys(window.__weModules).length : 0,
assetKeys: window.__simAssets ? Object.keys(window.__simAssets).length : 0,
audioSrcIsData: false,
audioDuration: null
};
var a = document.getElementById("background-music");
if (a) {
out.audioSrcIsData = /^data:/.test(a.src || "");
out.audioDuration = isFinite(a.duration) ? a.duration : null;
out.audioError = a.error ? a.error.code : null;
}
return out;
})()`);
console.log("=== 渲染 ===");
check(ready, "出现 Spine 画布", `canvas=${state.canvas}`);
check(state.canvas === 1, "只有一张 canvas", `canvas=${state.canvas}`);
check(state.canvasSize === "1920x1080", "画布尺寸等于窗口", state.canvasSize);
// 画布真的画了东西:跨帧采样非透明比例。
//
// 两个坑叠在一起,先前两次都判成了"空白":
// ① 页面里有两张 canvas——spine 自己建的那张(承载渲染)和一张 300×150 的默认尺寸空画布。
// 按 `#spine-container canvas` 取到的是后者,尺寸就不对。
// ② 播放器用 `preserveDrawingBuffer: false`(默认),**帧外**读像素拿到的永远是已清空的缓冲。
// 只读一次必然是 0%,与画没画无关。必须在 rAF 里连续采样、取历史最大值。
await evaluate(`(function () {
var list = [].slice.call(document.querySelectorAll("#spine-container canvas"));
var canvas = list.sort(function (a, b) { return b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight; })[0];
if (!canvas) { window.__px = { error: "没有 canvas" }; return; }
var gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
if (!gl) { window.__px = { error: "拿不到 WebGL 上下文" }; return; }
window.__px = { best: 0, frames: 0, w: canvas.width, h: canvas.height, count: list.length };
var buf = new Uint8Array(canvas.width * canvas.height * 4);
var tick = function () {
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, buf);
var n = 0;
for (var i = 3; i < buf.length; i += 4) if (buf[i] > 8) n++;
var ratio = n / (canvas.width * canvas.height);
if (ratio > window.__px.best) window.__px.best = ratio;
window.__px.frames++;
if (window.__px.frames < 90) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
})()`);
await sleep(2500);
const pixels = await evaluate("window.__px");
check(
pixels !== null && pixels.best !== undefined && pixels.best > 0.02,
"画布上有实际像素(不是空白)",
pixels === null
? "没有 canvas"
: pixels.error
? pixels.error
: `非透明占比 ${((pixels.best ?? 0) * 100).toFixed(1)}%(${pixels.w}x${pixels.h},采样 ${pixels.frames} 帧)`,
);
console.log("\n=== 自包含性 ===");
check(state.bgIsData, "背景图是 data: URL(没有外部文件依赖)", state.bg);
check(state.assetKeys > 0, "内联资源表已建立", `${state.assetKeys} 个 key`);
check(state.modules >= 9, "模块注册表已建立", `${state.modules} 个模块`);
check(state.audioSrcIsData, "音频是 data: URL(内联)");
check(state.audioDuration !== null && state.audioDuration > 60, "音频可解码", state.audioDuration ? `${state.audioDuration.toFixed(0)}s` : `error=${state.audioError}`);
check(pageAbs.length > 0 && !/^https?:/.test(state.bg), "没有指向 http(s) 的资源");
console.log("\n=== 模拟器 ===");
check(state.sim, "window.__weSim 存在");
check(/模拟环境/.test(state.banner ?? ""), "面板标着「模拟环境」", state.banner);
// 自包含包里的预览图:必须是内联的 data: URL,而且要真的解码出来(坏图也会是 data:)。
if (state.previewSrc !== null) {
check(state.previewSrc === "data:", "预览图已内联成 data: URL", state.previewSrc);
check(state.previewNatural === "160x160", "内联的预览图能解码", state.previewNatural);
}
console.log("\n=== 干净度 ===");
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 3).join(" | ") || "无");
const noisy = consoleLines.filter((l) => /error|failed|blocked|CORS/i.test(l));
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
});
console.log(failures === 0 ? "\n自包含包可用:双击即可打开,无需 pnpm dev,也无需任何 http 服务" : `\n自包含包验收失败:${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+157
View File
@@ -0,0 +1,157 @@
// 静态预览验收:证明 WE 模拟面板**不依赖调试服**,且是**右边缘滑出**的。
//
// 为什么要有这个测试:
// ① 面板曾经只在调试服注入时才存在。用户要求 GitHub Pages 预览也能弹出它——
// 静态托管没有调试服,所以必须由 `--with-sim` 把驱动**注入 index.html**。
// ② Pages 把站点放在 `/<repo>/` **子路径**下。驱动若用根绝对路径 `/scripts/…`,
// 在子路径下必然 404。所以这个测试刻意把分发挂在 `/repo/` 前缀后面服务,
// 而不是挂在根上——挂根上测不出这个错。
//
// 用法:node tools/checks/verify-static-preview.mts [分发目录名]
// 前提:已经 `pnpm build --with-sim`。
import { createServer } from "node:http";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import { withPage, sleep } from "../cdp.mjs";
const DIR = process.argv[2] ?? "collection-all";
const PORT = Number(process.env.STATIC_PORT ?? 8195);
const ROOT = resolve("dist/releases");
/** 模拟 GitHub Pages 的 /<repo>/ 子路径。**不能**是空串,否则测不出根绝对路径的错。 */
const PREFIX = "/repo";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".flac": "audio/flac",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
};
const notFound: string[] = [];
const server = createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
if (!pathname.startsWith(`${PREFIX}/`)) {
notFound.push(pathname);
res.writeHead(404).end("outside prefix");
return;
}
let rel = pathname.slice(PREFIX.length + 1);
if (rel === "" || rel.endsWith("/")) rel += "index.html";
const file = join(ROOT, normalize(rel));
if (!file.startsWith(ROOT)) {
res.writeHead(403).end("escape");
return;
}
const info = await stat(file).catch(() => null);
if (!info?.isFile()) {
notFound.push(pathname);
res.writeHead(404).end("not found");
return;
}
res.writeHead(200, { "content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream", "content-length": info.size });
createReadStream(file).pipe(res);
});
await new Promise<void>((r) => server.listen(PORT, "127.0.0.1", () => r()));
const BASE = `http://127.0.0.1:${PORT}${PREFIX}/${DIR}/`;
console.log(`静态托管(模拟 Pages 子路径):${BASE}\n`);
const W = 1920;
const H = 1080;
const EDGE_X = W - 8; // 16px 热区之内
const AWAY_X = 200; // 远离右边缘
const result = await withPage({ width: W, height: H, profile: "tools/.cache/edge-static" }, async ({ send, evaluate, consoleLines, exceptions }) => {
const move = async (x, y) => {
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none", buttons: 0 });
await sleep(320); // > 面板 .18s 过渡
};
const open = () => evaluate(`document.getElementById("wesim").dataset.open`);
await send("Page.navigate", { url: BASE });
await sleep(3500);
const initial = await evaluate(`(function(){
var p = document.getElementById("wesim");
var e = document.getElementById("wesim-edge");
return {
sim: !!window.__weSim,
isSimulated: window.__weSim ? window.__weSim.__isSimulated : null,
driverOnce: window.__weSimDriver === true,
panel: !!p, edge: !!e,
open: p ? p.dataset.open : null,
banner: p && p.querySelector(".banner") ? p.querySelector(".banner").textContent : null,
canvas: document.querySelectorAll("canvas").length,
hasToggle: !!document.querySelector("#wesim .toggle")
};
})()`);
// ── 交互:右边缘滑出 ──
await move(AWAY_X, H / 2);
const closedInitially = await open();
await move(EDGE_X, H / 2);
const openedByEdge = await open();
await move(AWAY_X, H / 2);
const closedByLeave = await open();
await move(EDGE_X, H / 2);
const reopened = await open();
// 取消收起:此刻真实指针就停在右边缘热区里。没有 armed 规则的话,
// 面板滑走会立刻让边缘命中再次触发,表现为"取消关不掉"。
// (官方面板没有 ×,底部是确认/取消,所以这里点「取消」。)
await evaluate(`document.querySelector("#wesim .foot button:not(.primary)").click()`);
await sleep(400);
const afterClose = await open();
// 离开边缘再回来 → 重新武装
await move(AWAY_X, H / 2);
await move(EDGE_X, H / 2);
const afterRearm = await open();
return { initial, closedInitially, openedByEdge, closedByLeave, reopened, afterClose, afterRearm, consoleLines, exceptions };
});
const r = result;
console.log("=== ① 静态托管下模拟器可用(无调试服)===");
check(r.initial.sim === true, "window.__weSim 存在(驱动已随产物注入)");
check(r.initial.isSimulated === true, "window.__weSim.__isSimulated = true");
check(r.initial.driverOnce === true, "驱动幂等标记已置位");
check(r.initial.panel === true, "#wesim 面板已挂载");
check(r.initial.edge === true, "#wesim-edge 右边缘热区已挂载");
check(r.initial.hasToggle === false, "没有常驻开关按钮(打开方式只有右边缘)");
check(/模拟环境/.test(r.initial.banner ?? ""), "面板有「模拟环境」横幅", r.initial.banner?.trim());
check(r.initial.canvas > 0, "壁纸本身也渲染了", `canvas=${r.initial.canvas}`);
console.log("\n=== ② 右边缘滑出交互 ===");
check(r.closedInitially === "0", "初始收起", `data-open=${r.closedInitially}`);
check(r.openedByEdge === "1", "指针移到右边缘 → 滑出", `data-open=${r.openedByEdge}`);
check(r.closedByLeave === "0", "指针离开 → 自动收回", `data-open=${r.closedByLeave}`);
check(r.reopened === "1", "再次移到右边缘 → 再次滑出", `data-open=${r.reopened}`);
check(r.afterClose === "0", "「取消」收起后不会被边缘热区立刻弹回", `data-open=${r.afterClose}`);
check(r.afterRearm === "1", "离开边缘后再回来 → 重新可触发", `data-open=${r.afterRearm}`);
console.log("\n=== ③ 子路径下没有 404(相对路径正确)===");
check(notFound.length === 0, "静态服务器 0 个 404", notFound.slice(0, 3).join(" | ") || "无");
const noisy = r.consoleLines.filter((l) => /error|uncaught/i.test(l));
check(r.exceptions.length === 0, "0 未捕获异常", r.exceptions.slice(0, 2).join(" | ") || "无");
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
server.close();
console.log(failures === 0 ? "\n静态预览:通过(面板不依赖调试服,且随右边缘滑出)" : `\n静态预览:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,79 @@
// 视觉等价验收:当前 dist 与**重构前**的存档截图逐像素比对。
//
// 为什么需要它:`tools/checks/verify-sim-page.mts` 只证明"自包含包能跑",不证明"画面没变"。
// 内联、模块合成、URL 重写这些改动都可能悄悄改掉渲染结果。
//
// 判据:mean|diff| = 0 且 max|diff| = 0(逐像素相同)。
// 注意 `tools/capture.mjs` 打印的 "spine 层贡献" 是**同一轮里 ref 帧 vs 主帧**的差
// (即"spine 层占了多少像素"),不是与基线的比对——我一开始把它读成了后者,
// 白紧张了一场。真正的基线比对只有这里。
//
// 用法:
// node tools/serve.mjs 8190 --root dist/releases/collection-all
// node tools/capture.mjs visual-current --base http://127.0.0.1:8190
// node tools/checks/verify-visual-equivalence.mts
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
const BASELINE = "tools/shots/baseline-pre-refactor";
const CURRENT = process.argv[2] ?? "tools/shots/visual-current";
const combos: { preset: string; size: string }[] = [];
for (const preset of ["kv37", "xilian"]) {
for (const size of ["3440x1440", "1920x1080", "1080x1920"]) combos.push({ preset, size });
}
function diff(a, b) {
const out = execFileSync("node", ["tools/diff.mjs", a, b], { encoding: "utf8" });
return out.trim().split("\n").pop() ?? "";
}
/*
* 缺文件时**直接失败**,不跳过。
*
* 这里原来写的是"缺少文件就跳过",于是有一次当前截图目录被清掉之后,
* 它把 0 组比对报成了"0 组全部逐像素相同"——一句绿色的空话。
* 一个不检查前置条件的验证等于没有验证:先把该在的文件都确认在,再谈结论。
*/
const missing: string[] = [];
for (const { preset, size } of combos) {
for (const file of [`${BASELINE}/${preset}-${size}.png`, `${CURRENT}/${preset}-${size}.png`]) {
if (!existsSync(file)) missing.push(file);
}
}
if (missing.length > 0) {
console.error(`✗ 缺少 ${missing.length} 个截图,无法比对:`);
for (const file of missing.slice(0, 4)) console.error(` ${file}`);
if (missing.length > 4) console.error(` …还有 ${missing.length - 4} 个`);
console.error(`
基线在 ${BASELINE}(重构前存档,不能重新生成)。
当前截图需要现拍:
node tools/serve.mjs 8190 --root dist/releases/collection-all
node tools/capture.mjs visual-current --base http://127.0.0.1:8190
`);
process.exit(1);
}
let failed = 0;
let checked = 0;
for (const { preset, size } of combos) {
const a = `${BASELINE}/${preset}-${size}.png`;
const b = `${CURRENT}/${preset}-${size}.png`;
checked++;
const line = diff(a, b);
const identical = /mean\|diff\|=0\/255\s+max\|diff\|=0/.test(line);
if (!identical) failed++;
console.log(` ${identical ? "✓" : "✗"} ${preset.padEnd(7)} ${size.padEnd(11)} ${line}`);
}
// 走到这里 checked 必然等于 combos.length(上面缺文件就退了),这条是防回归的兜底。
if (checked !== combos.length) {
console.error(`✗ 只比对了 ${checked}/${combos.length} 组,结论不可信`);
process.exit(1);
}
console.log(
failed === 0
? `\n视觉等价:${checked} 组全部与重构前逐像素相同`
: `\n视觉等价:${failed}/${checked} 组与基线不同`,
);
process.exit(failed === 0 ? 0 : 1);