task-9-fix1-harness.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. /**
  2. * Task 9 修复轮 1 的执行级验证台。
  3. *
  4. * 本仓没有前端测试框架(`node_modules/.bin` 里没有 vitest/jest/mocha),所以这里拿
  5. * `@vue/compiler-sfc` 把页面真正的 `<script setup>` 编译出来、用真的 `vue` 跑起来,
  6. * 再驱动**真函数**(`handleMcc1Change` / `reloadCascades` / `uploadOnboardingFile` / `handleSubmit`),
  7. * 断言落在**真状态**(`form.merchant_mcc_code` 等)上。
  8. *
  9. * ★ 量具与被测量物:`vue` 是 `frontend/node_modules` 里那一份(真品);
  10. * `@/api/...` 与上传组件是桩(这一步没有后端可打);模板不渲染(`inlineTemplate: false`)。
  11. * ⇒ 本台证明的是「这些函数的判定逻辑」,不是「浏览器里长什么样」。
  12. *
  13. * 用法:node t9fix-harness.mjs <index.vue 路径> [标签]
  14. */
  15. import fs from "node:fs";
  16. import { createRequire } from "node:module";
  17. const FE = "D:/project2/payment-platform/frontend/";
  18. const require = createRequire(FE + "package.json");
  19. const vue = require("vue");
  20. const { parse, compileScript } = require("@vue/compiler-sfc");
  21. const SRC = process.argv[2];
  22. const LABEL = process.argv[3] || SRC;
  23. /** 把 SFC 编译成可 `new Function` 的普通脚本(import 换成对 `globalThis.__T9` 的取值)。 */
  24. function compileSfc(file) {
  25. const src = fs.readFileSync(file, "utf8");
  26. const { descriptor, errors } = parse(src, { filename: file });
  27. if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
  28. let code = compileScript(descriptor, { id: "t9probe", inlineTemplate: false }).content;
  29. // `type X` 说明符在 JS 里非法,去掉(它们本来就只是类型);`as` 别名要变成解构的 `:`
  30. const stripTypes = (names) => names.replace(/\btype\s+[\w$.]+\s*,?/g, "");
  31. const deAlias = (names) => names.replace(/\bas\b/g, ":");
  32. const braceImport = (spec) =>
  33. new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
  34. code = code
  35. // ★ 会有**两条** vue import:一条是 `defineComponent`(`defineOptions` 带来的包装),
  36. // 一条是本文件自己 import 的响应式 API ⇒ 必须带 `g`,且别名要转换。
  37. .replace(braceImport("vue"), (_m, n) => `const {${deAlias(n)}} = globalThis.__T9.vue;`)
  38. .replace(
  39. braceImport("@/api/module_payment/onboarding"),
  40. (_m, n) => `const {${stripTypes(n)}} = globalThis.__T9.api;`
  41. )
  42. .replace(
  43. /import\s+(\w+)\s+from\s*['"]@\/components\/Upload\/SingleImageUpload\.vue['"];?/,
  44. (_m, n) => `const ${n} = globalThis.__T9.SingleImageUpload;`
  45. );
  46. if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
  47. // ★ `compileScript` **不**剥 TS(它把 TS 留给下游的 esbuild)⇒ 这里自己过一遍 `tsc` 的
  48. // 单文件转译,否则 `interface Field {` 会以 SyntaxError 炸掉。顺序必须在 import 换完之后。
  49. const ts = require("typescript");
  50. code = ts.transpileModule(code, {
  51. compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
  52. fileName: "index.ts",
  53. }).outputText;
  54. code = code.replace(/export\s+default/, "const __sfc =");
  55. if (/^\s*export\s/m.test(code)) throw new Error("还有没换掉的 export");
  56. code += "\nglobalThis.__T9.sfc = __sfc;";
  57. return code;
  58. }
  59. const code = compileSfc(SRC);
  60. // ---------- 桩 ----------
  61. const ok = (d) => ({ data: { code: 0, data: d } });
  62. const M1 = { mcc_code: "M1", mcc_name: "一级A" };
  63. const M2 = { mcc_code: "M2", mcc_name: "二级B" };
  64. function makeApi(state) {
  65. return {
  66. MERCHANT_CATEGORY_OPTIONS: [],
  67. getOnboardingAreas: async () => ok([]),
  68. getOnboardingMcc: async (p) => {
  69. const h = state.mcc[p.level];
  70. if (!h) throw new Error("场景没给 level=" + p.level + " 的桩");
  71. return h(p);
  72. },
  73. uploadOnboardingImage: async () => state.upload(),
  74. createOnboardingMerchant: async (body) => state.create(body),
  75. };
  76. }
  77. /** 每个场景一套全新的 refs:重新求值一次编译产物即可。 */
  78. function mount(state) {
  79. globalThis.__T9 = { vue, api: makeApi(state), SingleImageUpload: { name: "Stub" } };
  80. new Function(code)();
  81. const bindings = globalThis.__T9.sfc.setup({}, { expose: () => {} });
  82. return bindings;
  83. }
  84. // ---------- 断言 ----------
  85. let nPass = 0;
  86. const failed = [];
  87. function check(name, actual, expected) {
  88. const pass = JSON.stringify(actual) === JSON.stringify(expected);
  89. if (pass) nPass++;
  90. else failed.push(name);
  91. console.log(` ${pass ? "PASS" : "FAIL"} ${name} (actual=${JSON.stringify(actual)} want=${JSON.stringify(expected)})`);
  92. }
  93. const throws = async (fn) => {
  94. try {
  95. await fn();
  96. return false;
  97. } catch {
  98. return true;
  99. }
  100. };
  101. console.log(`=== ${LABEL} ===`);
  102. // ---- A. 「/mcc 二级请求失败」不得被判成「一级就是末级」(F1 主症) ----
  103. {
  104. const state = { mcc: { "2": async () => { throw new Error("网络挂了"); } }, upload: async () => ok(""), create: async () => ok(null) };
  105. const b = mount(state);
  106. b.mcc1.value = "M1";
  107. b.mccOptions1.value = [M1];
  108. await b.handleMcc1Change();
  109. check("A1 /mcc 二级失败 ⇒ merchant_mcc_code 保持空", b.form.merchant_mcc_code, "");
  110. check("A2 /mcc 二级失败 ⇒ leafLabel 保持空(不显示假的已选)", b.leafLabel.value, "");
  111. check("A3 /mcc 二级失败 ⇒ mccOptions2 保持空", b.mccOptions2.value, []);
  112. check("A4 /mcc 二级失败 ⇒ cascadeFailed 置位(常驻提示)", b.cascadeFailed.value, true);
  113. check("A5 /mcc 二级失败 ⇒ 加载态已复位", b.mccLoading.value, false);
  114. }
  115. // ---- B. 正对照:上游**真的**回 0 条 ⇒ 一级就是末级(这条逻辑必须还在) ----
  116. {
  117. const state = { mcc: { "2": async () => ok([]) }, upload: async () => ok(""), create: async () => ok(null) };
  118. const b = mount(state);
  119. b.mcc1.value = "M1";
  120. b.mccOptions1.value = [M1];
  121. await b.handleMcc1Change();
  122. check("B1 二级真的回 0 条 ⇒ 一级即末级,写入 M1", b.form.merchant_mcc_code, "M1");
  123. check("B2 二级真的回 0 条 ⇒ leafLabel = 一级名", b.leafLabel.value, "一级A");
  124. }
  125. // ---- C. 三级树:三级有值 ⇒ 末级是三级 ----
  126. {
  127. const state = {
  128. mcc: { "2": async () => ok([M2]), "3": async () => ok([{ mcc_code: "M3", mcc_name: "三级C" }]) },
  129. upload: async () => ok(""),
  130. create: async () => ok(null),
  131. };
  132. const b = mount(state);
  133. b.mcc1.value = "M1";
  134. b.mccOptions1.value = [M1];
  135. await b.handleMcc1Change();
  136. check("C1 二级有值 ⇒ 此时还不该写末级", b.form.merchant_mcc_code, "");
  137. b.mcc2.value = "M2";
  138. b.mccOptions2.value = [M2];
  139. await b.handleMcc2Change();
  140. b.mcc3.value = "M3";
  141. b.mccOptions3.value = [{ mcc_code: "M3", mcc_name: "三级C" }];
  142. b.handleMcc3Change();
  143. check("C2 选到三级 ⇒ 末级 = M3", b.form.merchant_mcc_code, "M3");
  144. check("C3 选到三级 ⇒ leafLabel = 三级名", b.leafLabel.value, "三级C");
  145. }
  146. // ---- D. 两级树:三级回 0 条 ⇒ 末级是二级 ----
  147. {
  148. const state = {
  149. mcc: { "2": async () => ok([M2]), "3": async () => ok([]) },
  150. upload: async () => ok(""),
  151. create: async () => ok(null),
  152. };
  153. const b = mount(state);
  154. b.mcc1.value = "M1";
  155. b.mccOptions1.value = [M1];
  156. await b.handleMcc1Change();
  157. b.mcc2.value = "M2";
  158. b.mccOptions2.value = [M2];
  159. await b.handleMcc2Change();
  160. check("D1 三级回 0 条 ⇒ 末级 = M2(二级)", b.form.merchant_mcc_code, "M2");
  161. }
  162. // ---- E. 重新加载:①失败后不得留旧值 ②成功要重判末级 ----
  163. {
  164. const state = {
  165. mcc: { "1": async () => ok([M1]), "2": async () => { throw new Error("还是挂着"); } },
  166. upload: async () => ok(""),
  167. create: async () => ok(null),
  168. };
  169. const b = mount(state);
  170. b.mcc1.value = "M1";
  171. b.mccOptions1.value = [M1];
  172. await b.handleMcc1Change(); // 失败
  173. b.form.merchant_mcc_code = "M1"; // ← 模拟「改前那版已经写进去的错值」
  174. await b.reloadCascades(); // 重新加载**再失败**
  175. check("E1 重新加载再失败 ⇒ 旧末级必须被清掉", b.form.merchant_mcc_code, "");
  176. check("E2 重新加载再失败 ⇒ leafLabel 也要清", b.leafLabel.value, "");
  177. check("E3 重新加载再失败 ⇒ 红色常驻提示仍在", b.cascadeFailed.value, true);
  178. }
  179. {
  180. const state = {
  181. mcc: { "1": async () => ok([M1]), "2": async () => { throw new Error("先挂着"); } },
  182. upload: async () => ok(""),
  183. create: async () => ok(null),
  184. };
  185. const b = mount(state);
  186. b.mcc1.value = "M1";
  187. b.mccOptions1.value = [M1];
  188. await b.handleMcc1Change();
  189. state.mcc["2"] = async () => ok([]); // 这次通了,且是浅树
  190. await b.reloadCascades();
  191. check("E4 重新加载成功 ⇒ 重驱动后重新判出末级 M1", b.form.merchant_mcc_code, "M1");
  192. check("E5 重新加载成功 ⇒ 提示消失", b.cascadeFailed.value, false);
  193. check("E6 重新加载成功 ⇒ 一级选项已重取", b.mccOptions1.value.length, 1);
  194. }
  195. // ---- F. 上传:空地址必须走错误通道(F2 上半) ----
  196. {
  197. const state = { mcc: {}, upload: async () => ok(""), create: async () => ok(null) };
  198. const b = mount(state);
  199. check("F1 空地址 ⇒ 抛异常(组件 onError 那条路)", await throws(() => b.uploadOnboardingFile({})), true);
  200. }
  201. {
  202. const state = { mcc: {}, upload: async () => ok("https://img/x.png"), create: async () => ok(null) };
  203. const b = mount(state);
  204. let got = null;
  205. try {
  206. got = await b.uploadOnboardingFile({});
  207. } catch (e) {
  208. got = "THREW:" + e.message;
  209. }
  210. check("F2 正对照:正常地址 ⇒ 原样返回字符串", got, "https://img/x.png");
  211. }
  212. {
  213. const state = { mcc: {}, upload: async () => ok(null), create: async () => ok(null) };
  214. const b = mount(state);
  215. check("F3 data 为 null ⇒ 也抛(同样是拿不到地址)", await throws(() => b.uploadOnboardingFile({})), true);
  216. }
  217. // ---- G. 提交:没回商户号不能静默(F2 下半) ----
  218. {
  219. const state = { mcc: {}, upload: async () => ok(""), create: async () => ok({ mch_no: null }) };
  220. const b = mount(state);
  221. await b.handleSubmit();
  222. check("G1 mch_no 为 null ⇒ 常驻提示置位", b.submitNoMchNo?.value, true);
  223. check("G2 mch_no 为 null ⇒ 不显示商户号", b.createdMchNo.value, "");
  224. check("G3 mch_no 为 null ⇒ 提交态已复位", b.submitting.value, false);
  225. }
  226. {
  227. const state = { mcc: {}, upload: async () => ok(""), create: async () => ok({ mch_no: "M20260923" }) };
  228. const b = mount(state);
  229. await b.handleSubmit();
  230. check("G4 正对照:正常商户号 ⇒ 显示出来", b.createdMchNo.value, "M20260923");
  231. check("G5 正对照:正常商户号 ⇒ 不置警示", b.submitNoMchNo?.value, false);
  232. }
  233. console.log(`SUMMARY ${LABEL}: pass=${nPass} fail=${failed.length}${failed.length ? " -> " + failed.join(" | ") : ""}`);