| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482 |
- /**
- * 前端 `/update` 入口的执行级验证台。
- *
- * 形状抄自同目录 `task-9-fix1-harness.mjs`(那里有为什么必须这么搭的两条依据):
- * `@vue/compiler-sfc` 把页面**真**的 `<script setup>` 编译出来 → 用 `frontend/node_modules`
- * 里那份**真** `vue` 跑 `setup()` → 驱动**真函数** → 断言落在**真状态**上。
- *
- * ★ 量具与被测量物:`vue` / `@vue/compiler-sfc` / `typescript` 都是 frontend 里那一份(真品);
- * `@/api/...` 与上传组件是桩(这一步没有后端可打);模板**不渲染**(`inlineTemplate: false`)
- * ⇒ 本台证明的是「这些函数的判定逻辑」,**不是**「浏览器里长什么样」。
- * ★ 因此 `formRef.value?.validate()` 那一步因 `formRef.value === undefined` 被 `?.` 跳过 ——
- * 本台验不到 el-form 的校验行为,那部分只能靠 `vue-tsc` 与人工。
- *
- * ★ 本台**不设** `process.exitCode`(与 T9 那台一致)⇒ 失败也退 0,**只认最后那行 `SUMMARY`**。
- *
- * 用法:node fe-update-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.__U` 的取值)。 */
- 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: "uprobe", inlineTemplate: false }).content;
- 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(`defineOptions` 带出的 `defineComponent` 一条、文件自己一条)
- // ⇒ 正则必须带 `g`,且 `as` 别名要转成解构的 `:`。
- .replace(braceImport("vue"), (_m, n) => `const {${deAlias(n)}} = globalThis.__U.vue;`)
- .replace(
- braceImport("@/api/module_payment/onboarding"),
- (_m, n) => `const {${stripTypes(n)}} = globalThis.__U.api;`
- )
- .replace(
- /import\s+(\w+)\s+from\s*['"]@\/components\/Upload\/SingleImageUpload\.vue['"];?/,
- (_m, n) => `const ${n} = globalThis.__U.SingleImageUpload;`
- );
- if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
- // ★ `compileScript` **不**剥 TS ⇒ 自己过一遍单文件转译,否则 `interface Field {` 是 SyntaxError。
- 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 code = compileSfc(SRC);
- // ---------- 桩 ----------
- const ok = (d) => ({ data: { code: 0, data: d } });
- function makeApi(state) {
- return {
- MERCHANT_CATEGORY_OPTIONS: [],
- getOnboardingAreas: async () => ok([]),
- getOnboardingMcc: async () => ok([]),
- uploadOnboardingImage: async () => ok(""),
- createOnboardingMerchant: async (body) => {
- state.created = body;
- return state.create(body);
- },
- updateOnboardingMerchant: async (body) => {
- state.updated = body;
- return state.update(body);
- },
- // ★ Task 3 新增的两条腿(列表 / 提交审核)。原版台子没有它们 —— 页面现在要打 `/page`
- // 与 `/commit`,不补桩的话只要真跑到 `loadList` / `submitCommit` 就是 TypeError。
- getOnboardingPage: async (params) => {
- state.pageParams = params;
- return state.page ? state.page() : ok({ list: [], total: 0 });
- },
- commitOnboardingMerchant: async (body) => {
- state.committed = body;
- return state.commit(body);
- },
- };
- }
- /** 每个场景一套全新的 refs:重新求值一次编译产物即可。 */
- function mount(state) {
- globalThis.__U = { vue, api: makeApi(state), SingleImageUpload: { name: "Stub" } };
- new Function(code)();
- return globalThis.__U.sfc.setup({}, { expose: () => {} });
- }
- const KEYS = (o) => (o ? Object.keys(o).sort() : null);
- // ---------- 断言 ----------
- 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)})`
- );
- }
- /** 断言「body 里**没有**这个键」—— 比 check(keys, [...]) 少写一坨期望值,失败时也更好读。 */
- function checkAbsent(name, body, key) {
- check(name, body && Object.prototype.hasOwnProperty.call(body, key), false);
- }
- console.log(`=== ${LABEL} ===`);
- const fresh = () => ({
- create: async () => ok(null),
- update: async () => ok(null),
- commit: async () => ok(null),
- });
- // ---- U. 更新腿报文的键白名单(本台的主症:多一个键 = HTTP 500,少发 = 上游不改) ----
- {
- const state = fresh();
- const b = mount(state);
- b.mode.value = "update";
- b.form.mch_no = " Y52211090004 ";
- b.form.account_card_num = "6222000000000000";
- // ★★ 手动把五个**绝不该发**的键塞进表单模型 —— 只有塞进去,「白名单在挡」这句话才不是空的。
- // 前三个的来路是「有人图省事把封盖/更新页没有的字段接进了表单」;
- // 后两个的来路是「有人把两道封盖的字段当普通字段接了进来」。
- // (Task 3 之后 `openUpdate` 会清空表单,所以这条路不再是「切模式留下的」——
- // 但白名单挡的是**模型里有没有这个键**,与它怎么进去的无关,塞进去照样是最硬的量法。)
- b.form.merchant_name_business = "某某公司";
- b.form.lease_contract = "https://oss/lease.png";
- b.form.category = "MICRO";
- b.form.tenant_id = "2";
- b.form.rate_code = "RATE_X";
- await b.handleSubmit();
- const body = state.updated;
- check("U1 更新报文**不含** merchant_name_business(发了就是 500)", body?.merchant_name_business, undefined);
- checkAbsent("U1b …用 hasOwnProperty 再确认一次", body, "merchant_name_business");
- checkAbsent("U2 更新报文**不含** lease_contract(发了就是 500)", body, "lease_contract");
- checkAbsent("U3 更新报文**不含** category(更新 DTO 上没有它 ⇒ 500)", body, "category");
- checkAbsent("U4 更新报文**不含** tenant_id(封盖 ⇒ 400)", body, "tenant_id");
- checkAbsent("U5 更新报文**不含** rate_code(封盖 ⇒ 400)", body, "rate_code");
- check("U6 更新报文**含** mch_no,且已 trim", body?.mch_no, "Y52211090004");
- check("U7 增量:填了的那一项原样发出", body?.account_card_num, "6222000000000000");
- check("U8 增量:**没填**的项一个都不发(恰好两个键)", KEYS(body), ["account_card_num", "mch_no"]);
- check("U9 提交态已复位", b.submitting.value, false);
- check("U10 成功提示用的是**请求里**那个商户号(响应里没有)", b.updatedMchNo.value, "Y52211090004");
- }
- // ---- V. 正对照:建号腿一个都没被改坏 ----
- {
- const state = fresh();
- const b = mount(state);
- b.form.category = "MICRO";
- b.form.merchant_name = "某某";
- b.form.merchant_name_business = "某某公司";
- b.form.lease_contract = "https://oss/lease.png";
- b.form.mch_no = "不该出现在建号报文里"; // 手工塞的(同上:白名单挡的是模型里有没有这个键)
- await b.handleSubmit();
- const body = state.created;
- check("V1 正对照:建号报文**仍然带** category", body?.category, "MICRO");
- check("V2 正对照:建号报文**仍然带** merchant_name_business", body?.merchant_name_business, "某某公司");
- check("V3 正对照:建号报文**仍然带** lease_contract", body?.lease_contract, "https://oss/lease.png");
- checkAbsent("V4 建号报文**不含** mch_no(它不在 CREATE_KEYS 里,是服务端产出)", body, "mch_no");
- checkAbsent("V5 建号报文不含 tenant_id", body, "tenant_id");
- checkAbsent("V6 建号报文不含 rate_code", body, "rate_code");
- }
- // ---- W. 渲染投影:那两格在更新模式下根本不渲染 ----
- {
- const b = mount(fresh());
- const nameSec = b.SECTIONS.find((s) => s.key === "name");
- const matSec = b.SECTIONS.find((s) => s.key === "materials");
- check("W1 建号模式:name 分区 3 个字段", b.visibleFields(nameSec).length, 3);
- check("W2 建号模式:materials 分区 5 个字段", b.visibleFields(matSec).length, 5);
- b.mode.value = "update";
- check("W3 更新模式:name 分区只剩 2 个(少了 merchant_name_business)", b.visibleFields(nameSec).length, 2);
- check("W4 更新模式:materials 分区只剩 4 个(少了 lease_contract)", b.visibleFields(matSec).length, 4);
- check(
- "W5 更新模式:被去掉的正是那两个键",
- b.visibleFields(nameSec).map((f) => f.key).concat(b.visibleFields(matSec).map((f) => f.key)).filter((k) => k === "merchant_name_business" || k === "lease_contract"),
- []
- );
- const addr = b.SECTIONS.find((s) => s.key === "address");
- check("W6 更新模式:address 分区不受影响", b.visibleFields(addr).map((f) => f.key), ["address_street"]);
- const mcc = b.SECTIONS.find((s) => s.key === "mcc");
- check("W7 更新模式:mcc 分区不受影响(merchant_mcc_code 在更新 DTO 上)", b.visibleFields(mcc).map((f) => f.key), ["merchant_mcc_code"]);
- }
- // ---- X. 打开两张表单的入口(原「切模式」段 —— Task 3 把那个单选开关删了) ----
- //
- // ★ 为什么整段重写而不是删:原 X1–X3 钉的是 `switchMode()`「误点一下不能毁掉 41 个框」。
- // 新形状里**没有**可误点的模式开关(模式由「点的是哪颗按钮」决定,见 `openCreate`/`openUpdate`),
- // 那条契约于是没有对应物了;取而代之的新契约**方向相反**:
- // 打开一张表单必须以**干净表单**起步 —— 否则「改资料」会把上一张建号表单的 41 个值
- // 当作增量更新发出去,那正是原 X6 **标签**警告的「等于按建号值再覆盖一遍」。
- // ★ 原 X6 的标签是这么写的,而它的断言(`=== "建号时填的"`)查的是**相反**的事:
- // 标签对、断言错。本段把断言改成标签的说法,并把标签里那句话拆成两条可查的读数。
- {
- const b = mount(fresh());
- b.form.merchant_name = "上次建号填的";
- b.form.category = "MICRO";
- b.createdMchNo.value = "M20260923";
- b.submitNoMchNo.value = true;
- b.openCreate();
- check("X1 新建入口:模式是 create", b.mode.value, "create");
- check("X2 新建入口:表单是**干净的**(不带上一次建号的值)", b.form.merchant_name, "");
- check("X3 新建入口:清掉上一次的建号结果提示", b.createdMchNo.value, "");
- check("X4 新建入口:清掉「没回商户号」告警", b.submitNoMchNo.value, false);
- }
- {
- const b = mount(fresh());
- b.form.merchant_name = "建号时填的";
- b.form.lease_contract = "https://oss/lease.png";
- b.openUpdate("Y52211090004");
- check("X5 改资料入口:模式切到 update", b.mode.value, "update");
- check("X6 改资料入口:商户号已预填", b.form.mch_no, "Y52211090004");
- check("X7 改资料入口**不**把建号表单的值带过去(否则等于按建号值再覆盖一遍)", b.form.merchant_name, "");
- check("X8 改资料入口:那个被排除的键也不带过去", b.form.lease_contract, "");
- }
- {
- const b = mount(fresh());
- b.mode.value = "update";
- check("X9 更新模式只钉商户号一条必填", Object.keys(b.rules.value), ["mch_no"]);
- b.mode.value = "create";
- check("X10 建号模式只钉商户类别一条必填(没被本次改动动过)", Object.keys(b.rules.value), ["category"]);
- }
- // ---- P. 进度列:四个词逐字 = 后端本地词表 ----
- {
- const b = mount(fresh());
- check(
- "P1 进度表恰好四个键(不另立第五个词)",
- Object.keys(b.PROGRESS).sort(),
- ["APPROVED", "PENDING", "REJECTED", "SUBMITTED"]
- );
- check("P2 PENDING ⇒ 待提交审核", b.progressOf({ review_status: "PENDING" })?.text, "待提交审核");
- check("P3 SUBMITTED ⇒ 审核中", b.progressOf({ review_status: "SUBMITTED" })?.text, "审核中");
- check("P4 APPROVED ⇒ 可以收款了", b.progressOf({ review_status: "APPROVED" })?.text, "可以收款了");
- check("P5 REJECTED ⇒ 审核未通过", b.progressOf({ review_status: "REJECTED" })?.text, "审核未通过");
- // ★ 正对照:表外的值必须**不**被静默当成某一档(拿到 null,页面渲染「状态未知」)
- check("P6 正对照:表外的值 ⇒ null(不静默归一)", b.progressOf({ review_status: "WHATEVER" }), null);
- check("P7 review_status 为 null ⇒ 也走「状态未知」", b.progressOf({ review_status: null }), null);
- }
- // ---- A. 每行一个下一步:本页最要紧的判定 ----
- //
- // 判错的代价可量化:主按钮指错 ⇒ 操作员卡死;把「提交审核」给到不该给的行
- // ⇒ 上游再收一次**无法回滚**的提交。
- const acts = (b, status) => b.actionsFor({ mch_no: "M1", review_status: status });
- const primaryOf = (b, status) => acts(b, status).filter((a) => a.primary).map((a) => a.text);
- const allOf = (b, status) => acts(b, status).map((a) => a.text);
- {
- const b = mount(fresh());
- check("A1 PENDING ⇒ 主按钮是「提交审核」", primaryOf(b, "PENDING"), ["提交审核"]);
- check("A2 SUBMITTED ⇒ 主按钮是「改资料」", primaryOf(b, "SUBMITTED"), ["改资料"]);
- check("A3 APPROVED ⇒ 主按钮是「改资料」", primaryOf(b, "APPROVED"), ["改资料"]);
- check("A4 REJECTED ⇒ 主按钮是「改资料」", primaryOf(b, "REJECTED"), ["改资料"]);
- // ★ 这条是「每行恰好一个主按钮」的**总闸**:任何一档给出两个主按钮,操作员就得做选择题
- check(
- "A5 五种状态**每档恰好一个**主按钮(操作员不需要选)",
- ["PENDING", "SUBMITTED", "APPROVED", "REJECTED", "WHATEVER"].map((s) => primaryOf(b, s).length),
- [1, 1, 1, 1, 1]
- );
- // ★ 非「可以收款了」的行都必须留一条「提交审核」的路 —— 少了它,被驳回的行改完资料
- // 就再也走不到送审那一步(按钮会一直停在「改资料」上)
- check(
- "A6 PENDING/SUBMITTED/REJECTED 都有提交审核入口",
- ["PENDING", "SUBMITTED", "REJECTED"].map((s) => allOf(b, s).includes("提交审核")),
- [true, true, true]
- );
- // ★ 反面:已经能收款的行**不给**重新提交 —— 再送一次没有意义,而误点一次的代价不可回滚
- check("A7 正对照:APPROVED **没有**提交审核入口", allOf(b, "APPROVED").includes("提交审核"), false);
- check("A8 APPROVED 只有一个按钮", allOf(b, "APPROVED"), ["改资料"]);
- // ★ 表外的值:不猜该送审还是该改资料 ⇒ 主按钮给保守的「改资料」,但**保留**送审入口,免得这行变死路
- check("A9 表外状态:主按钮给保守的「改资料」(不猜)", primaryOf(b, "WHATEVER"), ["改资料"]);
- check("A10 表外状态:仍保留提交审核入口(不变死路)", allOf(b, "WHATEVER").includes("提交审核"), true);
- check("A11 review_status 为 null 时也有按钮可点(不返空数组)", acts(b, null).length > 0, true);
- }
- // ---- C. 提交审核报文:6 个业务字段 + mch_no,空白整个省掉 ----
- {
- const b = mount(fresh());
- check("C1 提交报文的业务字段恰好 6 个", b.COMMIT_KEYS.length, 6);
- check(
- "C2 六个键逐字 = 后端 DTO 字段名(错一个就是 HTTP 500)",
- b.COMMIT_KEYS.slice().sort(),
- ["latitude", "longitude", "mcht_aptitude", "mcht_aptitude_note", "mcht_landuse", "store_loca"]
- );
- // ★ 服务层发给上游的是 `store_Loca` / `mcht_Landuse`(L 大写,进签名原文);**入站**收的是小写。
- // 把上游那个写法抄进请求体 = 未知属性 = HTTP 500,不是「静默当没传」。
- check(
- "C3 入站键名里**没有**上游那个大写 L 的写法",
- b.COMMIT_KEYS.filter((k) => k === "store_Loca" || k === "mcht_Landuse"),
- []
- );
- check("C4 资质码表 = 12 项(上游 01–12)", b.APTITUDE_OPTIONS.length, 12);
- check("C5 码表首尾逐字", [b.APTITUDE_OPTIONS[0].value, b.APTITUDE_OPTIONS[11].value], ["01", "12"]);
- }
- {
- const state = fresh();
- const b = mount(state);
- b.openCommit({ mch_no: "M20260923", review_status: "PENDING" });
- b.commitForm.mcht_aptitude = "07";
- b.commitForm.longitude = "116.397";
- await b.submitCommit();
- const body = state.committed;
- check("C6 报文含 mch_no 且来自那一行(不让操作员手输)", body?.mch_no, "M20260923");
- check("C7 填了的原样发出", body?.mcht_aptitude, "07");
- check("C8 没填的项整个省掉(**不送空串**)", KEYS(body), ["longitude", "mch_no", "mcht_aptitude"]);
- checkAbsent("C9 …用 hasOwnProperty 再确认空串没被送出去", body, "store_loca");
- check("C10 提交成功后关掉弹窗", b.commitVisible.value, false);
- check("C11 提交成功 ⇒ 不置 409 那条提示", b.commitSyncFailedMchNo.value, "");
- check("C12 提交成功 ⇒ 「已提交」提示用的是那一行的商户号", b.committedMchNo.value, "M20260923");
- }
- // ---- S. 提交审核的 409:**必须当成「已提交」**,不能当成失败 ----
- //
- // ★★ 后端 `markSubmittedOrSayWhy` 特意造的那条 409:上游**已经收到**这次提交了,
- // 只是本地状态没同步上。当成失败 ⇒ 操作员停在原地再点一次 ⇒ 上游再收一次,**无法回滚**。
- {
- const state = {
- ...fresh(),
- commit: async () => {
- // 拦截器对信封失败走 `Promise.reject(response)` ⇒ 抛出来的就是那个 AxiosResponse
- throw { data: { code: 409, msg: "入网信息已提交(商户号 M1),但本地状态未同步…" } };
- },
- };
- const b = mount(state);
- b.openCommit({ mch_no: "M1", review_status: "PENDING" });
- b.commitForm.mcht_aptitude = "07";
- b.commitForm.mcht_aptitude_note = "无";
- b.commitForm.store_loca = "XX路1号";
- b.commitForm.mcht_landuse = "租赁";
- b.commitForm.longitude = "116.397";
- b.commitForm.latitude = "39.908";
- let escaped = false;
- try {
- await b.submitCommit();
- } catch {
- escaped = true;
- }
- check("S1 409:rejection 不外逃到页面", escaped, false);
- check("S2 409:关掉弹窗(按「已提交」对待,不让操作员停在原地)", b.commitVisible.value, false);
- // ★ 这里原有一条 S3「409 不置成功提示」(`committedMchNo === ""`),**已删** —— 变异实测它没有牙:
- // 把 409 那个码值改成表外的值(那条分支永不进入)后,S2/S4 都红了,**S3 照绿**。
- // 成因:`submitCommit` 在**调用之前**就把 `committedMchNo` 置成 `""`,于是**一切失败路径**
- // 上它都是空串 ⇒ 这条断言分辨不出「走了 409 分支」与「根本没走」。它对任何变异都恒真。
- // 「没当成成功」这件事由 S4(警示位**必须**被置上)与 S2(弹窗**必须**关掉)钉住,够用。
- check("S4 409:置那条常驻警示,且带商户号", b.commitSyncFailedMchNo.value, "M1");
- check("S5 409:提交态已复位", b.commitSubmitting.value, false);
- }
- {
- // ★ 正对照:同一条路上换个非 409 的信封码,必须**不**走 409 那条分支
- const state = {
- ...fresh(),
- commit: async () => {
- throw { data: { code: 400, msg: "进件必填项缺失(共 2 项):…" } };
- },
- };
- const b = mount(state);
- b.openCommit({ mch_no: "M2", review_status: "PENDING" });
- try {
- await b.submitCommit();
- } catch {}
- check("S6 正对照:400 ⇒ **不**置 409 那条警示", b.commitSyncFailedMchNo.value, "");
- check("S7 正对照:400 ⇒ 也不当成功", b.committedMchNo.value, "");
- check("S8 正对照:400 ⇒ 提交态照样复位", b.commitSubmitting.value, false);
- }
- // ---- L. 列表读法:两层 `.data`(响应拦截器不解包) ----
- {
- const ROW = { id: "1", mch_no: "M1", merchant_name: "甲店", review_status: "PENDING" };
- const state = {
- ...fresh(),
- page: async () => ok({ list: [ROW], total: 7 }),
- };
- const b = mount(state);
- await b.loadList();
- check("L1 行取自 res.data.data.list", b.list.value.length, 1);
- // ★ 取下标必须带 `?.`:变异实测(M-LIST-ONE-LAYER)里 L1 一红,`list.value` 就是空数组,
- // 写成 `b.list.value[0].mch_no` 会抛 TypeError ⇒ **整轮崩掉**、SUMMARY 与后面所有用例全丢。
- // 一条断言失败绝不能把量具打成全黑 —— 那正是最需要读数的时候。
- check("L2 行内容原样", b.list.value[0]?.mch_no, "M1");
- check("L3 total 取自 res.data.data.total", b.total.value, 7);
- check("L4 分页参数是 camelCase 的 pageNo/pageSize", [state.pageParams.pageNo, state.pageParams.pageSize], [1, 20]);
- }
- {
- // ★ 正对照:把 payload 抬一层(模拟「只解了一层」的回法)⇒ 必须**读不到**行。
- // 没有这一条,「L1 读到 1 行」不能证明它读的是两层 —— 碰巧读一层也可能对。
- const ROW = { id: "1", mch_no: "M1" };
- const state = {
- ...fresh(),
- page: async () => ({ data: { code: 0, list: [ROW], total: 7 } }),
- };
- const b = mount(state);
- await b.loadList();
- check("L5 正对照:payload 只解一层时读不到行(证明它真的在读两层)", b.list.value.length, 0);
- check("L6 正对照:total 同理为 0", b.total.value, 0);
- }
- {
- const state = { ...fresh(), page: async () => ok({ list: [], total: 0 }) };
- const b = mount(state);
- b.query.pageNo = 3;
- b.handleSearch();
- check("L7 查询把页码打回第 1 页", b.query.pageNo, 1);
- }
- // ---- Y. 更新腿的失败/形状 ----
- {
- const state = { create: async () => ok(null), update: async () => ok(null) };
- const b = mount(state);
- b.mode.value = "update";
- b.form.mch_no = "Y1";
- await b.handleSubmit();
- check("Y1 更新腿成功**不**去读响应里的 mch_no(Result<Void>)", b.submitNoMchNo.value, false);
- check("Y2 更新腿不碰建号腿的提示位", b.createdMchNo.value, "");
- }
- {
- const state = {
- create: async () => ok(null),
- update: async () => {
- throw new Error("信封 500(上游挂了)");
- },
- };
- const b = mount(state);
- b.mode.value = "update";
- b.form.mch_no = "Y2";
- let escaped = false;
- try {
- await b.handleSubmit();
- } catch {
- escaped = true;
- }
- check("Y3 更新失败:rejection 不外逃(拦截器已弹过文案)", escaped, false);
- check("Y4 更新失败:不留成功提示", b.updatedMchNo.value, "");
- check("Y5 更新失败:提交态已复位", b.submitting.value, false);
- }
- // ---- Z. 键集:本台要把「39」这个数字**跑出来**,而不是从注释里抄 ----
- {
- const b = mount(fresh());
- check("Z1 建号键白名单 = 41(与页面注释里那个数字对撞)", b.CREATE_KEYS.length, 41);
- check("Z2 更新键白名单 = 39(交付说明里写的那个数字)", b.UPDATE_KEYS.length, 39);
- check("Z3 更新集是建号集的**真子集**(差的恰好 2)", b.CREATE_KEYS.length - b.UPDATE_KEYS.length, 2);
- check(
- "Z4 更新集里没有任何一个被排除的键",
- b.UPDATE_KEYS.filter((k) => b.UPDATE_EXCLUDED_KEYS.includes(k)),
- []
- );
- check(
- "Z5 更新集里没有 category / tenant_id / rate_code(各自对应 500 / 400 / 400)",
- b.UPDATE_KEYS.filter((k) => ["category", "tenant_id", "rate_code"].includes(k)),
- []
- );
- check("Z6 建号集里也没有 tenant_id / rate_code", b.CREATE_KEYS.filter((k) => ["tenant_id", "rate_code"].includes(k)), []);
- }
- if (process.argv.includes("--dump-keys")) {
- const b = mount(fresh());
- console.log("KEYS_JSON " + JSON.stringify(b.UPDATE_KEYS));
- }
- console.log(`SUMMARY ${LABEL}: pass=${nPass} fail=${failed.length}${failed.length ? " -> " + failed.join(" | ") : ""}`);
|