t08-anchor-layout.mjs 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. /**
  2. * 票 08 原型的**纯逻辑断言**(第一层证据;第二三层见 `## Answer`/票 07 的可断言口径)。
  3. *
  4. * 只测两个不碰 DOM / Cesium / Vue 的纯模块:
  5. * · `prototypes/anchor-prototype-layout.js` —— 票 04 的锚定策略与阈值
  6. * · `prototypes/anchor-prototype-effects.js` —— 票 07 的设备状态/特效/降级
  7. *
  8. * 跑法:`node .scratch/cockpit-point-locate-and-device-link/verify/t08-anchor-layout.mjs`
  9. * 它不断言画面(画面由维护者目视走查),也**不**证明「特效真在跑」——
  10. * 那是场景图跨帧快照与帧差的活,本脚本只锁映射与几何不变量。
  11. * 按 AGENTS.md,本会话未运行任何 build/dev。
  12. */
  13. import { solveAnchorLayout, solveFixedLayout, intersects } from '../../../src/modules/cockpit/prototypes/anchor-prototype-layout.js'
  14. import {
  15. DEVICE_STATUS_VISUALS,
  16. deviceLegend,
  17. deviceVisual,
  18. planDeviceEffects,
  19. samplePointsNear,
  20. spreadDevices,
  21. } from '../../../src/modules/cockpit/prototypes/anchor-prototype-effects.js'
  22. let failed = 0
  23. const ok = (name, condition) => {
  24. if (!condition) failed += 1
  25. console.log(`${condition ? 'PASS' : 'FAIL'} ${name}`)
  26. }
  27. const canvas = { left: 0, top: 0, right: 1920, bottom: 900 }
  28. const icon18 = (x, y) => ({ left: x - 9, top: y - 9, right: x + 9, bottom: y + 9 })
  29. // ---------------------------------------------------------------- 锚定求解
  30. let r = solveAnchorLayout({ anchor: { x: 960, y: 450 }, canvas, blocks: [], form: 'leader' })
  31. ok('画布中央的锚点:优先贴右侧', r.visible && r.side === 'right')
  32. ok('卡片与图标外缘至少留 12 CSS px', r.rect.left >= 960 + 9 + 12 - 0.01)
  33. ok('卡片不与图标相交', !intersects(r.rect, icon18(960, 450)))
  34. ok('卡片完全落在画布内(留边缘 12px)', r.rect.left >= 12 && r.rect.top >= 12 && r.rect.right <= 1908 && r.rect.bottom <= 888)
  35. r = solveAnchorLayout({ anchor: { x: 1900, y: 450 }, canvas, blocks: [], form: 'leader' })
  36. ok('贴右边缘:翻到左侧', r.visible && r.side === 'left')
  37. // 四向都排不满时不许硬塞:必须如实暂隐(票 04:不回固定位)
  38. // 用一个真正放不下的小画布:左右宽度不足可读下限、上下高度不足可读下限。
  39. const tight = { left: 0, top: 0, right: 600, bottom: 400 }
  40. r = solveAnchorLayout({ anchor: { x: 300, y: 200 }, canvas: tight, blocks: [], form: 'leader' })
  41. ok('四向都放不下:暂隐且不硬塞', !r.visible && r.reason === 'no-space')
  42. ok('暂隐带人话原因(顶栏要显示)', typeof r.hiddenText === 'string' && r.hiddenText.length > 0)
  43. r = solveAnchorLayout({ anchor: { x: -40, y: 450 }, canvas, blocks: [], form: 'leader' })
  44. ok('标记在画布外:判 icon-offscreen', !r.visible && r.reason === 'icon-offscreen')
  45. r = solveAnchorLayout({ anchor: null, canvas, blocks: [], form: 'leader' })
  46. ok('锚点缺失/投影无效:判 no-anchor', !r.visible && r.reason === 'no-anchor')
  47. r = solveAnchorLayout({ anchor: { x: 10, y: 10 }, canvas: null, blocks: [], form: 'leader' })
  48. ok('画布尺寸未知:判 canvas-missing', !r.visible && r.reason === 'canvas-missing')
  49. r = solveAnchorLayout({ anchor: { x: 100, y: 450 }, canvas, blocks: [{ left: 0, top: 0, right: 300, bottom: 900 }], form: 'leader' })
  50. ok('标记被业务界面遮住:判 icon-covered', !r.visible && r.reason === 'icon-covered')
  51. // 三形态:flush 无边线,leader 出线,arrow 出箭头
  52. for (const form of ['flush', 'leader', 'arrow']) {
  53. r = solveAnchorLayout({ anchor: { x: 960, y: 450 }, canvas, blocks: [], form })
  54. ok(`形态 ${form} 可见`, r.visible)
  55. if (form === 'flush') ok('形态 flush:无连接件', r.connector === null)
  56. if (form === 'leader') ok('形态 leader:细短线', r.connector && r.connector.kind === 'line')
  57. if (form === 'arrow') ok('形态 arrow:角箭头', r.connector && r.connector.kind === 'arrow')
  58. }
  59. // 引线只接到图标外缘,不压图标
  60. r = solveAnchorLayout({ anchor: { x: 960, y: 450 }, canvas, blocks: [], form: 'leader' })
  61. ok('引线起点在图标外缘上(不压图标)', Math.abs(r.connector.from.x - (960 + 9)) < 0.01)
  62. // 阈值可调:把间距调大,卡片应随之远离
  63. const wide = solveAnchorLayout({ anchor: { x: 960, y: 450 }, canvas, blocks: [], form: 'leader', config: { gap: 40 } })
  64. ok('gap 可现场调参(40px 生效)', wide.rect.left >= 960 + 9 + 40 - 0.01)
  65. // 可读高度下限:把下限抬到超过可用高度,应判不放下
  66. const tall = solveAnchorLayout({ anchor: { x: 960, y: 450 }, canvas, blocks: [], form: 'leader', config: { minReadableHeight: 2000 } })
  67. ok('可读高度下限抬高后暂隐', !tall.visible && tall.reason === 'no-space')
  68. r = solveFixedLayout({ canvas })
  69. ok('固定位对照可见且不读锚点', r.visible && r.side === 'fixed')
  70. // ---------------------------------------------------------------- 设备状态与特效(票 07)
  71. ok('报警 = 扩散脉冲', deviceVisual('ALARM').effect === 'pulse')
  72. ok('预警 = 闪烁', deviceVisual('WARNING').effect === 'blink')
  73. ok('工单处置 = 旋转环', deviceVisual('WORK_ORDER').effect === 'spin')
  74. ok('正常 = 无特效', deviceVisual('NORMAL').effect === 'none' && deviceVisual('NORMAL').animated === false)
  75. ok('离线 = 无特效', deviceVisual('OFFLINE').effect === 'none' && deviceVisual('OFFLINE').animated === false)
  76. ok('状态未发布落 UNKNOWN,不猜 NORMAL', deviceVisual('MAINTENANCE').key === 'UNKNOWN')
  77. ok('设备词表不含 EMPTY(那是点位聚合态专有)', !('EMPTY' in DEVICE_STATUS_VISUALS))
  78. ok('图例 6 行(五态 + 状态未发布)', deviceLegend().length === 6)
  79. ok('设备状态表与点位表是两张表(键集不同)', !('EMPTY' in DEVICE_STATUS_VISUALS) && deviceLegend().some((row) => row.key === 'UNKNOWN'))
  80. // 样例数据确实覆盖齐五态
  81. const SAMPLE_POINTS = samplePointsNear({ lon: 110.39028164, lat: 28.4436787 })
  82. const allStatuses = new Set(SAMPLE_POINTS.flatMap((point) => point.devices.map((device) => device.status)))
  83. for (const status of ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE']) {
  84. ok(`样例数据覆盖 ${status}`, allStatuses.has(status))
  85. }
  86. // 关键回归:样例点位必须落在**真实 home 视角看得见的地方**
  87. // (第一次走查「找不到设备及监测点位」的根因就是写死坐标离 home 24 km,超出 5.7 km 可视半宽)
  88. {
  89. const home = { lon: 110.39028164, lat: 28.4436787, range: 12000, pitch: -55 }
  90. const R = 6371000
  91. const rad = (deg) => (deg * Math.PI) / 180
  92. const distanceM = (a, b) => {
  93. const dLat = rad(b.lat - a.lat)
  94. const dLon = rad(b.lon - a.lon)
  95. const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2
  96. return 2 * R * Math.asin(Math.sqrt(h))
  97. }
  98. // 归位视角的地面可视半宽(保守估计)
  99. const halfWidth = home.range * Math.sin(rad(Math.abs(home.pitch))) * Math.tan(rad(30))
  100. for (const point of SAMPLE_POINTS) {
  101. const away = distanceM(home, point)
  102. ok(`样例点位 ${point.code} 在归位视野内(${(away / 1000).toFixed(2)} km < ${(halfWidth / 1000).toFixed(2)} km)`, away < halfWidth)
  103. }
  104. // 坐标必须是现算的:换一个 home,点位应随之平移(不再写死)
  105. const shifted = samplePointsNear({ lon: 111.5, lat: 29.5 })
  106. ok('样例坐标随 home 现算(换 home 会平移,不是写死值)', Math.abs(shifted[0].lon - 111.5) < 0.05 && Math.abs(shifted[0].lon - SAMPLE_POINTS[0].lon) > 0.5)
  107. ok('三点互不重合', new Set(SAMPLE_POINTS.map((p) => `${p.lon.toFixed(6)},${p.lat.toFixed(6)}`)).size === 3)
  108. }
  109. // 并发降级:超限停动效,但**静止外观与动时一致**,所以仍看得出有异常
  110. const many = spreadDevices(SAMPLE_POINTS[0], { count: 20 })
  111. let p = planDeviceEffects(many, { animatedCap: 30 })
  112. ok('未超上限:不降级', p.degraded === false)
  113. ok('未超上限:异常设备都在播', p.animatedCount === p.abnormalCount && p.abnormalCount > 0)
  114. p = planDeviceEffects(many, { animatedCap: 3 })
  115. ok('超上限:判降级', p.degraded === true)
  116. ok('降级后:没有任何动效在播', p.animatedCount === 0)
  117. ok('降级后:异常仍被标记出来(不是「什么都没画」)', p.items.filter((item) => item.visual.animated).length === p.abnormalCount && p.abnormalCount > 0)
  118. ok('降级后:静止外观与动时同形同色(仍可辨认)', p.items.every((item) => item.visual.shape === deviceVisual(item.status).shape && item.visual.tone === deviceVisual(item.status).tone))
  119. // 门控与图层生命周期
  120. ok('门控关闭:一台都不播', planDeviceEffects(many, { gated: false }).animatedCount === 0)
  121. ok('图层收起(关闭详情):一台都不播', planDeviceEffects(many, { layerVisible: false }).animatedCount === 0)
  122. p = planDeviceEffects(many, { animatedCap: 30, selectedCode: many[0].code })
  123. ok('选中的设备被强化', p.items.find((item) => item.code === many[0].code).emphasis === 1)
  124. ok('强化只作用于一台', p.items.filter((item) => item.emphasis === 1).length === 1)
  125. ok('选中不改变它是否在播(选中 ≠ 只有它有特效)', p.items.find((item) => item.code === many[0].code).playing === deviceVisual(many[0].status).animated)
  126. // 铺点几何:不重叠
  127. const spread = spreadDevices(SAMPLE_POINTS[0], { count: 20 })
  128. ok('20 台设备铺开位置互不重合', new Set(spread.map((d) => `${d.lon.toFixed(6)},${d.lat.toFixed(6)}`)).size === 20)
  129. console.log(`\n${failed === 0 ? '全部通过' : `${failed} 项失败`}`)
  130. process.exit(failed === 0 ? 0 : 1)