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,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);
|
||||
Reference in new issue
Block a user