481 lines
18 KiB
TypeScript
481 lines
18 KiB
TypeScript
import { expect, test, type Page, type TestInfo } from '@playwright/test'
|
||
|
||
interface CameraReport {
|
||
position: { x: number; y: number; z: number }
|
||
target: { x: number; y: number; z: number }
|
||
up: { x: number; y: number; z: number }
|
||
quaternion: { x: number; y: number; z: number; w: number }
|
||
fov: number
|
||
zoom: number
|
||
near: number
|
||
far: number
|
||
distance: number
|
||
}
|
||
|
||
interface AnchorReport {
|
||
id: string
|
||
world: { x: number; y: number; z: number }
|
||
screen: { x: number; y: number; z: number; visible: boolean }
|
||
}
|
||
|
||
interface VisualReport {
|
||
camera: CameraReport
|
||
modelRoot: {
|
||
positionError: number
|
||
rotationError: number
|
||
scaleError: number
|
||
maxError: number
|
||
} | null
|
||
anchors: AnchorReport[]
|
||
activeView: string
|
||
floorId: string
|
||
activeFocusPoiId: string
|
||
hasPendingTargetFocus: boolean
|
||
isCameraTweening: boolean
|
||
poiFocusCameraAnimationCount: number
|
||
cameraSnapshotRestoreCount: number
|
||
focus: {
|
||
poiId: string
|
||
floorId: string
|
||
positionGltf: [number, number, number]
|
||
markerPosition: { x: number; y: number; z: number } | null
|
||
} | null
|
||
}
|
||
|
||
interface FloorReport {
|
||
floorId: string
|
||
label: string
|
||
modelMatchKeys: string[]
|
||
}
|
||
|
||
interface PoiFocusCandidate {
|
||
poiId: string
|
||
floorId: string
|
||
name: string
|
||
positionGltf: [number, number, number]
|
||
}
|
||
|
||
interface VisualStabilityApi {
|
||
getReport: () => VisualReport
|
||
getFloors: () => FloorReport[]
|
||
switchFloor: (floorId: string) => Promise<void>
|
||
showOverview: () => Promise<void>
|
||
resetToViewBaseline: (options: {
|
||
view: 'overview' | 'floor'
|
||
floorId?: string
|
||
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
|
||
}) => Promise<boolean>
|
||
resetToInitialState: () => Promise<boolean>
|
||
getFloorBaseline: (floorId: string) => CameraReport | null
|
||
getFloorPois: (floorId: string) => Promise<PoiFocusCandidate[]>
|
||
focusTargetPoi: (request: {
|
||
requestId: number | string
|
||
poiId: string
|
||
floorId: string
|
||
name?: string
|
||
positionGltf: [number, number, number]
|
||
}) => Promise<unknown>
|
||
clearTargetFocus: () => void
|
||
}
|
||
|
||
const getApiState = async (page: Page) => {
|
||
try {
|
||
return await page.evaluate(() => {
|
||
const windowWithDiagnostics = window as Window & {
|
||
__GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi
|
||
}
|
||
const api = windowWithDiagnostics.__GUIDE_3D_VISUAL_STABILITY__
|
||
return api
|
||
? { floors: api.getFloors(), report: api.getReport() }
|
||
: null
|
||
})
|
||
} catch (error) {
|
||
if (error instanceof Error && error.message.includes('Execution context was destroyed')) {
|
||
return null
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
|
||
const getReport = async (page: Page) => {
|
||
const state = await getApiState(page)
|
||
expect(state).not.toBeNull()
|
||
return state!.report
|
||
}
|
||
|
||
const getMaximumCameraDelta = (before: CameraReport, after: CameraReport) => Math.max(
|
||
Math.hypot(
|
||
before.position.x - after.position.x,
|
||
before.position.y - after.position.y,
|
||
before.position.z - after.position.z
|
||
),
|
||
Math.hypot(
|
||
before.target.x - after.target.x,
|
||
before.target.y - after.target.y,
|
||
before.target.z - after.target.z
|
||
),
|
||
Math.hypot(before.up.x - after.up.x, before.up.y - after.up.y, before.up.z - after.up.z),
|
||
Math.hypot(
|
||
before.quaternion.x - after.quaternion.x,
|
||
before.quaternion.y - after.quaternion.y,
|
||
before.quaternion.z - after.quaternion.z,
|
||
before.quaternion.w - after.quaternion.w
|
||
),
|
||
Math.abs(before.fov - after.fov),
|
||
Math.abs(before.zoom - after.zoom),
|
||
Math.abs(before.near - after.near),
|
||
Math.abs(before.far - after.far),
|
||
Math.abs(before.distance - after.distance)
|
||
)
|
||
|
||
const getMaximumAnchorDeltaPx = (before: AnchorReport[], after: AnchorReport[]) => Math.max(
|
||
...before.map((anchor) => {
|
||
const next = after.find((candidate) => candidate.id === anchor.id)
|
||
expect(next, `missing ${anchor.id}`).toBeDefined()
|
||
return Math.hypot(anchor.screen.x - next!.screen.x, anchor.screen.y - next!.screen.y)
|
||
})
|
||
)
|
||
|
||
const assertStableTransition = (before: VisualReport, after: VisualReport) => {
|
||
expect(after.isCameraTweening).toBe(false)
|
||
expect(after.modelRoot, 'the active model must stay committed').not.toBeNull()
|
||
expect(after.modelRoot!.maxError).toBeLessThan(1e-12)
|
||
expect(getMaximumCameraDelta(before.camera, after.camera)).toBeLessThan(1e-6)
|
||
expect(after.anchors).toHaveLength(3)
|
||
expect(getMaximumAnchorDeltaPx(before.anchors, after.anchors)).toBeLessThanOrEqual(2)
|
||
}
|
||
|
||
const screenshot = async (page: Page, testInfo: TestInfo, name: string) => {
|
||
await page.screenshot({ path: testInfo.outputPath(`${name}.png`) })
|
||
}
|
||
|
||
const waitForTransition = async (
|
||
page: Page,
|
||
transition: Promise<void>,
|
||
testInfo: TestInfo,
|
||
name: string
|
||
) => {
|
||
let complete = false
|
||
void transition.then(() => { complete = true })
|
||
|
||
for (let frame = 0; !complete && frame < 180; frame += 1) {
|
||
await page.waitForTimeout(80)
|
||
const report = await getReport(page)
|
||
expect(report.modelRoot, `blank model frame ${frame}`).not.toBeNull()
|
||
if (frame === 0) {
|
||
await screenshot(page, testInfo, `${name}-during`)
|
||
}
|
||
}
|
||
|
||
await transition
|
||
expect(complete, `${name} exceeded the sampling window`).toBe(true)
|
||
}
|
||
|
||
const switchFloor = async (page: Page, testInfo: TestInfo, floorId: string, name: string) => {
|
||
const before = await getReport(page)
|
||
await screenshot(page, testInfo, `${name}-before`)
|
||
const transition = page.evaluate(async (id) => {
|
||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
|
||
.__GUIDE_3D_VISUAL_STABILITY__
|
||
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
|
||
await api.switchFloor(id)
|
||
}, floorId)
|
||
await waitForTransition(page, transition, testInfo, name)
|
||
const after = await getReport(page)
|
||
await screenshot(page, testInfo, `${name}-after`)
|
||
assertStableTransition(before, after)
|
||
}
|
||
|
||
const showOverview = async (page: Page, testInfo: TestInfo, name: string) => {
|
||
const before = await getReport(page)
|
||
await screenshot(page, testInfo, `${name}-before`)
|
||
const transition = page.evaluate(async () => {
|
||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
|
||
.__GUIDE_3D_VISUAL_STABILITY__
|
||
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
|
||
await api.showOverview()
|
||
})
|
||
await waitForTransition(page, transition, testInfo, name)
|
||
const after = await getReport(page)
|
||
await screenshot(page, testInfo, `${name}-after`)
|
||
assertStableTransition(before, after)
|
||
}
|
||
|
||
const exitOverviewWithZoomInput = async (
|
||
page: Page,
|
||
testInfo: TestInfo,
|
||
name: string,
|
||
input: 'button' | 'wheel' | 'touch'
|
||
) => {
|
||
const before = await getReport(page)
|
||
await screenshot(page, testInfo, `${name}-before`)
|
||
|
||
if (input === 'button') {
|
||
await page.getByText('−', { exact: true }).click()
|
||
} else if (input === 'wheel') {
|
||
await page.locator('canvas').hover()
|
||
await page.mouse.wheel(0, 3000)
|
||
} else {
|
||
const canvas = page.locator('canvas')
|
||
const box = await canvas.boundingBox()
|
||
expect(box, 'the touch zoom target must be visible').not.toBeNull()
|
||
|
||
const session = await page.context().newCDPSession(page)
|
||
await session.send('Input.synthesizePinchGesture', {
|
||
x: Math.round(box!.x + box!.width / 2),
|
||
y: Math.round(box!.y + box!.height / 2),
|
||
scaleFactor: 0.5,
|
||
relativeSpeed: 800,
|
||
gestureSourceType: 'touch'
|
||
})
|
||
}
|
||
|
||
await expect.poll(async () => (await getReport(page)).activeView, { timeout: 10_000 }).toBe('overview')
|
||
|
||
const after = await getReport(page)
|
||
await screenshot(page, testInfo, `${name}-after`)
|
||
expect(after.isCameraTweening).toBe(false)
|
||
expect(after.modelRoot, 'the overview model must stay committed').not.toBeNull()
|
||
expect(after.modelRoot!.maxError).toBeLessThan(1e-12)
|
||
expect(after.camera.distance).toBeGreaterThanOrEqual(before.camera.distance * 1.2)
|
||
}
|
||
|
||
const normalizeFloorKey = (value: string) => value.trim().toLowerCase().replace(/[^a-z0-9.-]/g, '')
|
||
|
||
const resolveRequiredFloorId = (floors: FloorReport[], requested: string) => {
|
||
const normalizedRequested = normalizeFloorKey(requested)
|
||
const floor = floors.find((candidate) => (
|
||
[candidate.floorId, candidate.label, ...candidate.modelMatchKeys]
|
||
.some((value) => normalizeFloorKey(value) === normalizedRequested)
|
||
))
|
||
expect(floor, `missing requested floor ${requested}`).toBeDefined()
|
||
return floor!.floorId
|
||
}
|
||
|
||
const getFloorPois = async (page: Page, floorId: string) => {
|
||
const pois = await page.evaluate(async (id) => {
|
||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
|
||
.__GUIDE_3D_VISUAL_STABILITY__
|
||
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
|
||
return api.getFloorPois(id)
|
||
}, floorId)
|
||
expect(pois.length, `floor ${floorId} must contain a positioned POI`).toBeGreaterThan(0)
|
||
return pois
|
||
}
|
||
|
||
const getVectorDelta = (left: { x: number; y: number; z: number }, right: [number, number, number]) => (
|
||
Math.hypot(left.x - right[0], left.y - right[1], left.z - right[2])
|
||
)
|
||
|
||
const waitForPoiFocusToSettle = async (page: Page, testInfo: TestInfo, name: string) => {
|
||
let sawTween = false
|
||
let stableFrames = 0
|
||
|
||
for (let frame = 0; frame < 180; frame += 1) {
|
||
const report = await getReport(page)
|
||
expect(report.modelRoot, `blank POI focus frame ${frame}`).not.toBeNull()
|
||
expect(report.modelRoot!.maxError).toBeLessThan(1e-12)
|
||
sawTween ||= report.isCameraTweening
|
||
stableFrames = report.isCameraTweening ? 0 : stableFrames + 1
|
||
|
||
if (frame === 0) {
|
||
await screenshot(page, testInfo, `${name}-during`)
|
||
}
|
||
if (sawTween && stableFrames >= 3) return report
|
||
|
||
await page.waitForTimeout(40)
|
||
}
|
||
|
||
throw new Error(`${name} did not settle after one POI focus animation`)
|
||
}
|
||
|
||
test('ordinary exterior and floor switches preserve the GLB_METER camera projection', async ({ page }, testInfo) => {
|
||
await page.goto('/')
|
||
await Promise.all([
|
||
page.waitForURL(/tab=guide/, { timeout: 30_000 }),
|
||
page.getByText('馆内', { exact: true }).click()
|
||
])
|
||
await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true)
|
||
await expect.poll(async () => (await getApiState(page))?.report.anchors.length ?? 0, {
|
||
timeout: 180_000
|
||
}).toBe(3)
|
||
|
||
const state = await getApiState(page)
|
||
expect(state).not.toBeNull()
|
||
const lMinus2 = resolveRequiredFloorId(state!.floors, 'L-2')
|
||
const l1 = resolveRequiredFloorId(state!.floors, 'L1')
|
||
const l1Point5 = resolveRequiredFloorId(state!.floors, 'L1.5')
|
||
const lMinus1 = resolveRequiredFloorId(state!.floors, 'L-1')
|
||
const l4 = resolveRequiredFloorId(state!.floors, 'L4')
|
||
const l5 = resolveRequiredFloorId(state!.floors, 'L5')
|
||
|
||
await switchFloor(page, testInfo, l1, 'exterior-l1')
|
||
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1')
|
||
await switchFloor(page, testInfo, l4, 'lminus1-l4')
|
||
await switchFloor(page, testInfo, l1, 'l4-l1')
|
||
await showOverview(page, testInfo, 'l1-exterior')
|
||
|
||
await switchFloor(page, testInfo, lMinus2, 'exterior-lminus2')
|
||
await switchFloor(page, testInfo, l5, 'lminus2-l5')
|
||
await switchFloor(page, testInfo, lMinus2, 'l5-lminus2')
|
||
await showOverview(page, testInfo, 'lminus2-exterior')
|
||
|
||
await switchFloor(page, testInfo, l1Point5, 'exterior-l1point5')
|
||
await showOverview(page, testInfo, 'l1point5-exterior')
|
||
|
||
await switchFloor(page, testInfo, l1, 'exterior-l1-auto-exit')
|
||
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-auto-exit')
|
||
await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-button-exit', 'button')
|
||
|
||
await switchFloor(page, testInfo, l1, 'exterior-l1-wheel-exit')
|
||
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-wheel-exit')
|
||
await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-wheel-exit', 'wheel')
|
||
|
||
await switchFloor(page, testInfo, l1, 'exterior-l1-touch-exit')
|
||
await switchFloor(page, testInfo, lMinus1, 'l1-lminus1-touch-exit')
|
||
await exitOverviewWithZoomInput(page, testInfo, 'lminus1-exterior-touch-exit', 'touch')
|
||
})
|
||
|
||
test('queued cross-floor search POI focus keeps raw GLB_METER coordinates and does not rebound', async ({ page }, testInfo) => {
|
||
await page.goto('/')
|
||
await Promise.all([
|
||
page.waitForURL(/tab=guide/, { timeout: 30_000 }),
|
||
page.getByText('馆内', { exact: true }).click()
|
||
])
|
||
await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true)
|
||
|
||
const state = await getApiState(page)
|
||
expect(state).not.toBeNull()
|
||
const l1 = resolveRequiredFloorId(state!.floors, 'L1')
|
||
const lMinus1 = resolveRequiredFloorId(state!.floors, 'L-1')
|
||
const [targetPoi] = await getFloorPois(page, lMinus1)
|
||
|
||
await switchFloor(page, testInfo, l1, 'search-focus-exterior-l1')
|
||
const beforeFocus = await getReport(page)
|
||
await screenshot(page, testInfo, 'search-focus-before')
|
||
|
||
const focusTransition = page.evaluate(async (request) => {
|
||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
|
||
.__GUIDE_3D_VISUAL_STABILITY__
|
||
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
|
||
await api.focusTargetPoi(request)
|
||
}, {
|
||
requestId: 'e2e-cross-floor-search-focus',
|
||
poiId: targetPoi.poiId,
|
||
floorId: targetPoi.floorId,
|
||
name: targetPoi.name,
|
||
positionGltf: targetPoi.positionGltf
|
||
})
|
||
|
||
let focusRequestCompleted = false
|
||
void focusTransition.then(() => { focusRequestCompleted = true })
|
||
for (let frame = 0; !focusRequestCompleted && frame < 180; frame += 1) {
|
||
const report = await getReport(page)
|
||
expect(report.modelRoot, `blank cross-floor POI load frame ${frame}`).not.toBeNull()
|
||
expect(report.modelRoot!.maxError).toBeLessThan(1e-12)
|
||
await page.waitForTimeout(40)
|
||
}
|
||
expect(focusRequestCompleted, 'cross-floor POI focus exceeded the sampling window').toBe(true)
|
||
await focusTransition
|
||
|
||
const afterFocus = await waitForPoiFocusToSettle(page, testInfo, 'search-focus')
|
||
await screenshot(page, testInfo, 'search-focus-after')
|
||
|
||
expect(afterFocus.activeView).toBe('floor')
|
||
expect(afterFocus.floorId).toBe(lMinus1)
|
||
expect(afterFocus.focus).not.toBeNull()
|
||
expect(afterFocus.focus!.poiId).toBe(targetPoi.poiId)
|
||
expect(afterFocus.focus!.positionGltf).toEqual(targetPoi.positionGltf)
|
||
expect(getVectorDelta(afterFocus.camera.target, targetPoi.positionGltf)).toBeLessThan(1e-6)
|
||
expect(afterFocus.focus!.markerPosition).not.toBeNull()
|
||
expect(getVectorDelta(afterFocus.focus!.markerPosition!, targetPoi.positionGltf)).toBeLessThan(1e-6)
|
||
expect(afterFocus.cameraSnapshotRestoreCount - beforeFocus.cameraSnapshotRestoreCount).toBe(1)
|
||
expect(afterFocus.poiFocusCameraAnimationCount - beforeFocus.poiFocusCameraAnimationCount).toBe(1)
|
||
|
||
await page.waitForTimeout(240)
|
||
const afterIdle = await getReport(page)
|
||
expect(afterIdle.isCameraTweening).toBe(false)
|
||
expect(afterIdle.poiFocusCameraAnimationCount).toBe(afterFocus.poiFocusCameraAnimationCount)
|
||
expect(getMaximumCameraDelta(afterFocus.camera, afterIdle.camera)).toBeLessThan(1e-6)
|
||
|
||
await page.evaluate(() => {
|
||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
|
||
.__GUIDE_3D_VISUAL_STABILITY__
|
||
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
|
||
api.clearTargetFocus()
|
||
})
|
||
|
||
await switchFloor(page, testInfo, l1, 'search-focus-clear-lminus1-l1')
|
||
await showOverview(page, testInfo, 'search-focus-clear-l1-exterior')
|
||
})
|
||
|
||
test('reset invalidates an in-flight cross-floor focus before it can overwrite the initial state', async ({ page }) => {
|
||
await page.goto('/')
|
||
await Promise.all([
|
||
page.waitForURL(/tab=guide/, { timeout: 30_000 }),
|
||
page.getByText('馆内', { exact: true }).click()
|
||
])
|
||
await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true)
|
||
await expect.poll(async () => (await getApiState(page))?.floors.length ?? 0, { timeout: 180_000 }).toBeGreaterThan(0)
|
||
const state = await getApiState(page)
|
||
expect(state).not.toBeNull()
|
||
const targetFloor = state!.floors.find((floor) => floor.floorId !== state!.report.floorId)?.floorId
|
||
|| state!.floors[0]!.floorId
|
||
const targetPoi = (await getFloorPois(page, targetFloor))[0]!
|
||
|
||
await page.evaluate(async (request) => {
|
||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
|
||
.__GUIDE_3D_VISUAL_STABILITY__
|
||
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
|
||
const focus = api.focusTargetPoi({ requestId: 'reset-race', ...request })
|
||
await api.resetToInitialState()
|
||
await focus
|
||
}, targetPoi)
|
||
|
||
const report = await getReport(page)
|
||
expect(report.activeView).toBe('overview')
|
||
expect(report.activeFocusPoiId).toBe('')
|
||
expect(report.hasPendingTargetFocus).toBe(false)
|
||
expect(report.focus).toBeNull()
|
||
})
|
||
|
||
test('floor view baseline is deterministic across repeated resets', async ({ page }) => {
|
||
await page.goto('/')
|
||
await Promise.all([
|
||
page.waitForURL(/tab=guide/, { timeout: 30_000 }),
|
||
page.getByText('馆内', { exact: true }).click()
|
||
])
|
||
await expect.poll(async () => (await getApiState(page))?.floors.length ?? 0, { timeout: 180_000 }).toBeGreaterThan(0)
|
||
const state = await getApiState(page)
|
||
expect(state).not.toBeNull()
|
||
|
||
for (const floor of state!.floors) {
|
||
await page.evaluate(async (floorId) => {
|
||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
|
||
.__GUIDE_3D_VISUAL_STABILITY__
|
||
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
|
||
await api.resetToViewBaseline({ view: 'floor', floorId, reason: 'floor-reset' })
|
||
}, floor.floorId)
|
||
const first = await getReport(page)
|
||
const baseline = await page.evaluate((floorId) => {
|
||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
|
||
.__GUIDE_3D_VISUAL_STABILITY__
|
||
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
|
||
return api.getFloorBaseline(floorId)
|
||
}, floor.floorId)
|
||
expect(baseline, `missing floor baseline ${floor.floorId}`).not.toBeNull()
|
||
expect(getMaximumCameraDelta(baseline!, first.camera)).toBeLessThan(1e-6)
|
||
await page.evaluate(async (floorId) => {
|
||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
|
||
.__GUIDE_3D_VISUAL_STABILITY__
|
||
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
|
||
await api.resetToViewBaseline({ view: 'floor', floorId, reason: 'floor-reset' })
|
||
}, floor.floorId)
|
||
const second = await getReport(page)
|
||
expect(second.activeView).toBe('floor')
|
||
expect(second.floorId).toBe(floor.floorId)
|
||
expect(getMaximumCameraDelta(first.camera, second.camera)).toBeLessThan(1e-6)
|
||
expect(getMaximumCameraDelta(baseline!, second.camera)).toBeLessThan(1e-6)
|
||
}
|
||
})
|