Bläddra i källkod

chore(进件): 把 Task 9 验证台挪出批次工作区,交付说明同步

用户指示「把 Task 9 台子挪出工作区」。落实 §四 4 采纳 ② 时留下的那条前置。

新位置 .claude/plan/2026-09-23-lsy-onboarding-B-harness/task-9-fix1-harness.mjs,
在 git 跟踪范围内 ⇒ git clean -fdx 清不掉它(已用 git check-ignore 核过未被忽略)。
原路径 .superpowers/sdd/…/task-9-fix1-harness.mjs 已不存在。

搬家走四步,缺一不可:
  cp → 核 sha256 逐字节相同(两侧都是 7f07de40…c095)→ 从新位置实跑一遍
  → 才删源。
第三步是这次的要点:搬家最可能的坏法是「文件在、跑不动」,所以不是断言
「它没有相对路径所以没事」,而是真跑 —— SUMMARY 搬运后复跑: pass=25 fail=0、rc=0,
与搬家前同值。

敢搬的依据是实测:台子对路径零依赖。全文件只有一处绝对常量
D:/project2/payment-platform/frontend/(:18)与 process.argv[2](:23);
process.cwd / import.meta / __dirname / 相对 import / 任何写文件调用全部零命中,
并配了同一支 grep 的正控(readFileSync 命中 1)。

交付说明同步(18 增 7 删,511 → 522 行):
  ① 台子路径改为新位置,并写明原路径与「git clean 清不掉」;
  ② 那条「该台子在工作区内,若采纳 ② 需先挪」加删除线标注已完成;
  ③ 原「台子留在工作区原路径」与「要挪说一声」两句作废(它们是搬家前的读数);
  ④ 新登记仍留在工作区里的同类台子:fe-update-harness.mjs /
     fe-update-keycheck.mjs / fe-update-mutate.mjs —— 本次未动,因为它们与
     Task 9 台子不是同一件东西(那台子驱动进件页的真 setup(),这三支是 R-7c
     前端入口的验证),不顺手扩大范围。
alphaH 3 veckor sedan
förälder
incheckning
e7e29d48fd

+ 253 - 0
.claude/plan/2026-09-23-lsy-onboarding-B-harness/task-9-fix1-harness.mjs

@@ -0,0 +1,253 @@
+/**
+ * Task 9 修复轮 1 的执行级验证台。
+ *
+ * 本仓没有前端测试框架(`node_modules/.bin` 里没有 vitest/jest/mocha),所以这里拿
+ * `@vue/compiler-sfc` 把页面真正的 `<script setup>` 编译出来、用真的 `vue` 跑起来,
+ * 再驱动**真函数**(`handleMcc1Change` / `reloadCascades` / `uploadOnboardingFile` / `handleSubmit`),
+ * 断言落在**真状态**(`form.merchant_mcc_code` 等)上。
+ *
+ * ★ 量具与被测量物:`vue` 是 `frontend/node_modules` 里那一份(真品);
+ *   `@/api/...` 与上传组件是桩(这一步没有后端可打);模板不渲染(`inlineTemplate: false`)。
+ *   ⇒ 本台证明的是「这些函数的判定逻辑」,不是「浏览器里长什么样」。
+ *
+ * 用法:node t9fix-harness.mjs <index.vue 路径> [标签]
+ */
+import fs from "node:fs";
+import { createRequire } from "node:module";
+
+const FE = "D:/project2/payment-platform/frontend/";
+const require = createRequire(FE + "package.json");
+const vue = require("vue");
+const { parse, compileScript } = require("@vue/compiler-sfc");
+
+const SRC = process.argv[2];
+const LABEL = process.argv[3] || SRC;
+
+/** 把 SFC 编译成可 `new Function` 的普通脚本(import 换成对 `globalThis.__T9` 的取值)。 */
+function compileSfc(file) {
+  const src = fs.readFileSync(file, "utf8");
+  const { descriptor, errors } = parse(src, { filename: file });
+  if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
+  let code = compileScript(descriptor, { id: "t9probe", inlineTemplate: false }).content;
+
+  // `type X` 说明符在 JS 里非法,去掉(它们本来就只是类型);`as` 别名要变成解构的 `:`
+  const stripTypes = (names) => names.replace(/\btype\s+[\w$.]+\s*,?/g, "");
+  const deAlias = (names) => names.replace(/\bas\b/g, ":");
+  const braceImport = (spec) =>
+    new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
+  code = code
+    // ★ 会有**两条** vue import:一条是 `defineComponent`(`defineOptions` 带来的包装),
+    //   一条是本文件自己 import 的响应式 API ⇒ 必须带 `g`,且别名要转换。
+    .replace(braceImport("vue"), (_m, n) => `const {${deAlias(n)}} = globalThis.__T9.vue;`)
+    .replace(
+      braceImport("@/api/module_payment/onboarding"),
+      (_m, n) => `const {${stripTypes(n)}} = globalThis.__T9.api;`
+    )
+    .replace(
+      /import\s+(\w+)\s+from\s*['"]@\/components\/Upload\/SingleImageUpload\.vue['"];?/,
+      (_m, n) => `const ${n} = globalThis.__T9.SingleImageUpload;`
+    );
+
+  if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
+
+  // ★ `compileScript` **不**剥 TS(它把 TS 留给下游的 esbuild)⇒ 这里自己过一遍 `tsc` 的
+  //   单文件转译,否则 `interface Field {` 会以 SyntaxError 炸掉。顺序必须在 import 换完之后。
+  const ts = require("typescript");
+  code = ts.transpileModule(code, {
+    compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
+    fileName: "index.ts",
+  }).outputText;
+  code = code.replace(/export\s+default/, "const __sfc =");
+  if (/^\s*export\s/m.test(code)) throw new Error("还有没换掉的 export");
+  code += "\nglobalThis.__T9.sfc = __sfc;";
+  return code;
+}
+
+const code = compileSfc(SRC);
+
+// ---------- 桩 ----------
+const ok = (d) => ({ data: { code: 0, data: d } });
+const M1 = { mcc_code: "M1", mcc_name: "一级A" };
+const M2 = { mcc_code: "M2", mcc_name: "二级B" };
+
+function makeApi(state) {
+  return {
+    MERCHANT_CATEGORY_OPTIONS: [],
+    getOnboardingAreas: async () => ok([]),
+    getOnboardingMcc: async (p) => {
+      const h = state.mcc[p.level];
+      if (!h) throw new Error("场景没给 level=" + p.level + " 的桩");
+      return h(p);
+    },
+    uploadOnboardingImage: async () => state.upload(),
+    createOnboardingMerchant: async (body) => state.create(body),
+  };
+}
+
+/** 每个场景一套全新的 refs:重新求值一次编译产物即可。 */
+function mount(state) {
+  globalThis.__T9 = { vue, api: makeApi(state), SingleImageUpload: { name: "Stub" } };
+  new Function(code)();
+  const bindings = globalThis.__T9.sfc.setup({}, { expose: () => {} });
+  return bindings;
+}
+
+// ---------- 断言 ----------
+let nPass = 0;
+const failed = [];
+function check(name, actual, expected) {
+  const pass = JSON.stringify(actual) === JSON.stringify(expected);
+  if (pass) nPass++;
+  else failed.push(name);
+  console.log(`  ${pass ? "PASS" : "FAIL"}  ${name}  (actual=${JSON.stringify(actual)} want=${JSON.stringify(expected)})`);
+}
+const throws = async (fn) => {
+  try {
+    await fn();
+    return false;
+  } catch {
+    return true;
+  }
+};
+
+console.log(`=== ${LABEL} ===`);
+
+// ---- A. 「/mcc 二级请求失败」不得被判成「一级就是末级」(F1 主症) ----
+{
+  const state = { mcc: { "2": async () => { throw new Error("网络挂了"); } }, upload: async () => ok(""), create: async () => ok(null) };
+  const b = mount(state);
+  b.mcc1.value = "M1";
+  b.mccOptions1.value = [M1];
+  await b.handleMcc1Change();
+  check("A1 /mcc 二级失败 ⇒ merchant_mcc_code 保持空", b.form.merchant_mcc_code, "");
+  check("A2 /mcc 二级失败 ⇒ leafLabel 保持空(不显示假的已选)", b.leafLabel.value, "");
+  check("A3 /mcc 二级失败 ⇒ mccOptions2 保持空", b.mccOptions2.value, []);
+  check("A4 /mcc 二级失败 ⇒ cascadeFailed 置位(常驻提示)", b.cascadeFailed.value, true);
+  check("A5 /mcc 二级失败 ⇒ 加载态已复位", b.mccLoading.value, false);
+}
+
+// ---- B. 正对照:上游**真的**回 0 条 ⇒ 一级就是末级(这条逻辑必须还在) ----
+{
+  const state = { mcc: { "2": async () => ok([]) }, upload: async () => ok(""), create: async () => ok(null) };
+  const b = mount(state);
+  b.mcc1.value = "M1";
+  b.mccOptions1.value = [M1];
+  await b.handleMcc1Change();
+  check("B1 二级真的回 0 条 ⇒ 一级即末级,写入 M1", b.form.merchant_mcc_code, "M1");
+  check("B2 二级真的回 0 条 ⇒ leafLabel = 一级名", b.leafLabel.value, "一级A");
+}
+
+// ---- C. 三级树:三级有值 ⇒ 末级是三级 ----
+{
+  const state = {
+    mcc: { "2": async () => ok([M2]), "3": async () => ok([{ mcc_code: "M3", mcc_name: "三级C" }]) },
+    upload: async () => ok(""),
+    create: async () => ok(null),
+  };
+  const b = mount(state);
+  b.mcc1.value = "M1";
+  b.mccOptions1.value = [M1];
+  await b.handleMcc1Change();
+  check("C1 二级有值 ⇒ 此时还不该写末级", b.form.merchant_mcc_code, "");
+  b.mcc2.value = "M2";
+  b.mccOptions2.value = [M2];
+  await b.handleMcc2Change();
+  b.mcc3.value = "M3";
+  b.mccOptions3.value = [{ mcc_code: "M3", mcc_name: "三级C" }];
+  b.handleMcc3Change();
+  check("C2 选到三级 ⇒ 末级 = M3", b.form.merchant_mcc_code, "M3");
+  check("C3 选到三级 ⇒ leafLabel = 三级名", b.leafLabel.value, "三级C");
+}
+
+// ---- D. 两级树:三级回 0 条 ⇒ 末级是二级 ----
+{
+  const state = {
+    mcc: { "2": async () => ok([M2]), "3": async () => ok([]) },
+    upload: async () => ok(""),
+    create: async () => ok(null),
+  };
+  const b = mount(state);
+  b.mcc1.value = "M1";
+  b.mccOptions1.value = [M1];
+  await b.handleMcc1Change();
+  b.mcc2.value = "M2";
+  b.mccOptions2.value = [M2];
+  await b.handleMcc2Change();
+  check("D1 三级回 0 条 ⇒ 末级 = M2(二级)", b.form.merchant_mcc_code, "M2");
+}
+
+// ---- E. 重新加载:①失败后不得留旧值 ②成功要重判末级 ----
+{
+  const state = {
+    mcc: { "1": async () => ok([M1]), "2": async () => { throw new Error("还是挂着"); } },
+    upload: async () => ok(""),
+    create: async () => ok(null),
+  };
+  const b = mount(state);
+  b.mcc1.value = "M1";
+  b.mccOptions1.value = [M1];
+  await b.handleMcc1Change(); // 失败
+  b.form.merchant_mcc_code = "M1"; // ← 模拟「改前那版已经写进去的错值」
+  await b.reloadCascades(); // 重新加载**再失败**
+  check("E1 重新加载再失败 ⇒ 旧末级必须被清掉", b.form.merchant_mcc_code, "");
+  check("E2 重新加载再失败 ⇒ leafLabel 也要清", b.leafLabel.value, "");
+  check("E3 重新加载再失败 ⇒ 红色常驻提示仍在", b.cascadeFailed.value, true);
+}
+{
+  const state = {
+    mcc: { "1": async () => ok([M1]), "2": async () => { throw new Error("先挂着"); } },
+    upload: async () => ok(""),
+    create: async () => ok(null),
+  };
+  const b = mount(state);
+  b.mcc1.value = "M1";
+  b.mccOptions1.value = [M1];
+  await b.handleMcc1Change();
+  state.mcc["2"] = async () => ok([]); // 这次通了,且是浅树
+  await b.reloadCascades();
+  check("E4 重新加载成功 ⇒ 重驱动后重新判出末级 M1", b.form.merchant_mcc_code, "M1");
+  check("E5 重新加载成功 ⇒ 提示消失", b.cascadeFailed.value, false);
+  check("E6 重新加载成功 ⇒ 一级选项已重取", b.mccOptions1.value.length, 1);
+}
+
+// ---- F. 上传:空地址必须走错误通道(F2 上半) ----
+{
+  const state = { mcc: {}, upload: async () => ok(""), create: async () => ok(null) };
+  const b = mount(state);
+  check("F1 空地址 ⇒ 抛异常(组件 onError 那条路)", await throws(() => b.uploadOnboardingFile({})), true);
+}
+{
+  const state = { mcc: {}, upload: async () => ok("https://img/x.png"), create: async () => ok(null) };
+  const b = mount(state);
+  let got = null;
+  try {
+    got = await b.uploadOnboardingFile({});
+  } catch (e) {
+    got = "THREW:" + e.message;
+  }
+  check("F2 正对照:正常地址 ⇒ 原样返回字符串", got, "https://img/x.png");
+}
+{
+  const state = { mcc: {}, upload: async () => ok(null), create: async () => ok(null) };
+  const b = mount(state);
+  check("F3 data 为 null ⇒ 也抛(同样是拿不到地址)", await throws(() => b.uploadOnboardingFile({})), true);
+}
+
+// ---- G. 提交:没回商户号不能静默(F2 下半) ----
+{
+  const state = { mcc: {}, upload: async () => ok(""), create: async () => ok({ mch_no: null }) };
+  const b = mount(state);
+  await b.handleSubmit();
+  check("G1 mch_no 为 null ⇒ 常驻提示置位", b.submitNoMchNo?.value, true);
+  check("G2 mch_no 为 null ⇒ 不显示商户号", b.createdMchNo.value, "");
+  check("G3 mch_no 为 null ⇒ 提交态已复位", b.submitting.value, false);
+}
+{
+  const state = { mcc: {}, upload: async () => ok(""), create: async () => ok({ mch_no: "M20260923" }) };
+  const b = mount(state);
+  await b.handleSubmit();
+  check("G4 正对照:正常商户号 ⇒ 显示出来", b.createdMchNo.value, "M20260923");
+  check("G5 正对照:正常商户号 ⇒ 不置警示", b.submitNoMchNo?.value, false);
+}
+
+console.log(`SUMMARY ${LABEL}: pass=${nPass} fail=${failed.length}${failed.length ? " -> " + failed.join(" | ") : ""}`);

+ 18 - 7
.claude/plan/2026-09-23-lsy-onboarding-B-交付说明.md

@@ -151,19 +151,30 @@
    (加 UI 入口 / 改派生规则),**不是接口形状、不是 DTO、不是 SQL** —— DTO 里那个可覆盖字段一直在。
 4. **Task 9 的验证台是否收编进 `frontend/`。**
    修复轮造出一台可执行验证台(真 `@vue/compiler-sfc` + 真 `vue`,驱动页面真 `setup()`,只桩 `@/api` 与上传子组件):
-   `.superpowers/sdd/2026-09-23-lsy-onboarding-B/task-9-fix1-harness.mjs`(**10796 B / sha16 `7f07de4099c96a23`**)。
+   **`.claude/plan/2026-09-23-lsy-onboarding-B-harness/task-9-fix1-harness.mjs`**(10796 B / sha16 `7f07de4099c96a23`)
+   —— **2026-09-23 已挪出批次工作区**(原路径 `.superpowers/sdd/2026-09-23-lsy-onboarding-B/task-9-fix1-harness.mjs`,
+   现已不存在)。新位置**在 git 跟踪范围内** ⇒ `git clean -fdx` 清不掉它。
    它**钉的是行为不是文本**(主控实测两向变异:改前 `pass=16 fail=9` → 改后 `pass=25 fail=0`;重引同一缺陷仍红 4 条)。
    **三条路**:① 收编为正式测试 ⇒ `frontend/` 需引入测试入口(该目录**目前无测试框架**);
    ② 只留文件 ⇒ 它留在批次工作区里,而工作区可能被清;③ 随工作区丢弃 ⇒ 下次同类页面要重造。
    **局限(务必一并考虑)**:**模板不渲染** ⇒ 只证明函数判定逻辑、不证明浏览器表现;
    **exit code 恒 0** ⇒ 判成败**只能读 `SUMMARY` 行**。
-   ★ **注**:该台子在**工作区内**,若采纳 ② 需先把它挪到工作区之外。
+   ★ ~~**注**:该台子在**工作区内**,若采纳 ② 需先把它挪到工作区之外。~~ —— **2026-09-23 已挪,见上。**
    ★ **裁定(2026-09-23):按现方案走 ⇒ 不收编(排除 ①)、不丢弃(排除 ③),即 ② 只留文件。**
-   **终态。** 台子留在批次工作区原路径,`frontend/` 不引入测试入口。
-   ★ **② 那句「工作区可能被清」的残留**:工作区**已决定不删**(2026-09-23,理由:用户保留最终验收权 +
-   该操作不可逆 + 代价≈0),所以「只留文件」今天成立;但 `git clean -fdx` 仍会连它一起清掉
-   —— **挪出工作区是一条命令的事,要挪说一声**。
-   ★ **若你要的其实是 ①「收编」**(那是新工作:得先给 `frontend/` 引入测试入口),也说一声即可 ——
+   **终态** —— `frontend/` 不引入测试入口。
+   ★ **2026-09-23 用户指示「把 Task 9 台子挪出工作区」⇒ 已执行。** 落地走四步,**缺一不可**:
+   ① `cp` 到新位置 → ② 核 sha256 **逐字节相同**(两侧都是 `7f07de40…c095`)→
+   ③ **从新位置实跑一遍**(`SUMMARY 搬运后复跑: pass=25 fail=0`、rc=0,与搬家前同值)→ ④ 才删源。
+   ★ 敢搬的依据是**实测**不是推断:台子对路径**零依赖** —— 全文件只有一处绝对常量
+   `D:/project2/payment-platform/frontend/`(`:18`)与 `process.argv[2]`(`:23`);
+   `process.cwd` / `import.meta` / `__dirname` / 相对 import / 任何**写文件**调用 **全部零命中**
+   (配了**同一支** grep 的正控:`readFileSync` 命中 1)。
+   ★ ~~「工作区可能被清」那条残留~~ + ~~「挪出工作区是一条命令的事,要挪说一声」~~ **均已作废**。
+   ★ **仍留在工作区里的同类台子(本次未动)** —— 同一个「被 `git clean -fdx` 清掉」的风险,要挪一并说:
+   `fe-update-harness.mjs`(12783 B,见 §十)、`fe-update-keycheck.mjs`(6415 B,见 §七)、
+   `fe-update-mutate.mjs`(4197 B)。它们与 Task 9 台子**不是同一件东西**(那台子驱动进件页的**真 `setup()`**;
+   这三支是 R-7c 前端入口的验证),所以本次**只搬被裁定的那一台**,没顺手扩大范围。
+   ★ **若你要的其实是 ①「收编」**(那是新工作:得先给 `frontend/` 引入测试入口),说一声即可 ——
    本行按「现方案走」读作 ②,是因为 ① 会**改变仓库内容**,与「按现方案走」相反。
 
 ---