fe-update-harness.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. /**
  2. * 前端 `/update` 入口的执行级验证台。
  3. *
  4. * 形状抄自同目录 `task-9-fix1-harness.mjs`(那里有为什么必须这么搭的两条依据):
  5. * `@vue/compiler-sfc` 把页面**真**的 `<script setup>` 编译出来 → 用 `frontend/node_modules`
  6. * 里那份**真** `vue` 跑 `setup()` → 驱动**真函数** → 断言落在**真状态**上。
  7. *
  8. * ★ 量具与被测量物:`vue` / `@vue/compiler-sfc` / `typescript` 都是 frontend 里那一份(真品);
  9. * `@/api/...` 与上传组件是桩(这一步没有后端可打);模板**不渲染**(`inlineTemplate: false`)
  10. * ⇒ 本台证明的是「这些函数的判定逻辑」,**不是**「浏览器里长什么样」。
  11. * ★ 因此 `formRef.value?.validate()` 那一步因 `formRef.value === undefined` 被 `?.` 跳过 ——
  12. * 本台验不到 el-form 的校验行为,那部分只能靠 `vue-tsc` 与人工。
  13. *
  14. * ★ 本台**不设** `process.exitCode`(与 T9 那台一致)⇒ 失败也退 0,**只认最后那行 `SUMMARY`**。
  15. *
  16. * 用法:node fe-update-harness.mjs <index.vue 路径> [标签]
  17. */
  18. import fs from "node:fs";
  19. import { createRequire } from "node:module";
  20. const FE = "D:/project2/payment-platform/frontend/";
  21. const require = createRequire(FE + "package.json");
  22. const vue = require("vue");
  23. const { parse, compileScript } = require("@vue/compiler-sfc");
  24. const SRC = process.argv[2];
  25. const LABEL = process.argv[3] || SRC;
  26. /** 把 SFC 编译成可 `new Function` 的普通脚本(import 换成对 `globalThis.__U` 的取值)。 */
  27. function compileSfc(file) {
  28. const src = fs.readFileSync(file, "utf8");
  29. const { descriptor, errors } = parse(src, { filename: file });
  30. if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
  31. let code = compileScript(descriptor, { id: "uprobe", inlineTemplate: false }).content;
  32. const stripTypes = (names) => names.replace(/\btype\s+[\w$.]+\s*,?/g, "");
  33. const deAlias = (names) => names.replace(/\bas\b/g, ":");
  34. const braceImport = (spec) =>
  35. new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
  36. code = code
  37. // ★ 会有**两条** vue import(`defineOptions` 带出的 `defineComponent` 一条、文件自己一条)
  38. // ⇒ 正则必须带 `g`,且 `as` 别名要转成解构的 `:`。
  39. .replace(braceImport("vue"), (_m, n) => `const {${deAlias(n)}} = globalThis.__U.vue;`)
  40. .replace(
  41. braceImport("@/api/module_payment/onboarding"),
  42. (_m, n) => `const {${stripTypes(n)}} = globalThis.__U.api;`
  43. )
  44. .replace(
  45. /import\s+(\w+)\s+from\s*['"]@\/components\/Upload\/SingleImageUpload\.vue['"];?/,
  46. (_m, n) => `const ${n} = globalThis.__U.SingleImageUpload;`
  47. );
  48. if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
  49. // ★ `compileScript` **不**剥 TS ⇒ 自己过一遍单文件转译,否则 `interface Field {` 是 SyntaxError。
  50. const ts = require("typescript");
  51. code = ts.transpileModule(code, {
  52. compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
  53. fileName: "index.ts",
  54. }).outputText;
  55. code = code.replace(/export\s+default/, "const __sfc =");
  56. if (/^\s*export\s/m.test(code)) throw new Error("还有没换掉的 export");
  57. code += "\nglobalThis.__U.sfc = __sfc;";
  58. return code;
  59. }
  60. const code = compileSfc(SRC);
  61. // ---------- 桩 ----------
  62. const ok = (d) => ({ data: { code: 0, data: d } });
  63. function makeApi(state) {
  64. return {
  65. MERCHANT_CATEGORY_OPTIONS: [],
  66. getOnboardingAreas: async () => ok([]),
  67. getOnboardingMcc: async () => ok([]),
  68. uploadOnboardingImage: async () => ok(""),
  69. createOnboardingMerchant: async (body) => {
  70. state.created = body;
  71. return state.create(body);
  72. },
  73. updateOnboardingMerchant: async (body) => {
  74. state.updated = body;
  75. return state.update(body);
  76. },
  77. // ★ Task 3 新增的两条腿(列表 / 提交审核)。原版台子没有它们 —— 页面现在要打 `/page`
  78. // 与 `/commit`,不补桩的话只要真跑到 `loadList` / `submitCommit` 就是 TypeError。
  79. getOnboardingPage: async (params) => {
  80. state.pageParams = params;
  81. return state.page ? state.page() : ok({ list: [], total: 0 });
  82. },
  83. commitOnboardingMerchant: async (body) => {
  84. state.committed = body;
  85. return state.commit(body);
  86. },
  87. };
  88. }
  89. /** 每个场景一套全新的 refs:重新求值一次编译产物即可。 */
  90. function mount(state) {
  91. globalThis.__U = { vue, api: makeApi(state), SingleImageUpload: { name: "Stub" } };
  92. new Function(code)();
  93. return globalThis.__U.sfc.setup({}, { expose: () => {} });
  94. }
  95. const KEYS = (o) => (o ? Object.keys(o).sort() : null);
  96. // ---------- 断言 ----------
  97. let nPass = 0;
  98. const failed = [];
  99. function check(name, actual, expected) {
  100. const pass = JSON.stringify(actual) === JSON.stringify(expected);
  101. if (pass) nPass++;
  102. else failed.push(name);
  103. console.log(
  104. ` ${pass ? "PASS" : "FAIL"} ${name} (actual=${JSON.stringify(actual)} want=${JSON.stringify(expected)})`
  105. );
  106. }
  107. /** 断言「body 里**没有**这个键」—— 比 check(keys, [...]) 少写一坨期望值,失败时也更好读。 */
  108. function checkAbsent(name, body, key) {
  109. check(name, body && Object.prototype.hasOwnProperty.call(body, key), false);
  110. }
  111. console.log(`=== ${LABEL} ===`);
  112. const fresh = () => ({
  113. create: async () => ok(null),
  114. update: async () => ok(null),
  115. commit: async () => ok(null),
  116. });
  117. // ---- U. 更新腿报文的键白名单(本台的主症:多一个键 = HTTP 500,少发 = 上游不改) ----
  118. {
  119. const state = fresh();
  120. const b = mount(state);
  121. b.mode.value = "update";
  122. b.form.mch_no = " Y52211090004 ";
  123. b.form.account_card_num = "6222000000000000";
  124. // ★★ 手动把五个**绝不该发**的键塞进表单模型 —— 只有塞进去,「白名单在挡」这句话才不是空的。
  125. // 前三个的来路是「有人图省事把封盖/更新页没有的字段接进了表单」;
  126. // 后两个的来路是「有人把两道封盖的字段当普通字段接了进来」。
  127. // (Task 3 之后 `openUpdate` 会清空表单,所以这条路不再是「切模式留下的」——
  128. // 但白名单挡的是**模型里有没有这个键**,与它怎么进去的无关,塞进去照样是最硬的量法。)
  129. b.form.merchant_name_business = "某某公司";
  130. b.form.lease_contract = "https://oss/lease.png";
  131. b.form.category = "MICRO";
  132. b.form.tenant_id = "2";
  133. b.form.rate_code = "RATE_X";
  134. await b.handleSubmit();
  135. const body = state.updated;
  136. check("U1 更新报文**不含** merchant_name_business(发了就是 500)", body?.merchant_name_business, undefined);
  137. checkAbsent("U1b …用 hasOwnProperty 再确认一次", body, "merchant_name_business");
  138. checkAbsent("U2 更新报文**不含** lease_contract(发了就是 500)", body, "lease_contract");
  139. checkAbsent("U3 更新报文**不含** category(更新 DTO 上没有它 ⇒ 500)", body, "category");
  140. checkAbsent("U4 更新报文**不含** tenant_id(封盖 ⇒ 400)", body, "tenant_id");
  141. checkAbsent("U5 更新报文**不含** rate_code(封盖 ⇒ 400)", body, "rate_code");
  142. check("U6 更新报文**含** mch_no,且已 trim", body?.mch_no, "Y52211090004");
  143. check("U7 增量:填了的那一项原样发出", body?.account_card_num, "6222000000000000");
  144. check("U8 增量:**没填**的项一个都不发(恰好两个键)", KEYS(body), ["account_card_num", "mch_no"]);
  145. check("U9 提交态已复位", b.submitting.value, false);
  146. check("U10 成功提示用的是**请求里**那个商户号(响应里没有)", b.updatedMchNo.value, "Y52211090004");
  147. }
  148. // ---- V. 正对照:建号腿一个都没被改坏 ----
  149. {
  150. const state = fresh();
  151. const b = mount(state);
  152. b.form.category = "MICRO";
  153. b.form.merchant_name = "某某";
  154. b.form.merchant_name_business = "某某公司";
  155. b.form.lease_contract = "https://oss/lease.png";
  156. b.form.mch_no = "不该出现在建号报文里"; // 手工塞的(同上:白名单挡的是模型里有没有这个键)
  157. await b.handleSubmit();
  158. const body = state.created;
  159. check("V1 正对照:建号报文**仍然带** category", body?.category, "MICRO");
  160. check("V2 正对照:建号报文**仍然带** merchant_name_business", body?.merchant_name_business, "某某公司");
  161. check("V3 正对照:建号报文**仍然带** lease_contract", body?.lease_contract, "https://oss/lease.png");
  162. checkAbsent("V4 建号报文**不含** mch_no(它不在 CREATE_KEYS 里,是服务端产出)", body, "mch_no");
  163. checkAbsent("V5 建号报文不含 tenant_id", body, "tenant_id");
  164. checkAbsent("V6 建号报文不含 rate_code", body, "rate_code");
  165. }
  166. // ---- W. 渲染投影:那两格在更新模式下根本不渲染 ----
  167. {
  168. const b = mount(fresh());
  169. const nameSec = b.SECTIONS.find((s) => s.key === "name");
  170. const matSec = b.SECTIONS.find((s) => s.key === "materials");
  171. check("W1 建号模式:name 分区 3 个字段", b.visibleFields(nameSec).length, 3);
  172. check("W2 建号模式:materials 分区 5 个字段", b.visibleFields(matSec).length, 5);
  173. b.mode.value = "update";
  174. check("W3 更新模式:name 分区只剩 2 个(少了 merchant_name_business)", b.visibleFields(nameSec).length, 2);
  175. check("W4 更新模式:materials 分区只剩 4 个(少了 lease_contract)", b.visibleFields(matSec).length, 4);
  176. check(
  177. "W5 更新模式:被去掉的正是那两个键",
  178. b.visibleFields(nameSec).map((f) => f.key).concat(b.visibleFields(matSec).map((f) => f.key)).filter((k) => k === "merchant_name_business" || k === "lease_contract"),
  179. []
  180. );
  181. const addr = b.SECTIONS.find((s) => s.key === "address");
  182. check("W6 更新模式:address 分区不受影响", b.visibleFields(addr).map((f) => f.key), ["address_street"]);
  183. const mcc = b.SECTIONS.find((s) => s.key === "mcc");
  184. check("W7 更新模式:mcc 分区不受影响(merchant_mcc_code 在更新 DTO 上)", b.visibleFields(mcc).map((f) => f.key), ["merchant_mcc_code"]);
  185. }
  186. // ---- X. 打开两张表单的入口(原「切模式」段 —— Task 3 把那个单选开关删了) ----
  187. //
  188. // ★ 为什么整段重写而不是删:原 X1–X3 钉的是 `switchMode()`「误点一下不能毁掉 41 个框」。
  189. // 新形状里**没有**可误点的模式开关(模式由「点的是哪颗按钮」决定,见 `openCreate`/`openUpdate`),
  190. // 那条契约于是没有对应物了;取而代之的新契约**方向相反**:
  191. // 打开一张表单必须以**干净表单**起步 —— 否则「改资料」会把上一张建号表单的 41 个值
  192. // 当作增量更新发出去,那正是原 X6 **标签**警告的「等于按建号值再覆盖一遍」。
  193. // ★ 原 X6 的标签是这么写的,而它的断言(`=== "建号时填的"`)查的是**相反**的事:
  194. // 标签对、断言错。本段把断言改成标签的说法,并把标签里那句话拆成两条可查的读数。
  195. {
  196. const b = mount(fresh());
  197. b.form.merchant_name = "上次建号填的";
  198. b.form.category = "MICRO";
  199. b.createdMchNo.value = "M20260923";
  200. b.submitNoMchNo.value = true;
  201. b.openCreate();
  202. check("X1 新建入口:模式是 create", b.mode.value, "create");
  203. check("X2 新建入口:表单是**干净的**(不带上一次建号的值)", b.form.merchant_name, "");
  204. check("X3 新建入口:清掉上一次的建号结果提示", b.createdMchNo.value, "");
  205. check("X4 新建入口:清掉「没回商户号」告警", b.submitNoMchNo.value, false);
  206. }
  207. {
  208. const b = mount(fresh());
  209. b.form.merchant_name = "建号时填的";
  210. b.form.lease_contract = "https://oss/lease.png";
  211. b.openUpdate("Y52211090004");
  212. check("X5 改资料入口:模式切到 update", b.mode.value, "update");
  213. check("X6 改资料入口:商户号已预填", b.form.mch_no, "Y52211090004");
  214. check("X7 改资料入口**不**把建号表单的值带过去(否则等于按建号值再覆盖一遍)", b.form.merchant_name, "");
  215. check("X8 改资料入口:那个被排除的键也不带过去", b.form.lease_contract, "");
  216. }
  217. {
  218. const b = mount(fresh());
  219. b.mode.value = "update";
  220. check("X9 更新模式只钉商户号一条必填", Object.keys(b.rules.value), ["mch_no"]);
  221. b.mode.value = "create";
  222. check("X10 建号模式只钉商户类别一条必填(没被本次改动动过)", Object.keys(b.rules.value), ["category"]);
  223. }
  224. // ---- P. 进度列:四个词逐字 = 后端本地词表 ----
  225. {
  226. const b = mount(fresh());
  227. check(
  228. "P1 进度表恰好四个键(不另立第五个词)",
  229. Object.keys(b.PROGRESS).sort(),
  230. ["APPROVED", "PENDING", "REJECTED", "SUBMITTED"]
  231. );
  232. check("P2 PENDING ⇒ 待提交审核", b.progressOf({ review_status: "PENDING" })?.text, "待提交审核");
  233. check("P3 SUBMITTED ⇒ 审核中", b.progressOf({ review_status: "SUBMITTED" })?.text, "审核中");
  234. check("P4 APPROVED ⇒ 可以收款了", b.progressOf({ review_status: "APPROVED" })?.text, "可以收款了");
  235. check("P5 REJECTED ⇒ 审核未通过", b.progressOf({ review_status: "REJECTED" })?.text, "审核未通过");
  236. // ★ 正对照:表外的值必须**不**被静默当成某一档(拿到 null,页面渲染「状态未知」)
  237. check("P6 正对照:表外的值 ⇒ null(不静默归一)", b.progressOf({ review_status: "WHATEVER" }), null);
  238. check("P7 review_status 为 null ⇒ 也走「状态未知」", b.progressOf({ review_status: null }), null);
  239. }
  240. // ---- A. 每行一个下一步:本页最要紧的判定 ----
  241. //
  242. // 判错的代价可量化:主按钮指错 ⇒ 操作员卡死;把「提交审核」给到不该给的行
  243. // ⇒ 上游再收一次**无法回滚**的提交。
  244. const acts = (b, status) => b.actionsFor({ mch_no: "M1", review_status: status });
  245. const primaryOf = (b, status) => acts(b, status).filter((a) => a.primary).map((a) => a.text);
  246. const allOf = (b, status) => acts(b, status).map((a) => a.text);
  247. {
  248. const b = mount(fresh());
  249. check("A1 PENDING ⇒ 主按钮是「提交审核」", primaryOf(b, "PENDING"), ["提交审核"]);
  250. check("A2 SUBMITTED ⇒ 主按钮是「改资料」", primaryOf(b, "SUBMITTED"), ["改资料"]);
  251. check("A3 APPROVED ⇒ 主按钮是「改资料」", primaryOf(b, "APPROVED"), ["改资料"]);
  252. check("A4 REJECTED ⇒ 主按钮是「改资料」", primaryOf(b, "REJECTED"), ["改资料"]);
  253. // ★ 这条是「每行恰好一个主按钮」的**总闸**:任何一档给出两个主按钮,操作员就得做选择题
  254. check(
  255. "A5 五种状态**每档恰好一个**主按钮(操作员不需要选)",
  256. ["PENDING", "SUBMITTED", "APPROVED", "REJECTED", "WHATEVER"].map((s) => primaryOf(b, s).length),
  257. [1, 1, 1, 1, 1]
  258. );
  259. // ★ 非「可以收款了」的行都必须留一条「提交审核」的路 —— 少了它,被驳回的行改完资料
  260. // 就再也走不到送审那一步(按钮会一直停在「改资料」上)
  261. check(
  262. "A6 PENDING/SUBMITTED/REJECTED 都有提交审核入口",
  263. ["PENDING", "SUBMITTED", "REJECTED"].map((s) => allOf(b, s).includes("提交审核")),
  264. [true, true, true]
  265. );
  266. // ★ 反面:已经能收款的行**不给**重新提交 —— 再送一次没有意义,而误点一次的代价不可回滚
  267. check("A7 正对照:APPROVED **没有**提交审核入口", allOf(b, "APPROVED").includes("提交审核"), false);
  268. check("A8 APPROVED 只有一个按钮", allOf(b, "APPROVED"), ["改资料"]);
  269. // ★ 表外的值:不猜该送审还是该改资料 ⇒ 主按钮给保守的「改资料」,但**保留**送审入口,免得这行变死路
  270. check("A9 表外状态:主按钮给保守的「改资料」(不猜)", primaryOf(b, "WHATEVER"), ["改资料"]);
  271. check("A10 表外状态:仍保留提交审核入口(不变死路)", allOf(b, "WHATEVER").includes("提交审核"), true);
  272. check("A11 review_status 为 null 时也有按钮可点(不返空数组)", acts(b, null).length > 0, true);
  273. }
  274. // ---- C. 提交审核报文:6 个业务字段 + mch_no,空白整个省掉 ----
  275. {
  276. const b = mount(fresh());
  277. check("C1 提交报文的业务字段恰好 6 个", b.COMMIT_KEYS.length, 6);
  278. check(
  279. "C2 六个键逐字 = 后端 DTO 字段名(错一个就是 HTTP 500)",
  280. b.COMMIT_KEYS.slice().sort(),
  281. ["latitude", "longitude", "mcht_aptitude", "mcht_aptitude_note", "mcht_landuse", "store_loca"]
  282. );
  283. // ★ 服务层发给上游的是 `store_Loca` / `mcht_Landuse`(L 大写,进签名原文);**入站**收的是小写。
  284. // 把上游那个写法抄进请求体 = 未知属性 = HTTP 500,不是「静默当没传」。
  285. check(
  286. "C3 入站键名里**没有**上游那个大写 L 的写法",
  287. b.COMMIT_KEYS.filter((k) => k === "store_Loca" || k === "mcht_Landuse"),
  288. []
  289. );
  290. check("C4 资质码表 = 12 项(上游 01–12)", b.APTITUDE_OPTIONS.length, 12);
  291. check("C5 码表首尾逐字", [b.APTITUDE_OPTIONS[0].value, b.APTITUDE_OPTIONS[11].value], ["01", "12"]);
  292. }
  293. {
  294. const state = fresh();
  295. const b = mount(state);
  296. b.openCommit({ mch_no: "M20260923", review_status: "PENDING" });
  297. b.commitForm.mcht_aptitude = "07";
  298. b.commitForm.longitude = "116.397";
  299. await b.submitCommit();
  300. const body = state.committed;
  301. check("C6 报文含 mch_no 且来自那一行(不让操作员手输)", body?.mch_no, "M20260923");
  302. check("C7 填了的原样发出", body?.mcht_aptitude, "07");
  303. check("C8 没填的项整个省掉(**不送空串**)", KEYS(body), ["longitude", "mch_no", "mcht_aptitude"]);
  304. checkAbsent("C9 …用 hasOwnProperty 再确认空串没被送出去", body, "store_loca");
  305. check("C10 提交成功后关掉弹窗", b.commitVisible.value, false);
  306. check("C11 提交成功 ⇒ 不置 409 那条提示", b.commitSyncFailedMchNo.value, "");
  307. check("C12 提交成功 ⇒ 「已提交」提示用的是那一行的商户号", b.committedMchNo.value, "M20260923");
  308. }
  309. // ---- S. 提交审核的 409:**必须当成「已提交」**,不能当成失败 ----
  310. //
  311. // ★★ 后端 `markSubmittedOrSayWhy` 特意造的那条 409:上游**已经收到**这次提交了,
  312. // 只是本地状态没同步上。当成失败 ⇒ 操作员停在原地再点一次 ⇒ 上游再收一次,**无法回滚**。
  313. {
  314. const state = {
  315. ...fresh(),
  316. commit: async () => {
  317. // 拦截器对信封失败走 `Promise.reject(response)` ⇒ 抛出来的就是那个 AxiosResponse
  318. throw { data: { code: 409, msg: "入网信息已提交(商户号 M1),但本地状态未同步…" } };
  319. },
  320. };
  321. const b = mount(state);
  322. b.openCommit({ mch_no: "M1", review_status: "PENDING" });
  323. b.commitForm.mcht_aptitude = "07";
  324. b.commitForm.mcht_aptitude_note = "无";
  325. b.commitForm.store_loca = "XX路1号";
  326. b.commitForm.mcht_landuse = "租赁";
  327. b.commitForm.longitude = "116.397";
  328. b.commitForm.latitude = "39.908";
  329. let escaped = false;
  330. try {
  331. await b.submitCommit();
  332. } catch {
  333. escaped = true;
  334. }
  335. check("S1 409:rejection 不外逃到页面", escaped, false);
  336. check("S2 409:关掉弹窗(按「已提交」对待,不让操作员停在原地)", b.commitVisible.value, false);
  337. // ★ 这里原有一条 S3「409 不置成功提示」(`committedMchNo === ""`),**已删** —— 变异实测它没有牙:
  338. // 把 409 那个码值改成表外的值(那条分支永不进入)后,S2/S4 都红了,**S3 照绿**。
  339. // 成因:`submitCommit` 在**调用之前**就把 `committedMchNo` 置成 `""`,于是**一切失败路径**
  340. // 上它都是空串 ⇒ 这条断言分辨不出「走了 409 分支」与「根本没走」。它对任何变异都恒真。
  341. // 「没当成成功」这件事由 S4(警示位**必须**被置上)与 S2(弹窗**必须**关掉)钉住,够用。
  342. check("S4 409:置那条常驻警示,且带商户号", b.commitSyncFailedMchNo.value, "M1");
  343. check("S5 409:提交态已复位", b.commitSubmitting.value, false);
  344. }
  345. {
  346. // ★ 正对照:同一条路上换个非 409 的信封码,必须**不**走 409 那条分支
  347. const state = {
  348. ...fresh(),
  349. commit: async () => {
  350. throw { data: { code: 400, msg: "进件必填项缺失(共 2 项):…" } };
  351. },
  352. };
  353. const b = mount(state);
  354. b.openCommit({ mch_no: "M2", review_status: "PENDING" });
  355. try {
  356. await b.submitCommit();
  357. } catch {}
  358. check("S6 正对照:400 ⇒ **不**置 409 那条警示", b.commitSyncFailedMchNo.value, "");
  359. check("S7 正对照:400 ⇒ 也不当成功", b.committedMchNo.value, "");
  360. check("S8 正对照:400 ⇒ 提交态照样复位", b.commitSubmitting.value, false);
  361. }
  362. // ---- L. 列表读法:两层 `.data`(响应拦截器不解包) ----
  363. {
  364. const ROW = { id: "1", mch_no: "M1", merchant_name: "甲店", review_status: "PENDING" };
  365. const state = {
  366. ...fresh(),
  367. page: async () => ok({ list: [ROW], total: 7 }),
  368. };
  369. const b = mount(state);
  370. await b.loadList();
  371. check("L1 行取自 res.data.data.list", b.list.value.length, 1);
  372. // ★ 取下标必须带 `?.`:变异实测(M-LIST-ONE-LAYER)里 L1 一红,`list.value` 就是空数组,
  373. // 写成 `b.list.value[0].mch_no` 会抛 TypeError ⇒ **整轮崩掉**、SUMMARY 与后面所有用例全丢。
  374. // 一条断言失败绝不能把量具打成全黑 —— 那正是最需要读数的时候。
  375. check("L2 行内容原样", b.list.value[0]?.mch_no, "M1");
  376. check("L3 total 取自 res.data.data.total", b.total.value, 7);
  377. check("L4 分页参数是 camelCase 的 pageNo/pageSize", [state.pageParams.pageNo, state.pageParams.pageSize], [1, 20]);
  378. }
  379. {
  380. // ★ 正对照:把 payload 抬一层(模拟「只解了一层」的回法)⇒ 必须**读不到**行。
  381. // 没有这一条,「L1 读到 1 行」不能证明它读的是两层 —— 碰巧读一层也可能对。
  382. const ROW = { id: "1", mch_no: "M1" };
  383. const state = {
  384. ...fresh(),
  385. page: async () => ({ data: { code: 0, list: [ROW], total: 7 } }),
  386. };
  387. const b = mount(state);
  388. await b.loadList();
  389. check("L5 正对照:payload 只解一层时读不到行(证明它真的在读两层)", b.list.value.length, 0);
  390. check("L6 正对照:total 同理为 0", b.total.value, 0);
  391. }
  392. {
  393. const state = { ...fresh(), page: async () => ok({ list: [], total: 0 }) };
  394. const b = mount(state);
  395. b.query.pageNo = 3;
  396. b.handleSearch();
  397. check("L7 查询把页码打回第 1 页", b.query.pageNo, 1);
  398. }
  399. // ---- Y. 更新腿的失败/形状 ----
  400. {
  401. const state = { create: async () => ok(null), update: async () => ok(null) };
  402. const b = mount(state);
  403. b.mode.value = "update";
  404. b.form.mch_no = "Y1";
  405. await b.handleSubmit();
  406. check("Y1 更新腿成功**不**去读响应里的 mch_no(Result<Void>)", b.submitNoMchNo.value, false);
  407. check("Y2 更新腿不碰建号腿的提示位", b.createdMchNo.value, "");
  408. }
  409. {
  410. const state = {
  411. create: async () => ok(null),
  412. update: async () => {
  413. throw new Error("信封 500(上游挂了)");
  414. },
  415. };
  416. const b = mount(state);
  417. b.mode.value = "update";
  418. b.form.mch_no = "Y2";
  419. let escaped = false;
  420. try {
  421. await b.handleSubmit();
  422. } catch {
  423. escaped = true;
  424. }
  425. check("Y3 更新失败:rejection 不外逃(拦截器已弹过文案)", escaped, false);
  426. check("Y4 更新失败:不留成功提示", b.updatedMchNo.value, "");
  427. check("Y5 更新失败:提交态已复位", b.submitting.value, false);
  428. }
  429. // ---- Z. 键集:本台要把「39」这个数字**跑出来**,而不是从注释里抄 ----
  430. {
  431. const b = mount(fresh());
  432. check("Z1 建号键白名单 = 41(与页面注释里那个数字对撞)", b.CREATE_KEYS.length, 41);
  433. check("Z2 更新键白名单 = 39(交付说明里写的那个数字)", b.UPDATE_KEYS.length, 39);
  434. check("Z3 更新集是建号集的**真子集**(差的恰好 2)", b.CREATE_KEYS.length - b.UPDATE_KEYS.length, 2);
  435. check(
  436. "Z4 更新集里没有任何一个被排除的键",
  437. b.UPDATE_KEYS.filter((k) => b.UPDATE_EXCLUDED_KEYS.includes(k)),
  438. []
  439. );
  440. check(
  441. "Z5 更新集里没有 category / tenant_id / rate_code(各自对应 500 / 400 / 400)",
  442. b.UPDATE_KEYS.filter((k) => ["category", "tenant_id", "rate_code"].includes(k)),
  443. []
  444. );
  445. check("Z6 建号集里也没有 tenant_id / rate_code", b.CREATE_KEYS.filter((k) => ["tenant_id", "rate_code"].includes(k)), []);
  446. }
  447. if (process.argv.includes("--dump-keys")) {
  448. const b = mount(fresh());
  449. console.log("KEYS_JSON " + JSON.stringify(b.UPDATE_KEYS));
  450. }
  451. console.log(`SUMMARY ${LABEL}: pass=${nPass} fail=${failed.length}${failed.length ? " -> " + failed.join(" | ") : ""}`);