|
|
@@ -0,0 +1,195 @@
|
|
|
+/**
|
|
|
+ * 收银台「收款金额最低 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);
|