把项目从「手写 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 处引用自包含。
141 lines
7.6 KiB
TypeScript
141 lines
7.6 KiB
TypeScript
// 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);
|