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:
1 parent
b8eee05d78
commit
3f11426964
297 files changed
+216627
-1926
No files matched your search
@@ -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` 真的变了。两者缺一不可。
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user