/** * 票 40 闸门:闲置回引导态必须**看得出来**。 * * 维护者 2026-09-30 报障(原话): * 「退出到地球视图后,在重新回到引导状态时,摄像机角度没有回到引导状态, * 且地球也没有重新开始旋转,除了中心出现『点击进入系统』外用户不一定能意识到进入引导状态了」 * * 这条不是新需求 —— ADR 0009 第 27 行早已写死: * 「全球态闲置 5 秒未操作 → 回到程序初始态:**地球自转恢复** + 屏幕中央闪烁『点击进入系统』」。 * 所以它是一次**实现漂移**,本闸门把两个断点钉住: * ① 相机没回初始全景(引导态的"角度"就是 `INITIAL_PANORAMA`); * ② 自转起不来 —— 旧实现用了一个粘滞的 `spinPaused` 开关,**没人负责清**, * 而 `setGlobeSpin()` 命中它时 `return true`(假装成功),于是回引导态那次起转被静默吞掉。 * * **诚实边界**:本文件是**静态守卫**(读源码结构 + 一处纯行为断言), * 它证明"接线正确、且那类粘滞开关不会再出现",**不能**证明观感 —— 观感由维护者真机确认。 * 静态断言有牙与否由配套的一次性变异脚本证明(见票面「证据」)。 * * 运行:node .scratch/cockpit-shell-panel-layout/verify/t40-intro-return.mjs */ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) const root = resolve(here, '../../..') const read = (rel) => readFileSync(resolve(root, rel), 'utf8') const { INITIAL_PANORAMA, SETTLE_EPSILON } = await import('../../../src/modules/cockpit/domain/camera.js') const service = read('src/modules/cockpit/services/mapService.js') const shell = read('src/modules/cockpit/CockpitShell.vue') const strip = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') const serviceCode = strip(service) const shellCode = strip(shell) // ---------------------------------------------------------------- 1. 粘滞开关不得复活 // // 这是本 bug 的**形状**:一个"暂停自转"的开关被置起后没人清,而 `setGlobeSpin` 命中它时 // 返回 true(调用方以为成功了)。所以这里直接**禁止这个标识符**存在 —— // 意图与"正在转的循环"必须分开:意图叫 `spinWanted`,循环是 `instance.spin`。 assert.equal( /spinPaused/.test(serviceCode), false, '不得再有粘滞的 `spinPaused` 开关:它没人负责清,会把自转永久哑掉(维护者 2026-09-30 报障的根因)', ) // ---------------------------------------------------------------- 2. setGlobeSpin 不许"没做事却报成功" { const fn = /export function setGlobeSpin\(seconds\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode) assert.equal(!!fn, true, '应能定位 `setGlobeSpin`') const body = fn[1] const startAt = body.indexOf('startSpin(') assert.equal(startAt >= 0, true, '`setGlobeSpin` 必须真的能起转(调 `startSpin`)') // **记下意图之后**(`spinWanted = spinSeconds`)、真正起转之前,任何 `return true` // 都是"假装成功":调用方不会再重试,自转就永远不转了。 // (`<= 0` 那条停转分支在记意图**之前**,它 `return true` 是对的 —— 停转确实做成了。) assert.match(body, /spinWanted\s*=\s*spinSeconds/, '`setGlobeSpin` 应先记下意图(`spinWanted`)') const intentAt = body.indexOf('spinWanted = spinSeconds') const between = body.slice(intentAt, startAt) assert.equal( /return\s+true/.test(between), false, '记下意图之后、真正起转之前不得 `return true` —— 那是"没做事却报成功",会让调用方停止重试', ) assert.match(between, /return\s+false/, '这段(相机没接上 / 还没停稳)应如实 `return false`,由服务层稍后兑现') // 判据必须是**相机是否真的停稳**,而不是某个开关。 assert.match(body, /state\.settled/, '是否可起转应看相机是否真的停稳(`state.settled`)') } // ---------------------------------------------------------------- 3. 意图的生命周期 { const stop = /function stopSpin\(\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode) assert.equal(!!stop, true, '应能定位 `stopSpin`') assert.match( stop[1], /spinWanted\s*=\s*0/, '`stopSpin` 必须**放弃**意图 —— 进入系统/归位/返回全球之后都不该再自己转起来', ) const start = /function startSpin\(record, spinSeconds\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode) assert.equal(!!start, true, '应能定位 `startSpin`') assert.equal( /stopSpin\(\)/.test(start[1].split('record.spin =')[0]), false, '`startSpin` 不得调 `stopSpin()` —— 那会把刚立起来的意图自己抹掉(改用只停循环的 `cancelSpinLoop()`)', ) assert.match(start[1], /cancelSpinLoop\(\)/, '`startSpin` 应只停"正在转的循环"') } // ---------------------------------------------------------------- 4. 落定后把意图兑现 // // 飞行中起不了转(自转每帧以 immediate 写中心,会把飞行目标冲掉), // 但调用方(外壳回引导态)不会一直重试 —— 所以服务层必须自己接上。 { const notifyFn = /function notifyCameraStateChange\(\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode) assert.equal(!!notifyFn, true, '应能定位 `notifyCameraStateChange`') assert.match( notifyFn[1], /resumeSpinIfWanted\(/, '相机状态变化时(飞行最后一帧必到)应把"想要的自转"接上,否则意图永远兑现不了', ) const resume = /function resumeSpinIfWanted\(state\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode) assert.equal(!!resume, true, '应有 `resumeSpinIfWanted`') assert.match(resume[1], /spinWanted\s*<=\s*0/, '没有意图就直接返回(零开销)') assert.match(resume[1], /instance\.spin/, '已经在转就不要重复起转') assert.match(resume[1], /state\.settled/, '相机没停稳就不要起转(与飞行互斥)') } // ---------------------------------------------------------------- 5. 回引导态必须把相机送回去 { assert.match( shellCode, /import\s*\{[\s\S]*?resetToIntroView[\s\S]*?\}\s*from\s*'\.\/services\/mapService\.js'/, '外壳应 import `resetToIntroView`', ) const fn = /function returnToIntro\(\)\s*\{([\s\S]*?)\n\}/.exec(shellCode) assert.equal(!!fn, true, '应能定位 `returnToIntro`') assert.match(fn[1], /resetToIntroView\(\)/, '回引导态必须把相机送回初始全景(否则画面停在用户最后的视角上)') assert.match(fn[1], /setGlobeSpin\(/, '回引导态必须恢复自转(ADR 0009 明写「地球自转恢复」)') assert.match(fn[1], /introVisible\.value\s*=\s*true/, '回引导态必须把引导层显示出来') // 服务层那侧:必须真的套用**引导视角基准**(`introView` —— 引擎挂载时反读的 // Cesium 默认相机,维护者 2026-09-30 裁定初始引导页就是这张视角), // 下发前必须重算限位,且"已经在引导视角上"时不再排一次飞行。 const reset = /export function resetToIntroView\([^)]*\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode) assert.equal(!!reset, true, '应有 `resetToIntroView`') assert.match(reset[1], /introView\.pivot/, '回引导必须套用引导视角基准的 pivot') assert.match(reset[1], /introView\.hpr/, '回引导必须套用引导视角基准的 hpr') assert.match( reset[1], /rebindAreaLimits\(\)/, '下发预设前必须重算限位 —— 否则区内上限(12000)会把引导视角的 range 夹没,整屏被雾罩绿(真机复现)', ) assert.match(reset[1], /isAtPanorama\(/, '已在引导视角上就不该再排飞行 —— 否则自转要白等一个飞行时长') } // ---------------------------------------------------------------- 6. 不新立魔数 + 读数可诊断 { const helper = /function isAtPanorama\(hpr\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode) assert.equal(!!helper, true, '应有 `isAtPanorama` 判定') assert.match( helper[1], /SETTLE_EPSILON/, '"是否已在初始全景"应复用「停稳」那套阈值,不另立魔数', ) assert.match( serviceCode, /spinWanted,/, '读数里要有 `spinWanted`:只报 `spin` 分不清"意图立着但相机还没落定"和"压根没想转"', ) } // ---------------------------------------------------------------- 7. 纯行为:阈值口径自洽 // // `isAtPanorama` 用的是 SETTLE_EPSILON 的三项;这里断言这套阈值在初始全景上自洽 // (即"正好在初始全景"必须判为已在),否则第 5 条那个短路永远不会命中。 { const near = (a, b, eps) => Math.abs(a - b) <= eps assert.equal( near(INITIAL_PANORAMA.heading, INITIAL_PANORAMA.heading, SETTLE_EPSILON.headingDeg) && near(INITIAL_PANORAMA.pitch, INITIAL_PANORAMA.pitch, SETTLE_EPSILON.pitchDeg) && near(INITIAL_PANORAMA.range, INITIAL_PANORAMA.range, SETTLE_EPSILON.rangeMeters), true, '初始全景自身必须被判为"已在初始全景"(阈值口径自洽)', ) assert.equal( near(INITIAL_PANORAMA.range - 1000, INITIAL_PANORAMA.range, SETTLE_EPSILON.rangeMeters), false, '差 1000 m 不算"已在初始全景"(阈值不能宽到把别处当在家)', ) } console.log( 'T40 检查通过:回引导态三件事齐备(相机回初始全景 + 自转恢复 + 引导层显示);' + '粘滞开关 `spinPaused` 已被禁止;`setGlobeSpin` 不会"没做事却报成功";' + '`stopSpin` 放弃意图而 `startSpin` 只停循环(不自己抹掉意图);' + '落定后由 `resumeSpinIfWanted` 兑现意图;`resetToIntroView` 套用 `INITIAL_PANORAMA` ' + '且已在初始全景时不排飞行;阈值复用 `SETTLE_EPSILON`;读数带 `spinWanted`。', )