t40-intro-return.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. /**
  2. * 票 40 闸门:闲置回引导态必须**看得出来**。
  3. *
  4. * 维护者 2026-09-30 报障(原话):
  5. * 「退出到地球视图后,在重新回到引导状态时,摄像机角度没有回到引导状态,
  6. * 且地球也没有重新开始旋转,除了中心出现『点击进入系统』外用户不一定能意识到进入引导状态了」
  7. *
  8. * 这条不是新需求 —— ADR 0009 第 27 行早已写死:
  9. * 「全球态闲置 5 秒未操作 → 回到程序初始态:**地球自转恢复** + 屏幕中央闪烁『点击进入系统』」。
  10. * 所以它是一次**实现漂移**,本闸门把两个断点钉住:
  11. * ① 相机没回初始全景(引导态的"角度"就是 `INITIAL_PANORAMA`);
  12. * ② 自转起不来 —— 旧实现用了一个粘滞的 `spinPaused` 开关,**没人负责清**,
  13. * 而 `setGlobeSpin()` 命中它时 `return true`(假装成功),于是回引导态那次起转被静默吞掉。
  14. *
  15. * **诚实边界**:本文件是**静态守卫**(读源码结构 + 一处纯行为断言),
  16. * 它证明"接线正确、且那类粘滞开关不会再出现",**不能**证明观感 —— 观感由维护者真机确认。
  17. * 静态断言有牙与否由配套的一次性变异脚本证明(见票面「证据」)。
  18. *
  19. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t40-intro-return.mjs
  20. */
  21. import assert from 'node:assert/strict'
  22. import { readFileSync } from 'node:fs'
  23. import { dirname, resolve } from 'node:path'
  24. import { fileURLToPath } from 'node:url'
  25. const here = dirname(fileURLToPath(import.meta.url))
  26. const root = resolve(here, '../../..')
  27. const read = (rel) => readFileSync(resolve(root, rel), 'utf8')
  28. const { INITIAL_PANORAMA, SETTLE_EPSILON } = await import('../../../src/modules/cockpit/domain/camera.js')
  29. const service = read('src/modules/cockpit/services/mapService.js')
  30. const shell = read('src/modules/cockpit/CockpitShell.vue')
  31. const strip = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  32. const serviceCode = strip(service)
  33. const shellCode = strip(shell)
  34. // ---------------------------------------------------------------- 1. 粘滞开关不得复活
  35. //
  36. // 这是本 bug 的**形状**:一个"暂停自转"的开关被置起后没人清,而 `setGlobeSpin` 命中它时
  37. // 返回 true(调用方以为成功了)。所以这里直接**禁止这个标识符**存在 ——
  38. // 意图与"正在转的循环"必须分开:意图叫 `spinWanted`,循环是 `instance.spin`。
  39. assert.equal(
  40. /spinPaused/.test(serviceCode),
  41. false,
  42. '不得再有粘滞的 `spinPaused` 开关:它没人负责清,会把自转永久哑掉(维护者 2026-09-30 报障的根因)',
  43. )
  44. // ---------------------------------------------------------------- 2. setGlobeSpin 不许"没做事却报成功"
  45. {
  46. const fn = /export function setGlobeSpin\(seconds\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode)
  47. assert.equal(!!fn, true, '应能定位 `setGlobeSpin`')
  48. const body = fn[1]
  49. const startAt = body.indexOf('startSpin(')
  50. assert.equal(startAt >= 0, true, '`setGlobeSpin` 必须真的能起转(调 `startSpin`)')
  51. // **记下意图之后**(`spinWanted = spinSeconds`)、真正起转之前,任何 `return true`
  52. // 都是"假装成功":调用方不会再重试,自转就永远不转了。
  53. // (`<= 0` 那条停转分支在记意图**之前**,它 `return true` 是对的 —— 停转确实做成了。)
  54. assert.match(body, /spinWanted\s*=\s*spinSeconds/, '`setGlobeSpin` 应先记下意图(`spinWanted`)')
  55. const intentAt = body.indexOf('spinWanted = spinSeconds')
  56. const between = body.slice(intentAt, startAt)
  57. assert.equal(
  58. /return\s+true/.test(between),
  59. false,
  60. '记下意图之后、真正起转之前不得 `return true` —— 那是"没做事却报成功",会让调用方停止重试',
  61. )
  62. assert.match(between, /return\s+false/, '这段(相机没接上 / 还没停稳)应如实 `return false`,由服务层稍后兑现')
  63. // 判据必须是**相机是否真的停稳**,而不是某个开关。
  64. assert.match(body, /state\.settled/, '是否可起转应看相机是否真的停稳(`state.settled`)')
  65. }
  66. // ---------------------------------------------------------------- 3. 意图的生命周期
  67. {
  68. const stop = /function stopSpin\(\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode)
  69. assert.equal(!!stop, true, '应能定位 `stopSpin`')
  70. assert.match(
  71. stop[1],
  72. /spinWanted\s*=\s*0/,
  73. '`stopSpin` 必须**放弃**意图 —— 进入系统/归位/返回全球之后都不该再自己转起来',
  74. )
  75. const start = /function startSpin\(record, spinSeconds\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode)
  76. assert.equal(!!start, true, '应能定位 `startSpin`')
  77. assert.equal(
  78. /stopSpin\(\)/.test(start[1].split('record.spin =')[0]),
  79. false,
  80. '`startSpin` 不得调 `stopSpin()` —— 那会把刚立起来的意图自己抹掉(改用只停循环的 `cancelSpinLoop()`)',
  81. )
  82. assert.match(start[1], /cancelSpinLoop\(\)/, '`startSpin` 应只停"正在转的循环"')
  83. }
  84. // ---------------------------------------------------------------- 4. 落定后把意图兑现
  85. //
  86. // 飞行中起不了转(自转每帧以 immediate 写中心,会把飞行目标冲掉),
  87. // 但调用方(外壳回引导态)不会一直重试 —— 所以服务层必须自己接上。
  88. {
  89. const notifyFn = /function notifyCameraStateChange\(\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode)
  90. assert.equal(!!notifyFn, true, '应能定位 `notifyCameraStateChange`')
  91. assert.match(
  92. notifyFn[1],
  93. /resumeSpinIfWanted\(/,
  94. '相机状态变化时(飞行最后一帧必到)应把"想要的自转"接上,否则意图永远兑现不了',
  95. )
  96. const resume = /function resumeSpinIfWanted\(state\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode)
  97. assert.equal(!!resume, true, '应有 `resumeSpinIfWanted`')
  98. assert.match(resume[1], /spinWanted\s*<=\s*0/, '没有意图就直接返回(零开销)')
  99. assert.match(resume[1], /instance\.spin/, '已经在转就不要重复起转')
  100. assert.match(resume[1], /state\.settled/, '相机没停稳就不要起转(与飞行互斥)')
  101. }
  102. // ---------------------------------------------------------------- 5. 回引导态必须把相机送回去
  103. {
  104. assert.match(
  105. shellCode,
  106. /import\s*\{[\s\S]*?resetToIntroView[\s\S]*?\}\s*from\s*'\.\/services\/mapService\.js'/,
  107. '外壳应 import `resetToIntroView`',
  108. )
  109. const fn = /function returnToIntro\(\)\s*\{([\s\S]*?)\n\}/.exec(shellCode)
  110. assert.equal(!!fn, true, '应能定位 `returnToIntro`')
  111. assert.match(fn[1], /resetToIntroView\(\)/, '回引导态必须把相机送回初始全景(否则画面停在用户最后的视角上)')
  112. assert.match(fn[1], /setGlobeSpin\(/, '回引导态必须恢复自转(ADR 0009 明写「地球自转恢复」)')
  113. assert.match(fn[1], /introVisible\.value\s*=\s*true/, '回引导态必须把引导层显示出来')
  114. // 服务层那侧:必须真的套用**引导视角基准**(`introView` —— 引擎挂载时反读的
  115. // Cesium 默认相机,维护者 2026-09-30 裁定初始引导页就是这张视角),
  116. // 下发前必须重算限位,且"已经在引导视角上"时不再排一次飞行。
  117. const reset = /export function resetToIntroView\([^)]*\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode)
  118. assert.equal(!!reset, true, '应有 `resetToIntroView`')
  119. assert.match(reset[1], /introView\.pivot/, '回引导必须套用引导视角基准的 pivot')
  120. assert.match(reset[1], /introView\.hpr/, '回引导必须套用引导视角基准的 hpr')
  121. assert.match(
  122. reset[1],
  123. /rebindAreaLimits\(\)/,
  124. '下发预设前必须重算限位 —— 否则区内上限(12000)会把引导视角的 range 夹没,整屏被雾罩绿(真机复现)',
  125. )
  126. assert.match(reset[1], /isAtPanorama\(/, '已在引导视角上就不该再排飞行 —— 否则自转要白等一个飞行时长')
  127. }
  128. // ---------------------------------------------------------------- 6. 不新立魔数 + 读数可诊断
  129. {
  130. const helper = /function isAtPanorama\(hpr\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode)
  131. assert.equal(!!helper, true, '应有 `isAtPanorama` 判定')
  132. assert.match(
  133. helper[1],
  134. /SETTLE_EPSILON/,
  135. '"是否已在初始全景"应复用「停稳」那套阈值,不另立魔数',
  136. )
  137. assert.match(
  138. serviceCode,
  139. /spinWanted,/,
  140. '读数里要有 `spinWanted`:只报 `spin` 分不清"意图立着但相机还没落定"和"压根没想转"',
  141. )
  142. }
  143. // ---------------------------------------------------------------- 7. 纯行为:阈值口径自洽
  144. //
  145. // `isAtPanorama` 用的是 SETTLE_EPSILON 的三项;这里断言这套阈值在初始全景上自洽
  146. // (即"正好在初始全景"必须判为已在),否则第 5 条那个短路永远不会命中。
  147. {
  148. const near = (a, b, eps) => Math.abs(a - b) <= eps
  149. assert.equal(
  150. near(INITIAL_PANORAMA.heading, INITIAL_PANORAMA.heading, SETTLE_EPSILON.headingDeg)
  151. && near(INITIAL_PANORAMA.pitch, INITIAL_PANORAMA.pitch, SETTLE_EPSILON.pitchDeg)
  152. && near(INITIAL_PANORAMA.range, INITIAL_PANORAMA.range, SETTLE_EPSILON.rangeMeters),
  153. true,
  154. '初始全景自身必须被判为"已在初始全景"(阈值口径自洽)',
  155. )
  156. assert.equal(
  157. near(INITIAL_PANORAMA.range - 1000, INITIAL_PANORAMA.range, SETTLE_EPSILON.rangeMeters),
  158. false,
  159. '差 1000 m 不算"已在初始全景"(阈值不能宽到把别处当在家)',
  160. )
  161. }
  162. console.log(
  163. 'T40 检查通过:回引导态三件事齐备(相机回初始全景 + 自转恢复 + 引导层显示);'
  164. + '粘滞开关 `spinPaused` 已被禁止;`setGlobeSpin` 不会"没做事却报成功";'
  165. + '`stopSpin` 放弃意图而 `startSpin` 只停循环(不自己抹掉意图);'
  166. + '落定后由 `resumeSpinIfWanted` 兑现意图;`resetToIntroView` 套用 `INITIAL_PANORAMA` '
  167. + '且已在初始全景时不排飞行;阈值复用 `SETTLE_EPSILON`;读数带 `spinWanted`。',
  168. )