|
|
@@ -25,6 +25,8 @@ const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/pupp
|
|
|
const XIANGLING = { lon: 110.396, lat: 28.455, height: 0 }
|
|
|
|
|
|
const results = []
|
|
|
+/** 页面在走查期间被 HMR 重载时置位:本轮红绿不作数(并发编辑场景)。 */
|
|
|
+let runInvalidated = false
|
|
|
function check(id, desc, pass, evidence) {
|
|
|
results.push({ id, desc, pass })
|
|
|
console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`)
|
|
|
@@ -32,6 +34,8 @@ function check(id, desc, pass, evidence) {
|
|
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
|
const round = (n, digits = 3) => Number(n.toFixed(digits))
|
|
|
+/** 角度/经度的最短弧差:heading 是 360 ≡ 0、经度是 ±180 同一点,直接相减会得到假差值。 */
|
|
|
+const shortest = (from, to) => ((to - from + 540) % 360) - 180
|
|
|
|
|
|
const puppeteer = (await import(PUPPETEER)).default
|
|
|
const browser = await puppeteer.launch({
|
|
|
@@ -81,10 +85,40 @@ try {
|
|
|
* 等阻尼收敛(实际相机跟上目标)。有了阻尼之后,手势结束不等于画面停稳,
|
|
|
* 量「中心是否落在画布中心」这类**实际态**几何之前必须先等 settled。
|
|
|
*/
|
|
|
- const waitSettled = async (timeout = 4000) => {
|
|
|
+ const waitSettled = async (timeout = 5000) => {
|
|
|
await page.waitForFunction(() => window.__cockpitMap.getCameraState().settled === true, { timeout }).catch(() => {})
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 票 30 起有「进入引导」全屏遮罩:点击即进入,而且**引导期间拖动是转星球、滚轮是缩放星球**
|
|
|
+ * —— 不先关掉它,第一次拖拽会被遮罩吃掉,后续断言测的就成了引导层而不是相机契约。
|
|
|
+ * 所以进入系统、等镜头在主页视角停稳,再开始走查。
|
|
|
+ */
|
|
|
+ const dismissIntro = async () => {
|
|
|
+ const overlay = await page.$('[data-cockpit-intro]')
|
|
|
+ if (!overlay) return false
|
|
|
+ await overlay.click()
|
|
|
+ await page
|
|
|
+ .waitForFunction(() => !document.querySelector('[data-cockpit-intro]'), { timeout: 5000 })
|
|
|
+ .catch(() => {})
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ const hadIntro = await dismissIntro()
|
|
|
+ console.log(`进入引导:${hadIntro ? '已关闭(点击进入系统)' : '未出现(配置里未启用)'}`)
|
|
|
+ await waitSettled()
|
|
|
+ await sleep(300)
|
|
|
+
|
|
|
+ // 并发会话在改同仓代码时会触发 Vite HMR 重载页面:一重载,引导层重新出现、dev 把手重建、
|
|
|
+ // 相机回到初始视角 —— 后面所有断言都会变成假失败。这里打个运行标记,收尾时核验;
|
|
|
+ // 不一致就把本轮判为「环境重载、结果作废」,而不是报一堆红。
|
|
|
+ const runTag = await page.evaluate(() => {
|
|
|
+ const tag = `t03-${Math.random().toString(36).slice(2)}`
|
|
|
+ window.__t03RunTag = tag
|
|
|
+ return tag
|
|
|
+ })
|
|
|
+ const pageStillSameRun = () => page.evaluate((tag) => window.__t03RunTag === tag, runTag)
|
|
|
+
|
|
|
// ---------- A1 就绪与状态可读 ----------
|
|
|
const base = await readState()
|
|
|
check(
|
|
|
@@ -111,17 +145,20 @@ try {
|
|
|
const a2after = await readState()
|
|
|
|
|
|
{
|
|
|
- const dEast = a2after.pivot.lon - a2before.pivot.lon
|
|
|
+ // 经度差按最短弧取:跨 ±180 时直接相减会得到 ±359 的假位移,让方向判断整个反过来。
|
|
|
+ const dEast = shortest(a2before.pivot.lon, a2after.pivot.lon)
|
|
|
const dNorth = a2after.pivot.lat - a2before.pivot.lat
|
|
|
// 相机右手方向(东北分量):heading 上「前方」=(sin h, cos h),右手 = (cos h, -sin h)。
|
|
|
// 维护者 2026-09-17 裁定:**抓着地图拖** —— 拖右 → 画面右移、相机左移,
|
|
|
// 所以中心沿相机右手的**反方向**移动(alongRight 应为负)。
|
|
|
const h = (a2before.hpr.heading * Math.PI) / 180
|
|
|
const alongRight = dEast * Math.cos(h) + dNorth * -Math.sin(h)
|
|
|
+ // heading 是圆周量:360 与 0 是同一个角度,必须按最短弧比较(此前直接用减法,360→0 会被误判成变了 360°)。
|
|
|
+ const headingDrift = Math.abs(shortest(a2before.hpr.heading, a2after.hpr.heading))
|
|
|
check(
|
|
|
'A2-left-drag-pans-only',
|
|
|
'左键拖拽只平移:heading/pitch/range 不变,中心沿「抓地图」方向移动(默认左键旋转已被替换)',
|
|
|
- Math.abs(a2after.hpr.heading - a2before.hpr.heading) < 0.05 &&
|
|
|
+ headingDrift < 0.05 &&
|
|
|
Math.abs(a2after.hpr.pitch - a2before.hpr.pitch) < 0.05 &&
|
|
|
Math.abs(a2after.hpr.range - a2before.hpr.range) < 1 &&
|
|
|
Math.abs(dEast) + Math.abs(dNorth) > 1e-6 &&
|
|
|
@@ -129,6 +166,7 @@ try {
|
|
|
JSON.stringify({
|
|
|
hBefore: round(a2before.hpr.heading),
|
|
|
hAfter: round(a2after.hpr.heading),
|
|
|
+ headingDrift: round(headingDrift),
|
|
|
pivotBefore: a2before.pivot,
|
|
|
pivotAfter: a2after.pivot,
|
|
|
direction: alongRight < 0 ? '沿相机左手方向(画面右移)' : '方向反了',
|
|
|
@@ -309,9 +347,17 @@ try {
|
|
|
|
|
|
const samples = []
|
|
|
const began = performance.now()
|
|
|
- while (performance.now() - began < 2000) {
|
|
|
+ // 采样窗要够长:换中心飞行 700ms + 阻尼把缺口从 ~0.02° 收敛到阈值 1e-7° 还要
|
|
|
+ // τ·ln(0.02/1e-7) ≈ 1.3s,合计约 2s —— 之前的 2000ms 窗口刚好卡在边界上会假失败。
|
|
|
+ while (performance.now() - began < 6000) {
|
|
|
const s = read()
|
|
|
- samples.push({ t: Math.round(performance.now() - began), settled: s.settled, flying: s.flying, lon: s.pivot.lon, shownLon: s.shownPivot ? s.shownPivot.lon : null })
|
|
|
+ samples.push({
|
|
|
+ t: Math.round(performance.now() - began),
|
|
|
+ settled: s.settled,
|
|
|
+ flying: s.flying,
|
|
|
+ lon: s.pivot.lon,
|
|
|
+ shownLon: s.shownPivot ? s.shownPivot.lon : null,
|
|
|
+ })
|
|
|
if (s.settled) break
|
|
|
await new Promise((r) => setTimeout(r, 30))
|
|
|
}
|
|
|
@@ -323,27 +369,52 @@ try {
|
|
|
const last = s[s.length - 1]
|
|
|
// 实际态必须**滞后于**目标态:中途至少采样到一次 shown != target(否则就是瞬移,没有阻尼)。
|
|
|
const lagged = s.some((x) => x.shownLon !== null && Math.abs(x.shownLon - x.lon) > 1e-9)
|
|
|
- // 且滞后是收敛的:缺口单调缩小到 0。
|
|
|
- const gaps = s.filter((x) => x.shownLon !== null).map((x) => Math.abs(x.lon - x.shownLon))
|
|
|
+ const flyingFrames = s.filter((x) => x.flying).length
|
|
|
+
|
|
|
+ // 单调收敛只对「换中心飞行结束之后」那一段成立:飞行期间目标自己在动(缓动推进),
|
|
|
+ // 目标-实际缺口会先被拉开再收拢,拿全程做单调判断会误判成「阻尼没在收敛」。
|
|
|
+ const afterFlight = s.filter((x) => !x.flying && x.shownLon !== null)
|
|
|
+ const gaps = afterFlight.map((x) => Math.abs(x.lon - x.shownLon))
|
|
|
const shrinking = gaps.every((g, i) => i === 0 || g <= gaps[i - 1] + 1e-12)
|
|
|
+
|
|
|
check(
|
|
|
'A10-damping-is-live',
|
|
|
- '阻尼在真机上生效:实际相机滞后目标态、缺口单调收敛,最终 settled 且吸附到目标',
|
|
|
+ '阻尼在真机上生效:飞行期间实际态滞后、飞行后缺口单调收敛到 0,最终 settled 且吸附到目标',
|
|
|
lagged &&
|
|
|
+ flyingFrames >= 1 &&
|
|
|
gaps.length >= 2 &&
|
|
|
shrinking &&
|
|
|
last.settled === true &&
|
|
|
Math.abs(last.lon - XIANGLING.lon) < 1e-6,
|
|
|
- JSON.stringify({ frames: s.length, lagged, firstGap: round(gaps[0] || 0, 6), lastGap: round(gaps[gaps.length - 1] || 0, 6) }),
|
|
|
+ JSON.stringify({
|
|
|
+ frames: s.length,
|
|
|
+ flyingFrames,
|
|
|
+ afterFlightFrames: gaps.length,
|
|
|
+ lagged,
|
|
|
+ firstGap: round(gaps[0] || 0, 6),
|
|
|
+ lastGap: round(gaps[gaps.length - 1] || 0, 6),
|
|
|
+ lastLon: round(last.lon, 6),
|
|
|
+ settled: last.settled,
|
|
|
+ }),
|
|
|
)
|
|
|
}
|
|
|
|
|
|
check('A9-no-page-errors', '走查期间无页面级报错', pageErrors.length === 0, JSON.stringify(pageErrors.slice(0, 3)))
|
|
|
+
|
|
|
+ // 收尾核验:页面若在走查期间被 HMR 重载,上面的红绿都不作数。
|
|
|
+ const sameRun = await pageStillSameRun()
|
|
|
+ if (!sameRun) {
|
|
|
+ runInvalidated = true
|
|
|
+ }
|
|
|
} finally {
|
|
|
await browser.close()
|
|
|
}
|
|
|
|
|
|
const failed = results.filter((r) => !r.pass)
|
|
|
+if (runInvalidated) {
|
|
|
+ console.log('\n⚠ 页面在走查期间被重载(并发编辑触发 Vite HMR)——本轮结果作废,请在没有其它会话改文件时重跑。')
|
|
|
+ process.exit(2)
|
|
|
+}
|
|
|
console.log(
|
|
|
`\nT03 浏览器走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`,
|
|
|
)
|