t67-mutations.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. /**
  2. * 票 67 变异测试:证明 t67 闸门的每条关键断言**抓得住它要防的缺陷**(t59/t60/t63/t64/t68 同一纪律)。
  3. *
  4. * 做法:对源文件做**单点**变异注入 → 跑 t67 → 断言**指定断言**转红 → 按字节还原并校验一致。
  5. * 「随便红一条就算」是不允许的:每条变异都点名它必须打红的那个断言名。
  6. *
  7. * 十四条变异对应本票的真实风险:
  8. * M1 多维分析(唯一真收片区的端点)不再下发 district → A1 红(片区静默失效,用户以为筛过了)
  9. * M2 当前事件被塞上 district(伪造一次后端不认的筛选) → A4 红(票面「不得伪造」)
  10. * M3 scope 不进条件键(换片区页码不复位) → B2 红(条文 5/6)
  11. * M4 watch(scope) 丢掉 eventStore(切片区四块面板不刷新) → C1 红(票 67 缺口 2 复发)
  12. * M5 历史分析那块不再声明片区缺口 → D-gap:historyAnalysis 红(如实说明缺失)
  13. * M6 缺口句里塞接口字段名 district → D5 红(票 05 文案纪律)
  14. * M7 districtApplied 恒 false(管线明明按片区筛了却说没筛)→ E3 红(语义退回半成品)
  15. * M8 管线说明写回「不作用于管线与构件图层」(旧失实结论) → E5 红(把能做的说成做不到)
  16. * M9 定时刷新漏传 scope(预警分析悄悄退回全域) → C2 红
  17. * M10 工单列表的行业缺口说明被摘掉 → D3b 红(同屏两数各说各话)
  18. * M11 当前预警面板的缺口说明被摘掉 → D-gap:currentEvents 红
  19. * M12 注册表出口绕过 withFilterCaliber(接缝被旁路) → D3 红(正向普查兜住)
  20. * M13 前缀映射错位(TC→0,太常的管线被当成城南) → E1/E2 红(推导正确率与筛选结果同时崩)
  21. * M14 推不出片区的对象被静默保留在具体片区结果里 → E4 红(既不硬塞也不该混进来)
  22. *
  23. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t67-mutations.mjs
  24. */
  25. import assert from "node:assert/strict"
  26. import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs"
  27. import { spawnSync } from "node:child_process"
  28. import { tmpdir } from "node:os"
  29. import { join } from "node:path"
  30. import { fileURLToPath } from "node:url"
  31. const repoRoot = fileURLToPath(new URL("../../../", import.meta.url))
  32. const GATE = fileURLToPath(new URL("t67-filter-caliber.mjs", import.meta.url))
  33. const srcPath = (rel) => repoRoot + "src/modules/cockpit/" + rel
  34. /** 用文件重定向取子进程输出(Windows 沙箱下管道捕获是文档化边界,见 t60/t63 注释)。 */
  35. const reportDir = mkdtempSync(join(tmpdir(), "t67-mutations-"))
  36. function runGate() {
  37. const outPath = join(reportDir, "stdout.txt")
  38. const errPath = join(reportDir, "stderr.txt")
  39. const out = openSync(outPath, "w")
  40. const err = openSync(errPath, "w")
  41. let status = 0
  42. try {
  43. status = spawnSync(process.execPath, [GATE], { stdio: ["ignore", out, err], timeout: 300000 }).status
  44. } finally {
  45. closeSync(out)
  46. closeSync(err)
  47. }
  48. return { output: readFileSync(outPath, "utf8") + readFileSync(errPath, "utf8"), status }
  49. }
  50. /** 断言名形如 "D-gap:historyAnalysis"(含冒号),所以按「非空白」切,不按冒号切。 */
  51. function failingNames(output) {
  52. return [...output.matchAll(/^FAIL (\S+)/gm)].map((match) => match[1])
  53. }
  54. let injected = 0
  55. let proved = 0
  56. /** 文字替换式变异:from 找不到就报「源未命中」(文件形状变了,变异脚本要跟着更新)。 */
  57. function mutate(label, file, from, to, expectRed) {
  58. return mutateWith(label, file, expectRed, (text) => {
  59. const candidates = [from, from.replace(/\n/g, "\r\n")]
  60. const needle = candidates.find((candidate) => text.includes(candidate))
  61. if (!needle) return null
  62. return text.replace(needle, to)
  63. })
  64. }
  65. /** 通用的单点变异:transform 回 null 表示变异源未命中。 */
  66. function mutateWith(label, file, expectRed, transform) {
  67. const path = srcPath(file)
  68. const original = readFileSync(path, "utf8")
  69. const mutated = transform(original)
  70. injected += 1
  71. if (mutated === null || mutated === original) {
  72. console.error("FAIL " + label + ": 变异源未命中(文件形状已变,需人工更新变异脚本)")
  73. return
  74. }
  75. writeFileSync(path, mutated, "utf8")
  76. let caught = false
  77. let how = ""
  78. try {
  79. const result = runGate()
  80. const red = failingNames(result.output)
  81. const expected = Array.isArray(expectRed) ? expectRed : [expectRed]
  82. const hit = red.find((name) => expected.some((want) => name === want || name.startsWith(want)))
  83. if (hit) {
  84. caught = true
  85. how = expected.join(" 或 ") + " 转红(实际命中 " + hit + ")"
  86. } else if (result.status !== 0 && /does not provide an export|SyntaxError|Cannot find (module|export)/.test(result.output)) {
  87. caught = true
  88. how = "闸门整体加载失败(导出缺失/语法错)— 崩溃式抓住"
  89. } else if (result.status !== 0) {
  90. how = "退出码 " + result.status + " 但目标断言未红(红项:" + (red.join(", ") || "无") + ")— 判据不明,按未抓住记"
  91. } else {
  92. how = "未转红(假绿!闸门仍全绿)"
  93. }
  94. console.log((caught ? "PASS " : "FAIL ") + label + " → " + how)
  95. if (caught) proved += 1
  96. } finally {
  97. writeFileSync(path, original, "utf8")
  98. assert.equal(readFileSync(path, "utf8"), original, label + ": 还原失败")
  99. }
  100. }
  101. const WARNING_RESPONSE = "services/warningResponseApi.js"
  102. const EVENT_API = "services/eventApi.js"
  103. const EVENT_STORE = "stores/eventStore.js"
  104. const SHELL = "CockpitShell.vue"
  105. const CALIBER = "domain/filterCaliber.js"
  106. const PANEL_VIEWS = "domain/panelViews.js"
  107. const PANELS = "domain/warningResponsePanels.js"
  108. const PIPES = "domain/pipeNetwork.js"
  109. const EVENT_LIST = "domain/eventList.js"
  110. // M1 多维分析不再下发 district(事件族的片区接线整条消失,界面却仍写着「当前范围」)
  111. mutate(
  112. "M1-analysis-drops-district",
  113. WARNING_RESPONSE,
  114. " if (hooks.acceptsDistrict === true) base.district = districtQuery(options.scope)",
  115. " if (hooks.acceptsDistrict === true) base.district = ''",
  116. "A1",
  117. )
  118. // M2 当前事件被塞上 district(后端实测忽略该参数 —— 传了就是伪造一次筛选,票面明令禁止)
  119. mutateWith("M2-current-events-fakes-district", EVENT_API, "A4", (text) => {
  120. const anchor = " industry: industryQuery(options.industry),"
  121. if (!text.includes(anchor)) return null
  122. return text.replace(anchor, anchor + "\n district: 0,")
  123. })
  124. // M3 scope 不进条件键(换片区页码不回第 1 页,用户停在第 3 页看另一片区的第 3 页)
  125. mutateWith("M3-scope-not-a-condition-key", EVENT_STORE, "B2", (text) => {
  126. const matched = /(const CONDITION_KEYS = \[)([^\]]*)(\])/.exec(text)
  127. if (!matched) return null
  128. const inner = matched[2]
  129. .split(",")
  130. .map((item) => item.trim())
  131. .filter((item) => item && item !== "'scope'" && item !== '"scope"')
  132. .join(", ")
  133. return text.replace(matched[0], matched[1] + inner + matched[3])
  134. })
  135. // M4 watch(scope) 丢掉 eventStore(票 67 缺口 2 原样复发:切片区预警四块面板不刷新)
  136. mutateWith("M4-scope-watch-drops-event-store", SHELL, "C1", (text) => {
  137. const start = text.indexOf("watch(scope")
  138. if (start < 0) return null
  139. const open = text.indexOf("{", start)
  140. let depth = 0
  141. let end = -1
  142. for (let index = open; index < text.length; index += 1) {
  143. if (text[index] === "{") depth += 1
  144. else if (text[index] === "}") {
  145. depth -= 1
  146. if (depth === 0) { end = index; break }
  147. }
  148. }
  149. if (end < 0) return null
  150. const block = text.slice(start, end)
  151. const stripped = block.replace(/^[ \t]*eventStore\.loadPanels\([^)]*\)[ \t]*\r?\n/m, "")
  152. if (stripped === block) return null
  153. return text.slice(0, start) + stripped + text.slice(end)
  154. })
  155. // M5 历史分析那块不再声明片区缺口(读者只会以为「切了片区、数字没变」是数据问题)
  156. mutateWith("M5-history-gap-removed", CALIBER, "D-gap:historyAnalysis", (text) => {
  157. const stripped = text.replace(/^[ \t]*historyAnalysis: .*\r?\n/m, "")
  158. return stripped === text ? null : stripped
  159. })
  160. // M6 缺口句里塞进接口字段名(票 05 纪律:接口词汇不上大屏)
  161. mutate(
  162. "M6-gap-leaks-api-field-name",
  163. CALIBER,
  164. "const NO_DISTRICT_PARAM = '后端该端点未提供片区入参'",
  165. "const NO_DISTRICT_PARAM = '后端该端点未提供 district 入参'",
  166. "D5",
  167. )
  168. // M7 districtApplied 恒 false(管线明明已经按编码前缀筛过了,界面却退回「没筛」的说法)
  169. mutate(
  170. "M7-district-applied-forced-false",
  171. PIPES,
  172. " districtApplied: district !== '',",
  173. " districtApplied: false,",
  174. "E3",
  175. )
  176. // M8 管线片区说明被写回旧结论「不作用于管线与构件图层」(把已经能做到的又说成做不到)
  177. mutateWith("M8-pipe-note-back-to-not-applied", CALIBER, "E5", (text) => {
  178. const start = text.indexOf("export const PIPE_DISTRICT_NOTE =")
  179. if (start < 0) return null
  180. // 不写反引号字面量(本脚本自身用模板串拼装):用字符码定位模板串的收尾引号。
  181. const quote = String.fromCharCode(96)
  182. const firstQuote = text.indexOf(quote, start)
  183. const lastQuote = firstQuote < 0 ? -1 : text.indexOf(quote, firstQuote + 1)
  184. if (lastQuote < 0) return null
  185. const lineEnd = text.indexOf("\n", lastQuote)
  186. if (lineEnd < 0) return null
  187. const replacement = "export const PIPE_DISTRICT_NOTE = '「当前范围」不作用于管线与构件图层:几何数据里没有片区字段'"
  188. return text.slice(0, start) + replacement + text.slice(lineEnd)
  189. })
  190. // M9 定时刷新漏传 scope(预警分析悄悄退回全域,而筛选条仍显示某个片区)
  191. mutate(
  192. "M9-refresh-drops-scope",
  193. SHELL,
  194. " await eventStore.loadPanels({ mode: eventStore.mode, industry: industry.value, scope: scope.value })",
  195. " await eventStore.loadPanels({ mode: eventStore.mode, industry: industry.value })",
  196. "C2",
  197. )
  198. // M10 工单列表的行业缺口说明被摘掉(同屏上方分布随行业变、下方列表不变且不说原因)
  199. mutate(
  200. "M10-work-order-industry-gap-removed",
  201. PANELS,
  202. " const industryNote = industryGap('workOrderList')",
  203. " const industryNote = ''",
  204. "D3b",
  205. )
  206. // M11 当前预警面板的缺口说明被摘掉(字段没了=屏上那句也没了)
  207. mutateWith("M11-current-events-gap-removed", EVENT_LIST, "D-gap:currentEvents", (text) => {
  208. const stripped = text.replace(/^[ \t]*gap: districtGap\('currentEvents'\),\r?\n/m, "")
  209. return stripped === text ? null : stripped
  210. })
  211. // M12 注册表出口绕过 withFilterCaliber(接缝被旁路:面板自己没写缺口,缺口就整体消失)
  212. mutate(
  213. "M12-registry-bypasses-filter-caliber",
  214. PANEL_VIEWS,
  215. " return withFilterCaliber(build(data, section, extra), panelId)",
  216. " return build(data, section, extra)",
  217. "D3",
  218. )
  219. // M13 前缀映射错位(TC→城南 0:太常的 4116 条管线会被当成城南,推导与筛选同时崩)
  220. mutate(
  221. "M13-prefix-map-misaligned",
  222. PIPES,
  223. " TC: DISTRICT_CODES.taichang,",
  224. " TC: DISTRICT_CODES.south,",
  225. ["E1", "E2"],
  226. )
  227. // M14 推不出片区的对象被静默保留在具体片区结果里(既不硬塞进某片区,也不该混进来)
  228. mutate(
  229. "M14-unresolved-kept-under-concrete-district",
  230. PIPES,
  231. " return row.district === district",
  232. " return true",
  233. "E4",
  234. )
  235. rmSync(reportDir, { recursive: true, force: true })
  236. console.log("")
  237. console.log("T67 变异:" + proved + "/" + injected + " 条被闸门抓住。")
  238. if (proved !== injected) {
  239. console.error("有变异未被抓住(闸门有假绿风险)。")
  240. process.exit(1)
  241. }
  242. console.log("T67 变异全部打红并按字节还原。")