t07-coordinate.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. /**
  2. * T07 坐标校验与无地图位置 纯逻辑检查(票 07)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs / t08-map-summary.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t07-coordinate.mjs
  6. *
  7. * 分八段:
  8. * A 唯一准入判据(只认后端 renderable;前端不纠偏、不查范围、不用默认坐标)
  9. * B 原因文案(后端六个 reason 逐一命中;「缺失」与「非法」分开;认不出的不泄露原因码)
  10. * C 切分不静默丢对象(不上图 ≠ 不存在,两堆都要交出去)
  11. * D 两个口径分开(后端全量 vs 前端已加载,不混成一个数)
  12. * E 后端汇总进领域批次(票 05 遗留的漏读;且不得跨页累加)
  13. * F 契约化 mock 的三桶分法照抄后端
  14. * G 静态守卫(domain 不重算坐标范围;界面零原因码;不上图清单由领域层供货)
  15. * H 「已上图」措辞不再把被拦下的对象算进去
  16. *
  17. * 契约依据是**后端实现**:`CockpitCoordinateValidator`(六种 reason、state 三态、
  18. * renderable 是结论)与 `CockpitMapSnapshotService`(汇总按整份快照算、三桶分法)。
  19. */
  20. import { readFileSync } from 'node:fs'
  21. import { createPinia, setActivePinia } from 'pinia'
  22. import {
  23. COORDINATE_REASON_TEXT,
  24. NO_MAP_LOCATION_TEXT,
  25. UNVALIDATED_TEXT,
  26. blockReason,
  27. blockedEntries,
  28. coordinateOverview,
  29. coordinateSummaryText,
  30. isRenderable,
  31. splitByCoordinate,
  32. toCoordinateSummary,
  33. } from '../../../src/modules/cockpit/domain/coordinate.js'
  34. import { mergeMapPage, summaryView, toMapBatch } from '../../../src/modules/cockpit/domain/mapSummary.js'
  35. import { fetchMapSnapshot } from '../../../src/modules/cockpit/services/mapApi.js'
  36. import {
  37. getMockTransportPaths,
  38. registerMockTransport,
  39. requestCockpitJson,
  40. resetMockTransportsForTest,
  41. } from '../../../src/modules/cockpit/services/cockpitApi.js'
  42. import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
  43. import { MAP_SUMMARY_PAGE_SIZE, useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js'
  44. const checks = []
  45. function check(name, pass, evidence) {
  46. checks.push({ name, pass })
  47. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  48. }
  49. const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
  50. /** 造一个领域地图对象(形状同 `domain/mapSummary.js`)。 */
  51. function object({ code = 'X', kind = 'monitoring-point', name = '', longitude = 110.1, latitude = 28.4, coordinate = null } = {}) {
  52. return {
  53. kind,
  54. code,
  55. name: name || `${code} 对象`,
  56. status: '',
  57. statusReasons: [],
  58. longitude,
  59. latitude,
  60. coordinate: coordinate || { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
  61. missing: [],
  62. }
  63. }
  64. /** 后端校验段的六种取值(照抄 `CockpitCoordinateValidator`)。 */
  65. const BACKEND_CASES = [
  66. { state: 'VALID', reason: null, renderable: true },
  67. { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false },
  68. { state: 'INVALID', reason: 'MISSING_LONGITUDE', renderable: false },
  69. { state: 'INVALID', reason: 'MISSING_LATITUDE', renderable: false },
  70. { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false },
  71. { state: 'INVALID', reason: 'LATITUDE_OUT_OF_RANGE', renderable: false },
  72. { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false },
  73. ]
  74. /** 后端载荷里的对象 → 领域对象(走真实的适配层,不手搓 coordinate 段)。 */
  75. function rawToObject(raw, kind = 'monitoring-point') {
  76. const payload =
  77. kind === 'monitoring-point'
  78. ? { monitoringPoints: [raw], unassociatedEquipment: [] }
  79. : { monitoringPoints: [], unassociatedEquipment: [raw] }
  80. return toMapBatch(payload).objects[0]
  81. }
  82. // ================================================================ A 唯一准入判据
  83. {
  84. // 后端每一种结论都如实照搬,不做二次判断。
  85. const mapped = BACKEND_CASES.map((item) => ({
  86. expected: item.renderable,
  87. actual: isRenderable(rawToObject({ pointCode: 'MP-A', coordinateValidation: item })),
  88. state: item.state,
  89. reason: item.reason,
  90. }))
  91. check(
  92. 'A1-admission-follows-renderable',
  93. mapped.every((item) => item.actual === item.expected),
  94. JSON.stringify(mapped.map((item) => `${item.state}/${item.reason || '-'}→${item.actual}`)),
  95. )
  96. // 关键对抗:state 说 VALID 但后端 renderable=false 时,**必须**听 renderable。
  97. // 这一条守着「前端不另立一套判据」——否则两个答案必然有一个是错的,而错的会画到地图上。
  98. const contradictory = rawToObject({
  99. pointCode: 'MP-C',
  100. coordinateValidation: { state: 'VALID', reason: null, renderable: false, crs: 'WGS84' },
  101. })
  102. check('A2-renderable-beats-state', isRenderable(contradictory) === false, 'state=VALID 但 renderable=false → 不上图')
  103. // 反向:state 说 INVALID 但后端 renderable=true,同样听后端(前端不做"再校验一遍")。
  104. const permissive = rawToObject({
  105. pointCode: 'MP-P',
  106. coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: true, crs: 'WGS84' },
  107. })
  108. check('A3-no-frontend-recheck', isRenderable(permissive) === true, '后端说可绘制就绘制,前端不推翻')
  109. // 前端不纠偏、不用默认坐标:坐标值原样带出,不被改成"看起来合法"的数。
  110. const outOfRange = toMapBatch({
  111. page: 1,
  112. pageSize: 1,
  113. snapshotAt: 'S',
  114. total: 1,
  115. complete: true,
  116. unassociatedEquipment: [
  117. {
  118. deviceCode: 'EQ-X',
  119. deviceName: '超范围设备',
  120. longitude: 181,
  121. latitude: 28.455,
  122. coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' },
  123. },
  124. ],
  125. }).objects[0]
  126. check(
  127. 'A4-no-correction-no-default',
  128. outOfRange.longitude === 181 && outOfRange.latitude === 28.455 && isRenderable(outOfRange) === false,
  129. `原值 ${outOfRange.longitude},${outOfRange.latitude} 未被纠正,且不上图`,
  130. )
  131. }
  132. // ================================================================ B 原因文案
  133. {
  134. const texts = BACKEND_CASES.filter((item) => item.reason).map((item) =>
  135. blockReason(
  136. rawToObject({
  137. pointCode: 'MP-B',
  138. coordinateValidation: { ...item, crs: 'WGS84' },
  139. }),
  140. ),
  141. )
  142. check(
  143. 'B1-six-reasons-have-text',
  144. texts.length === 6 && texts.every((item) => item.text && item.text !== NO_MAP_LOCATION_TEXT && item.text !== UNVALIDATED_TEXT),
  145. JSON.stringify(texts.map((item) => `${item.code}→${item.text}`)),
  146. )
  147. check(
  148. 'B2-reason-dictionary-covers-backend',
  149. Object.keys(COORDINATE_REASON_TEXT).length === 6 &&
  150. BACKEND_CASES.filter((item) => item.reason).every((item) => !!COORDINATE_REASON_TEXT[item.reason]),
  151. JSON.stringify(Object.keys(COORDINATE_REASON_TEXT)),
  152. )
  153. // 后端整段没给校验段(票 05 的 validated:false)→「坐标未校验」,这是「缺失」而不是「非法」。
  154. const missing = rawToObject({ pointCode: 'MP-N' })
  155. const missingReason = blockReason(missing)
  156. check(
  157. 'B3-unvalidated-is-missing-not-invalid',
  158. missingReason.text === UNVALIDATED_TEXT && missingReason.code === '',
  159. JSON.stringify(missingReason),
  160. )
  161. // 认不出的原因:只说「无地图位置」,不把原因码甩给值班人员。
  162. const unknown = object({ code: 'MP-U', coordinate: { state: 'INVALID', reason: 'SOMETHING_NEW', crs: 'WGS84', renderable: false, validated: true } })
  163. check('B4-unknown-reason-generic', blockReason(unknown).text === NO_MAP_LOCATION_TEXT, JSON.stringify(blockReason(unknown)))
  164. // 界面上给的是中文:六个原因里不得出现后端原因码原文。
  165. const leaked = texts.filter((item) => /^[A-Z_]+$/.test(item.text))
  166. check('B5-no-reason-code-as-text', leaked.length === 0, leaked.length ? JSON.stringify(leaked) : '文案全是中文,零原因码')
  167. }
  168. // ================================================================ C 不静默丢对象
  169. {
  170. const objects = [
  171. object({ code: 'OK-1' }),
  172. object({ code: 'BAD-1', coordinate: { state: 'INVALID', reason: 'ZERO_COORDINATE', crs: 'WGS84', renderable: false, validated: true } }),
  173. object({ code: 'OK-2' }),
  174. object({ code: 'BAD-2', coordinate: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', crs: 'WGS84', renderable: false, validated: true } }),
  175. ]
  176. const { renderable, blocked } = splitByCoordinate(objects)
  177. check(
  178. 'C1-split-keeps-both',
  179. renderable.length === 2 && blocked.length === 2 && renderable.length + blocked.length === objects.length,
  180. `可上图 ${renderable.length} / 不上图 ${blocked.length} / 输入 ${objects.length}`,
  181. )
  182. check(
  183. 'C2-blocked-keeps-identity',
  184. blocked.map((item) => item.code).join(',') === 'BAD-1,BAD-2',
  185. '被拦下的对象仍带编码与名称(列表要用),不是被丢掉的黑洞',
  186. )
  187. const entries = blockedEntries(objects)
  188. check(
  189. 'C3-entries-carry-reason',
  190. entries.length === 2 && entries[0].reasonText === '零坐标(经纬度均为 0)' && entries[1].reasonText === '坐标未经校验',
  191. JSON.stringify(entries.map((item) => `${item.code}:${item.reasonText}`)),
  192. )
  193. check('C4-empty-input-safe', splitByCoordinate(null).blocked.length === 0 && splitByCoordinate(undefined).renderable.length === 0, '空输入不抛错')
  194. }
  195. // ================================================================ D 两个口径分开
  196. {
  197. // 后端说全量 20 个、可上图 12、非法 5、未校验 3;前端只加载了 4 个(3 好 1 坏)。
  198. const backend = toCoordinateSummary({ crs: 'WGS84', totalObjectCount: 20, renderableCount: 12, invalidCount: 5, unverifiedCount: 3 })
  199. const loaded = [
  200. object({ code: 'L-1' }),
  201. object({ code: 'L-2' }),
  202. object({ code: 'L-3' }),
  203. object({ code: 'L-4', coordinate: { state: 'INVALID', reason: 'ZERO_COORDINATE', crs: 'WGS84', renderable: false, validated: true } }),
  204. ]
  205. const overview = coordinateOverview(loaded, backend)
  206. check(
  207. 'D1-two-calibers-kept-apart',
  208. overview.backend.total === 20 && overview.backend.renderable === 12 && overview.loaded.total === 4 && overview.loaded.renderable === 3 && overview.loaded.blocked === 1,
  209. `后端 ${overview.backend.renderable}/${overview.backend.total} · 已加载 ${overview.loaded.renderable}/${overview.loaded.total}`,
  210. )
  211. check(
  212. 'D2-texts-name-their-subject',
  213. overview.backendText.includes('全量') && overview.backendText.includes('12/20') && overview.loadedText.includes('已加载'),
  214. `${overview.backendText} | ${overview.loadedText}`,
  215. )
  216. check(
  217. 'D3-blocked-entry-listed',
  218. overview.blockedEntries.length === 1 && overview.blockedEntries[0].code === 'L-4',
  219. JSON.stringify(overview.blockedEntries),
  220. )
  221. // 后端没给汇总时:不补 0(0 会被读成"一个坏坐标都没有"),如实说没有。
  222. const absent = coordinateOverview(loaded, toCoordinateSummary(null))
  223. check(
  224. 'D4-missing-summary-not-zero-faked',
  225. absent.backend.available === false && absent.backend.total === null && coordinateSummaryText(absent.backend).includes('未取到'),
  226. coordinateSummaryText(absent.backend),
  227. )
  228. // 后端汇总字段缺失时同样不补 0。
  229. const partial = toCoordinateSummary({ crs: 'WGS84', totalObjectCount: 7 })
  230. check(
  231. 'D5-null-counts-stay-null',
  232. partial.available === true && partial.total === 7 && partial.renderable === null && partial.invalid === null,
  233. JSON.stringify(partial),
  234. )
  235. }
  236. // ================================================================ E 汇总进领域批次(票 05 的漏读)
  237. {
  238. const batch = toMapBatch({
  239. page: 1,
  240. pageSize: 2,
  241. snapshotAt: '2026-09-02T10:00:00+08:00',
  242. total: 5,
  243. complete: false,
  244. monitoringPoints: [
  245. { pointCode: 'MP-1', pointName: '一', longitude: 110.1, latitude: 28.4, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } },
  246. ],
  247. unassociatedEquipment: [
  248. { deviceCode: 'EQ-1', deviceName: '二', longitude: 181, latitude: 28.4, coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' } },
  249. ],
  250. coordinateValidation: { crs: 'WGS84', totalObjectCount: 5, renderableCount: 2, invalidCount: 2, unverifiedCount: 1 },
  251. })
  252. check(
  253. 'E1-batch-carries-backend-summary',
  254. batch.coordinateSummary.available === true && batch.coordinateSummary.total === 5 && batch.coordinateSummary.invalid === 2 && batch.coordinateSummary.unverified === 1,
  255. JSON.stringify(batch.coordinateSummary),
  256. )
  257. // 跨页合并:汇总是全量口径,取最新一次响应,**不得累加**(累加会变成"页数 × 全量")。
  258. const first = mergeMapPage(null, batch)
  259. const second = mergeMapPage(first, { ...batch, page: 2, coordinateSummary: batch.coordinateSummary })
  260. check(
  261. 'E2-no-accumulation-across-pages',
  262. second.coordinateSummary.total === 5 && second.coordinateSummary.renderable === 2,
  263. `两页之后 total=${second.coordinateSummary.total}(不是 10)`,
  264. )
  265. // 后端没给汇总的页不会把已拿到的汇总清掉。
  266. const third = mergeMapPage(second, { page: 3, objects: [], coordinateSummary: toCoordinateSummary(null) })
  267. check('E3-absent-summary-keeps-last', third.coordinateSummary.total === 5, `total=${third.coordinateSummary.total}`)
  268. // 空态的初值也有这个字段(界面不必判 undefined)。
  269. check('E4-empty-state-has-field', mergeMapPage(null, { page: 1, objects: [] }).coordinateSummary.available === false, '初值 available=false')
  270. }
  271. // ================================================================ F mock 三桶分法照抄后端
  272. {
  273. resetMockTransportsForTest()
  274. registerCockpitMocks({
  275. scenario: 'success',
  276. mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE },
  277. handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
  278. })
  279. const first = await fetchMapSnapshot({ page: 1, pageSize: MAP_SUMMARY_PAGE_SIZE, config: mockConfig })
  280. const summary = first.data.coordinateSummary
  281. // mock 的五条样例:2 个 VALID、1 个 UNVERIFIED、2 个 INVALID(超范围 + 零坐标)。
  282. check(
  283. 'F1-mock-summary-three-buckets',
  284. summary.total === 5 && summary.renderable === 2 && summary.invalid === 2 && summary.unverified === 1 && summary.crs === 'WGS84',
  285. JSON.stringify(summary),
  286. )
  287. // 全量口径不随页码变化(后端是按整份快照算完再切片)。
  288. const second = await fetchMapSnapshot({
  289. page: 2,
  290. pageSize: MAP_SUMMARY_PAGE_SIZE,
  291. snapshotAt: first.data.snapshotAt,
  292. config: mockConfig,
  293. })
  294. check(
  295. 'F2-summary-stable-across-pages',
  296. second.data.coordinateSummary.total === summary.total && second.data.coordinateSummary.renderable === summary.renderable,
  297. `第 2 页 total=${second.data.coordinateSummary.total}`,
  298. )
  299. // 已加载口径随翻页增长,与后端全量口径**本来就不等**——这正是要分开报的原因。
  300. const merged = mergeMapPage(mergeMapPage(null, first.data), second.data)
  301. const overview = coordinateOverview(merged.objects, merged.coordinateSummary)
  302. check(
  303. 'F3-loaded-differs-from-backend',
  304. overview.backend.renderable === 2 && overview.loaded.total === 4 && overview.loaded.renderable === 2 && overview.loaded.blocked === 2,
  305. `后端可上图 ${overview.backend.renderable}/5 · 已加载 ${overview.loaded.renderable}/${overview.loaded.total}(无地图位置 ${overview.loaded.blocked})`,
  306. )
  307. // 空样例:0 是真实值(不是缺失)。
  308. // 注意这里读**原始载荷**:`empty` 在票 05 的词汇里是没有领域载荷的(`data: null`),
  309. // 所以 `fetchMapSnapshot` 看不到汇总段,得直接问统一出口要后端回的那一份。
  310. resetMockTransportsForTest()
  311. registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest } })
  312. const emptyRaw = await requestCockpitJson('/map-snapshot?page=1&pageSize=2', { config: mockConfig })
  313. const emptySummary = toCoordinateSummary(emptyRaw.data && emptyRaw.data.coordinateValidation)
  314. check(
  315. 'F4-empty-scenario-zeroes',
  316. emptyRaw.ok === true && emptySummary.available === true && emptySummary.total === 0 && emptySummary.invalid === 0,
  317. JSON.stringify(emptySummary),
  318. )
  319. // 字段缺失样例:没给 coordinateValidation 的对象落进「未校验」桶(与后端同一分法)。
  320. resetMockTransportsForTest()
  321. registerCockpitMocks({ scenario: 'degraded', handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest } })
  322. const degraded = await fetchMapSnapshot({ page: 1, pageSize: MAP_SUMMARY_PAGE_SIZE, config: mockConfig })
  323. check(
  324. 'F5-degraded-counts-unverified',
  325. degraded.data.coordinateSummary.total === 1 && degraded.data.coordinateSummary.unverified === 1 && degraded.data.coordinateSummary.renderable === 0,
  326. JSON.stringify(degraded.data.coordinateSummary),
  327. )
  328. }
  329. // ================================================================ G store 供货(组件不自己算)
  330. {
  331. setActivePinia(createPinia())
  332. resetMockTransportsForTest()
  333. registerCockpitMocks({
  334. scenario: 'success',
  335. mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE },
  336. handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
  337. })
  338. const mapStore = useMapStore()
  339. mapStore.reset()
  340. mapStore.setup({ config: mockConfig })
  341. await mapStore.advance()
  342. const afterFirst = mapStore.coordinate
  343. await mapStore.advance()
  344. const afterSecond = mapStore.coordinate
  345. check(
  346. 'G1-store-supplies-overview',
  347. afterFirst.loaded.total === 2 && afterFirst.loaded.renderable === 2 && afterFirst.loaded.blocked === 0,
  348. JSON.stringify(afterFirst.loaded),
  349. )
  350. check(
  351. 'G2-overview-grows-with-pages',
  352. afterSecond.loaded.total === 4 && afterSecond.loaded.renderable === 2 && afterSecond.loaded.blocked === 2 && afterSecond.blockedEntries.length === 2,
  353. `${afterSecond.loadedText} | ${JSON.stringify(afterSecond.blockedEntries.map((item) => item.reasonText))}`,
  354. )
  355. check(
  356. 'G3-backend-caliber-unchanged',
  357. afterSecond.backend.total === 5 && afterSecond.backend.renderable === 2,
  358. afterSecond.backendText,
  359. )
  360. }
  361. // ================================================================ H 「已上图」措辞不再骗人
  362. {
  363. const blocked = object({ code: 'BAD', coordinate: { state: 'INVALID', reason: 'ZERO_COORDINATE', crs: 'WGS84', renderable: false, validated: true } })
  364. const state = { phase: 'complete', loaded: 2, total: 2, objects: [object({ code: 'OK' }), blocked] }
  365. const view = summaryView(state)
  366. check(
  367. 'H1-complete-label-separates-blocked',
  368. view.label.includes('已上图 1 个') && view.label.includes('无地图位置 1 个'),
  369. view.label,
  370. )
  371. // 没有对象可判时(纯文案单测的老用法)保持票 08 的原措辞,不凭空造数。
  372. const legacy = summaryView({ phase: 'complete', loaded: 7, total: 7 })
  373. check('H2-legacy-usage-unchanged', legacy.label.includes('已上图 7 个') && !legacy.label.includes('无地图位置'), legacy.label)
  374. // 终态措辞不得把被拦下的对象算进「已上图」。
  375. const allBlocked = summaryView({ phase: 'complete', loaded: 2, total: 2, objects: [blocked, object({ code: 'BAD2', coordinate: { state: 'INVALID', reason: 'ZERO_COORDINATE', crs: 'WGS84', renderable: false, validated: true } })] })
  376. check('H3-all-blocked-reports-zero', allBlocked.label.includes('已上图 0 个') && allBlocked.label.includes('无地图位置 2 个'), allBlocked.label)
  377. }
  378. // ================================================================ I 静态守卫
  379. {
  380. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  381. /**
  382. * 读一个模块并**去掉注释**再断言。
  383. * 守卫必须看代码而不是说明文字:本模块的注释里就写着「为什么连 `state === 'VALID'` 都不作为判据」
  384. * 这句反例(第一版守卫正是被自己的注释判红的),拿注释当证据等于没守。
  385. */
  386. const stripComments = (text) =>
  387. text
  388. .replace(/\/\*[\s\S]*?\*\//g, ' ')
  389. // 行注释;`://` 里的双斜杠是 URL,别误伤。
  390. .replace(/(^|[^:])\/\/.*$/gm, '$1')
  391. const read = (relative) => stripComments(readFileSync(new URL(relative, moduleRoot), 'utf8'))
  392. // 票 07 验收第 4 条:前端不自动纠偏。落在代码上就是"没有任何地方再查一遍经纬度范围"。
  393. // 查数值边界比较(180 / 90 与经纬度变量的比较)—— 判定只许来自后端 renderable。
  394. const RANGE_PATTERN = /(longitude|latitude|lon|lat)\s*[<>]=?\s*-?(90|180)\b|-?(90|180)\s*[<>]=?\s*(longitude|latitude|lon|lat)\b/i
  395. const domainFiles = ['domain/coordinate.js', 'domain/mapSummary.js', 'domain/fields.js', 'domain/pointDetail.js']
  396. const rechecked = domainFiles.filter((file) => RANGE_PATTERN.test(read(file)))
  397. check(
  398. 'I1-no-frontend-range-recheck',
  399. rechecked.length === 0,
  400. rechecked.length ? `出现前端范围重算:${rechecked.join(', ')}` : '领域层零范围重算,准入只认后端结论',
  401. )
  402. // 准入判据只有一处:只许与 renderable 比较,不许出现 `state === 'VALID'` 当第二套判据。
  403. const coordinateText = read('domain/coordinate.js')
  404. check(
  405. 'I2-single-admission-path',
  406. /coordinate\.renderable === true/.test(coordinateText) && !/state === ['"]VALID['"]/.test(coordinateText),
  407. '准入只看 renderable,没有第二套 state 判据',
  408. )
  409. // 界面不得自己判坐标:只许读 store 算好的数,不许调判据函数、不许碰后端校验段。
  410. const UI_JUDGES = /coordinateValidation|isRenderable\(|blockReason\(|splitByCoordinate\(|\.renderable\s*===/
  411. const uiFiles = [
  412. 'components/MapCanvas.vue',
  413. 'components/MapCoordinateBlocked.vue',
  414. 'CockpitShell.vue',
  415. ]
  416. const uiJudging = uiFiles.filter((file) => UI_JUDGES.test(read(file)))
  417. check(
  418. 'I3-ui-does-not-judge',
  419. uiJudging.length === 0,
  420. uiJudging.length ? `界面自己判坐标:${uiJudging.join(', ')}` : '三个界面文件零坐标判据,全部取自 store',
  421. )
  422. // 内部原因码不上界面:清单组件只渲染领域层给的中文文案字段。
  423. const listText = read('components/MapCoordinateBlocked.vue')
  424. const rawCodes = Object.keys(COORDINATE_REASON_TEXT).filter((code) => listText.includes(code))
  425. check('I4-no-reason-code-in-list-ui', rawCodes.length === 0, rawCodes.length ? JSON.stringify(rawCodes) : '清单组件零后端原因码')
  426. // 「保留在列表里」的可达性:清单组件确实渲染了原因文案与对象名(不是只有计数)。
  427. check(
  428. 'I5-list-renders-reason-and-name',
  429. /item\.reasonText/.test(listText) && /item\.name/.test(listText) && /blockedEntries/.test(coordinateText),
  430. '清单渲染对象名与中文原因',
  431. )
  432. // 后端全量口径只许经领域层转换,界面与 store 不出现后端裸字段名(票 05 的 D1 同一条纪律)。
  433. const backendField = /totalObjectCount|renderableCount|invalidCount|unverifiedCount/
  434. const leaked = ['stores/mapStore.js', 'components/MapCanvas.vue', 'components/MapCoordinateBlocked.vue'].filter((file) =>
  435. backendField.test(read(file)),
  436. )
  437. check(
  438. 'I6-backend-summary-fields-confined',
  439. leaked.length === 0,
  440. leaked.length ? `裸字段泄漏:${leaked.join(', ')}` : '后端汇总字段只出现在 domain/coordinate.js 与 mock',
  441. )
  442. }
  443. const failed = checks.filter((item) => !item.pass)
  444. console.log('')
  445. console.log(`T07 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  446. if (failed.length) {
  447. console.log('未通过:' + failed.map((item) => item.name).join(', '))
  448. process.exit(1)
  449. }