t67-filter-caliber.mjs 63 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067
  1. /**
  2. * 票 67 闸门:全局「当前范围」(片区)与「行业」的筛选生效面。
  3. *
  4. * 背景(2026-10-22 只读探针实测,原始载荷落在 artifacts/t67/):维护者反馈第 8 条
  5. * 「当前范围没有影响到场景及物体的筛选及部分 UI 中的信息筛选」。逐端点双判据实测
  6. * (「district=99 是否 400」+「district=0/1 响应是否真的不同」)的结论分两类:
  7. *
  8. * 真收片区(非法值 400):/map-snapshot、/infrastructure/base-archives、
  9. * /infrastructure/management-information、/monitoring/distribution、
  10. * 「/events/multidimensional-analysis」(唯一真收片区的事件端点)
  11. * 忽略片区(非法值也 200、响应只差 requestId):/events/current、/events/history-analysis、
  12. * /events/handling-efficiency、/monitoring/overview、/monitoring/situation、
  13. * /infrastructure/operating-information、/infrastructure/emergency-support、
  14. * /overview/monitoring、/overview/warning-settings、/work-orders、/infrastructure/geometry
  15. *
  16. * 所以本票的交付不是「把所有端点都塞上 district」,而是分两类如实处理:
  17. * 1. 真收的端点接线(事件族里只有多维分析收片区);
  18. * 2. 忽略的端点不伪造参数,改成界面上如实说明「本块不随当前范围变化」。
  19. *
  20. * 六组断言(不启浏览器、不联网;真实载荷用已落盘 artifacts):
  21. * R 后端事实复核:逐端点从 artifacts 复算「收/不收片区」的判据(不靠记忆)。
  22. * A 参数面:注入 fetchImpl 捕获请求 URL,逐条断言「该带的带上、不该带的没有」
  23. * (analysis 四个 scope 取值 + 90 天多窗每窗都带;current/history/efficiency 一律不带;
  24. * monitoring 三块分化;infrastructure 四块分化;overview 三块全不带)。
  25. * B store 行为:真 pinia + 真 eventStore + 注入 transport。scope 是条件键——
  26. * 变化时三个页码回 1 且请求带 district;同条件重取(定时刷新)不复位。
  27. * C 外壳接线:stripComments 后源码断言 watch(scope) 调 eventStore、
  28. * refreshCurrentModePanels 的 warningResponse 分支把 scope 一起传。
  29. * D 如实说明:缺口句只有一处来源 domain/filterCaliber.js,由 domain/panelViews.js 的
  30. * panelView() 出口(withFilterCaliber)统一并进视图的 gap —— 所以闸门也从 panelView()
  31. * 取视图(不另拼一份)。11 个未接线面板逐块都要有片区缺口句、已接线面板一块都不许有;
  32. * 全注册表再做一次正向普查(新增面板漏说明就红);SSR 断言 data-panel-gap 真的进 DOM;
  33. * 行业缺口只一处(报警效率面板的工单列表块 pageNote)。
  34. * E 管线图层(维护者 2026-10-23 更正):管线编码是「片区 + 行业 + 时间编码」,所以片区
  35. * **由编码前缀推导**(几何 properties 里确实没有片区字段,两件事不矛盾)。E1 推导正确率
  36. * (6762/6762 与 name 的片区词一致、零反例)、E2 四个范围的筛选结果、E3 districtApplied
  37. * 语义(具体片区 true / 全域 false)、E4 推不出片区的对象如何计数与排除、E5 说明文案、
  38. * E6 外壳与 store 接线、E7 行业×片区正交。
  39. * F 不得伪造:反证组——current/history/efficiency 在任何 scope 下都不得出现 district
  40. * (后端实测忽略该参数,传了就是伪造一次筛选)。
  41. *
  42. * 变异有牙见 t67-mutations.mjs。
  43. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t67-filter-caliber.mjs
  44. */
  45. import assert from "node:assert/strict"
  46. import { readFileSync } from "node:fs"
  47. import { registerHooks } from "node:module"
  48. import { parse, compileScript, compileTemplate } from "@vue/compiler-sfc"
  49. import { h } from "@vue/runtime-core"
  50. import { createSSRApp } from "vue"
  51. import { renderToString } from "@vue/server-renderer"
  52. import { createPinia, setActivePinia } from "pinia"
  53. const SRC = "../../../src/modules/cockpit/"
  54. const repoRoot = new URL("../../../", import.meta.url)
  55. const read = (rel) => readFileSync(new URL(rel, repoRoot), "utf8")
  56. /** 判据钉代码,不钉说明文字(t59 成例):注释里出现什么字样都不算命中。 */
  57. const stripComments = (text) =>
  58. String(text)
  59. .replace(/\/\*[\s\S]*?\*\//g, "")
  60. .replace(/<!--[\s\S]*?-->/g, "")
  61. .replace(/(^|[^:])\/\/.*$/gm, "$1")
  62. /** 从 text[start] 附近的花括号块取出完整片段(源码接线断言用;纯文本级别,够用)。 */
  63. function braceBlock(text, start) {
  64. const open = text.indexOf("{", start)
  65. if (open < 0) return ""
  66. let depth = 0
  67. for (let index = open; index < text.length; index += 1) {
  68. if (text[index] === "{") depth += 1
  69. else if (text[index] === "}") {
  70. depth -= 1
  71. if (depth === 0) return text.slice(start, index + 1)
  72. }
  73. }
  74. return ""
  75. }
  76. if (typeof globalThis.document === "undefined") globalThis.document = { activeElement: null }
  77. let passed = 0
  78. let failed = 0
  79. function check(name, condition, detail = "") {
  80. if (condition) { passed += 1; console.log("PASS " + name) }
  81. else { failed += 1; console.error("FAIL " + name + ": " + detail) }
  82. }
  83. // .vue 一律走编译钩子(默认 SSR 版)。本票只做 SSR 渲染判据,不启浏览器。
  84. registerHooks({
  85. load(url, context, nextLoad) {
  86. const isClient = url.endsWith(".vue?client")
  87. if (url.endsWith(".vue") || isClient) {
  88. const path = isClient ? url.slice(0, -"?client".length) : url
  89. const source = readFileSync(new URL(path), "utf8")
  90. const { descriptor } = parse(source, { filename: path })
  91. const script = compileScript(descriptor, { id: "t67", genDefaultAs: "__sfc__" })
  92. const template = compileTemplate({
  93. source: descriptor.template.content,
  94. filename: path,
  95. id: "t67",
  96. ssr: !isClient,
  97. compilerOptions: { bindingMetadata: script.bindings },
  98. })
  99. assert.deepEqual(template.errors, [], path + " template")
  100. const tail = isClient ? "__sfc__.render = render; export default __sfc__;" : "__sfc__.ssrRender = ssrRender; export default __sfc__;"
  101. return { format: "module", shortCircuit: true, source: script.content + "\n" + template.code + "\n" + tail }
  102. }
  103. return nextLoad(url, context)
  104. },
  105. })
  106. // ---------------------------------------------------------------- 需要的模块
  107. const { useEventStore } = await import(SRC + "stores/eventStore.js")
  108. const { fetchCurrentEvents } = await import(SRC + "services/eventApi.js")
  109. const { fetchEventAnalysis, fetchEventHistory, fetchHandlingEfficiency } = await import(SRC + "services/warningResponseApi.js")
  110. const { fetchMonitoringDistribution, fetchMonitoringOverview, fetchMonitoringSituation } = await import(SRC + "services/monitoringApi.js")
  111. const { fetchBaseArchives, fetchEmergencySupport, fetchManagementInformation, fetchOperatingInformation } = await import(SRC + "services/infrastructureApi.js")
  112. // 票 05–08(并行在制)把概览改成四个**不收入参**的新端点,本行按新导出取。
  113. const { fetchPerception, fetchPipeNetwork, fetchWarningStudy, fetchAlarmDisposal } = await import(SRC + "services/overviewApi.js")
  114. const { fetchWorkOrderList } = await import(SRC + "services/detailApi.js")
  115. const { currentEventsView } = await import(SRC + "domain/eventList.js")
  116. const { responseEfficiencyPanel, eventAnalysisPanel } = await import(SRC + "domain/warningResponsePanels.js")
  117. const { panelView, hasPanelView, implementedPanelIds } = await import(SRC + "domain/panelViews.js")
  118. const { REGION_SCOPE_NOTE } = await import(SRC + "domain/monitoringPanels.js")
  119. const pipeNetworkModule = await import(SRC + "domain/pipeNetwork.js")
  120. const { DISTRICT_CODES } = await import(SRC + "domain/filterOptions.js")
  121. const { usePipeNetworkStore } = await import(SRC + "stores/pipeNetworkStore.js")
  122. /**
  123. * 管线领域的新接口用「命名空间 + 运行时探测」取:pipeDistrictOf / PIPE_DISTRICT_PREFIX
  124. * 还没落地时,E 组逐条报红(而不是整个闸门 import 失败、别的组的结论也看不到)。
  125. */
  126. const pipeFn = (name) => (typeof pipeNetworkModule[name] === "function" ? pipeNetworkModule[name] : null)
  127. const pipeDistrictOf = (code) => {
  128. const fn = pipeFn("pipeDistrictOf")
  129. return fn ? fn(code) : undefined
  130. }
  131. const filterPipeNetworkOf = (network, filters) => {
  132. const fn = pipeFn("filterPipeNetwork")
  133. return fn ? fn(network, filters) : null
  134. }
  135. const { SECTION_STATE, section } = await import(SRC + "domain/sectionState.js")
  136. const { default: PanelBlocks } = await import(SRC + "components/PanelBlocks.vue")
  137. // 单一口径来源(本票新增):文件还没落地时不要让整个闸门崩掉 —— D 组逐条报红更有用。
  138. let caliber = null
  139. let caliberError = ""
  140. try {
  141. caliber = await import(SRC + "domain/filterCaliber.js")
  142. } catch (error) {
  143. caliberError = String((error && error.message) || error)
  144. }
  145. const caliberValue = (name, fallback) => (caliber && caliber[name] !== undefined ? caliber[name] : fallback)
  146. const gapSentence = (fnName, panelId) => {
  147. const fn = caliber && caliber[fnName]
  148. if (typeof fn !== "function") return ""
  149. try { return String(fn(panelId) || "") } catch { return "" }
  150. }
  151. const districtGapOf = (panelId) => gapSentence("districtGap", panelId)
  152. const industryGapOf = (panelId) => gapSentence("industryGap", panelId)
  153. /** 用户可见文案里不许出现接口字段名(票 05 纪律:读者是值班人员,不是接口调用方)。 */
  154. const API_FIELD_TOKENS = [
  155. "district", "districtApplied", "districtFilter", "geometry", "geometryType",
  156. "mapFilter", "workOrders", "pageSize", "snapshotAt", "pointStatus", "geoJson",
  157. ]
  158. const CAMEL_IDENTIFIER = /\b[a-z]+[A-Z][A-Za-z]{2,}\b/
  159. const leakedFields = (text) => {
  160. const value = String(text || "")
  161. const hits = API_FIELD_TOKENS.filter((token) => value.toLowerCase().includes(token.toLowerCase()))
  162. const camel = CAMEL_IDENTIFIER.exec(value)
  163. if (camel) hits.push(camel[0])
  164. return hits
  165. }
  166. /** 「不随当前范围变化」这类语义的措辞(任一命中即可;不钉具体句式,钉语义)。 */
  167. const NOT_APPLIED = /不随|不作用|不生效|不参与|未提供|不提供|不含|没有/
  168. const FALSELY_APPLIED = /已按片区|已按当前范围|片区筛选已生效/
  169. const readySection = () => ({ ...section(SECTION_STATE.SUCCESS, { data: null }), loaded: true })
  170. // ================================================================ R. 后端事实复核(artifacts 复算)
  171. const ARTIFACT = (name) => JSON.parse(readFileSync(new URL("./artifacts/t67/" + name, import.meta.url), "utf8"))
  172. /**
  173. * 请求级易变字段:两次探针必然不同(requestId 单调、窗口按当前时间重算、运行信息的
  174. * 指标「已过去多少秒」随挂钟走),不算「响应不同」。
  175. */
  176. const VOLATILE = /(requestId|serverUpdatedAt|updatedAt|ageSeconds|\.range\.start$|\.range\.end$|geoWindow)/
  177. function flatten(value, prefix = "", out = {}) {
  178. if (value && typeof value === "object") {
  179. for (const key of Object.keys(value)) flatten(value[key], prefix ? prefix + "." + key : key, out)
  180. return out
  181. }
  182. out[prefix] = value
  183. return out
  184. }
  185. /** district=0 与 district=1 的响应除易变字段外是否逐字节同(同=后端确实忽略片区)。 */
  186. function sameModuloVolatile(left, right) {
  187. const a = flatten(left)
  188. const b = flatten(right)
  189. const keys = new Set([...Object.keys(a), ...Object.keys(b)])
  190. const drift = [...keys].filter((key) => !VOLATILE.test(key) && JSON.stringify(a[key]) !== JSON.stringify(b[key]))
  191. return { same: drift.length === 0, drift }
  192. }
  193. {
  194. // R1 真收片区的五个端点:district=99 必须 400(非法值被校验=这个参数真的进了查询)。
  195. const wired = ["map-snapshot", "infra-base-archives", "infra-management", "mon-distribution", "ev-analysis"]
  196. const verdicts = wired.map((name) => {
  197. const payload = ARTIFACT("v2-" + name + "-d99.json")
  198. return { name, code: payload && payload.code, msg: String((payload && payload.msg) || "") }
  199. })
  200. check(
  201. "R1 真收片区的端点对 district=99 一律 400(参数确实生效)",
  202. verdicts.every((row) => Number(row.code) === 400 && /district/i.test(row.msg)),
  203. "实测:" + verdicts.map((row) => row.name + "=" + row.code).join(" "),
  204. )
  205. // R2 被忽略的端点:district=99 也 200,且 district=0/1 只差易变字段。
  206. const ignored = ["ev-current", "ev-history", "ev-efficiency", "mon-overview", "mon-situation", "infra-operating", "infra-emergency", "ov-monitoring", "ov-warning-settings", "work-orders"]
  207. const ignores = ignored.map((name) => {
  208. const d99 = ARTIFACT("v2-" + name + "-d99.json")
  209. const d0 = ARTIFACT("v2-" + name + "-d0.json")
  210. const d1 = ARTIFACT("v2-" + name + "-d1.json")
  211. const diff = sameModuloVolatile(d0, d1)
  212. return { name, code: d99 && d99.code, same: diff.same, drift: diff.drift }
  213. })
  214. check(
  215. "R2 忽略片区的端点:district=99 也 200、且 district=0/1 响应除易变字段外完全一致",
  216. ignores.every((row) => Number(row.code) === 200 && row.same),
  217. "不符:" + ignores.filter((row) => Number(row.code) !== 200 || !row.same).map((row) => row.name + "(" + row.code + "," + row.drift.join("/") + ")").join(" "),
  218. )
  219. // R3 geometry:6766 个 feature 没有任何片区字段 —— 这是「管线图层永远不随片区变化」的数据依据。
  220. const geom = ARTIFACT("geom-none.json")
  221. const features = (geom.data && geom.data.features) || []
  222. const propertyKeys = new Set()
  223. for (const feature of features) for (const key of Object.keys((feature && feature.properties) || {})) propertyKeys.add(key)
  224. const districtKeys = [...propertyKeys].filter((key) => /district|片区|area|region/i.test(key))
  225. check(
  226. "R3 管线几何载荷里没有片区字段(管线图层不随片区变化是数据事实,不是前端偷懒)",
  227. features.length > 1000 && districtKeys.length === 0,
  228. "features=" + features.length + " 片区相关字段=" + (districtKeys.join(",") || "无"),
  229. )
  230. }
  231. // ================================================================ 装配:注入 fetchImpl 捕获请求 URL
  232. const CONFIG = { dataSource: "real", api: { baseUrl: "http://stub.local/api/cockpit/v1", token: "" } }
  233. const REQUESTS = []
  234. function eventItemRaw(no) {
  235. return {
  236. eventNo: no, eventName: "事件" + no, eventType: "WARN-WATER-LEVEL", level: 2, levelColor: "YELLOW",
  237. status: "PENDING", eventTime: "2026-10-21T10:00:00+08:00", deviceCode: "EQ-" + no, deviceName: "设备" + no,
  238. monitoringPointCode: "MP-1", location: "城区", industry: "GAS", value: 1.2, unit: "%LEL",
  239. }
  240. }
  241. function payloadFor(url) {
  242. const query = new URLSearchParams(url.split("?")[1] || "")
  243. const page = Number(query.get("page") || 1)
  244. const pageSize = Number(query.get("pageSize") || 10)
  245. const pagination = { page, pageSize, total: 25, totalPages: 3, hasMore: page < 3 }
  246. const range = { type: "TREND_WINDOW", startTime: query.get("startTime") || "", endTime: query.get("endTime") || "" }
  247. const family = query.get("eventFamily") || query.get("mode") || "WARNING"
  248. if (url.includes("/events/current")) {
  249. return {
  250. mode: family, displayName: "当前预警", updatedAt: "2026-10-21T10:00:00+08:00",
  251. filters: { mode: family, level: null, industry: null, status: null },
  252. pagination, mapFilter: { mode: family, deviceCodes: [] },
  253. sections: {
  254. events: { state: "SUCCESS", reason: "SUCCESS", message: null, data: [eventItemRaw("W-1")] },
  255. workOrders: { state: "EMPTY", reason: "NO_WORK_ORDERS", message: null, data: null },
  256. },
  257. }
  258. }
  259. if (url.includes("/events/history-analysis")) {
  260. return { mode: family, range, events: [eventItemRaw("H-1")], rows: [{ timeBucket: range.startTime || "2026-10-21T00:00:00+08:00", eventCount: 1, handledCount: 0 }], pagination }
  261. }
  262. if (url.includes("/events/handling-efficiency")) {
  263. return {
  264. eventFamily: family, range, events: [eventItemRaw("E-1")],
  265. summary: {
  266. totalEvents: 1, linkedWorkOrderCount: 0, missingWorkOrderCount: 1,
  267. handlingEfficiency: { value: "0.5000", numerator: 1, denominator: 2, state: "AVAILABLE", reason: "READY" },
  268. averageDuration: { value: "1.00", unit: "HOURS", state: "AVAILABLE", reason: "READY", sampleCount: 2 },
  269. },
  270. workOrderStateCounts: [],
  271. }
  272. }
  273. if (url.includes("/events/multidimensional-analysis")) {
  274. return { eventFamily: family, range, totalCount: 1, dimensions: [] }
  275. }
  276. if (url.includes("/work-orders")) {
  277. return {
  278. state: "SUCCESS", stateReason: "READY", stateDictionary: [], pagination,
  279. workOrders: [{ workOrderCode: "WO-1", stateCode: 0, stateName: "待派单", createTime: "2026-10-21T10:00:00+08:00", description: "样例工单" }],
  280. }
  281. }
  282. return {}
  283. }
  284. function captureFetch() {
  285. REQUESTS.length = 0
  286. const fetchImpl = async (url) => {
  287. REQUESTS.push(String(url))
  288. return { status: 200, ok: true, json: async () => ({ code: 200, msg: "ok", data: payloadFor(String(url)) }) }
  289. }
  290. return fetchImpl
  291. }
  292. const queryOf = (url) => new URLSearchParams(String(url).split("?")[1] || "")
  293. const districtOf = (url) => queryOf(url).get("district")
  294. const urlsWith = (fragment) => REQUESTS.filter((url) => String(url).includes(fragment))
  295. const SCOPE_CASES = [
  296. ["south", "0"],
  297. ["north", "1"],
  298. ["taichang", "2"],
  299. ["unassigned", "UNASSIGNED"],
  300. ]
  301. async function withCapture(run) {
  302. REQUESTS.length = 0
  303. await run(captureFetch())
  304. return [...REQUESTS]
  305. }
  306. // ================================================================ A. 参数面:该带的带上、不该带的没有
  307. {
  308. // A1 多维分析(唯一真收片区的事件端点):四个片区取值逐个落成后端编码。
  309. const analysis = []
  310. for (const [scope, expected] of SCOPE_CASES) {
  311. const urls = await withCapture((fetchImpl) => fetchEventAnalysis({ mode: "WARNING", rangeType: "LAST_7_DAYS", scope, config: CONFIG, fetchImpl }))
  312. analysis.push({ scope, expected, got: districtOf(urls[0] || "") })
  313. }
  314. check(
  315. "A1 多维分析把片区落成 district=0/1/2/UNASSIGNED(四个取值逐个对)",
  316. analysis.every((row) => row.got === row.expected),
  317. "实测:" + analysis.map((row) => row.scope + "=" + row.got).join(" "),
  318. )
  319. // A2 全域 / 未传:不传 district(不是传空串占位,也不是传 0 冒充城南)。
  320. const noneUrls = [
  321. ...(await withCapture((fetchImpl) => fetchEventAnalysis({ mode: "WARNING", rangeType: "LAST_7_DAYS", scope: "all", config: CONFIG, fetchImpl }))),
  322. ...(await withCapture((fetchImpl) => fetchEventAnalysis({ mode: "WARNING", rangeType: "LAST_7_DAYS", config: CONFIG, fetchImpl }))),
  323. ]
  324. check(
  325. "A2 全域/未传片区时多维分析不带 district(全域不等于城南)",
  326. noneUrls.length === 2 && noneUrls.every((url) => queryOf(url).get("district") === null),
  327. "实测:" + noneUrls.map((url) => queryOf(url).get("district")).join(" / "),
  328. )
  329. // A3 90 天多窗:后端单请求上限 31 天,前端切窗逐请求 —— 每一窗都要带同一个 district。
  330. const windowUrls = await withCapture((fetchImpl) => fetchEventAnalysis({ mode: "WARNING", rangeType: "LAST_90_DAYS", scope: "south", config: CONFIG, fetchImpl }))
  331. const analysisWindows = urlsWith("/events/multidimensional-analysis")
  332. check(
  333. "A3 90 天多窗的每一窗都带同一个 district=0(不是只给第一窗带上)",
  334. windowUrls.length >= 2 && analysisWindows.length === windowUrls.length && analysisWindows.every((url) => districtOf(url) === "0"),
  335. "窗口数=" + analysisWindows.length + " district=" + analysisWindows.map((url) => districtOf(url)).join(","),
  336. )
  337. // A4 当前/历史/效率三块事件端点:任何片区取值都不许带 district(后端实测忽略,传了就是伪造)。
  338. const eventLeaks = []
  339. for (const [scope] of [...SCOPE_CASES, ["all"]]) {
  340. await withCapture(async (fetchImpl) => {
  341. await fetchCurrentEvents({ mode: "WARNING", scope, config: CONFIG, fetchImpl })
  342. await fetchEventHistory({ mode: "WARNING", rangeType: "LAST_7_DAYS", scope, config: CONFIG, fetchImpl })
  343. await fetchHandlingEfficiency({ mode: "WARNING", rangeType: "LAST_7_DAYS", scope, config: CONFIG, fetchImpl })
  344. })
  345. for (const url of REQUESTS) {
  346. if (districtOf(url) !== null) eventLeaks.push(scope + ":" + url)
  347. }
  348. }
  349. check(
  350. "A4 当前事件/历史分析/处置效率在任意片区下都不带 district(后端忽略该参数)",
  351. eventLeaks.length === 0,
  352. "泄漏:" + eventLeaks.slice(0, 3).join(" | "),
  353. )
  354. // A5 监测三块分化:分布真收片区,概览与运行概况不收。
  355. const monitoringUrls = await withCapture(async (fetchImpl) => {
  356. await fetchMonitoringOverview({ scope: "north", config: CONFIG, fetchImpl })
  357. await fetchMonitoringSituation({ scope: "north", config: CONFIG, fetchImpl })
  358. await fetchMonitoringDistribution({ scope: "north", config: CONFIG, fetchImpl })
  359. })
  360. const distributionUrls = urlsWith("/monitoring/distribution")
  361. const noticeUrls = urlsWith("/monitoring/overview").concat(urlsWith("/monitoring/situation"))
  362. check(
  363. "A5 监测三块:分布带 district=1,概览与运行概况不带",
  364. distributionUrls.length === 1 && districtOf(distributionUrls[0]) === "1" && noticeUrls.length === 2 && noticeUrls.every((url) => districtOf(url) === null),
  365. "分布=" + districtOf(distributionUrls[0] || "") + " 概览/概况=" + noticeUrls.map((url) => districtOf(url)).join(",") + " 请求数=" + monitoringUrls.length,
  366. )
  367. // A6 基础设施四块分化:基础档案与管理信息真收,运行信息与应急保障不收。
  368. const infraUrls = await withCapture(async (fetchImpl) => {
  369. await fetchBaseArchives({ scope: "taichang", config: CONFIG, fetchImpl })
  370. await fetchManagementInformation({ scope: "taichang", config: CONFIG, fetchImpl })
  371. await fetchOperatingInformation({ scope: "taichang", config: CONFIG, fetchImpl })
  372. await fetchEmergencySupport({ scope: "taichang", config: CONFIG, fetchImpl })
  373. })
  374. const archiveUrl = urlsWith("/infrastructure/base-archives")[0]
  375. const managementUrl = urlsWith("/infrastructure/management-information")[0]
  376. const silentInfra = urlsWith("/infrastructure/operating-information").concat(urlsWith("/infrastructure/emergency-support"))
  377. check(
  378. "A6 基础设施四块:档案与管理信息带 district=2,运行信息与应急保障不带",
  379. districtOf(archiveUrl || "") === "2" && districtOf(managementUrl || "") === "2" && silentInfra.length === 2 && silentInfra.every((url) => districtOf(url) === null),
  380. "档案=" + districtOf(archiveUrl || "") + " 管理=" + districtOf(managementUrl || "") + " 运行/应急=" + silentInfra.map((url) => districtOf(url)).join(",") + " 请求数=" + infraUrls.length,
  381. )
  382. // A7 概览四块(票 05–08 的新四端点):按规格 §1 **都不收入参**,所以即便给了片区也不许带 district。
  383. // 注:这四个端点在本机后端尚未部署(实测 404),因此这条判据钉的是**前端契约**(不擅自加参数),
  384. // 而不是"后端会忽略";等端点上线后由票 77 的复跑探针补实测证据。
  385. const overviewUrls = await withCapture(async (fetchImpl) => {
  386. await fetchPerception({ scope: "south", config: CONFIG, fetchImpl })
  387. await fetchPipeNetwork({ scope: "south", config: CONFIG, fetchImpl })
  388. await fetchWarningStudy({ scope: "south", config: CONFIG, fetchImpl })
  389. await fetchAlarmDisposal({ scope: "south", config: CONFIG, fetchImpl })
  390. })
  391. check(
  392. "A7 概览四块即便给了片区也不带 district(四个端点按规格都不收入参)",
  393. overviewUrls.length === 4 && overviewUrls.every((url) => districtOf(url) === null),
  394. "实测:" + overviewUrls.map((url) => districtOf(url)).join(","),
  395. )
  396. // A8 同一批端点也不带行业:行业筛选在概览四块同样无处落脚(界面上由口径说明交代)。
  397. const overviewIndustry = await withCapture(async (fetchImpl) => {
  398. await fetchPerception({ industry: "gas", config: CONFIG, fetchImpl })
  399. await fetchPipeNetwork({ industry: "gas", config: CONFIG, fetchImpl })
  400. await fetchWarningStudy({ industry: "gas", config: CONFIG, fetchImpl })
  401. await fetchAlarmDisposal({ industry: "gas", config: CONFIG, fetchImpl })
  402. })
  403. check(
  404. "A8 概览四块即便给了行业也不带 industry(端点没有该入参,不得伪造)",
  405. overviewIndustry.length === 4 && overviewIndustry.every((url) => queryOf(url).get("industry") === null),
  406. "实测:" + overviewIndustry.map((url) => queryOf(url).get("industry")).join(","),
  407. )
  408. // A9 工单列表(/work-orders)同样不收片区 —— 传了只会是一次静默无效的筛选。
  409. const workOrderUrls = await withCapture((fetchImpl) => fetchWorkOrderList({ page: 1, pageSize: 5, scope: "south", industry: "gas" }, { config: CONFIG, fetchImpl }))
  410. check(
  411. "A9 工单列表不带 district(后端忽略该参数)",
  412. workOrderUrls.length === 1 && districtOf(workOrderUrls[0]) === null,
  413. "实测:" + workOrderUrls.map((url) => queryOf(url).get("district")).join(","),
  414. )
  415. }
  416. // ================================================================ B. store 行为:scope 是条件键
  417. {
  418. const pinia = createPinia()
  419. setActivePinia(pinia)
  420. const store = useEventStore()
  421. const boot = () => {
  422. store.reset()
  423. store.configure({ config: CONFIG, fetchImpl: captureFetch() })
  424. }
  425. await boot()
  426. await store.loadPanels({ mode: "WARNING", range: "7d", scope: "south" })
  427. // B1 首屏:四块面板各自的请求里,只有多维分析带片区。
  428. const analysisUrl = urlsWith("/events/multidimensional-analysis")[0]
  429. const noDistrict = urlsWith("/events/current").concat(
  430. urlsWith("/events/history-analysis"),
  431. urlsWith("/events/handling-efficiency"),
  432. urlsWith("/work-orders"),
  433. )
  434. check(
  435. "B1 store 首取:多维分析带 district=0,其余四块不带",
  436. districtOf(analysisUrl || "") === "0" && noDistrict.length === 4 && noDistrict.every((url) => districtOf(url) === null),
  437. "分析=" + districtOf(analysisUrl || "") + " 其余=" + noDistrict.map((url) => districtOf(url)).join(",") + "(请求 " + REQUESTS.length + " 个)",
  438. )
  439. // B2 换片区:三个页码都回第 1 页,且请求带新片区的 district。
  440. await store.setCurrentPage(2)
  441. await store.setHistoryPage(2)
  442. await store.setWorkOrderPage(2)
  443. const beforeSwitch = { current: store.currentPage, history: store.historyPage, workOrder: store.workOrderPage }
  444. REQUESTS.length = 0
  445. await store.loadPanels({ scope: "north" })
  446. const switchAnalysis = urlsWith("/events/multidimensional-analysis")[0]
  447. check(
  448. "B2 换片区(条件键变化):当前事件/历史/工单三个页码全部回 1,且请求带 district=1",
  449. beforeSwitch.current === 2 &&
  450. store.currentPage === 1 && store.historyPage === 1 && store.workOrderPage === 1 &&
  451. districtOf(switchAnalysis || "") === "1" &&
  452. urlsWith("/events/current").every((url) => queryOf(url).get("page") === "1"),
  453. "切换前 " + JSON.stringify(beforeSwitch) + " → 切换后 current=" + store.currentPage + " history=" + store.historyPage + " workOrder=" + store.workOrderPage +
  454. ";分析 district=" + districtOf(switchAnalysis || "") + ";请求 " + REQUESTS.length + " 个",
  455. )
  456. // B3 同条件重取(定时刷新,不带 conditions):页码保持、片区条件保持。
  457. await store.setCurrentPage(2)
  458. REQUESTS.length = 0
  459. await store.loadPanels()
  460. const refreshCurrent = urlsWith("/events/current")[0]
  461. check(
  462. "B3 同条件重取(定时刷新)不复位页码,且仍按当前片区取数",
  463. store.currentPage === 2 &&
  464. queryOf(refreshCurrent || "").get("page") === "2" &&
  465. districtOf(urlsWith("/events/multidimensional-analysis")[0] || "") === "1",
  466. "刷新后 page=" + store.currentPage + ";当前事件 page=" + queryOf(refreshCurrent || "").get("page") + ";分析 district=" + districtOf(urlsWith("/events/multidimensional-analysis")[0] || ""),
  467. )
  468. // B4 条件键源码面(行为已在 B2 钉住;这条是给「谁把 scope 从表里拿掉」的直读信号)。
  469. const storeSource = stripComments(read("src/modules/cockpit/stores/eventStore.js"))
  470. const conditionLine = /CONDITION_KEYS = \[([^\]]*)\]/.exec(storeSource)
  471. check(
  472. "B4 CONDITION_KEYS 含 scope(条件键表里点名,不只靠行为)",
  473. !!conditionLine && /['"]scope['"]/.test(conditionLine[1]),
  474. "CONDITION_KEYS=" + (conditionLine ? conditionLine[1].trim() : "未找到"),
  475. )
  476. }
  477. // ================================================================ C. 外壳接线(stripComments 后源码)
  478. {
  479. const shell = stripComments(read("src/modules/cockpit/CockpitShell.vue"))
  480. const watchIndex = shell.indexOf("watch(scope")
  481. const watchBlock = watchIndex < 0 ? "" : braceBlock(shell, watchIndex)
  482. const watchCall = /eventStore\.loadPanels\(\{([^}]*)\}\)/.exec(watchBlock)
  483. check(
  484. "C1 watch(scope) 里调 eventStore.loadPanels 并把 scope 传下去",
  485. watchIndex >= 0 && !!watchCall && /\bscope\b/.test(watchCall[1]),
  486. watchIndex < 0 ? "没找到 watch(scope" : "watch(scope) 块:" + watchBlock.replace(/\s+/g, " ").slice(0, 240),
  487. )
  488. const refreshIndex = shell.indexOf("function refreshCurrentModePanels")
  489. const refreshBlock = refreshIndex < 0 ? "" : braceBlock(shell, refreshIndex)
  490. const branchIndex = refreshBlock.indexOf("'warningResponse'")
  491. const branchTail = branchIndex < 0 ? "" : refreshBlock.slice(branchIndex)
  492. const branchCall = /eventStore\.loadPanels\(\{([^}]*)\}\)/.exec(branchTail)
  493. check(
  494. "C2 refreshCurrentModePanels 的 warningResponse 分支把 scope 一起传",
  495. refreshIndex >= 0 && branchIndex >= 0 && !!branchCall && /\bscope\b/.test(branchCall[1]),
  496. refreshIndex < 0 ? "没找到 refreshCurrentModePanels" : "分支调用:" + (branchCall ? branchCall[0].replace(/\s+/g, " ") : "未找到"),
  497. )
  498. // C3 外壳仍守住「配置就绪后才发请求」(别为了接片区把既有的抢跑守卫拆了)。
  499. check(
  500. "C3 watch(scope) 仍守住 lifecycleReady(不抢在配置就落地前发请求)",
  501. watchIndex >= 0 && /lifecycleReady\.value/.test(watchBlock),
  502. "watch(scope) 块里没有 lifecycleReady 守卫",
  503. )
  504. }
  505. // ================================================================ D. 如实说明(单一口径来源 + 正向普查)
  506. /**
  507. * 逐面板产出真实视图。
  508. *
  509. * 落点纪律(票 67 实施):缺口说明不是在各自 builder 里手写的,而是在
  510. * domain/panelViews.js 的 panelView() 出口由 filterCaliber.withFilterCaliber() 统一并进
  511. * 视图的 gap —— 所以闸门也**必须**从 panelView() 拿视图,才能验到真实产物(而不是另拼一份)。
  512. *
  513. * currentEvents 是唯一例外:它由 EventListPane 自己读 store.view,不在注册表里,
  514. * 退到 currentEventsView 取视图(闸门只认「落到视图上」,不替它发明落点)。
  515. */
  516. function buildPanelView(panelId, extra) {
  517. const registered = hasPanelView(panelId) ? panelView(panelId, null, readySection(), extra) : null
  518. if (registered) return registered
  519. if (panelId === "currentEvents") return currentEventsView(null, readySection())
  520. return null
  521. }
  522. /** 已接线(后端端点真收片区)的面板:不得出现片区缺口说明(说反了就是撒谎)。 */
  523. const WIRED_DISTRICT = ["archive", "management", "monitoringDistribution", "monitoringPointLedger", "eventAnalysis"]
  524. /** 未接线(后端端点不认片区)的面板:每一块都必须有片区缺口说明。 */
  525. const UNWIRED_DISTRICT = [
  526. "currentEvents", "historyAnalysis", "responseEfficiency", "monitoringOverview", "operationOverview",
  527. "operation", "emergency", "perceptionDevices", "pipeNetwork", "warningStudy", "alarmDisposal",
  528. ]
  529. /** 行业缺口只登记一处:报警效率面板里走全量工单端点的「当前工单列表」块。 */
  530. // 行业缺口有两类成因:① 工单端点实测不认行业;② 概览四块的端点按规格完全不收入参。
  531. // 概览四块的片区/行业两条都登记 —— 维护者反馈第 8 条要求"行业也一并检查",只登记一半等于半答。
  532. const UNWIRED_INDUSTRY = ["workOrderList", "perceptionDevices", "pipeNetwork", "warningStudy", "alarmDisposal"]
  533. /** 报警族的效率面板(工单列表块在这一支里)。 */
  534. const alarmEfficiencyView = () => responseEfficiencyPanel({ family: "ALARM" }, readySection(), {
  535. mode: "ALARM",
  536. workOrderList: { ...section(SECTION_STATE.SUCCESS, { data: { orders: [], pagination: null, state: "", stateReason: "", dictionary: [] } }), loaded: true },
  537. })
  538. {
  539. const districtGaps = caliberValue("DISTRICT_GAPS", null)
  540. const industryGaps = caliberValue("INDUSTRY_GAPS", null)
  541. const declaredDistrict = districtGaps && typeof districtGaps === "object" ? Object.keys(districtGaps) : []
  542. const declaredIndustry = industryGaps && typeof industryGaps === "object" ? Object.keys(industryGaps) : []
  543. const registered = implementedPanelIds()
  544. // D0 口径表 vs 未接线普查集:双向一致(漏登记=面板漏说明;多登记=把真收片区的面板说成不收)。
  545. const districtMismatch = [
  546. ...UNWIRED_DISTRICT.filter((id) => !declaredDistrict.includes(id)),
  547. ...declaredDistrict.filter((id) => !UNWIRED_DISTRICT.includes(id)),
  548. ]
  549. check(
  550. "D0 DISTRICT_GAPS 的键集合=未接线面板集(漏登记/多登记都红)",
  551. declaredDistrict.length > 0 && districtMismatch.length === 0,
  552. "登记=" + declaredDistrict.join(",") + " 未接线=" + UNWIRED_DISTRICT.join(",") + (caliberError ? "(filterCaliber 加载失败:" + caliberError + ")" : ""),
  553. )
  554. // D0b 行业缺口的两向锁定:工单列表必须在(它是实测缺口),且不许登记到概览四块 + 工单列表之外。
  555. // 概览四块登记的依据写在 filterCaliber.js 里(票 05–08 规格 §1:四端点不收入参),
  556. // 与"实测后端忽略行业"是两种不同强度的证据,别混为一谈。
  557. const industryMismatch = [
  558. ...UNWIRED_INDUSTRY.filter((id) => !declaredIndustry.includes(id)),
  559. ...declaredIndustry.filter((id) => !UNWIRED_INDUSTRY.includes(id)),
  560. ]
  561. check(
  562. "D0b INDUSTRY_GAPS 的键集合=工单列表+概览四块(漏登记/多登记都红)",
  563. declaredIndustry.includes("workOrderList") && industryMismatch.length === 0,
  564. "登记=" + declaredIndustry.join(",") + " 应有=" + UNWIRED_INDUSTRY.join(","),
  565. )
  566. // D0c 全注册表正向普查:**每一个**已实现面板要么有片区缺口、要么是已接线面板 ——
  567. // 新增面板却两样都不是 ⇒ 红(「谁漏了就红」不靠人工记得改这张表)。
  568. const unclassified = registered.filter((id) => !declaredDistrict.includes(id) && !WIRED_DISTRICT.includes(id))
  569. const phantomWired = WIRED_DISTRICT.filter((id) => !registered.includes(id))
  570. check(
  571. "D0c 全注册表普查:每个已实现面板要么有片区缺口、要么是已接线(新增面板漏说明就红)",
  572. registered.length >= 10 && unclassified.length === 0 && phantomWired.length === 0,
  573. "未分类=" + unclassified.join(",") + " 已接线但未注册=" + phantomWired.join(",") + " 注册数=" + registered.length,
  574. )
  575. // D1 每句片区缺口:含「当前范围」、说明不作用于本面板及原因、含片区语义、不含接口字段名。
  576. for (const panelId of UNWIRED_DISTRICT) {
  577. const sentence = districtGapOf(panelId)
  578. check(
  579. "D-sentence:" + panelId,
  580. sentence.length >= 12 &&
  581. sentence.includes("当前范围") &&
  582. /片区/.test(sentence) &&
  583. NOT_APPLIED.test(sentence) &&
  584. !FALSELY_APPLIED.test(sentence) &&
  585. leakedFields(sentence).length === 0,
  586. "句=" + (sentence || "(空)") + " 泄漏=" + leakedFields(sentence).join(","),
  587. )
  588. }
  589. // D2 行业缺口句本身:含「行业」、说明不生效及原因、不含接口字段名。
  590. for (const panelId of UNWIRED_INDUSTRY) {
  591. const sentence = industryGapOf(panelId)
  592. check(
  593. "D-industry-gap:" + panelId,
  594. sentence.length >= 12 &&
  595. sentence.includes("行业") &&
  596. NOT_APPLIED.test(sentence) &&
  597. !FALSELY_APPLIED.test(sentence) &&
  598. leakedFields(sentence).length === 0,
  599. "句=" + (sentence || "(空)") + " 泄漏=" + leakedFields(sentence).join(","),
  600. )
  601. }
  602. // D3 正向普查(行为面):每一块未接线面板的**视图** gap 都要含它那句(谁漏了就红)。
  603. const missingGaps = []
  604. for (const panelId of UNWIRED_DISTRICT) {
  605. const view = buildPanelView(panelId)
  606. const sentence = districtGapOf(panelId)
  607. if (!view) missingGaps.push(panelId + "(无视图)")
  608. else if (!String(view.gap || "").includes(sentence)) missingGaps.push(panelId)
  609. }
  610. check(
  611. "D3 未接线面板的视图 gap 都含各自缺口句(正向普查,漏一个就红)",
  612. missingGaps.length === 0,
  613. "缺:" + missingGaps.join(","),
  614. )
  615. for (const panelId of UNWIRED_DISTRICT) {
  616. const view = buildPanelView(panelId)
  617. const sentence = districtGapOf(panelId)
  618. check(
  619. "D-gap:" + panelId,
  620. !!view && !!sentence && String(view.gap || "").includes(sentence),
  621. (view ? "gap=" + String(view.gap || "(空)").slice(0, 100) : "视图未产出(panelView 返回 null)"),
  622. )
  623. }
  624. // D3b 行业缺口落在工单列表块的 pageNote 上(data-list-page-note 渲染位);
  625. // 同一屏上方分布随行业变、下方列表不变,不说明就是看不出来的假象。
  626. const alarmView = alarmEfficiencyView()
  627. const workOrderBlock = (alarmView.blocks || []).find((block) => String(block.pageNote || "").includes(industryGapOf("workOrderList")))
  628. check(
  629. "D3b 报警效率面板的工单列表块 pageNote 含行业缺口句(含「行业」「未提供行业入参」)",
  630. !!workOrderBlock && /行业/.test(String(workOrderBlock.pageNote)) && /未提供行业入参/.test(String(workOrderBlock.pageNote)),
  631. "块 pageNote=" + (workOrderBlock ? String(workOrderBlock.pageNote).slice(0, 160) : "未找到携带行业缺口的 list 块"),
  632. )
  633. // D4 反向普查:已接线面板**不得**出现"不作用于本面板"的片区缺口;
  634. // 监测分布自己的片区落点说明(REGION_SCOPE_NOTE)必须保留,别被当成缺口删掉。
  635. const wronglyGapped = []
  636. for (const panelId of WIRED_DISTRICT) {
  637. const view = hasPanelView(panelId) ? panelView(panelId, null, readySection(), panelId === "monitoringPointLedger" ? undefined : undefined) : null
  638. if (districtGapOf(panelId) !== "") wronglyGapped.push(panelId + "(登记了缺口)")
  639. else if (view && /不作用于本面板/.test(String(view.gap || ""))) wronglyGapped.push(panelId + "(视图里有缺口句)")
  640. }
  641. check(
  642. "D4 已接线面板(档案/管理/监测分布/点位台账/预警分析)都没有片区缺口句",
  643. wronglyGapped.length === 0,
  644. "误挂:" + wronglyGapped.join(","),
  645. )
  646. const distributionView = hasPanelView("monitoringDistribution") ? panelView("monitoringDistribution", null, readySection()) : null
  647. const regionNote = String(REGION_SCOPE_NOTE || "")
  648. check(
  649. "D4b 监测分布的既有片区落点说明仍保留(那是「作用在哪」,不是缺口)",
  650. !!distributionView && String(distributionView.gap || "").includes(regionNote),
  651. "gap=" + String(distributionView && distributionView.gap),
  652. )
  653. // D5 全量豁免扫描:所有缺口句 + 所有视图 gap 里都没有接口字段名。
  654. const sentences = [
  655. ...UNWIRED_DISTRICT.map(districtGapOf),
  656. ...UNWIRED_INDUSTRY.map(industryGapOf),
  657. String(caliberValue("PIPE_DISTRICT_NOTE", "")),
  658. ]
  659. const viewGaps = registered.map((panelId) => {
  660. try { return String((panelView(panelId, null, readySection()) || {}).gap || "") } catch (error) { return "" }
  661. })
  662. const leaks = [...sentences, ...viewGaps].flatMap((text) => leakedFields(text).map((token) => token + "@" + String(text).slice(0, 20)))
  663. check(
  664. "D5 口径句里不出现接口字段名(district / geometry / mapFilter 这类英文词不上屏)",
  665. sentences.every((sentence) => typeof sentence === "string") && leaks.length === 0,
  666. "泄漏:" + leaks.slice(0, 5).join(" "),
  667. )
  668. // D6 筛选条的措辞与说明用同一个词(单一口径来源)。
  669. check(
  670. "D6 SCOPE_FILTER_LABEL 就是「当前范围」",
  671. caliberValue("SCOPE_FILTER_LABEL", null) === "当前范围",
  672. "实测=" + caliberValue("SCOPE_FILTER_LABEL", "(未导出)"),
  673. )
  674. }
  675. // ---------------------------------------------------------------- D7 SSR:缺口说明真的进 DOM
  676. {
  677. const renderPanel = async (view) => renderToString(createSSRApp({ render: () => h(PanelBlocks, { view }) }))
  678. const domMisses = []
  679. for (const panelId of UNWIRED_DISTRICT) {
  680. if (panelId === "currentEvents") continue // 事件列表是独立组件,见 D7b
  681. const view = buildPanelView(panelId)
  682. const sentence = districtGapOf(panelId)
  683. if (!view || !sentence) { domMisses.push(panelId + "(无视图/无句)"); continue }
  684. const html = await renderPanel(view)
  685. if (!/data-panel-gap/.test(html) || !html.includes(sentence.slice(0, 10))) domMisses.push(panelId)
  686. }
  687. check(
  688. "D7 SSR:PanelBlocks 十个面板的缺口说明都带 data-panel-gap 真的进了 DOM",
  689. domMisses.length === 0,
  690. "缺:" + domMisses.join(","),
  691. )
  692. // D7b 当前预警走独立的事件列表组件:缺口说明也要有 DOM 出口(字段里有、屏上没有等于没说)。
  693. const paneSource = stripComments(read("src/modules/cockpit/components/EventListPane.vue"))
  694. check(
  695. "D7b 事件列表组件里有 data-panel-gap 出口(当前预警的缺口说明不只存在字段里)",
  696. /data-panel-gap/.test(paneSource) && /view\.gap/.test(paneSource),
  697. "EventListPane 里没有 data-panel-gap / view.gap",
  698. )
  699. }
  700. // ================================================================ E. 管线图层:片区由设施编码前缀推导
  701. /**
  702. * 维护者 2026-10-23 更正:管线编码 network_code 的构成是「片区 + 行业 + 时间编码」
  703. * (TCRQ202609080001 = 太常·燃气·管),所以片区**可以**从 code 前两位推出来。
  704. * 上一版「管线图层永远不随片区变化」的结论因此作废 —— 那条只对「几何 properties 里有片区字段」
  705. * 成立(实测 properties 确实没有片区字段),两件事不矛盾:**字段没有,编码里有**。
  706. *
  707. * 本组从「如实说明做不到」改成五件事:推导正确性(E1)、筛选结果(E2)、
  708. * districtApplied 语义(E3)、推不出片区时怎么办(E4)、说明文案与接线(E5/E6)、
  709. * 行业×片区正交(E7)。真实载荷用 artifacts/t67/geom-none.json(6766 feature)。
  710. */
  711. const GEOM_PAYLOAD = ARTIFACT("geom-none.json")
  712. const REAL_FEATURES = (GEOM_PAYLOAD && GEOM_PAYLOAD.data && GEOM_PAYLOAD.data.features) || []
  713. /** 片区号 → 中文(只用于和 name 里的片区词对照,不参与推导)。 */
  714. const DISTRICT_NAME = { 0: "城南", 1: "城北", 2: "太常" }
  715. const codePrefix = (code) => {
  716. const matched = /^([A-Za-z]{2})/.exec(String(code || ""))
  717. return matched ? matched[1].toUpperCase() : ""
  718. }
  719. /** 真实载荷里出现过的编码前缀分布(现算,不写死「现网有什么」)。 */
  720. const PREFIX_COUNTS = (() => {
  721. const counts = new Map()
  722. for (const feature of REAL_FEATURES) {
  723. const prefix = codePrefix(((feature && feature.properties) || {}).code)
  724. counts.set(prefix, (counts.get(prefix) || 0) + 1)
  725. }
  726. return counts
  727. })()
  728. const prefixCount = (prefix) => PREFIX_COUNTS.get(prefix) || 0
  729. /** 真实载荷 → 图层领域对象(toPipeNetwork,票 24 的同一个入口,不另拼一份)。 */
  730. const REAL_LAYER = (() => {
  731. const build = pipeFn("toPipeNetwork")
  732. if (!build) return null
  733. try { return build(GEOM_PAYLOAD.data) } catch (error) { return null }
  734. })()
  735. const withPrefix = (rows, prefix) => (rows || []).filter((row) => codePrefix(row.code) === prefix)
  736. /** 推不出片区的行(district 不是 0/1/2,含 null 与字段缺失)。 */
  737. const unresolvedRows = (rows) => (rows || []).filter((row) => row.district !== 0 && row.district !== 1 && row.district !== 2)
  738. const filterLayer = (filters) => (REAL_LAYER ? filterPipeNetworkOf(REAL_LAYER, filters) : null)
  739. const layerObjects = (layer) => [...((layer && layer.pipes) || []), ...((layer && layer.components) || []), ...((layer && layer.blocked) || [])]
  740. const drawnObjects = (layer) => [...((layer && layer.pipes) || []), ...((layer && layer.components) || [])]
  741. {
  742. // E1 前缀表与全局片区编码同值(不另造一套片区号),且推导本身在真实载荷上 100% 对得上 name。
  743. const prefixes = pipeNetworkModule.PIPE_DISTRICT_PREFIX || null
  744. check(
  745. "E1 前缀→片区表与全局片区编码同值(CN=城南0 / CB=城北1 / TC=太常2)",
  746. !!prefixes && prefixes.CN === DISTRICT_CODES.south && prefixes.CB === DISTRICT_CODES.north && prefixes.TC === DISTRICT_CODES.taichang,
  747. "前缀表=" + JSON.stringify(prefixes) + " DISTRICT_CODES=" + JSON.stringify(DISTRICT_CODES),
  748. )
  749. const resolver = pipeFn("pipeDistrictOf")
  750. const resolved = []
  751. const unresolved = []
  752. for (const feature of REAL_FEATURES) {
  753. const properties = (feature && feature.properties) || {}
  754. const district = pipeDistrictOf(properties.code)
  755. if (district === 0 || district === 1 || district === 2) resolved.push({ code: properties.code, name: properties.name, district })
  756. else unresolved.push({ code: properties.code, name: properties.name })
  757. }
  758. const counterexamples = resolved.filter((row) => !String(row.name || "").includes(DISTRICT_NAME[row.district]))
  759. check(
  760. "E1b 真实载荷 6766 条:编码前缀推出的片区与 name 里的片区词 100% 一致、零反例",
  761. !!resolver && REAL_FEATURES.length === 6766 && resolved.length === 6762 && counterexamples.length === 0,
  762. "推出 " + resolved.length + "/" + REAL_FEATURES.length + " 条;反例=" +
  763. counterexamples.slice(0, 3).map((row) => row.code + "→" + DISTRICT_NAME[row.district] + " 但 name=" + row.name).join(" | "),
  764. )
  765. const unresolvedCodes = unresolved.map((row) => row.code).sort()
  766. check(
  767. "E1c 编码推不出片区的只有 4 条(YL/GD 另一套编码),一律回 null、不猜成任何片区",
  768. unresolved.length === 4 &&
  769. unresolvedCodes.join(",") === ["GD-001", "YL-YJ-001", "YL-YJ-002", "YL-YJ-003"].join(",") &&
  770. unresolved.every((row) => pipeDistrictOf(row.code) === null),
  771. "推不出:" + unresolvedCodes.join(","),
  772. )
  773. // 现网没有城北数据:按载荷现算(前缀 0 条 + name 含「城北」0 条),映射本身仍然在表里。
  774. const chengbeiNames = REAL_FEATURES.filter((feature) => String(((feature && feature.properties) || {}).name || "").includes(DISTRICT_NAME[1]))
  775. check(
  776. "E1d 现网无城北数据(CB 前缀 0 条、name 含「城北」0 条)——按载荷现算,映射仍保留",
  777. prefixCount("CB") === 0 && chengbeiNames.length === 0 && !!prefixes && prefixes.CB === DISTRICT_CODES.north,
  778. "CB 前缀=" + prefixCount("CB") + " name 含城北=" + chengbeiNames.length + " 映射存在=" + Boolean(prefixes && prefixes.CB !== undefined),
  779. )
  780. }
  781. {
  782. // E2 四个范围的筛选结果(期望值全部从真实载荷现算,不写死数字)。
  783. const layer = REAL_LAYER
  784. const south = filterLayer({ scope: "south" })
  785. const taichang = filterLayer({ scope: "taichang" })
  786. const north = filterLayer({ scope: "north" })
  787. const all = filterLayer({ scope: "all" })
  788. const expectedSouth = withPrefix(layer && layer.pipes, "CN")
  789. const expectedTaichang = withPrefix(layer && layer.pipes, "TC")
  790. check(
  791. "E2 scope=south 只留 CN 编码管段、taichang 只留 TC(数量与载荷逐类一致)",
  792. !!layer && !!south && !!taichang &&
  793. south.pipes.length === expectedSouth.length && south.pipes.length > 0 &&
  794. south.pipes.every((row) => codePrefix(row.code) === "CN" && row.district === 0) &&
  795. taichang.pipes.length === expectedTaichang.length && taichang.pipes.length > 0 &&
  796. taichang.pipes.every((row) => codePrefix(row.code) === "TC" && row.district === 2),
  797. "城南=" + (south ? south.pipes.length : "null") + "/" + expectedSouth.length +
  798. " 太常=" + (taichang ? taichang.pipes.length : "null") + "/" + expectedTaichang.length,
  799. )
  800. check(
  801. "E2b scope=all 保留全部管段(不裁剪),且管段仍带各自的片区号",
  802. !!layer && !!all &&
  803. all.pipes.length === layer.pipes.length &&
  804. all.pipes.every((row) => row.district === 0 || row.district === 2),
  805. "全域=" + (all ? all.pipes.length : "null") + "/" + (layer ? layer.pipes.length : "null"),
  806. )
  807. check(
  808. "E2c scope=north 如实为空(现网没有城北编码),不是「接线错了」",
  809. !!north && north.pipes.length === 0 && (north.components || []).length === 0 && prefixCount("CB") === 0,
  810. "城北结果 管段=" + (north ? north.pipes.length : "null") + " 构件=" + (north ? (north.components || []).length : "null"),
  811. )
  812. }
  813. {
  814. // E3 districtApplied 语义:具体片区 true、全域/空 false(不再恒定 false)。
  815. const appliedOf = (scope) => {
  816. const result = filterPipeNetworkOf(REAL_LAYER || { pipes: [], components: [], blocked: [] }, { scope })
  817. return result ? result.districtApplied : undefined
  818. }
  819. const concrete = ["south", "north", "taichang"].map((scope) => ({ scope, applied: appliedOf(scope) }))
  820. const broad = [{ scope: "all" }, { scope: undefined }, { scope: "" }].map((row) => ({ scope: String(row.scope), applied: appliedOf(row.scope) }))
  821. check(
  822. "E3 districtApplied 语义:具体片区=true、全域/空=false(管线片区真的生效了才说生效)",
  823. concrete.every((row) => row.applied === true) && broad.every((row) => row.applied === false),
  824. "具体=" + concrete.map((row) => row.scope + ":" + String(row.applied)).join(" ") + " 全域=" + broad.map((row) => row.scope + ":" + String(row.applied)).join(" "),
  825. )
  826. }
  827. {
  828. // E4 推不出片区的对象:全域保留、选具体片区时排除,并如实计数(不静默保留、也不静默消失)。
  829. //
  830. // 口径(维护者 2026-10-23 复核):真实载荷 6766 条 = pipes 6762 + components 1 + blocked 3,
  831. // 其中编码推不出片区的 4 个里只有 1 个是**可上图**对象(YL-YJ-003),另外 3 个本来就被
  832. // 「无地图位置」拦下。所以 districtUnresolvedCount 只统计可上图对象(pipes + components)——
  833. // blocked 已经在「无地图位置」清单里单独报告过,再计一次就是双报。
  834. // 期望值一律**从载荷算**(不写死 4 / 1),并断言它 > 0,否则这条断言在「全都能推导」时变空转。
  835. const layer = REAL_LAYER
  836. const universe = layerObjects(layer)
  837. const all = filterLayer({ scope: "all" })
  838. const south = filterLayer({ scope: "south" })
  839. const north = filterLayer({ scope: "north" })
  840. const unassigned = filterLayer({ scope: "unassigned" })
  841. const unresolvedTotal = unresolvedRows(universe).length
  842. const expectedDrawn = unresolvedRows(drawnObjects(layer)).length
  843. const fourCodes = ["GD-001", "YL-YJ-001", "YL-YJ-002", "YL-YJ-003"]
  844. const codesIn = (result) => new Set(layerObjects(result).map((row) => row.code))
  845. check(
  846. "E4 全域时推不出片区的对象全都还在(不被静默丢掉),且不计缺口数",
  847. !!layer && !!all &&
  848. unresolvedTotal === 4 &&
  849. layerObjects(all).length === universe.length &&
  850. fourCodes.every((code) => codesIn(all).has(code)) &&
  851. all.districtUnresolvedCount === 0,
  852. "全域对象数=" + layerObjects(all).length + "/" + universe.length + " 推不出=" + unresolvedTotal + " 计数=" + (all && all.districtUnresolvedCount),
  853. )
  854. check(
  855. "E4b 选具体片区时推不出片区的对象不进结果,districtUnresolvedCount 只报可上图对象里被排除的条数",
  856. !!south && !!north && expectedDrawn > 0 &&
  857. unresolvedRows(drawnObjects(south)).length === 0 &&
  858. unresolvedRows(drawnObjects(north)).length === 0 &&
  859. fourCodes.every((code) => !unresolvedRows(drawnObjects(layer)).some((row) => row.code === code) || !codesIn(south).has(code)) &&
  860. south.districtUnresolvedCount === expectedDrawn &&
  861. north.districtUnresolvedCount === expectedDrawn,
  862. "期望缺口数(可上图推不出)=" + expectedDrawn + " 城南=" + (south && south.districtUnresolvedCount) + " 城北=" + (north && north.districtUnresolvedCount),
  863. )
  864. // blocked 与行业同一条既有行为:也按片区过滤(推不出片区的那些在选具体片区时不出现)。
  865. check(
  866. "E4c blocked(无地图位置清单)同样按片区过滤:选具体片区时只剩该片区的对象",
  867. !!south && !!north && (south.blocked || []).every((row) => row.district === 0) && (north.blocked || []).length === 0,
  868. "城南 blocked=" + (south ? (south.blocked || []).length : "null") + " 城北 blocked=" + (north ? (north.blocked || []).length : "null"),
  869. )
  870. // 「未分区」是推不出片区那批对象的读法(与后端 district=null 同一口径),不是空集。
  871. check(
  872. "E4d scope=unassigned 的语义=推不出片区的那批对象(可上图的那 1 个真被单独列出来)",
  873. !!unassigned && unassigned.districtApplied === true &&
  874. unresolvedRows(drawnObjects(unassigned)).length === expectedDrawn &&
  875. unresolvedRows(drawnObjects(unassigned)).length === drawnObjects(unassigned).length,
  876. "未分区 可上图=" + (unassigned ? drawnObjects(unassigned).length : "null") + "/" + expectedDrawn,
  877. )
  878. }
  879. {
  880. // E5 管线片区说明:不再是「不作用于」,而是说清「由编码前缀推出、几何没有片区字段、推不出时不显示」。
  881. const pipeNote = String(caliberValue("PIPE_DISTRICT_NOTE", ""))
  882. check(
  883. "E5 PIPE_DISTRICT_NOTE 改写如实(当前范围 + 编码前缀推出 + 几何无片区字段 + 推不出不显示,且不再写「不作用于管线」)",
  884. pipeNote.length >= 20 &&
  885. pipeNote.includes("当前范围") &&
  886. /编码/.test(pipeNote) && /前缀/.test(pipeNote) && /推出|推导/.test(pipeNote) &&
  887. /几何/.test(pipeNote) &&
  888. /(没有|不含|未提供).{0,8}片区字段|片区字段.{0,8}(没有|不含)/.test(pipeNote) &&
  889. !/不作用于管线/.test(pipeNote) &&
  890. !/不作用于(管线|构件)/.test(pipeNote) &&
  891. !FALSELY_APPLIED.test(pipeNote) &&
  892. !/[0-9]{3,}/.test(pipeNote) &&
  893. leakedFields(pipeNote).length === 0,
  894. "句=" + (pipeNote || "(空)"),
  895. )
  896. }
  897. {
  898. // E6 接线:外壳 watch(scope) 走本地推导(不重发请求),启动取数带 scope;store 派生随之生效。
  899. const shell = stripComments(read("src/modules/cockpit/CockpitShell.vue"))
  900. const watchIndex = shell.indexOf("watch(scope")
  901. const watchBlock = watchIndex < 0 ? "" : braceBlock(shell, watchIndex)
  902. check(
  903. "E6 watch(scope) 里调 pipeNetworkStore.setScope(本地推导,不重新发请求)",
  904. watchIndex >= 0 && /pipeNetworkStore\.setScope\(/.test(watchBlock),
  905. watchIndex < 0 ? "没找到 watch(scope" : "watch(scope) 块:" + watchBlock.replace(/\s+/g, " ").slice(0, 240),
  906. )
  907. const loadMatched = /pipeNetworkStore\.load\(\{([\s\S]{0,300}?)\}\)/.exec(shell)
  908. check(
  909. "E6b 启动取数把 scope 一起传(图层取数后即按当前范围推导)",
  910. !!loadMatched && /\bscope\b/.test(loadMatched[1]),
  911. loadMatched ? "load 调用:" + loadMatched[0].replace(/\s+/g, " ").slice(0, 200) : "没找到 pipeNetworkStore.load(",
  912. )
  913. let storeResult = null
  914. let storeError = ""
  915. try {
  916. const pinia = createPinia()
  917. setActivePinia(pinia)
  918. const pipeStore = usePipeNetworkStore()
  919. if (typeof pipeStore.setScope !== "function") storeError = "store 没有 setScope"
  920. else if (!REAL_LAYER) storeError = "真实图层未产出"
  921. else {
  922. pipeStore.setLayer(section(SECTION_STATE.SUCCESS, { data: REAL_LAYER }))
  923. pipeStore.setScope("south")
  924. const southPipelines = [...(pipeStore.pipelines || [])]
  925. const southApplied = pipeStore.filtered ? pipeStore.filtered.districtApplied : undefined
  926. pipeStore.setScope("north")
  927. const northPipelines = [...(pipeStore.pipelines || [])]
  928. pipeStore.setScope("all")
  929. const allPipelines = [...(pipeStore.pipelines || [])]
  930. const allApplied = pipeStore.filtered ? pipeStore.filtered.districtApplied : undefined
  931. storeResult = { southPipelines, northPipelines, allPipelines, southApplied, allApplied }
  932. }
  933. } catch (error) {
  934. storeError = String((error && error.message) || error)
  935. }
  936. check(
  937. "E6c pipeNetworkStore 派生:城南只剩 CN、城北为空、全域恢复,districtApplied 随之翻转",
  938. !!storeResult &&
  939. storeResult.southPipelines.length > 0 &&
  940. storeResult.southPipelines.every((row) => row.district === 0) &&
  941. storeResult.northPipelines.length === 0 &&
  942. storeResult.allPipelines.length === (REAL_LAYER ? REAL_LAYER.pipes.length : -1) &&
  943. storeResult.southApplied === true && storeResult.allApplied === false,
  944. storeError || "store 派生=" + JSON.stringify(storeResult && { south: storeResult.southPipelines.length, north: storeResult.northPipelines.length, all: storeResult.allPipelines.length, southApplied: storeResult.southApplied, allApplied: storeResult.allApplied }),
  945. )
  946. }
  947. {
  948. // E7 行业 × 片区正交:两个筛选各管一维,结果应是交集而不是互相覆盖。
  949. const layer = REAL_LAYER
  950. const gasSouth = filterLayer({ industry: "gas", scope: "south" })
  951. const gasTaichang = filterLayer({ industry: "gas", scope: "taichang" })
  952. const gasAll = filterLayer({ industry: "gas", scope: "all" })
  953. const expectedSouth = withPrefix(layer && layer.pipes, "CN").filter((row) => row.industry === "GAS")
  954. const expectedTaichang = withPrefix(layer && layer.pipes, "TC").filter((row) => row.industry === "GAS")
  955. check(
  956. "E7 行业×片区正交:燃气∩城南 + 燃气∩太常 = 燃气全域,两集合不相交",
  957. !!gasSouth && !!gasTaichang && !!gasAll && expectedSouth.length > 0 && expectedTaichang.length > 0 &&
  958. gasSouth.pipes.length === expectedSouth.length &&
  959. gasTaichang.pipes.length === expectedTaichang.length &&
  960. gasSouth.pipes.length + gasTaichang.pipes.length === gasAll.pipes.length &&
  961. gasSouth.pipes.every((row) => row.district === 0 && row.industry === "GAS") &&
  962. gasTaichang.pipes.every((row) => row.district === 2 && row.industry === "GAS") &&
  963. gasAll.pipes.every((row) => row.industry === "GAS"),
  964. "燃气 城南=" + (gasSouth ? gasSouth.pipes.length : "null") + "/" + expectedSouth.length +
  965. " 太常=" + (gasTaichang ? gasTaichang.pipes.length : "null") + "/" + expectedTaichang.length +
  966. " 全域=" + (gasAll ? gasAll.pipes.length : "null"),
  967. )
  968. }
  969. // ================================================================ F. 不得伪造(反证组)
  970. {
  971. // F1 三个事件端点:任何片区取值下都不得出现 district 参数(后端忽略,传了就是伪造一次筛选)。
  972. const forbidden = []
  973. for (const [scope] of [...SCOPE_CASES, ["all"], [undefined]]) {
  974. await withCapture(async (fetchImpl) => {
  975. await fetchCurrentEvents({ mode: "WARNING", scope, config: CONFIG, fetchImpl })
  976. await fetchEventHistory({ mode: "WARNING", rangeType: "LAST_30_DAYS", scope, config: CONFIG, fetchImpl })
  977. await fetchHandlingEfficiency({ mode: "WARNING", rangeType: "LAST_30_DAYS", scope, config: CONFIG, fetchImpl })
  978. })
  979. for (const url of REQUESTS) if (queryOf(url).has("district")) forbidden.push(String(scope) + ":" + url)
  980. }
  981. check(
  982. "F1 当前事件/历史/效率在全部片区取值下都不出现 district(含 30 天多窗)",
  983. forbidden.length === 0,
  984. "出现:" + forbidden.slice(0, 3).join(" | "),
  985. )
  986. // F2 反向:真收片区的端点必须带(漏带=静默少筛一层,同样是假)。
  987. await withCapture(async (fetchImpl) => {
  988. await fetchMonitoringDistribution({ scope: "south", config: CONFIG, fetchImpl })
  989. await fetchBaseArchives({ scope: "south", config: CONFIG, fetchImpl })
  990. await fetchManagementInformation({ scope: "south", config: CONFIG, fetchImpl })
  991. await fetchEventAnalysis({ mode: "WARNING", rangeType: "LAST_7_DAYS", scope: "south", config: CONFIG, fetchImpl })
  992. })
  993. const wiredMisses = []
  994. for (const fragment of ["/monitoring/distribution", "/infrastructure/base-archives", "/infrastructure/management-information", "/events/multidimensional-analysis"]) {
  995. const url = urlsWith(fragment)[0]
  996. if (!url || districtOf(url) !== "0") wiredMisses.push(fragment + "=" + (url ? districtOf(url) : "无请求"))
  997. }
  998. check(
  999. "F2 真收片区的四个端点都带上了 district=0(漏带也是假,不只是多带)",
  1000. wiredMisses.length === 0,
  1001. "漏:" + wiredMisses.join(" "),
  1002. )
  1003. // F3 事件族只有多维分析一处接片区(别把「当前事件」也接上——那正是 M2 要证明能抓住的伪造)。
  1004. const warningSource = stripComments(read("src/modules/cockpit/services/warningResponseApi.js"))
  1005. const eventSource = stripComments(read("src/modules/cockpit/services/eventApi.js"))
  1006. const districtCalls = (warningSource.match(/districtQuery\(/g) || []).length
  1007. check(
  1008. "F3 片区入参在事件族只有多维分析一处(eventApi 源码里不出现 district)",
  1009. !/district/i.test(eventSource) && districtCalls >= 1,
  1010. "eventApi 出现 district=" + /district/i.test(eventSource) + ";warningResponseApi 的 districtQuery 次数=" + districtCalls,
  1011. )
  1012. }
  1013. // ================================================================ 结果
  1014. console.log("")
  1015. console.log("T67 筛选口径闸门:" + passed + "/" + (passed + failed) + " 项通过。")
  1016. if (failed) {
  1017. console.error("未通过 " + failed + " 项。")
  1018. process.exit(1)
  1019. }
  1020. console.log("T67 全部通过。")