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