Files
SpineWallpaper/tools/checks/verify-panel.mts
T
Shuery 3f11426964 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 处引用自包含。
2026-10-02 01:27:02 +08:00

550 lines
28 KiB
TypeScript
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.
// 面板还原度验收:模拟器的设置面板必须长得像 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);