|
|
@@ -0,0 +1,253 @@
|
|
|
+/**
|
|
|
+ * 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(" | ") : ""}`);
|