t41-point-locate-anchor.mjs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. /**
  2. * T41 监测点定位与面板锚定 纯逻辑检查(票 41)。
  3. *
  4. * 零依赖 node 断言脚本(约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t41-point-locate-anchor.mjs
  6. *
  7. * 覆盖验收里能脱浏览器断言的部分:
  8. * · 定位档位来自配置(`map.locate.point`),缺省 0/−55/500,非法值退回缺省且**不静默**;
  9. * · 定位走完整视角预设(连 hpr 一起换),而不是 `setCameraFocus` 的"只换中心";
  10. * · 定位采样迟到不得覆盖后一次选择(按序号作废);
  11. * · 锚定四向顺序、四向避让、12px 间距不压图标、离屏判据;
  12. * · **拥挤时不再"no-space 暂隐"**(规格 R-2:允许裁切),只在完全离屏时隐藏(R-4)。
  13. *
  14. * 另有一段静态守卫:确认外壳确实用统一入口、且 **不读 Cesium 私有实体**
  15. * (本票验收第 6 条明写"禁止读取 Cesium 私有实体")。
  16. */
  17. import assert from 'node:assert/strict'
  18. import { readFileSync } from 'node:fs'
  19. import { LOCATE_DEFAULTS, isStaleLocate, planLocateView } from '../../../src/modules/cockpit/domain/pointLocate.js'
  20. import { locationVerdict } from '../../../src/modules/cockpit/domain/coordinate.js'
  21. import { ANCHOR_HIDDEN, ANCHOR_SIDES, solvePointAnchor } from '../../../src/modules/cockpit/domain/pointAnchor.js'
  22. import { createCameraControl } from '../../../src/modules/cockpit/services/cameraControl.js'
  23. const checks = []
  24. function check(name, pass, detail = '') {
  25. checks.push({ name, pass: !!pass, detail })
  26. }
  27. // ================================================================ A 定位档位(配置驱动)
  28. {
  29. check('A1-default-point-tier', LOCATE_DEFAULTS.point.range === 500 && LOCATE_DEFAULTS.point.pitch === -55,
  30. `缺省点位档 ${JSON.stringify(LOCATE_DEFAULTS.point)}`)
  31. check('A2-default-device-tier', LOCATE_DEFAULTS.device.range === 150,
  32. `缺省设备档 range=${LOCATE_DEFAULTS.device.range}(供后续设备联动票消费)`)
  33. // 未配置 → 缺省(组件不硬编码,配置缺省就是规格那一套)。
  34. // 一律走 `check`(不用裸 `assert`):裸 assert 会在汇总行之前抛出,闸门"红"了却看不出是哪一条
  35. // —— 变异测试里 M1/M2/M3 正是因此读不到断言名(同一段还有 A3 要守,红的原因会被误读)。
  36. check('A2b-unconfigured-uses-defaults',
  37. JSON.stringify(planLocateView({}, 'point')) === JSON.stringify({ heading: 0, pitch: -55, range: 500 })
  38. && JSON.stringify(planLocateView({ locate: {} }, 'point')) === JSON.stringify({ heading: 0, pitch: -55, range: 500 }),
  39. '未配置时用缺省档(0/−55/500)')
  40. // 部署覆盖生效。
  41. const custom = planLocateView({ locate: { point: { heading: 30, pitch: -40, range: 800 } } }, 'point')
  42. check('A3-config-overrides',
  43. custom.heading === 30 && custom.pitch === -40 && custom.range === 800,
  44. `配置可覆盖点位档,实际 ${JSON.stringify(custom)}`)
  45. // 非法值**逐项**退回缺省,不整档作废、也不静默接受负距离。
  46. const dirty = planLocateView({ locate: { point: { heading: null, pitch: 'x', range: -5 } } }, 'point')
  47. check('A4-invalid-values-fall-back',
  48. dirty.heading === 0 && dirty.pitch === -55 && dirty.range === 500,
  49. `非法值逐项退回缺省,实际 ${JSON.stringify(dirty)}(负 range 不生效)`)
  50. // 认不出的档位回 null —— 不发明第三档。
  51. check('A5-unknown-tier-null', planLocateView({}, 'nope') === null, '认不出的档位返回 null')
  52. }
  53. // ================================================================ B 定位=完整预设(不是只换中心)
  54. function createFakeEngine({ viewport = { width: 1680, height: 900 }, pick = { lon: 110.14, lat: 28.45, height: 111.6 } } = {}) {
  55. const applied = []
  56. let pending = null
  57. let clock = 0
  58. return {
  59. applied,
  60. engine: {
  61. applyView: (pivot, hpr) => applied.push({ pivot: { ...pivot }, hpr: { ...hpr } }),
  62. readView: () => ({ heading: 0, pitch: -55, range: 12000 }),
  63. pickGroundAtCenter: () => ({ ...pick }),
  64. groundHeightBelowCamera: () => 111.6,
  65. projectPivot: () => ({ x: 10, y: 10 }),
  66. inputTarget: () => null,
  67. viewport: () => ({ ...viewport }),
  68. fovY: () => Math.PI / 3,
  69. now: () => clock,
  70. requestFrame: (cb) => { pending = cb },
  71. },
  72. // 同步跑一帧(阻尼收敛用):
  73. runFrame(steps = 400) {
  74. for (let i = 0; i < steps && pending; i++) {
  75. const cb = pending
  76. pending = null
  77. clock += 16
  78. cb(clock)
  79. }
  80. },
  81. }
  82. }
  83. {
  84. const fake = createFakeEngine()
  85. const control = createCameraControl({ engine: fake.engine })
  86. control.syncFromEngine()
  87. // `setCameraFocus` 的旧语义:只换中心、**range 保持县域 12000**(ADR 0008)。
  88. control.setFocus({ lon: 110.141, lat: 28.44, height: 0 })
  89. fake.runFrame()
  90. const afterFocus = control.getState()
  91. check('B1-setFocus-keeps-range', Math.round(afterFocus.hpr.range) === 12000,
  92. `setFocus 后 range=${Math.round(afterFocus.hpr.range)}(旧语义:只换中心)`)
  93. // 票 41 的定位路径:完整视角预设 —— 中心与 hpr 一起换。
  94. const hpr = planLocateView({}, 'point')
  95. control.applyViewPreset({ lon: 110.141, lat: 28.44, height: 111.6 }, hpr)
  96. fake.runFrame()
  97. const located = control.getState()
  98. check('B2-locate-applies-full-preset',
  99. Math.round(located.hpr.range) === 500 && located.hpr.pitch === -55 && located.hpr.heading === 0,
  100. `定位后 hpr=${JSON.stringify(located.hpr)}(range 换成点位档 500)`)
  101. check('B3-locate-center-is-the-target',
  102. Math.abs(located.pivot.lon - 110.141) < 1e-9 && Math.abs(located.pivot.height - 111.6) < 1e-9,
  103. `中心落在点位地表实测高程 ${located.pivot.height} m(不是椭球 0)`)
  104. // 两档都不被区域上限夹(range_max=12000):任何画布比例下行为一致。
  105. control.setRangeLimits({ min: 120, max: 12000, area: null })
  106. control.applyViewPreset({ lon: 110.141, lat: 28.44, height: 111.6 }, planLocateView({}, 'device'))
  107. fake.runFrame()
  108. check('B4-device-tier-not-clamped', Math.round(control.getState().hpr.range) === 150,
  109. `设备档 range=${Math.round(control.getState().hpr.range)},未被 12000 上限夹`)
  110. }
  111. // ================================================================ C 采样竞态(迟到不得覆盖)
  112. {
  113. // 判据取自**实现本身**(`isStaleLocate`),不在本文件重述算法 ——
  114. // 复述一遍的测试是"自己跟自己比",本仓在票 09/18 已经栽过。
  115. check('C1-stale-predicate',
  116. isStaleLocate(1, 2) === true && isStaleLocate(2, 2) === false,
  117. `作废判据是纯函数(序号不等即作废):isStaleLocate(1,2)=${isStaleLocate(1, 2)}、isStaleLocate(2,2)=${isStaleLocate(2, 2)},实现与闸门共用同一份`)
  118. // 调用点守卫:服务层必须在**落位前**用它判据,且取消入口要让在途采样失效。
  119. // 只测纯函数而不钉调用点,就会出现"函数写好了、没人调"(票 18 的徽标真缺陷就是这样)。
  120. const mapServiceText = readFileSync(new URL('../../../src/modules/cockpit/services/mapService.js', import.meta.url), 'utf8')
  121. const locateBody = mapServiceText.slice(mapServiceText.indexOf('export async function locateMonitoringPoint'))
  122. .slice(0, mapServiceText.slice(mapServiceText.indexOf('export async function locateMonitoringPoint')).indexOf('\n}'))
  123. check('C2-callsite-uses-predicate', /isStaleLocate\(\s*token\s*,\s*pointLocateToken\s*\)/.test(locateBody),
  124. '定位服务在落位前用 isStaleLocate(token, pointLocateToken) 判定')
  125. check('C3-predicate-before-apply', locateBody.indexOf('isStaleLocate') < locateBody.indexOf('applyViewPreset'),
  126. '判据在 applyViewPreset **之前**(迟到采样不得下发到相机)')
  127. check('C4-cancel-bumps-token', /export function cancelPointLocate\(\)\s*\{\s*pointLocateToken \+= 1/.test(mapServiceText),
  128. '取消入口(归位/返回全球/手动三键)自增序号即在途采样失效')
  129. }
  130. // ================================================================ D 锚定布局(四向 / 间距 / 离屏)
  131. const CANVAS = { left: 0, top: 0, right: 1600, bottom: 900 }
  132. {
  133. // 中间偏左的标记:右边有空间 → 首选右。
  134. const right = solvePointAnchor({ anchor: { x: 400, y: 450 }, canvas: CANVAS, width: 340, height: 300 })
  135. check('D1-prefers-right', right.visible && right.side === 'right', `首选右侧(实际 ${right.side})`)
  136. // 图标 18px、间距 12px → 卡片左缘应在 anchor.x + 9 + 12 = 421 附近,绝不压住图标。
  137. const gapPx = right.rect.left - 400
  138. check('D2-gap-not-covering-icon', gapPx >= 21, `卡片左缘距标记中心 ${gapPx.toFixed(1)}px(≥9+12,不压图标)`)
  139. // 靠右边缘 → 右侧放不下,翻到左。
  140. const left = solvePointAnchor({ anchor: { x: 1450, y: 300 }, canvas: CANVAS, width: 340, height: 300 })
  141. check('D3-flips-to-left', left.visible && left.side === 'left', `贴右缘翻到左侧(实际 ${left.side})`)
  142. // 画布高 900、标记贴顶、卡片内容 3000px:高度被 80% 上限收成 720,四向都能完整放下
  143. // (规格 R-2 的另一面:**先收高度**,不是先消失)。所以这里必须 visible 且**不**标裁切。
  144. const tall = solvePointAnchor({ anchor: { x: 800, y: 8 }, canvas: CANVAS, width: 340, height: 3000 })
  145. check('D4-crowded-still-visible', tall.visible === true,
  146. `内容超长时仍显示(高度收到画布 80% 上限,不暂隐;实际 visible=${tall.visible})`)
  147. check('D5-tall-content-not-cropped', tall.cropped === false && Math.round(tall.rect.height) === 720,
  148. `高度收成 720px 后四向都放得下,不必裁切(实际 height=${Math.round(tall.rect.height)} cropped=${tall.cropped})`)
  149. // 真正会裁切的算例:**窄画布**(320px)装不下 340px 的卡与两侧 12px 留白 ⇒ 允许裁切、
  150. // 但**绝不消失**(规格 R-2/R-4:能装多少显示多少,消失只留给"完全离开画面")。
  151. const narrow = solvePointAnchor({ anchor: { x: 100, y: 450 }, canvas: { left: 0, top: 0, right: 320, bottom: 900 }, width: 340, height: 300 })
  152. check('D6-narrow-canvas-crops-not-hides', narrow.visible === true && narrow.cropped === true,
  153. `窄画布下允许被裁切但不隐藏(实际 visible=${narrow.visible} cropped=${narrow.cropped})`)
  154. // 完全离开画面 → 唯一隐藏条件(R-4)。
  155. const off = solvePointAnchor({ anchor: { x: -40, y: 450 }, canvas: CANVAS, width: 340, height: 300 })
  156. check('D6-offscreen-hides', off.visible === false && off.reason === 'marker-offscreen',
  157. `完全离屏才隐藏(reason=${off.reason})`)
  158. const noAnchor = solvePointAnchor({ anchor: null, canvas: CANVAS })
  159. check('D7-no-anchor-hides', noAnchor.visible === false && noAnchor.reason === 'marker-missing',
  160. `投影拿不到 → 暂隐(reason=${noAnchor.reason})`)
  161. const noCanvas = solvePointAnchor({ anchor: { x: 1, y: 1 }, canvas: null })
  162. check('D8-no-canvas-hides', noCanvas.visible === false && noCanvas.reason === 'canvas-missing', '画布尺寸未知 → 暂隐')
  163. // 四向顺序是契约(票 04 已锁),不是实现细节。
  164. assert.deepEqual(ANCHOR_SIDES, ['right', 'left', 'below', 'above'])
  165. check('D9-side-order-locked', true, "四向顺序=右→左→下→上(票 04 已锁)")
  166. // 每种隐藏原因都有中文说法(顶栏暂隐入口要如实说明)。
  167. check('D10-hidden-reasons-have-text',
  168. ['canvas-missing', 'marker-missing', 'marker-offscreen'].every((key) => !!ANCHOR_HIDDEN[key]),
  169. `暂隐原因文案齐备:${Object.values(ANCHOR_HIDDEN).join('/')}`)
  170. }
  171. // ================================================================ E2 无地图位置 vs 暂隐(纯判据)
  172. {
  173. const renderablePoint = { code: 'P-OK', coordinate: { renderable: true, validated: true } }
  174. const blockedPoint = { code: 'P-BAD', coordinate: { renderable: false, validated: true, reason: 'UNVERIFIED_COORDINATE' } }
  175. const objects = [renderablePoint, blockedPoint]
  176. const ok = locationVerdict(objects, 'P-OK')
  177. check('E2a-renderable-point-is-not-fixed', ok.present === true && ok.renderable === true && ok.reasonText === '',
  178. `可上图点位:present=${ok.present} renderable=${ok.renderable}(走锚定跟随)`)
  179. const blocked = locationVerdict(objects, 'P-BAD')
  180. check('E2b-blocked-point-goes-fixed-with-reason',
  181. blocked.present === true && blocked.renderable === false && blocked.reasonText === '坐标未经校验',
  182. `后端判不可上图:固定详情 + 原因「${blocked.reasonText}」(不是空原因)`)
  183. // **最容易被写错的一条**:不在已加载快照里 ≠ 无地图位置。
  184. // 写成 present:true 会让"分页还没翻到"的点位显示一张"无地图位置"的卡片 —— 那是假结论。
  185. const missing = locationVerdict(objects, 'P-NOT-LOADED')
  186. check('E2c-not-loaded-is-not-no-location',
  187. missing.present === false && missing.renderable === false && missing.reasonText === '',
  188. `不在快照里:present=${missing.present} 且原因留空(走暂隐 + 保留选择,不冒充"无地图位置")`)
  189. const empty = locationVerdict(objects, '')
  190. check('E2d-empty-code-is-not-loaded', empty.present === false, '空编码同样按"未确认"处理')
  191. }
  192. // ================================================================ E 静态守卫(统一入口 + 禁止私有实体)
  193. {
  194. const root = new URL('../../../src/modules/cockpit/', import.meta.url)
  195. const read = (rel) => readFileSync(new URL(rel, root), 'utf8')
  196. // 验收第 1 条:地图点选与台账选择走同一入口。
  197. // 守卫要连**判据**一起钉住:只查"文本里出现过 selectMonitoringPoint("会被 `if (false) …` 满足
  198. // (变异 M9 实测全绿)—— 所以要求它挂在 `picked.kind === 'monitoring-point'` 的分支里。
  199. const canvas = read('components/MapCanvas.vue')
  200. const shell = read('CockpitShell.vue')
  201. const monitoring = read('stores/monitoringStore.js')
  202. const selectionModule = read('pointSelection.js')
  203. check('E1-map-uses-shared-entry',
  204. /if \(picked\.kind === 'monitoring-point'\) selectMonitoringPoint\(picked\.code\)/.test(canvas),
  205. '地图点选在监测点分支里调用 selectMonitoringPoint(统一入口)')
  206. check('E2-ledger-uses-shared-entry', /return selectMonitoringPoint\(pointCode\)/.test(monitoring),
  207. '台账卡片 return selectMonitoringPoint(pointCode)(同一条入口,不再各写一份)')
  208. // 入口落在**接线模块**而不是 detailStore:两侧都不许自己长出对方(票 10 的 F4 / 票 02 的层次纪律)。
  209. check('E3-entry-lives-in-wiring-module',
  210. /export function selectMonitoringPoint/.test(selectionModule)
  211. && /from '\.\/services\/mapService\.js'/.test(selectionModule)
  212. && /from '\.\/stores\/detailStore\.js'/.test(selectionModule),
  213. '统一入口落在 pointSelection.js(同时认识详情 store 与地图服务,两侧各自保持纪律)')
  214. check('E3b-detail-store-stays-clean',
  215. !/from '\.\.\/services\/mapService\.js'/.test(read('stores/detailStore.js')),
  216. 'detailStore 不 import mapService(票 10 的 F4 守卫仍成立)')
  217. // 统一入口必须在选点位时**先**作废在途采样,否则"选 A 后立刻选 B"仍会被 A 的迟到采样覆盖。
  218. check('E3c-entry-cancels-pending', /cancelPointLocate\(\)/.test(selectionModule),
  219. '统一选择入口在打开详情前作废上一次在途采样')
  220. // 验收第 6 条:禁止读取 Cesium 私有实体。
  221. const engine = read('services/cesiumEngine.js')
  222. const privateRead = /\._entity|\._billboard|\._position|drillPick|\._id\b|\._show\b/.test(engine)
  223. check('E4-no-private-cesium-members', !privateRead, '不读 Cesium 私有成员(只用公开实体/属性与公开拾取)')
  224. // 锚点的高度回退:**瓦片未加载时按椭球面**,不能因为"配了地形却取不到高度"就判标记离屏。
  225. // 这是真机走查抓到的缺陷(相机飞到未加载区域时面板整块消失,而标记明明还看得见)。
  226. //
  227. // 守卫要**正面**要求回退分支存在,不能只否定一个已删掉的参数名
  228. // (变异 M15 实测:`!/terrainReady\) return null/` 对删参后的实现恒真、断言没牙)。
  229. const projectEntry = (/export function projectPointMarker[\s\S]*?\n\}/.exec(engine) || [''])[0]
  230. const projectBody = (/function projectMarkerEntity[\s\S]*?\n\}/.exec(engine) || [''])[0] || projectEntry
  231. check('E4b-project-falls-back-to-ellipsoid',
  232. /if \(Number\.isFinite\(sampled\)\) height = sampled/.test(projectBody)
  233. && /Cartesian3\.fromRadians\(carto\.longitude, carto\.latitude, height\)/.test(projectBody)
  234. && !/if \(!Number\.isFinite\(sampled\)\) return null/.test(projectBody),
  235. '投影取不到地形高度时回退椭球面(与 Cesium 无地形时画在椭球面一致),不误判为离屏')
  236. // 锚定必须有**渲染帧之外**的触发路径:隐藏标签页里 rAF 冻结、postRender 不触发。
  237. check('E4c-camera-change-triggers-anchor',
  238. /export function onCameraStateChange/.test(read('services/mapService.js'))
  239. && /onCameraStateChange\(/.test(shell),
  240. '相机状态变化也触发锚定重算(不只依赖渲染帧)')
  241. // **程序化下发视角**(定位/归位/返回全球)也要通知 —— 它不经过相机输入回调,
  242. // 只靠输入回调会让「重新定位后面板仍显示隐藏」在隐藏标签页里稳定复现(真机走查抓到)。
  243. check('E4c2-programmatic-preset-notifies',
  244. /const applied = instance\.cameraControl\.applyViewPreset\(home, hpr, options\)[\s\S]{0,400}notifyCameraStateChange\(\)/.test(
  245. read('services/mapService.js'),
  246. ),
  247. 'applyViewPreset 下发成功后通知一次相机状态变化(定位/归位/返回全球都覆盖)')
  248. check('E4d-explicit-refresh-not-blocked-by-hidden',
  249. /document\.hidden && options\.force !== true/.test(shell),
  250. '显式重算(选择/尺寸/相机变化)不被「页面不可见」早退挡住,逐帧重算才跳过')
  251. // 暂隐的面板必须同时关掉 pointer-events(否则看不见却拦截地图点击)。
  252. check('E4e-hidden-panel-not-clickable',
  253. /visibility: 'hidden', pointerEvents: 'none'/.test(shell),
  254. '暂隐时同时关掉 pointer-events(不可见的面板不得拦截地图点击)')
  255. // 外壳不得自己拼投影(投影只在地图服务/引擎一处)。
  256. check('E5-shell-no-cesium-import', !/from 'cesium'/.test(shell) && !/from 'cesium'/.test(canvas),
  257. '外壳与地图组件都不 import cesium(投影归服务层/引擎)')
  258. // 层级:锚定面板必须**低于**左右栏(规格 R-3:允许被四个详情窗遮盖)。
  259. check('E6-anchor-below-columns', /\.cockpit-shell__detail\.is-anchored\s*\{[^}]*z-index:\s*1/.test(shell),
  260. '锚定面板 z-index=1(低于 --z-columns=2,允许被侧栏遮盖)')
  261. // 暂隐时不转固定卡:固定详情只由**后端判定**触发,且判据取自 mapStore 的只读结论
  262. // (票 07 的 I3:界面不许自己调 isRenderable / blockReason 判坐标)。
  263. check('E7-fixed-only-when-blocked',
  264. /detailAnchorLocation\(target\)/.test(shell)
  265. && /locationVerdict\(target\.object/.test(read('domain/detailAnchor.js'))
  266. && !/\b(isRenderable|blockReason)\s*\(/.test(shell),
  267. '外壳消费当前锚定对象的领域坐标准入结论;设备无位置不借父点坐标,未确认不冒充无位置')
  268. // 暂隐时的「重新定位」也必须走统一入口(别处改路径不会让这里悄悄失效)。
  269. check('E7c-relocate-uses-shared-entry',
  270. /function relocateSelected[\s\S]*?\n\}/.test(shell)
  271. && /selectMonitoringPoint\(target\.code\)/.test((/function relocateSelected[\s\S]*?\n\}/.exec(shell) || [''])[0]),
  272. '外壳的「重新定位」调用统一入口(不是旧的 detailStore 方法)')
  273. // 判定收进 domain(纯函数、可断言),store 只喂已加载对象 —— 与"判定归 domain"的成例一致。
  274. check('E7b-verdict-lives-in-domain',
  275. /export function locationVerdict/.test(read('domain/coordinate.js'))
  276. && /locationVerdict\(summary\.value\.objects, code\)/.test(read('stores/mapStore.js')),
  277. 'locationVerdict 的判定在 domain/coordinate.js,mapStore 只把已加载对象喂给它')
  278. // 帧订阅与尺寸监听必须在卸载时拆掉(票 15/37 的"漏取消定时器"同一个坑)。
  279. // 守卫要**连判据一起**钉住:只查 `stopMapFrame()` 会被 `if (false) { stopMapFrame() }` 满足
  280. // (票 18 的 H13 就是这么栽的)—— 所以要求调用确实挂在 `if (stopMapFrame)` 之下。
  281. check('E8-anchor-teardown',
  282. /if \(stopMapFrame\) \{\s*stopMapFrame\(\)/.test(shell)
  283. && /if \(stopCameraState\) \{\s*stopCameraState\(\)/.test(shell)
  284. && /removeEventListener\('resize'/.test(shell),
  285. '卸载时在 if (stopMapFrame) / if (stopCameraState) 下真的拆掉两条订阅,并移除 resize 监听')
  286. }
  287. const failed = checks.filter((item) => !item.pass)
  288. console.log('')
  289. for (const item of checks) {
  290. if (!item.pass) console.log(` ✗ ${item.name}${item.detail ? ` — ${item.detail}` : ''}`)
  291. }
  292. console.log(`T41 点位定位与面板锚定检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  293. if (failed.length) process.exit(1)
  294. console.log('T41 定位与锚定全部通过。')