| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- /**
- * 收银台「收款金额最低 1 元」这条前端闸的执行级验证台。
- *
- * 【要证的一句话】`cashier/index.vue` 的 `start()` 在金额低于 1 元时**不发那一枪**。
- *
- * 【为什么不能只看源码】「代码里写了 `if (fen < MIN_FEN) return;`」与「按下去真的不会
- * 发出下单请求」是两句不同的话。前者是文本读数,后者是运行时行为。本台测**后者**:
- * 编译**真** SFC(`@vue/compiler-sfc`)、用 `frontend/node_modules` 里那份**真** `vue`
- * 挂起来、抓到 setup 返回的那袋绑定、直接驱动**真** `start()`,
- * 然后数 `createCashierQr` **被调了几次**。
- *
- * 【两向读数一次跑完】场景表里既有「必须不下单」的,也有「必须下单」的:
- * A/B/E/F/G 是拦下侧(含旧的 `fen <= 0` 分支,防它被新闸顶掉)
- * C/D/H 是放行侧 —— 没有它们,「一律 return」也会让拦下侧全绿
- * 只有一侧的话,把 `start()` 改成第一行 `return` 也能全绿。
- *
- * 【配方来源】`../2026-09-23-lsy-onboarding-B-harness/lsy-keepalive-harness.mjs`:
- * 同一套「编译真 SFC + 打桩 import + 用 typescript 转译成 `new Function` 能跑的普通脚本」。
- * 区别是那台数 `setup()` 跑几次,本台**驱动 setup 返回的那个 `start`**。
- *
- * 【不做 DOM】模板不渲染(`inlineTemplate: false`)。因此 `amountEl.value` 是 undefined
- * —— 页面里的 `amountEl.value?.focus()` 是空操作,不影响本台要证的那件事。
- * 【不设 process.exitCode】失败也退 0,**只认最后那行 `SUMMARY`**。
- *
- * 用法:node amount-floor-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] || FE + "src/views/module_payment/collection/cashier/index.vue";
- /** 把 SFC 编译成可 `new Function` 的普通脚本(配方抄自 keep-alive 台)。 */
- 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: "afprobe", inlineTemplate: false }).content;
- const braceImport = (spec) =>
- new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
- const asStub = (slot) => (_m, n) =>
- `const {${n.replace(/\btype\s+[\w$.]+\s*,?/g, "").replace(/\bas\b/g, ":")}} = globalThis.__U.${slot};`;
- code = code
- // ★ 会有**两条** vue import(`defineOptions` 带出的那一条 + 文件自己那条)⇒ 正则必须带 g。
- .replace(braceImport("vue"), asStub("vue"))
- .replace(braceImport("@/api/module_payment/collection"), asStub("api"))
- .replace(braceImport("element-plus"), asStub("ep"))
- .replace(/import\s+(\w+)\s+from\s*['"]qrcode['"];?/, (_m, n) => `const ${n} = globalThis.__U.ep;`);
- // 漏换的 import 会当场炸,而不是悄悄跑出假绿。
- if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
- 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.__U.sfc = __sfc;";
- return code;
- }
- // ---------- 桩 ----------
- /** 记下每一次接口调用 —— 本台要数的就是「下单这一枪有没有发出去」。 */
- const calls = [];
- const okBody = () => ({
- data: { code: 0, data: { out_trade_no: "AFPROBE", status: "PAYING", total_amount: 100 } },
- });
- const apiStub = new Proxy(
- {},
- {
- get: (_t, name) =>
- name === "then"
- ? undefined
- : async (...args) => {
- calls.push({ name: String(name), args });
- return okBody();
- },
- }
- );
- /** UI 库:任何名字都可调用、可取属性(`ElMessage.error(...)` 要能点得下去)。 */
- const callable = new Proxy(async () => undefined, {
- get: (_t, k) => (k === "then" ? undefined : callable),
- apply: () => Promise.resolve(),
- });
- const epStub = new Proxy({}, { get: () => callable });
- // 无 DOM 台子的垫片:页面里用的是 `window.setTimeout`(`schedulePoll`)。
- // ★ 这是**台子**的垫片,不是页面的问题 —— 指出来免得后人把它当缺陷去改页面。
- if (typeof globalThis.window === "undefined") {
- globalThis.window = globalThis;
- }
- globalThis.__U = { vue, api: apiStub, ep: epStub, sfc: null };
- new Function(compileSfc(SRC))();
- const Real = globalThis.__U.sfc;
- if (!Real || !Real.setup) throw new Error("编译出来的组件没有 setup —— compileScript 变了?");
- // ---------- 无 DOM 渲染器 ----------
- const node = () => ({ children: [] });
- const noop = () => {};
- const { createRenderer, h } = vue;
- const renderer = createRenderer({
- createElement: node,
- createText: node,
- createComment: node,
- setText: noop,
- setElementText: noop,
- insert: noop,
- remove: noop,
- parentNode: () => null,
- nextSibling: () => null,
- patchProp: noop,
- });
- /** 挂一次,把 setup 返回的那袋绑定留下来 —— `start` 与 `inputHint` 都在里面。 */
- function mountAndCapture() {
- let bindings = null;
- const Wrapped = {
- ...Real,
- render: () => null,
- setup(props, ctx) {
- bindings = Real.setup(props, ctx);
- return bindings;
- },
- };
- renderer.createApp({ render: () => h(Wrapped) }).mount(node());
- if (!bindings) throw new Error("setup 没有返回绑定 —— 拿不到 start()");
- return bindings;
- }
- // ---------- 场景表 ----------
- // amountText 是输入框里的**元**(字符串,页面自己 Math.round(*100) 成分)。
- const CASES = [
- ["A", "0.5", false, "收款金额最低 1 元", "门槛外侧:0.5 元"],
- ["B", "0.99", false, "收款金额最低 1 元", "门槛内侧最后一格"],
- ["C", "1", true, "", "门槛本身:1 元"],
- ["D", "1.01", true, "", "门槛之上"],
- ["E", "0", false, "请先填一个大于 0 的金额", "旧的 `fen <= 0` 分支没被新闸顶掉"],
- ["F", "", false, "请先填一个大于 0 的金额", "空串"],
- ["G", "abc", false, "请先填一个大于 0 的金额", "非数字"],
- ["H", "0.999", true, "", "四舍五入到分正好 100 ⇒ 放行(读数,不是缺陷)"],
- ];
- console.log(`文件=${SRC}`);
- console.log("");
- console.log(" # 输入 期望下单 实读下单 下单金额 提示语");
- let bad = 0;
- for (const [tag, text, wantCall, wantHint, why] of CASES) {
- const b = mountAndCapture();
- calls.length = 0;
- b.amountText.value = text;
- b.inputHint.value = "";
- try {
- // `start` 是 async 且自身吞异常(catch 里注释说明「拦截器已经弹过 toast」),
- // 这里再包一层是防 `nextTick`/桩抛出来打断本台的读数。
- await b.start();
- } catch (e) {
- console.log(` ${tag} (start 抛了:${String(e).slice(0, 60)})`);
- }
- // ★ 必须收尾:桩永远回 PAYING ⇒ `start()` 成功后 `poll()` 每 3 秒自己重排一次
- // (`schedulePoll`),`startTicking` 还有一个 1 秒的 interval。不清的话 Node 的
- // 事件循环永远有活干,**台子跑完全部场景也不退出** —— 第一版就是这样挂住的
- // (8 条读数全对、SUMMARY 也打了,进程却一直不退;靠 `Get-CimInstance` 按命令行
- // 认出 PID 才只杀掉自己那一个)。
- b.stopTimers?.();
- const hit = calls.filter((c) => c.name === "createCashierQr");
- const gotCall = hit.length > 0;
- const gotAmount = gotCall ? hit[0].args[0]?.amount : "-";
- const gotHint = b.inputHint.value;
- const ok = gotCall === wantCall && (wantCall ? gotAmount > 0 : gotHint === wantHint);
- if (!ok) bad++;
- console.log(
- ` ${tag} ${JSON.stringify(text).padEnd(9)} ${String(wantCall).padEnd(9)} ` +
- `${String(gotCall).padEnd(9)} ${String(gotAmount).padEnd(9)} ` +
- `${JSON.stringify(gotHint)}${ok ? "" : " <<< 不符(" + why + ")"}`
- );
- }
- console.log("");
- console.log(`SUMMARY ${bad === 0 ? "PASS" : "FAIL"} 场景=${CASES.length} 不符=${bad}`);
- // 兜底退出:即使某个场景漏清了定时器,也不让台子吊在事件循环上。
- // 仍**不设 exitCode**(同目录各台一致)⇒ 失败也退 0,**只认最后那行 SUMMARY**。
- process.exit(0);
|