Files
SpineWallpaper/tools/checks/test-transform-module.mts
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

141 lines
7.6 KiB
TypeScript
Raw Permalink 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.
// export 改写的**单元测试**:直接调用 tools/lib/bundle.ts 里真正在用的那个函数。
//
// 六个坑,每个都对应一次真实事故,所以每个都有一条断言:
// ① 字符串替换里的 `$1exports.` 被解析成捕获组 "1e" → "exports." 整段消失
// ② 两条规则互相截胡(`export function` 先被吃成 `exports.function`,function 留在原地)
// ③ V8 把声明关键字吞掉 → `exports.foo(a) {`
// ④ 嵌套可选组让捕获位置漂移 → `exports.function resolveViewport(...)`
// ⑤ `export default {`(对象字面量)没有匹配到任何规则 → `exports.default {`
// ⑥ **改写毁掉本地绑定**:`export const X = 1` 直接变成 `exports.X = 1`,
// 于是同模块里别处引用 `X` 变成 ReferenceError。真事故:`frameForAspect` 的
// 默认参数 `referenceAspect = REFERENCE_ASPECT` 直到渲染第一帧才求值——
// 加载、尺寸、资源全部正常,只有画面是白的。所以现在一律保留声明原文,
// 导出推迟到模块体末尾。下面有专门一条断言盯着"声明必须还在"。
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { transformModuleForTest } from "../lib/bundle.ts";
function body(code: string) {
// 去掉包装体,只留模块体,方便断言
const m = /^__weModules\["__test__"\] = \(function \(\) \{\n const exports = \{\};\n([\s\S]*)\n return exports;\n\}\)\(\);\n$/.exec(code);
if (!m) throw new Error("包装体结构变了,测试需要同步更新");
return (m[1] ?? "")
.split("\n")
.map((l) => (l.startsWith(" ") ? l.slice(2) : l))
.join("\n");
}
function parses(code) {
const child = spawnSync(
process.execPath,
["--experimental-vm-modules", "--no-warnings", "-e", 'new (require("node:vm").SourceTextModule)(require("node:fs").readFileSync(0, "utf8"));'],
{ input: code, encoding: "utf8" },
);
return child.status === 0 ? null : (child.stderr || "").split("\n").find((l) => /SyntaxError|ReferenceError/.test(l)) ?? "解析失败";
}
const cases: [string, string][] = [
// [输入, 期望输出(精确,含空格)]
// 声明原文必须**逐字保留**,导出在末尾统一赋值——见文件头 ⑥。
["export default class C {}", "class C {}\nexports.default = C;"],
["export default Presets;", "exports.default = Presets;"],
["export default {", "exports.default = {"],
["export default function f(a) {}", "function f(a) {}\nexports.default = f;"],
["export default async function f(a) {}", "async function f(a) {}\nexports.default = f;"],
["export const x = 1;", "const x = 1;\nexports.x = x;"],
["export let y = 2;", "let y = 2;\nexports.y = y;"],
["export function resolveViewport(v) {}", "function resolveViewport(v) {}\nexports.resolveViewport = resolveViewport;"],
["export async function load(u) {}", "async function load(u) {}\nexports.load = load;"],
["export class PresetController {}", "class PresetController {}\nexports.PresetController = PresetController;"],
// 含 "exports" 的属性访问绝不能被误改
["class A {\n exports2 = 1;\n}", "class A {\n exports2 = 1;\n}"],
["obj.exports = 1;\nobj.exports.foo = 2;", "obj.exports = 1;\nobj.exports.foo = 2;"],
["export const a = 1;\nexport const b = 2;\n", "const a = 1;\nconst b = 2;\n\nexports.a = a;\nexports.b = b;"],
['export const defaultPresetId = "kv37";', 'const defaultPresetId = "kv37";\nexports.defaultPresetId = defaultPresetId;'],
];
let failed = 0;
const report = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failed++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail ? `\n ${detail}` : ""}`);
};
console.log("=== 单元断言(调真实实现) ===");
for (const [input, expected] of cases) {
const actual = body(transformModuleForTest(input));
report(
actual === expected,
JSON.stringify(input),
actual === expected ? undefined : `得到 ${JSON.stringify(actual)}\n 期望 ${JSON.stringify(expected)}`,
);
}
// ⑥ 的专用断言:声明必须原样活着。这条比"导出键对得上"更根本——
// 导出键对了、本地绑定没了,症状是运行到某一帧才炸,最难查。
console.log("\n=== 本地绑定必须活着(⑥) ===");
const bindingCases: [string, string][] = [
["export const X = 16 / 9;\nfunction f(a = X) { return a; }", "X"],
["export function f() {}\nfunction g() { return f(); }", "f"],
["export class C {}\nnew C();", "C"],
];
for (const [input, name] of bindingCases) {
const actual = body(transformModuleForTest(input));
// 声明本身要在(`const X =` / `function f(` / `class C `),不能只剩 exports.X = X
const declared = new RegExp(`(?:^|\\n)(?:const|let|var|async function|function|class) ${name}\\b`).test(actual);
report(declared, `${JSON.stringify(input)} 里 ${name} 的声明还在`, declared ? undefined : `实际 ${JSON.stringify(actual)}`);
}
// 声明与赋值都在的话,真跑一遍也不能抛 ReferenceError
console.log("\n=== 改写后可执行(不是只可解析) ===");
const runnable = [
"export const REFERENCE_ASPECT = 16 / 9;\nexport function frameForAspect(a = REFERENCE_ASPECT) { return a; }",
"export function helper() { return 42; }\nexport const value = helper();",
"export class Base {}\nexport const instance = new Base();",
];
for (const input of runnable) {
const code = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(input)}\n`;
const child = spawnSync(process.execPath, ["--input-type=module", "--no-warnings", "-e", code], { encoding: "utf8" });
const ok = child.status === 0;
report(ok, `${JSON.stringify(input.slice(0, 46))}… 执行不抛错`, ok ? undefined : (child.stderr || "").split("\n").slice(0, 3).join(" | "));
}
console.log("\n=== 真实产物:改写后必须可解析 ===");
const files = [ "build/scripts/index.js",
"build/scripts/viewport-fitter.js",
"build/scripts/preset-controller.js",
"build/scripts/wallpaper-engine.js",
"dist/releases/single-hsr-kv37/scripts/presets.js",
// 生成的 preset.js 里是 `export default {` 对象字面量——最容易漏的一类
"dist/releases/single-hsr-kv37/preset.js",
];
for (const file of files) {
let source;
try {
source = readFileSync(file, "utf8");
} catch {
report(false, `${file} 不存在`);
continue;
}
const wrapped = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(source)}\n`;
const problem = parses(wrapped);
report(problem === null, `${file} 改写后可解析`, problem ?? undefined);
}
// 关键:改写后 exports 的键必须与源码里的默认导出/具名导出对得上
console.log("\n=== 语义断言:导出的键不能丢 ===");
const semantic: [string, string[]][] = [
["export default { id: 1 };", ["default"]],
["export default Presets;", ["default"]],
["export default class C {}", ["default"]],
["export default function f() {}", ["default"]],
["export const a = 1;\nexport function b() {}", ["a", "b"]],
];
for (const [input, keys] of semantic) {
const actual = body(transformModuleForTest(input));
const missing = keys.filter((k) => !new RegExp(`^exports\\.${k}\\b`, "m").test(actual));
report(missing.length === 0, `${JSON.stringify(input)} 导出 ${JSON.stringify(keys)}`, missing.length ? `缺 ${JSON.stringify(missing)},实际 ${JSON.stringify(actual)}` : undefined);
}
console.log(failed === 0 ? "\nexport 改写:全部通过" : `\nexport 改写:${failed} 处失败`);
process.exit(failed === 0 ? 0 : 1);