Files
SpineWallpaper/.scratch/test-acceptance.mjs
T
Shuery 0ca78ed117 feat: 音频改回无损 FLAC(用户决定:壁纸在本地,不计体积)
撤销 issue 02 里的音频转码,把两条 FLAC 放回 dist。

- 恢复方式是**字节级还原**而非重新编码:masters/ 里两份文件的 git 对象哈希与基线
  `d8c7e49:dist/assets/audios/*.flac` 完全一致,即与已发布的 v2 是同一份文件。
  因此本次提交不新增任何 blob 存储——这两份内容早已在仓库历史里。
- `preset.js` 的音源指向改回 `.flac`。`pv37.mp3` 保持原样:它**没有无损源**,
  把有损数据转封装成 FLAC 只会涨体积,而一个 bit 的信息都不会回来。
- 体积基准由「≤35 MB」改为「不超过已发布的 v2(104.98 MB)」,实际 **96.94 MB**,
  比 v2 还小(图片部分的无损 WebP 收益仍在),不引入新的上传或订阅风险。

顺带修掉一个自己引入的 bug:改 `preset.js` 时漏写了文件名里的 `「」`,
浏览器请求 404 → `<audio>` 在 **64 ms** 内报 `MediaError.code = 4`(SRC_NOT_SUPPORTED),
看上去像"CEF 不支持 FLAC",实际是路径不存在。判定方法记进 ADR 0004:
把同一份字节交给 `decodeAudioData`,能解出正确时长就说明解码器没问题,问题在传输或路径。

验证:
- `test-props`:CEF 支持 audio/flac、无解码错误、readyState=4、时长 205.1 s,14 项音源断言全过。
- `test-acceptance` 新增第 ⑤ 节音频格式不变式:xilian 两个音源全为 flac、无 `.ogg`/`.opus`
  残留、`preset.js` 引用的每个音源文件都存在;三种比例 0 报错;预设往返不丢音频设置。
- 分带能量实测:FLAC 高频平滑延伸到 22 kHz(20–22 kHz 处 −50 dB),无有损断崖;
  与 Opus 版逐带相差 0.0–0.3 dB。即这是一个由偏好驱动、而非由测量差异驱动的决定。
2026-09-19 20:36:58 +08:00

174 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 "../tools/cdp.mjs";
let failures = 0;
const check = (ok, msg, detail) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${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 files = walk("dist");
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("dist/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("dist/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);
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({
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 = [
["dist/assets/崩坏:星穹铁道/昔涟立绘/audios", "flac", "昔涟立绘(无损)"],
["dist/assets/崩坏:星穹铁道/「成为昨日的明天」/audios", "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);
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(/asset\("(\.\/audios\/[^"]+)"\)/g)) {
const target = join(pf, "..", m[1]);
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);