| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 |
- /**
- * 票 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/, '回引导态必须把引导层显示出来')
- // 服务层那侧:必须真的套用初始全景,且"已经在初始全景上"时不再排一次飞行。
- const reset = /export function resetToIntroView\([^)]*\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode)
- assert.equal(!!reset, true, '应有 `resetToIntroView`')
- assert.match(reset[1], /INITIAL_PANORAMA/, '引导态的角度=`INITIAL_PANORAMA`,必须套用它')
- 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`。',
- )
|