amount-floor-harness.mjs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. /**
  2. * 收银台「收款金额最低 1 元」这条前端闸的执行级验证台。
  3. *
  4. * 【要证的一句话】`cashier/index.vue` 的 `start()` 在金额低于 1 元时**不发那一枪**。
  5. *
  6. * 【为什么不能只看源码】「代码里写了 `if (fen < MIN_FEN) return;`」与「按下去真的不会
  7. * 发出下单请求」是两句不同的话。前者是文本读数,后者是运行时行为。本台测**后者**:
  8. * 编译**真** SFC(`@vue/compiler-sfc`)、用 `frontend/node_modules` 里那份**真** `vue`
  9. * 挂起来、抓到 setup 返回的那袋绑定、直接驱动**真** `start()`,
  10. * 然后数 `createCashierQr` **被调了几次**。
  11. *
  12. * 【两向读数一次跑完】场景表里既有「必须不下单」的,也有「必须下单」的:
  13. * A/B/E/F/G 是拦下侧(含旧的 `fen <= 0` 分支,防它被新闸顶掉)
  14. * C/D/H 是放行侧 —— 没有它们,「一律 return」也会让拦下侧全绿
  15. * 只有一侧的话,把 `start()` 改成第一行 `return` 也能全绿。
  16. *
  17. * 【配方来源】`../2026-09-23-lsy-onboarding-B-harness/lsy-keepalive-harness.mjs`:
  18. * 同一套「编译真 SFC + 打桩 import + 用 typescript 转译成 `new Function` 能跑的普通脚本」。
  19. * 区别是那台数 `setup()` 跑几次,本台**驱动 setup 返回的那个 `start`**。
  20. *
  21. * 【不做 DOM】模板不渲染(`inlineTemplate: false`)。因此 `amountEl.value` 是 undefined
  22. * —— 页面里的 `amountEl.value?.focus()` 是空操作,不影响本台要证的那件事。
  23. * 【不设 process.exitCode】失败也退 0,**只认最后那行 `SUMMARY`**。
  24. *
  25. * 用法:node amount-floor-harness.mjs [index.vue 路径]
  26. */
  27. import fs from "node:fs";
  28. import { createRequire } from "node:module";
  29. const FE = "D:/project2/payment-platform/frontend/";
  30. const require = createRequire(FE + "package.json");
  31. const vue = require("vue");
  32. const { parse, compileScript } = require("@vue/compiler-sfc");
  33. const SRC = process.argv[2] || FE + "src/views/module_payment/collection/cashier/index.vue";
  34. /** 把 SFC 编译成可 `new Function` 的普通脚本(配方抄自 keep-alive 台)。 */
  35. function compileSfc(file) {
  36. const src = fs.readFileSync(file, "utf8");
  37. const { descriptor, errors } = parse(src, { filename: file });
  38. if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
  39. let code = compileScript(descriptor, { id: "afprobe", inlineTemplate: false }).content;
  40. const braceImport = (spec) =>
  41. new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
  42. const asStub = (slot) => (_m, n) =>
  43. `const {${n.replace(/\btype\s+[\w$.]+\s*,?/g, "").replace(/\bas\b/g, ":")}} = globalThis.__U.${slot};`;
  44. code = code
  45. // ★ 会有**两条** vue import(`defineOptions` 带出的那一条 + 文件自己那条)⇒ 正则必须带 g。
  46. .replace(braceImport("vue"), asStub("vue"))
  47. .replace(braceImport("@/api/module_payment/collection"), asStub("api"))
  48. .replace(braceImport("element-plus"), asStub("ep"))
  49. .replace(/import\s+(\w+)\s+from\s*['"]qrcode['"];?/, (_m, n) => `const ${n} = globalThis.__U.ep;`);
  50. // 漏换的 import 会当场炸,而不是悄悄跑出假绿。
  51. if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
  52. const ts = require("typescript");
  53. code = ts.transpileModule(code, {
  54. compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
  55. fileName: "index.ts",
  56. }).outputText;
  57. code = code.replace(/export\s+default/, "const __sfc =");
  58. if (/^\s*export\s/m.test(code)) throw new Error("还有没换掉的 export");
  59. code += "\nglobalThis.__U.sfc = __sfc;";
  60. return code;
  61. }
  62. // ---------- 桩 ----------
  63. /** 记下每一次接口调用 —— 本台要数的就是「下单这一枪有没有发出去」。 */
  64. const calls = [];
  65. const okBody = () => ({
  66. data: { code: 0, data: { out_trade_no: "AFPROBE", status: "PAYING", total_amount: 100 } },
  67. });
  68. const apiStub = new Proxy(
  69. {},
  70. {
  71. get: (_t, name) =>
  72. name === "then"
  73. ? undefined
  74. : async (...args) => {
  75. calls.push({ name: String(name), args });
  76. return okBody();
  77. },
  78. }
  79. );
  80. /** UI 库:任何名字都可调用、可取属性(`ElMessage.error(...)` 要能点得下去)。 */
  81. const callable = new Proxy(async () => undefined, {
  82. get: (_t, k) => (k === "then" ? undefined : callable),
  83. apply: () => Promise.resolve(),
  84. });
  85. const epStub = new Proxy({}, { get: () => callable });
  86. // 无 DOM 台子的垫片:页面里用的是 `window.setTimeout`(`schedulePoll`)。
  87. // ★ 这是**台子**的垫片,不是页面的问题 —— 指出来免得后人把它当缺陷去改页面。
  88. if (typeof globalThis.window === "undefined") {
  89. globalThis.window = globalThis;
  90. }
  91. globalThis.__U = { vue, api: apiStub, ep: epStub, sfc: null };
  92. new Function(compileSfc(SRC))();
  93. const Real = globalThis.__U.sfc;
  94. if (!Real || !Real.setup) throw new Error("编译出来的组件没有 setup —— compileScript 变了?");
  95. // ---------- 无 DOM 渲染器 ----------
  96. const node = () => ({ children: [] });
  97. const noop = () => {};
  98. const { createRenderer, h } = vue;
  99. const renderer = createRenderer({
  100. createElement: node,
  101. createText: node,
  102. createComment: node,
  103. setText: noop,
  104. setElementText: noop,
  105. insert: noop,
  106. remove: noop,
  107. parentNode: () => null,
  108. nextSibling: () => null,
  109. patchProp: noop,
  110. });
  111. /** 挂一次,把 setup 返回的那袋绑定留下来 —— `start` 与 `inputHint` 都在里面。 */
  112. function mountAndCapture() {
  113. let bindings = null;
  114. const Wrapped = {
  115. ...Real,
  116. render: () => null,
  117. setup(props, ctx) {
  118. bindings = Real.setup(props, ctx);
  119. return bindings;
  120. },
  121. };
  122. renderer.createApp({ render: () => h(Wrapped) }).mount(node());
  123. if (!bindings) throw new Error("setup 没有返回绑定 —— 拿不到 start()");
  124. return bindings;
  125. }
  126. // ---------- 场景表 ----------
  127. // amountText 是输入框里的**元**(字符串,页面自己 Math.round(*100) 成分)。
  128. const CASES = [
  129. ["A", "0.5", false, "收款金额最低 1 元", "门槛外侧:0.5 元"],
  130. ["B", "0.99", false, "收款金额最低 1 元", "门槛内侧最后一格"],
  131. ["C", "1", true, "", "门槛本身:1 元"],
  132. ["D", "1.01", true, "", "门槛之上"],
  133. ["E", "0", false, "请先填一个大于 0 的金额", "旧的 `fen <= 0` 分支没被新闸顶掉"],
  134. ["F", "", false, "请先填一个大于 0 的金额", "空串"],
  135. ["G", "abc", false, "请先填一个大于 0 的金额", "非数字"],
  136. ["H", "0.999", true, "", "四舍五入到分正好 100 ⇒ 放行(读数,不是缺陷)"],
  137. ];
  138. console.log(`文件=${SRC}`);
  139. console.log("");
  140. console.log(" # 输入 期望下单 实读下单 下单金额 提示语");
  141. let bad = 0;
  142. for (const [tag, text, wantCall, wantHint, why] of CASES) {
  143. const b = mountAndCapture();
  144. calls.length = 0;
  145. b.amountText.value = text;
  146. b.inputHint.value = "";
  147. try {
  148. // `start` 是 async 且自身吞异常(catch 里注释说明「拦截器已经弹过 toast」),
  149. // 这里再包一层是防 `nextTick`/桩抛出来打断本台的读数。
  150. await b.start();
  151. } catch (e) {
  152. console.log(` ${tag} (start 抛了:${String(e).slice(0, 60)})`);
  153. }
  154. // ★ 必须收尾:桩永远回 PAYING ⇒ `start()` 成功后 `poll()` 每 3 秒自己重排一次
  155. // (`schedulePoll`),`startTicking` 还有一个 1 秒的 interval。不清的话 Node 的
  156. // 事件循环永远有活干,**台子跑完全部场景也不退出** —— 第一版就是这样挂住的
  157. // (8 条读数全对、SUMMARY 也打了,进程却一直不退;靠 `Get-CimInstance` 按命令行
  158. // 认出 PID 才只杀掉自己那一个)。
  159. b.stopTimers?.();
  160. const hit = calls.filter((c) => c.name === "createCashierQr");
  161. const gotCall = hit.length > 0;
  162. const gotAmount = gotCall ? hit[0].args[0]?.amount : "-";
  163. const gotHint = b.inputHint.value;
  164. const ok = gotCall === wantCall && (wantCall ? gotAmount > 0 : gotHint === wantHint);
  165. if (!ok) bad++;
  166. console.log(
  167. ` ${tag} ${JSON.stringify(text).padEnd(9)} ${String(wantCall).padEnd(9)} ` +
  168. `${String(gotCall).padEnd(9)} ${String(gotAmount).padEnd(9)} ` +
  169. `${JSON.stringify(gotHint)}${ok ? "" : " <<< 不符(" + why + ")"}`
  170. );
  171. }
  172. console.log("");
  173. console.log(`SUMMARY ${bad === 0 ? "PASS" : "FAIL"} 场景=${CASES.length} 不符=${bad}`);
  174. // 兜底退出:即使某个场景漏清了定时器,也不让台子吊在事件循环上。
  175. // 仍**不设 exitCode**(同目录各台一致)⇒ 失败也退 0,**只认最后那行 SUMMARY**。
  176. process.exit(0);