// 抓取器(tools/downloader/)的门:**先证明它会红**。 // // 四件事,缺一不可: // 1. 语法门:整个包能编译(Python 不在 `pnpm check` 的任何一道门里,这是它唯一的机会) // 2. 绿:自造一个形状正确的产物夹具 → `verify` 必须退出 0 // 3. 红:把夹具弄坏几处 → `verify` 必须退出非 0(点名的必须是**刚弄坏的那处**) // 4. 还原:必须重新变绿(证明红是那处改动引起的,不是夹具本身坏的) // // 夹具是自造的,不依赖 `_out/`(那是 gitignore 的 staging,别人机器上不一定有),也不联网。 // 形状 = 「一个场景一个可直接搬走的壁纸目录」(见 tools/downloader/layout.py)。 import { spawnSync } from "node:child_process"; import { mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const PY = process.env.PYTHON ?? "python"; const FIXTURE = join(ROOT, "tools", ".cache", "check-downloader"); let failures = 0; const check = (ok: unknown, label: string, detail?: unknown) => { if (!ok) failures++; console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`); }; /** 跑一条 python 命令,返回 { code, out }。 */ function run(args: string[], cwd = ROOT): { code: number; out: string } { const result = spawnSync(PY, args, { cwd, encoding: "utf8", env: { ...process.env, PYTHONIOENCODING: "utf-8" } }); if (result.error) { throw new Error(`跑不动 ${PY}:${result.error.message}(设 PYTHON 环境变量可指定解释器)`); } return { code: result.status ?? -1, out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() }; } const PAGE = () => join(FIXTURE, "ys", "fixture-page"); const SCENE = () => join(PAGE(), "scene_main"); const sceneJson = () => join(SCENE(), "scene.json"); const presetJson = () => join(SCENE(), "preset.template.json"); const metaJson = () => join(SCENE(), "meta.json"); const heroPng = () => join(SCENE(), "spines", "hero", "hero.png"); /** 造一个形状正确的产物夹具:一页、一个场景目录、一具骨架、一块贴图平面。 */ function writeFixture(dir: string): void { rmSync(dir, { recursive: true, force: true }); const scene = SCENE(); const spine = join(scene, "spines", "hero"); mkdirSync(spine, { recursive: true }); mkdirSync(join(scene, "scene"), { recursive: true }); mkdirSync(join(scene, "audios"), { recursive: true }); writeFileSync( join(PAGE(), "page.json"), JSON.stringify({ id: "fixture-page", game: "ys", url: "https://example.invalid/", scenes: [{ id: "scene_main", dir: "scene_main", spines: 1, images: 1, solids: 1 }], chosenScene: "scene_main", chosenDir: "scene_main", sceneDirs: [{ dir: "scene_main", scene: "scene_main", spines: 1, images: 1, solids: 1, parts: 2, bytes: 0 }], skippedScenes: [], totalBytes: 0, }), ); writeFileSync( sceneJson(), JSON.stringify({ version: 1, id: "scene_main", parts: [ { kind: "spine", id: "hero", order: 0, position: [0, 0, 0], scale: [1, 1, 1] }, { kind: "image", id: "sky", order: 1, position: [0, 0, 0], scale: [1, 1, 1], geometrySize: [100, 50] }, { kind: "solid", id: "DEFAULT", order: 2, position: [0, 0, 0], scale: [1, 1, 1] }, ], }), ); writeFileSync( presetJson(), JSON.stringify({ backgroundImage: "./scene/sky.png", sceneConfig: { ui: [100, 100], parts: [ { kind: "spine", id: "hero", order: 0, position: [0, 0, 0], scale: [1, 1, 1], jsonUrl: "./spines/hero/hero.json", atlasUrl: "./spines/hero/hero.atlas", }, { kind: "image", id: "sky", order: 1, position: [0, 0, 0], scale: [1, 1, 1], width: 100, height: 50, image: "./scene/sky.png", }, ], }, }), ); writeFileSync( metaJson(), JSON.stringify({ id: "scene_main", name: "夹具页(scene_main)", title: "夹具页(scene_main)", description: "夹具", game: "ys", page: "fixture-page", scene: "scene_main", source: "https://example.invalid/", audio: { choices: [] }, }), ); writeFileSync( join(spine, "hero.json"), JSON.stringify({ skeleton: { spine: "4.2.43", images: "" }, bones: [], slots: [] }), ); writeFileSync(join(spine, "hero.atlas"), "hero.png\nsize:8,8\nfilter:Linear,Linear\nbody\nbounds:0,0,4,4\n"); writeFileSync(heroPng(), Buffer.from([0x89, 0x50, 0x4e, 0x47])); writeFileSync(join(spine, "meta.json"), JSON.stringify({ name: "hero", spine: "4.2.43", pages: ["hero.png"] })); writeFileSync(join(scene, "scene", "sky.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); } console.log("抓取器门:\n"); // ① 前置条件:解释器与包目录都在 const pre = run(["-c", "import sys; print(sys.version.split()[0])"]); check(pre.code === 0, `${PY} 可用`, pre.out.split("\n")[0] || pre.out); if (pre.code !== 0) { console.log("\n前置条件不满足,无法继续。"); process.exit(1); } // ② 语法门 const compile = run(["-m", "compileall", "-q", "tools/downloader"]); check(compile.code === 0, "tools/downloader 全部能编译", compile.out.split("\n").slice(-2).join(" | ") || "无输出"); // ③ 绿:形状正确的夹具必须通过 writeFixture(FIXTURE); const good = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]); check(good.code === 0, "形状正确的夹具 → verify 退出 0", good.code === 0 ? good.out.split("\n").pop() : good.out); // ④ 红之一:贴图页缺文件(atlas 声明的页名必须逐字落盘) const savedPng = readFileSync(heroPng()); rmSync(heroPng()); const missingPage = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]); check(missingPage.code !== 0, "删掉贴图页 → verify 报红", missingPage.out.split("\n")[0]); check(/hero\.png/.test(missingPage.out), "报错里点名了缺的那张页", missingPage.out.split("\n")[0]); writeFileSync(heroPng(), savedPng); // ⑤ 红之二:侧车引用一具不存在的骨架 const savedScene = readFileSync(sceneJson(), "utf8"); const scene = JSON.parse(savedScene) as { parts: { kind: string; id: string; order?: number }[] }; scene.parts.push({ kind: "spine", id: "ghost", order: 3 }); writeFileSync(sceneJson(), JSON.stringify(scene)); const ghost = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]); check(ghost.code !== 0, "侧车引用不存在的骨架 → verify 报红", ghost.out.split("\n")[0]); check(/ghost/.test(ghost.out), "报错里点名了 ghost", ghost.out.split("\n")[0]); writeFileSync(sceneJson(), savedScene); // ⑥ 红之三:预设里有一条 ./… 指向不存在的文件(构建期才会炸的那类) const savedPreset = readFileSync(presetJson(), "utf8"); const preset = JSON.parse(savedPreset) as { backgroundImage: string }; preset.backgroundImage = "./scene/nope.png"; writeFileSync(presetJson(), JSON.stringify(preset)); const badPath = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]); check(badPath.code !== 0, "预设路径指向不存在的文件 → verify 报红", badPath.out.split("\n")[0]); check(/nope\.png/.test(badPath.out), "报错里点名了那条路径", badPath.out.split("\n")[0]); writeFileSync(presetJson(), savedPreset); // ⑦ 红之四:meta.json 的 id 与场景目录名不一致(搬进 wallpapers/ 会直接构建报错) const savedMeta = readFileSync(metaJson(), "utf8"); const meta = JSON.parse(savedMeta) as { id: string }; meta.id = "other-id"; writeFileSync(metaJson(), JSON.stringify(meta)); const badId = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]); check(badId.code !== 0, "meta.json 的 id 与目录名不一致 → verify 报红", badId.out.split("\n")[0]); check(/other-id/.test(badId.out), "报错里点名了那个 id", badId.out.split("\n")[0]); writeFileSync(metaJson(), savedMeta); // ⑧ 还原后必须重新变绿(证明红是那处改动引起的,不是夹具本身坏的) const restored = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]); check(restored.code === 0, "还原四处破坏 → 重新变绿", restored.code === 0 ? restored.out.split("\n").pop() : restored.out); rmSync(FIXTURE, { recursive: true, force: true }); console.log(failures === 0 ? "\n抓取器门:通过" : `\n抓取器门:失败 ${failures} 处`); process.exit(failures === 0 ? 0 : 1);