diff --git a/tests/browser/journey-integration.spec.ts b/tests/browser/journey-integration.spec.ts index a74753e..d59a56e 100644 --- a/tests/browser/journey-integration.spec.ts +++ b/tests/browser/journey-integration.spec.ts @@ -51,7 +51,7 @@ test('keeps navigation, HUD, progress, chapter, and arrival on the same next-fra const viewportHeight = window.visualViewport?.height ?? window.innerHeight; const destination = id === 'contact' ? document.documentElement.scrollHeight - : Math.max(0, top - viewportHeight * 0.35 + 1); + : Math.max(0, top - viewportHeight * 0.6 + 1); window.scrollTo({ top: destination, behavior: 'instant' }); }, section.id); await waitForOneAnimationFrame(page); @@ -102,7 +102,7 @@ test('draws only the independently eligible next roadside sign during passive fo const headingTop = profile.getBoundingClientRect().top + window.scrollY; const destination = Math.max( 1, - headingTop - viewportHeight * (0.35 + candidateDistanceVh), + headingTop - viewportHeight * (0.6 + candidateDistanceVh), ); return { destination, viewportHeight, expectedLabel: 'Profile' }; }, triggerProbeDistanceVh(viewport.width, viewport.height)); @@ -133,7 +133,7 @@ test('keeps a readable sign immutable through reversal, resize, and a multi-sect const viewportHeight = window.visualViewport?.height ?? window.innerHeight; const headingTop = profile.getBoundingClientRect().top + window.scrollY; return { - destination: Math.max(1, headingTop - viewportHeight * (0.35 + 0.45)), + destination: Math.max(1, headingTop - viewportHeight * (0.6 + 0.45)), viewportHeight, }; }); @@ -176,13 +176,31 @@ test('keeps a readable sign immutable through reversal, resize, and a multi-sect expect((await readSceneInstrumentation(page)).maximumCanvasCount).toBeLessThanOrEqual(1); }); -test('carries a departed sign past the car before its restrained edge fade', async ({ +/** The gantry window's box in CSS pixels, read from the live layout. */ +async function gantryWindowBox(page: import('@playwright/test').Page) { + const box = await page.locator('.gantry-window').boundingBox(); + if (box === null) throw new Error('Missing gantry window'); + return { left: box.x, top: box.y, right: box.x + box.width, bottom: box.y + box.height, width: box.width }; +} + +/** + * Round 6 replaced the roadside pass-by with window docking: the sign arcs + * in large over the road, docks into the gantry window, holds there while it + * is read, then departs in place as the gantry reports it passing. + */ +test('docks a passing sign in the gantry window after its approach arc and lets it go in place', async ({ page, distUrl, }) => { await page.setViewportSize({ width: 1_440, height: 900 }); await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running'); + await page.evaluate(() => { + const states: string[] = []; + (window as unknown as { __gantryStates: string[] }).__gantryStates = states; + new MutationObserver(() => states.push(document.documentElement.dataset.gantry ?? '')) + .observe(document.documentElement, { attributes: true, attributeFilter: ['data-gantry'] }); + }); const viewport = page.viewportSize(); if (viewport === null) throw new Error('Missing browser viewport'); @@ -193,37 +211,49 @@ test('carries a departed sign past the car before its restrained edge fade', asy return Math.max( 1, profile.getBoundingClientRect().top + window.scrollY - - viewportHeight * (0.35 + candidateDistanceVh), + - viewportHeight * (0.6 + candidateDistanceVh), ); }, triggerProbeDistanceVh(viewport.width, viewport.height)); await page.mouse.wheel(0, 1); await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination); await expect.poll(async () => ( await readSceneInstrumentation(page) - ).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true); + ).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell'), { timeout: 10_000 }).toBe(true); + await expect.poll(async () => page.evaluate(() => ( + (window as unknown as { __gantryStates: string[] }).__gantryStates.includes('passing') + )), { timeout: 5_000 }).toBe(true); await expect.poll(async () => ( await readSceneInstrumentation(page) - ).signFrames.some((frame) => ( - frame.targetId === 'profile' - && frame.phase === 'depart' - && frame.progress >= 0.52 - )), { intervals: [16], timeout: 5_000 }).toBe(true); + ).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'depart')).toBe(true); - const frames = (await readSceneInstrumentation(page)).signFrames; - const dwell = frames.findLast((frame) => frame.targetId === 'profile' && frame.phase === 'dwell'); - const passing = frames.findLast((frame) => ( - frame.targetId === 'profile' - && frame.phase === 'depart' - && frame.progress >= 0.3 - && frame.opacity >= 0.98 - )); - if (dwell === undefined || passing === undefined) { - throw new Error('Missing comparable Profile dwell and pass-by frames'); + const windowBox = await gantryWindowBox(page); + const frames = (await readSceneInstrumentation(page)).signFrames + .filter((frame) => frame.targetId === 'profile'); + const approachPeak = Math.max(...frames + .filter((frame) => frame.phase === 'approach') + .map((frame) => frame.bounds.width)); + const dwell = frames.findLast((frame) => frame.phase === 'dwell'); + const departs = frames.filter((frame) => frame.phase === 'depart'); + if (dwell === undefined || departs.length === 0) { + throw new Error('Missing comparable Profile dwell and depart frames'); } - expect(passing.opacity).toBeGreaterThanOrEqual(0.98); - expect(passing.bounds.width).toBeGreaterThan(dwell.bounds.width * 1.18); - expect(passing.bounds.left).toBeLessThan(dwell.bounds.left - 24); - expect(passing.targetId).toBe(dwell.targetId); + // The arc: the sign is read large over the road before it docks. + expect(approachPeak).toBeGreaterThan(dwell.bounds.width * 1.5); + // Docked: wholly inside the window, never across the reading column. + expect(dwell.opacity).toBeGreaterThanOrEqual(0.98); + expect(dwell.bounds.left).toBeGreaterThanOrEqual(windowBox.left - 1); + expect(dwell.bounds.right).toBeLessThanOrEqual(windowBox.right + 1); + expect(dwell.bounds.top).toBeGreaterThanOrEqual(windowBox.top - 1); + expect(dwell.bounds.bottom).toBeLessThanOrEqual(windowBox.bottom + 1); + // Departing: it fades where it docked instead of sweeping past the car. + for (const depart of departs) { + expect(Math.abs(depart.bounds.left - dwell.bounds.left)).toBeLessThanOrEqual(1); + expect(Math.abs(depart.bounds.width - dwell.bounds.width)).toBeLessThanOrEqual(1); + } + const states = await page.evaluate(() => (window as unknown as { __gantryStates: string[] }).__gantryStates); + const order = ['incoming', 'docked', 'passing'].map((state) => states.indexOf(state)); + expect(order.every((index) => index >= 0)).toBe(true); + expect(order).toEqual([...order].sort((a, b) => a - b)); }); test('shows exactly one destination sign for a navigator click and suppresses history restoration', async ({ @@ -274,7 +304,7 @@ test('keeps the non-navigator Explore work section jump sign-suppressed', async return Math.max( 1, heading.getBoundingClientRect().top + window.scrollY - - viewportHeight * (0.35 + candidateDistanceVh), + - viewportHeight * (0.6 + candidateDistanceVh), ); }, triggerProbeDistanceVh(viewport.width, viewport.height)); await page.mouse.wheel(0, 1); @@ -414,7 +444,7 @@ test('moves an already-readable portrait sign into navigator-safe placement with return Math.max( 1, profile.getBoundingClientRect().top + window.scrollY - - viewportHeight * (0.35 + 0.22), + - viewportHeight * (0.6 + 0.22), ); }); await page.mouse.wheel(0, 1); @@ -468,7 +498,7 @@ test('re-arms passive signage after a navigator round trip to Start', async ({ return Math.max( 1, profile.getBoundingClientRect().top + window.scrollY - - viewportHeight * (0.35 + candidateDistanceVh), + - viewportHeight * (0.6 + candidateDistanceVh), ); }, triggerProbeDistanceVh(viewport.width, viewport.height)); await page.mouse.wheel(0, 1); @@ -494,7 +524,7 @@ test('reconciles an idle rapid jump to the latest active route without flashing if (!(target instanceof HTMLElement)) throw new Error('Missing selected builds heading'); const top = target.getBoundingClientRect().top + window.scrollY; const viewportHeight = window.visualViewport?.height ?? window.innerHeight; - window.scrollTo(0, Math.max(1, top - viewportHeight * 0.35 + 1)); + window.scrollTo(0, Math.max(1, top - viewportHeight * 0.6 + 1)); }); await expect.poll(async () => (await readSceneInstrumentation(page)).routeLabelsDrawn) @@ -515,7 +545,7 @@ test('reconciles an idle rapid jump to the latest active route without flashing expect(dwell.bounds.right).toBeLessThanOrEqual(1_432); }); -test('keeps the readable Profile face outside its destination panel with verified texel density', async ({ +test('docks the readable Profile face in the gantry window, clear of its panel, with verified texel density', async ({ page, distUrl, }) => { @@ -530,7 +560,7 @@ test('keeps the readable Profile face outside its destination panel with verifie return Math.max( 1, profile.getBoundingClientRect().top + window.scrollY - - viewportHeight * (0.35 + 0.45), + - viewportHeight * (0.6 + 0.45), ); }); await page.mouse.wheel(0, 1); @@ -545,11 +575,16 @@ test('keeps the readable Profile face outside its destination panel with verifie candidate.targetId === 'profile' && candidate.phase === 'dwell' )); if (frame === undefined) throw new Error('Missing readable Profile sign frame'); - const panel = await page.locator('#profile').evaluate((element) => { - const bounds = element.getBoundingClientRect(); - return { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom }; - }); - expect(frame.bounds.right).toBeLessThanOrEqual(panel.left - 16); + // The face is read in the gantry window, which sits above the reading + // column, so it can never overlap the Profile panel below it. + const windowBox = await gantryWindowBox(page); + expect(frame.bounds.left).toBeGreaterThanOrEqual(windowBox.left - 1); + expect(frame.bounds.right).toBeLessThanOrEqual(windowBox.right + 1); + expect(frame.bounds.bottom).toBeLessThanOrEqual(windowBox.bottom + 1); + const panelTop = await page.locator('#profile .chapter-body').evaluate((element) => ( + element.getBoundingClientRect().top + )); + expect(frame.bounds.bottom).toBeLessThanOrEqual(panelTop); expect(frame.texture).toMatchObject({ width: 2_048, height: 512, @@ -583,7 +618,7 @@ test('keeps a fresh readable sign inside responsive world-safe corridors', async return Math.max( 1, profile.getBoundingClientRect().top + window.scrollY - - viewportHeight * (0.35 + candidateDistanceVh), + - viewportHeight * (0.6 + candidateDistanceVh), ); }, triggerProbeDistanceVh(viewport.width, viewport.height)); await page.mouse.wheel(0, 1); @@ -649,7 +684,7 @@ test('keeps the upcoming Experience sign clear of both outgoing and incoming pan return Math.max( 1, heading.getBoundingClientRect().top + window.scrollY - - viewportHeight * (0.35 + 0.45), + - viewportHeight * (0.6 + 0.45), ); }); await page.mouse.wheel(0, 1); @@ -698,7 +733,7 @@ test('keeps the short-landscape Experience sign in the right roadside gap', asyn return Math.max( 1, heading.getBoundingClientRect().top + window.scrollY - - viewportHeight * (0.35 + 0.38), + - viewportHeight * (0.6 + 0.38), ); }); await page.mouse.wheel(0, 1); @@ -776,7 +811,7 @@ test('keeps every passive transition sign clear of visible opaque panels and fix return Math.max( 1, heading.getBoundingClientRect().top + window.scrollY - - viewportHeight * (0.35 + candidateDistanceVh), + - viewportHeight * (0.6 + candidateDistanceVh), ); }, { targetId: next.id,