// 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);