把项目从「手写 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 处引用自包含。
177 lines
8.6 KiB
JavaScript
177 lines
8.6 KiB
JavaScript
// 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);
|