| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571 |
- /**
- * 「trade_status=16 超时关闭」前端转译的执行级验证台(2026-10-10)。
- *
- * 【要证的三句话】
- * A. 订单列表页:挂**真 SFC**(真模板),喂 trade_status="16" 的订单,状态列
- * 渲染出「超时关闭」;且整个页面文本里**不再出现裸 "16"**。
- * B. 订单详情弹层:驱动 detail / detailVisible 后,详情里的状态 tag 同样转译
- * (列表与详情是模板里两处独立的映射点,测一处不能代表另一处)。
- * C. 收银台 result computed:16 → 超时关闭(不再落进「表外状态原样显示」的兜底)。
- *
- * 【两向读数】表里既有「必须转译」的 16,也有正控:已知状态(1 / 8)行为不变;
- * 表外的 "99" 仍**原样显示** —— 那是「表外不猜」的既定哲学,谁顺手把它删掉,A4/C4 该红。
- *
- * 【配方来源】../2026-09-29-lsy-amount-floor-harness/amount-floor-harness.mjs:
- * 同一套「编译真 SFC + 打桩 import + typescript 转译 + new Function」。本台多两件:
- * ① 订单页要**渲染真模板**:compileScript(inlineTemplate:false) + 单独 compileTemplate
- * 出 render(**必须带 bindingMetadata**,script setup 的 __isScriptSetup 旗子会让
- * runtime 拒绝走 _ctx 代理),二者拼回一个组件再挂;
- * ② el-table / el-tag / el-dialog / el-option 用「会转发插槽或记录读数」的桩
- * (el-table 按 data 逐行把 row 塞进列插槽;el-tag 记 {type,text};el-option 记
- * {label,value}),其余 el-* 直通默认插槽或渲染空。
- * 模板里的 HAR_TRADE_STATUS_TEXT 等映射表来自**真 har.ts**(转译求值后取出),
- * 只有 HarAPI 本身换成桩(免得真发请求)。
- *
- * 【A5b 为什么直接钉表值】两处 el-tag 都写成 `TAG[st] || 'info'` —— 兜底把
- * 「TAG 表里 16 缺格」和「给了 info」渲染成同一个样子,变异体 M2(删掉这格)
- * 在渲染层分辨不出(2026-10-10 实测:只加渲染断言时 M2 全绿)。所以表值单独钉。
- *
- * 【不设 process.exitCode】失败也退 0,只认最后那行 SUMMARY(同目录各台惯例)。
- *
- * 用法:node har-status16-harness.mjs
- */
- 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, compileTemplate } = require("@vue/compiler-sfc");
- const ts = require("typescript");
- const ORDER = FE + "src/views/module_payment/har/order/index.vue";
- const CASHIER = FE + "src/views/module_payment/har/cashier/index.vue";
- const HAR_TS = FE + "src/api/module_payment/har.ts";
- // 无 DOM 台子的垫片:页面里用的是 `window.setTimeout` / `window.clearTimeout`。
- if (typeof globalThis.window === "undefined") {
- globalThis.window = globalThis;
- }
- // ---------- 通用:翻 SFC 里的 import 成桩 ----------
- const braceImport = (spec) =>
- new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
- const braceAsConst = (slot) => (m, names) => {
- const clean = names
- .replace(/\btype\s+[\w$.]+\s*,?/g, "")
- .replace(/\bas\b/g, ":");
- return `const {${clean}} = globalThis.__U.${slot};`;
- };
- const transpile = (code, fileName) =>
- ts.transpileModule(code, {
- compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
- fileName,
- }).outputText;
- // ---------- 真 har.ts:取映射表(HarAPI 换成桩) ----------
- const HAR_NAMES = [
- "HAR_TRADE_STATUS_TEXT",
- "HAR_TRADE_STATUS_TAG",
- "HAR_TRADE_TYPE_OPTIONS",
- "HAR_ORDER_STATUS_PAID",
- "HAR_NOTIFY_STATUS_TEXT",
- "HAR_BIZ_UN_STAGE",
- ];
- function evalRealHarModule() {
- let code = fs.readFileSync(HAR_TS, "utf8");
- code = code.replace(
- /import\s+(\w+)\s+from\s*['"]@\/utils\/request['"];?/,
- "const $1 = globalThis.__U.request;"
- );
- code = code.replace(/export\s+default\s+/g, "const __har_default = ");
- code = code.replace(/^\s*export\s+/gm, "");
- if (/^\s*import\s/m.test(code) || /^\s*export\s/m.test(code)) {
- throw new Error("har.ts 还有没换掉的 import/export —— 配方要更新");
- }
- code = transpile(code, "har.ts");
- code +=
- "\nglobalThis.__U.realHar = { __default: __har_default, " +
- HAR_NAMES.map((n) => `${n}: (typeof ${n} !== "undefined" ? ${n} : undefined)`).join(", ") +
- " };";
- new Function(code)();
- for (const n of HAR_NAMES) {
- if (globalThis.__U.realHar[n] === undefined) throw new Error("har.ts 里没有导出 " + n);
- }
- }
- // ---------- 桩:请求 / UI 库 / store ----------
- const apiCalls = [];
- const apiStub = new Proxy(
- {},
- {
- get: (_t, name) =>
- name === "then"
- ? undefined
- : async (...args) => {
- apiCalls.push({ name: String(name), args });
- return { data: { code: 0, data: { items: [], total: 0 } } };
- },
- }
- );
- const callable = new Proxy(async () => undefined, {
- get: (_t, k) => (k === "then" ? undefined : callable),
- apply: () => Promise.resolve(),
- });
- const epStub = new Proxy({}, { get: () => callable });
- const noop = () => {};
- const enterpriseStoreStub = () => ({
- getEnterpriseList: [],
- loadEnterpriseList: async () => {},
- });
- // ---------- 无 DOM 渲染器:垫出足够的节点操作,让真模板能挂/能更新 ----------
- const noopNode = () => {};
- const renderer = vue.createRenderer({
- createElement: (tag) => ({ kind: "el", tag, children: [], parent: null }),
- createText: (text) => ({ kind: "text", text, parent: null }),
- createComment: (text) => ({ kind: "comment", text, parent: null }),
- setText: (n, text) => {
- n.text = text;
- },
- setElementText: (el, text) => {
- el.children = [{ kind: "text", text, parent: el }];
- },
- insert: (child, parent, anchor) => {
- child.parent = parent;
- if (anchor) {
- const i = parent.children.indexOf(anchor);
- parent.children.splice(i < 0 ? parent.children.length : i, 0, child);
- } else {
- parent.children.push(child);
- }
- },
- remove: (child) => {
- const p = child.parent;
- if (p) {
- const i = p.children.indexOf(child);
- if (i >= 0) p.children.splice(i, 1);
- }
- child.parent = null;
- },
- parentNode: (n) => n.parent ?? null,
- nextSibling: (n) => {
- const p = n.parent;
- if (!p) return null;
- const i = p.children.indexOf(n);
- return i >= 0 ? p.children[i + 1] ?? null : null;
- },
- patchProp: noopNode, // 属性/class/style 与本台断言无关(只读文本)
- insertStaticContent: (content, parent, anchor, ns, start, end) => {
- if (start && end) {
- // 缓存节点搬到新位置(同 DOM 语义)
- const move = (n) => {
- if (n.parent) renderer.remove ? null : null;
- if (n.parent) {
- const i = n.parent.children.indexOf(n);
- if (i >= 0) n.parent.children.splice(i, 1);
- }
- n.parent = parent;
- if (anchor) {
- const i = parent.children.indexOf(anchor);
- parent.children.splice(i < 0 ? parent.children.length : i, 0, n);
- } else {
- parent.children.push(n);
- }
- };
- move(start);
- if (end !== start) move(end);
- return [start, end];
- }
- const n = { kind: "static", html: String(content), children: [], parent: null };
- n.parent = parent;
- parent.children.push(n);
- return [n, n];
- },
- });
- // ---------- 桩组件:转发插槽的 el-table / el-tag / el-dialog / el-option ----------
- const tagPairs = []; // el-tag 每次渲染记一对 {type, text} —— TAG 表就是靠它钉
- const optionPairs = []; // el-option 每次渲染记一对 {label, value} —— 筛选下拉吃 TEXT 表
- function collectText(nodes, out = []) {
- for (const n of Array.isArray(nodes) ? nodes : [nodes]) {
- if (n == null || n === false) continue;
- if (typeof n === "string" || typeof n === "number") out.push(String(n));
- else if (Array.isArray(n)) collectText(n, out);
- else if (typeof n === "object" && n.children != null) collectText(n.children, out);
- }
- return out;
- }
- const ElTableStub = {
- name: "el-table",
- props: { data: { type: Array, default: () => [] } },
- setup(props, { slots }) {
- return () => {
- const cols = slots.default ? slots.default().flat(Infinity) : [];
- const out = [];
- for (const row of props.data || []) {
- for (const col of cols) {
- const colSlots = col && col.children;
- if (colSlots && typeof colSlots === "object" && typeof colSlots.default === "function") {
- out.push(vue.h("div", { class: "cell" }, colSlots.default({ row })));
- }
- }
- }
- return vue.h("div", out);
- };
- },
- };
- const ElTagStub = {
- name: "el-tag",
- props: { type: { type: String, default: "" } },
- setup(props, { slots }) {
- return () => {
- const kids = slots.default ? slots.default() : [];
- tagPairs.push({ type: props.type, text: collectText(kids).join("") });
- return vue.h("span", kids);
- };
- },
- };
- const ElOptionStub = {
- name: "el-option",
- props: { label: { type: String, default: "" }, value: { type: [String, Number], default: "" } },
- setup(props) {
- return () => {
- optionPairs.push({ label: props.label, value: String(props.value) });
- return null; // 选项的文字在真实组件里来自 label prop,槽里没有内容可渲染
- };
- },
- };
- const ElDialogStub = {
- name: "el-dialog",
- props: { modelValue: { type: Boolean, default: false } },
- setup(props, { slots }) {
- return () =>
- props.modelValue
- ? vue.h("div", [slots.default ? slots.default() : null, slots.footer ? slots.footer() : null])
- : null;
- },
- };
- function registerStubs(app) {
- const passthrough = (name) => ({
- name,
- setup: (_p, { slots }) => () => (slots.default ? slots.default() : null),
- });
- for (const n of [
- "el-alert",
- "el-button",
- "el-card",
- "el-date-picker",
- "el-descriptions",
- "el-descriptions-item",
- "el-empty",
- "el-form",
- "el-form-item",
- "el-input",
- "el-input-number",
- "el-pagination",
- "el-select",
- "el-table-column",
- ]) {
- app.component(n, passthrough(n));
- }
- app.component("el-table", ElTableStub);
- app.component("el-tag", ElTagStub);
- app.component("el-option", ElOptionStub);
- app.component("el-dialog", ElDialogStub);
- app.directive("hasPerm", {});
- app.directive("loading", {});
- }
- // ---------- 编译 + 挂载:订单页(真模板) ----------
- function compileOrder() {
- const src = fs.readFileSync(ORDER, "utf8");
- const { descriptor, errors } = parse(src, { filename: ORDER });
- if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
- const script = compileScript(descriptor, { id: "s16order", inlineTemplate: false });
- let code = script.content;
- code = code
- .replace(braceImport("vue"), braceAsConst("vue"))
- .replace(braceImport("element-plus"), braceAsConst("ep"))
- .replace(/import\s+dayjs\s+from\s*['"]dayjs['"];?/, "const dayjs = globalThis.__U.dayjs;")
- .replace(
- /import\s+(\w+)\s*,\s*\{([^}]*)\}\s*from\s*['"]@\/api\/module_payment\/har['"];?/g,
- (_m, def, names) => {
- const clean = names
- .split(",")
- .map((s) => s.trim())
- .filter((s) => s && !/^type\s/.test(s));
- return (
- `const ${def} = globalThis.__U.api;` +
- (clean.length ? ` const { ${clean.join(", ")} } = globalThis.__U.realHar;` : "")
- );
- }
- )
- .replace(
- /import\s*\{\s*useEnterpriseStore\s*\}\s*from\s*['"]@\/store\/modules\/enterprise\.store['"];?/,
- "const useEnterpriseStore = globalThis.__U.enterpriseStore;"
- )
- .replace(
- /import\s*\{\s*useLiveRefetch\s*\}\s*from\s*['"]@\/composables['"];?/,
- "const useLiveRefetch = globalThis.__U.noop;"
- );
- if (/^\s*import\s/m.test(code)) throw new Error("order 还有没换掉的 import:\n" + code.slice(0, 500));
- code = transpile(code, "order.ts");
- code = code.replace(/export\s+default/, "const __sfc =");
- if (/^\s*export\s/m.test(code)) throw new Error("order 还有没换掉的 export");
- code += "\nglobalThis.__U.orderSfc = __sfc;";
- new Function(code)();
- const tpl = compileTemplate({
- source: descriptor.template.content,
- filename: ORDER,
- id: "s16order",
- // bindingMetadata 必须带:script setup 的 setup 结果带 __isScriptSetup 旗子,
- // runtime 的 hasSetupBinding 见到它就拒绝走 _ctx 代理(查不到 = undefined + 警告)。
- // 带上它,模板才编成 $setup.xx 直取。2026-10-10 实测踩过一次。
- compilerOptions: { bindingMetadata: script.bindings },
- });
- if (tpl.errors && tpl.errors.length) throw new Error("模板编译失败: " + tpl.errors.map(String).join("; "));
- let tcode = tpl.code.replace(braceImport("vue"), braceAsConst("vue"));
- tcode = tcode.replace(/export\s+function\s+render/, "function render");
- if (/^\s*(import|export)\s/m.test(tcode)) {
- throw new Error("模板码还有没换掉的 import/export:\n" + tcode.slice(0, 400));
- }
- tcode = transpile(tcode, "order.render.ts");
- tcode += "\nglobalThis.__U.orderRender = render;";
- new Function(tcode)();
- }
- function mountOrder() {
- const Real = globalThis.__U.orderSfc;
- let bindings = null;
- const Wrapped = {
- render: globalThis.__U.orderRender,
- setup(props, ctx) {
- bindings = Real.setup(props, ctx);
- return bindings;
- },
- };
- const root = { kind: "el", tag: "__root__", children: [], parent: null };
- const app = renderer.createApp({ render: () => vue.h(Wrapped) });
- registerStubs(app);
- app.mount(root);
- if (!bindings) throw new Error("setup 没有返回绑定");
- return { bindings, root };
- }
- // ---------- 编译 + 挂载:收银台(只驱动绑定,不渲染模板,同金额闸台) ----------
- function compileCashier() {
- const src = fs.readFileSync(CASHIER, "utf8");
- const { descriptor, errors } = parse(src, { filename: CASHIER });
- if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
- let code = compileScript(descriptor, { id: "s16cashier", inlineTemplate: false }).content;
- code = code
- .replace(braceImport("vue"), braceAsConst("vue"))
- .replace(braceImport("element-plus"), braceAsConst("ep"))
- .replace(/import\s+(\w+)\s+from\s*['"]qrcode['"];?/, "const $1 = globalThis.__U.ep;")
- .replace(
- /import\s+(\w+)\s*,\s*\{([^}]*)\}\s*from\s*['"]@\/api\/module_payment\/har['"];?/g,
- (_m, def) => `const ${def} = globalThis.__U.api;`
- );
- if (/^\s*import\s/m.test(code)) throw new Error("cashier 还有没换掉的 import:\n" + code.slice(0, 500));
- code = transpile(code, "cashier.ts");
- code = code.replace(/export\s+default/, "const __sfc =");
- if (/^\s*export\s/m.test(code)) throw new Error("cashier 还有没换掉的 export");
- code += "\nglobalThis.__U.cashierSfc = __sfc;";
- new Function(code)();
- }
- function mountCashier() {
- const Real = globalThis.__U.cashierSfc;
- let bindings = null;
- const Wrapped = {
- ...Real,
- render: () => null, // 模板不渲染(同金额闸台):本段只驱动 result / apply
- setup(props, ctx) {
- bindings = Real.setup(props, ctx);
- return bindings;
- },
- };
- renderer.createApp({ render: () => vue.h(Wrapped) }).mount({ kind: "el", tag: "__root__", children: [], parent: null });
- if (!bindings) throw new Error("setup 没有返回绑定");
- return bindings;
- }
- // ---------- 读数工具 ----------
- function flattenText(node, out = []) {
- if (!node) return out;
- if (node.kind === "text") out.push(String(node.text));
- else if (node.kind === "static") out.push(String(node.html).replace(/<[^>]*>/g, ""));
- for (const c of node.children || []) flattenText(c, out);
- return out;
- }
- const flush = async (n = 4) => {
- for (let i = 0; i < n; i++) {
- await new Promise((r) => setTimeout(r, 0));
- await vue.nextTick();
- }
- };
- // ---------- 夹具(所有非状态字段都不含子串 "16",负向断言才干净) ----------
- const MK = (no, status, extra = {}) => ({
- id: "R-" + no,
- order_no: "APTEST-A" + no,
- out_biz_no: "BIZ-A" + no,
- price: "1.00",
- title: "样例单" + no,
- trade_status: status,
- trade_type: "alipay_un_stage",
- merchant_code: "M0001",
- created_time: "2026-10-10 09:00:00",
- notify_status: "FAILED",
- ...extra,
- });
- const ROW16 = MK("1", "16");
- const ROW8 = MK("2", "8");
- const ROW1 = MK("3", "1");
- const ROW99 = MK("4", "99");
- const ROWS = [ROW16, ROW8, ROW1, ROW99];
- // ---------- 跑 ----------
- globalThis.__U = {
- vue,
- api: apiStub,
- ep: epStub,
- request: noop,
- dayjs: require("dayjs"),
- enterpriseStore: enterpriseStoreStub,
- noop,
- realHar: null,
- orderSfc: null,
- orderRender: null,
- cashierSfc: null,
- };
- // orderList 要喂夹具行;其余接口空列表(merchantList / enterprise 等)
- const rawApi = apiStub;
- globalThis.__U.api = new Proxy(
- {},
- {
- get: (_t, name) =>
- name === "then"
- ? undefined
- : async (...args) => {
- apiCalls.push({ name: String(name), args });
- if (name === "orderList") {
- return { data: { code: 0, data: { items: ROWS, total: ROWS.length } } };
- }
- if (name === "orderDetail") {
- return { data: { code: 0, data: ROW16 } };
- }
- return { data: { code: 0, data: { items: [], total: 0 } } };
- },
- }
- );
- evalRealHarModule();
- compileOrder();
- compileCashier();
- const results = [];
- const check = (tag, ok, detail) => {
- results.push({ tag, ok });
- console.log(` ${tag.padEnd(4)} ${ok ? "PASS" : "FAIL"} ${detail}`);
- };
- console.log(`har.ts = ${HAR_TS}`);
- console.log(`order = ${ORDER}`);
- console.log(`cashier = ${CASHIER}`);
- console.log("");
- console.log("== A/B 订单列表页与详情弹层(真 SFC + 真模板) ==");
- const { bindings: bOrder, root: rootOrder } = mountOrder();
- await flush();
- const textsList = flattenText(rootOrder);
- const allList = textsList.join(" | ");
- check("A1", allList.includes("超时关闭"), "列表 16 行转译为「超时关闭」");
- check("A2", !textsList.some((t) => t.includes("16")), "全页文本不含裸 \"16\"");
- check("A3", allList.includes("交易关闭"), "正控:8 行「交易关闭」仍是老文案");
- check("A4", allList.includes("99"), "正控:表外值 99 仍原样显示(兜底没被删)");
- check(
- "A5",
- tagPairs.some((p) => p.text === "超时关闭" && p.type === "info"),
- "tag 配对 {type:info, text:超时关闭} 存在"
- );
- check(
- "A6",
- tagPairs.some((p) => p.text === "支付成功" && p.type === "success"),
- "正控:1 行仍是 {success, 支付成功}"
- );
- check(
- "A5b",
- globalThis.__U.realHar.HAR_TRADE_STATUS_TAG["16"] === "info",
- "TAG 表里 16 显式给了 info(模板 `|| 'info'` 兜底让渲染分辨不出,直接钉表值)"
- );
- check(
- "A7",
- optionPairs.some((o) => o.value === "16" && o.label === "超时关闭"),
- "筛选下拉(v-for 吃 TEXT 表)出现 {16 → 超时关闭}"
- );
- check(
- "A8",
- optionPairs.some((o) => o.value === "8" && o.label === "交易关闭"),
- "正控:下拉里 8 仍是「交易关闭」"
- );
- const tagCountBefore = tagPairs.length;
- bOrder.detail.value = ROW16;
- bOrder.detailVisible.value = true;
- await flush();
- const added = tagPairs.slice(tagCountBefore);
- const textsAfterDetail = flattenText(rootOrder);
- check(
- "B1",
- added.some((p) => p.text === "超时关闭" && p.type === "info"),
- "详情弹层里的状态 tag 同款转译"
- );
- check("B2", !textsAfterDetail.some((t) => t.includes("16")), "打开详情后全页文本仍不含裸 \"16\"");
- console.log("");
- console.log("== C 收银台 result / apply ==");
- const bCashier = mountCashier();
- await flush();
- const r16 = (() => {
- bCashier.order.value = { trade_status: "16", price: "1.00" };
- return bCashier.result.value;
- })();
- check(
- "C1",
- r16.title === "超时关闭" && r16.tone === "muted" && String(r16.detail || "").includes("没有收到钱"),
- `16 → ${JSON.stringify(r16.title)} / ${JSON.stringify(r16.detail)}`
- );
- bCashier.order.value = { trade_status: "1", rcv_amount: "1.00", price: "1.00" };
- const r1 = bCashier.result.value;
- check("C2", r1.title === "收款成功" && r1.money === "1.00", `正控:1 → ${JSON.stringify(r1.title)} / money=${r1.money}`);
- bCashier.order.value = { trade_status: "8" };
- const r8 = bCashier.result.value;
- check("C3", r8.title === "交易关闭", `正控:8 → ${JSON.stringify(r8.title)}`);
- bCashier.order.value = { trade_status: "99" };
- const r99 = bCashier.result.value;
- check("C4", r99.title === "99", `正控:表外值 99 仍原样(兜底没被删)`);
- bCashier.phase.value = "waiting";
- bCashier.apply = bCashier.apply; // 绑定即函数本身
- bCashier.apply({ trade_status: "16", price: "1.00" });
- check("C5", bCashier.phase.value === "done", "apply(16) 停轮询进 done(现状行为回归)");
- console.log("");
- const bad = results.filter((r) => !r.ok).length;
- console.log(`SUMMARY ${bad === 0 ? "PASS" : "FAIL"} 断言=${results.length} 不符=${bad}`);
- // 兜底退出:即使有残留定时器也不吊在事件循环上;失败也退 0,只认 SUMMARY。
- process.exit(0);
|