| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253 |
- /**
- * Task 9 修复轮 1 的执行级验证台。
- *
- * 本仓没有前端测试框架(`node_modules/.bin` 里没有 vitest/jest/mocha),所以这里拿
- * `@vue/compiler-sfc` 把页面真正的 `<script setup>` 编译出来、用真的 `vue` 跑起来,
- * 再驱动**真函数**(`handleMcc1Change` / `reloadCascades` / `uploadOnboardingFile` / `handleSubmit`),
- * 断言落在**真状态**(`form.merchant_mcc_code` 等)上。
- *
- * ★ 量具与被测量物:`vue` 是 `frontend/node_modules` 里那一份(真品);
- * `@/api/...` 与上传组件是桩(这一步没有后端可打);模板不渲染(`inlineTemplate: false`)。
- * ⇒ 本台证明的是「这些函数的判定逻辑」,不是「浏览器里长什么样」。
- *
- * 用法:node t9fix-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];
- const LABEL = process.argv[3] || SRC;
- /** 把 SFC 编译成可 `new Function` 的普通脚本(import 换成对 `globalThis.__T9` 的取值)。 */
- 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: "t9probe", inlineTemplate: false }).content;
- // `type X` 说明符在 JS 里非法,去掉(它们本来就只是类型);`as` 别名要变成解构的 `:`
- const stripTypes = (names) => names.replace(/\btype\s+[\w$.]+\s*,?/g, "");
- const deAlias = (names) => names.replace(/\bas\b/g, ":");
- const braceImport = (spec) =>
- new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
- code = code
- // ★ 会有**两条** vue import:一条是 `defineComponent`(`defineOptions` 带来的包装),
- // 一条是本文件自己 import 的响应式 API ⇒ 必须带 `g`,且别名要转换。
- .replace(braceImport("vue"), (_m, n) => `const {${deAlias(n)}} = globalThis.__T9.vue;`)
- .replace(
- braceImport("@/api/module_payment/onboarding"),
- (_m, n) => `const {${stripTypes(n)}} = globalThis.__T9.api;`
- )
- .replace(
- /import\s+(\w+)\s+from\s*['"]@\/components\/Upload\/SingleImageUpload\.vue['"];?/,
- (_m, n) => `const ${n} = globalThis.__T9.SingleImageUpload;`
- );
- if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
- // ★ `compileScript` **不**剥 TS(它把 TS 留给下游的 esbuild)⇒ 这里自己过一遍 `tsc` 的
- // 单文件转译,否则 `interface Field {` 会以 SyntaxError 炸掉。顺序必须在 import 换完之后。
- 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.__T9.sfc = __sfc;";
- return code;
- }
- const code = compileSfc(SRC);
- // ---------- 桩 ----------
- const ok = (d) => ({ data: { code: 0, data: d } });
- const M1 = { mcc_code: "M1", mcc_name: "一级A" };
- const M2 = { mcc_code: "M2", mcc_name: "二级B" };
- function makeApi(state) {
- return {
- MERCHANT_CATEGORY_OPTIONS: [],
- getOnboardingAreas: async () => ok([]),
- getOnboardingMcc: async (p) => {
- const h = state.mcc[p.level];
- if (!h) throw new Error("场景没给 level=" + p.level + " 的桩");
- return h(p);
- },
- uploadOnboardingImage: async () => state.upload(),
- createOnboardingMerchant: async (body) => state.create(body),
- };
- }
- /** 每个场景一套全新的 refs:重新求值一次编译产物即可。 */
- function mount(state) {
- globalThis.__T9 = { vue, api: makeApi(state), SingleImageUpload: { name: "Stub" } };
- new Function(code)();
- const bindings = globalThis.__T9.sfc.setup({}, { expose: () => {} });
- return bindings;
- }
- // ---------- 断言 ----------
- let nPass = 0;
- const failed = [];
- function check(name, actual, expected) {
- const pass = JSON.stringify(actual) === JSON.stringify(expected);
- if (pass) nPass++;
- else failed.push(name);
- console.log(` ${pass ? "PASS" : "FAIL"} ${name} (actual=${JSON.stringify(actual)} want=${JSON.stringify(expected)})`);
- }
- const throws = async (fn) => {
- try {
- await fn();
- return false;
- } catch {
- return true;
- }
- };
- console.log(`=== ${LABEL} ===`);
- // ---- A. 「/mcc 二级请求失败」不得被判成「一级就是末级」(F1 主症) ----
- {
- const state = { mcc: { "2": async () => { throw new Error("网络挂了"); } }, upload: async () => ok(""), create: async () => ok(null) };
- const b = mount(state);
- b.mcc1.value = "M1";
- b.mccOptions1.value = [M1];
- await b.handleMcc1Change();
- check("A1 /mcc 二级失败 ⇒ merchant_mcc_code 保持空", b.form.merchant_mcc_code, "");
- check("A2 /mcc 二级失败 ⇒ leafLabel 保持空(不显示假的已选)", b.leafLabel.value, "");
- check("A3 /mcc 二级失败 ⇒ mccOptions2 保持空", b.mccOptions2.value, []);
- check("A4 /mcc 二级失败 ⇒ cascadeFailed 置位(常驻提示)", b.cascadeFailed.value, true);
- check("A5 /mcc 二级失败 ⇒ 加载态已复位", b.mccLoading.value, false);
- }
- // ---- B. 正对照:上游**真的**回 0 条 ⇒ 一级就是末级(这条逻辑必须还在) ----
- {
- const state = { mcc: { "2": async () => ok([]) }, upload: async () => ok(""), create: async () => ok(null) };
- const b = mount(state);
- b.mcc1.value = "M1";
- b.mccOptions1.value = [M1];
- await b.handleMcc1Change();
- check("B1 二级真的回 0 条 ⇒ 一级即末级,写入 M1", b.form.merchant_mcc_code, "M1");
- check("B2 二级真的回 0 条 ⇒ leafLabel = 一级名", b.leafLabel.value, "一级A");
- }
- // ---- C. 三级树:三级有值 ⇒ 末级是三级 ----
- {
- const state = {
- mcc: { "2": async () => ok([M2]), "3": async () => ok([{ mcc_code: "M3", mcc_name: "三级C" }]) },
- upload: async () => ok(""),
- create: async () => ok(null),
- };
- const b = mount(state);
- b.mcc1.value = "M1";
- b.mccOptions1.value = [M1];
- await b.handleMcc1Change();
- check("C1 二级有值 ⇒ 此时还不该写末级", b.form.merchant_mcc_code, "");
- b.mcc2.value = "M2";
- b.mccOptions2.value = [M2];
- await b.handleMcc2Change();
- b.mcc3.value = "M3";
- b.mccOptions3.value = [{ mcc_code: "M3", mcc_name: "三级C" }];
- b.handleMcc3Change();
- check("C2 选到三级 ⇒ 末级 = M3", b.form.merchant_mcc_code, "M3");
- check("C3 选到三级 ⇒ leafLabel = 三级名", b.leafLabel.value, "三级C");
- }
- // ---- D. 两级树:三级回 0 条 ⇒ 末级是二级 ----
- {
- const state = {
- mcc: { "2": async () => ok([M2]), "3": async () => ok([]) },
- upload: async () => ok(""),
- create: async () => ok(null),
- };
- const b = mount(state);
- b.mcc1.value = "M1";
- b.mccOptions1.value = [M1];
- await b.handleMcc1Change();
- b.mcc2.value = "M2";
- b.mccOptions2.value = [M2];
- await b.handleMcc2Change();
- check("D1 三级回 0 条 ⇒ 末级 = M2(二级)", b.form.merchant_mcc_code, "M2");
- }
- // ---- E. 重新加载:①失败后不得留旧值 ②成功要重判末级 ----
- {
- const state = {
- mcc: { "1": async () => ok([M1]), "2": async () => { throw new Error("还是挂着"); } },
- upload: async () => ok(""),
- create: async () => ok(null),
- };
- const b = mount(state);
- b.mcc1.value = "M1";
- b.mccOptions1.value = [M1];
- await b.handleMcc1Change(); // 失败
- b.form.merchant_mcc_code = "M1"; // ← 模拟「改前那版已经写进去的错值」
- await b.reloadCascades(); // 重新加载**再失败**
- check("E1 重新加载再失败 ⇒ 旧末级必须被清掉", b.form.merchant_mcc_code, "");
- check("E2 重新加载再失败 ⇒ leafLabel 也要清", b.leafLabel.value, "");
- check("E3 重新加载再失败 ⇒ 红色常驻提示仍在", b.cascadeFailed.value, true);
- }
- {
- const state = {
- mcc: { "1": async () => ok([M1]), "2": async () => { throw new Error("先挂着"); } },
- upload: async () => ok(""),
- create: async () => ok(null),
- };
- const b = mount(state);
- b.mcc1.value = "M1";
- b.mccOptions1.value = [M1];
- await b.handleMcc1Change();
- state.mcc["2"] = async () => ok([]); // 这次通了,且是浅树
- await b.reloadCascades();
- check("E4 重新加载成功 ⇒ 重驱动后重新判出末级 M1", b.form.merchant_mcc_code, "M1");
- check("E5 重新加载成功 ⇒ 提示消失", b.cascadeFailed.value, false);
- check("E6 重新加载成功 ⇒ 一级选项已重取", b.mccOptions1.value.length, 1);
- }
- // ---- F. 上传:空地址必须走错误通道(F2 上半) ----
- {
- const state = { mcc: {}, upload: async () => ok(""), create: async () => ok(null) };
- const b = mount(state);
- check("F1 空地址 ⇒ 抛异常(组件 onError 那条路)", await throws(() => b.uploadOnboardingFile({})), true);
- }
- {
- const state = { mcc: {}, upload: async () => ok("https://img/x.png"), create: async () => ok(null) };
- const b = mount(state);
- let got = null;
- try {
- got = await b.uploadOnboardingFile({});
- } catch (e) {
- got = "THREW:" + e.message;
- }
- check("F2 正对照:正常地址 ⇒ 原样返回字符串", got, "https://img/x.png");
- }
- {
- const state = { mcc: {}, upload: async () => ok(null), create: async () => ok(null) };
- const b = mount(state);
- check("F3 data 为 null ⇒ 也抛(同样是拿不到地址)", await throws(() => b.uploadOnboardingFile({})), true);
- }
- // ---- G. 提交:没回商户号不能静默(F2 下半) ----
- {
- const state = { mcc: {}, upload: async () => ok(""), create: async () => ok({ mch_no: null }) };
- const b = mount(state);
- await b.handleSubmit();
- check("G1 mch_no 为 null ⇒ 常驻提示置位", b.submitNoMchNo?.value, true);
- check("G2 mch_no 为 null ⇒ 不显示商户号", b.createdMchNo.value, "");
- check("G3 mch_no 为 null ⇒ 提交态已复位", b.submitting.value, false);
- }
- {
- const state = { mcc: {}, upload: async () => ok(""), create: async () => ok({ mch_no: "M20260923" }) };
- const b = mount(state);
- await b.handleSubmit();
- check("G4 正对照:正常商户号 ⇒ 显示出来", b.createdMchNo.value, "M20260923");
- check("G5 正对照:正常商户号 ⇒ 不置警示", b.submitNoMchNo?.value, false);
- }
- console.log(`SUMMARY ${LABEL}: pass=${nPass} fail=${failed.length}${failed.length ? " -> " + failed.join(" | ") : ""}`);
|