diff --git a/example/e2e/firstTest.e2e.js b/example/e2e/firstTest.e2e.js index eb43e1347..5a51231b0 100644 --- a/example/e2e/firstTest.e2e.js +++ b/example/e2e/firstTest.e2e.js @@ -2,8 +2,10 @@ const MAP_LOAD_TIMEOUT = 60000; const TAP_ATTEMPTS = 3; +const MAX_HIDDEN_PUCK_FRAMES = 10; +const { expect: expectValue } = require('@jest/globals'); -async function openExample(groupTitle, exampleTitle) { +async function openExample(groupTitle, exampleTitle, disableSync = false) { await waitFor(element(by.text(groupTitle))) .toBeVisible() .whileElement(by.id('example-list')) @@ -13,9 +15,24 @@ async function openExample(groupTitle, exampleTitle) { .toBeVisible() .whileElement(by.id('example-list')) .scroll(50, 'down'); + if (disableSync) await device.disableSynchronization(); await element(by.text(exampleTitle)).tap(); } +async function puckStateWhen(predicate) { + const deadline = Date.now() + 15000; + let state; + do { + const attributes = await element(by.id('puck-state')).getAttributes(); + state = JSON.parse(attributes.label); + if (predicate(state)) return state; + await new Promise((resolve) => setTimeout(resolve, 250)); + } while (Date.now() < deadline); + throw new Error( + `Puck state did not satisfy assertion: ${JSON.stringify(state)}`, + ); +} + // A main thread stall between Detox's touch down and up turns a tap into a // long press, which the map doesn't report as a press, so tap again. async function tapUntilVisible(target, expected) { @@ -45,6 +62,7 @@ describe('Maps Example App', () => { afterEach(async () => { await device.reloadReactNative(); + await device.enableSynchronization(); }); it('should show initial screen', async () => { @@ -74,4 +92,40 @@ describe('Maps Example App', () => { by.id('location-bubble-latitude'), ); }); + + it('should stop hidden puck rendering while preserving camera following', async () => { + await openExample('E2E', 'Location Puck Lifecycle', true); + await waitFor(element(by.id('puck-state'))) + .toExist() + .withTimeout(15000); + const initial = await puckStateWhen((state) => state.ready); + // Settle initial rendering, then observe three more location/heading updates. + const before = await puckStateWhen( + (state) => state.locations >= initial.locations + 3, + ); + const hidden = await puckStateWhen( + (state) => state.locations >= before.locations + 3, + ); + // Allow incidental CI repaints while still rejecting continuous puck rendering. + expectValue(hidden.frames - before.frames).toBeLessThanOrEqual( + MAX_HIDDEN_PUCK_FRAMES, + ); + + await element(by.id('puck-follow')).tap(); + const start = await puckStateWhen((state) => state.follow); + const followed = await puckStateWhen( + (state) => state.locations >= start.locations + 2, + ); + expectValue(followed.center[0]).toBeGreaterThan(hidden.center[0]); + expectValue( + Math.abs(followed.center[0] - followed.coordinate[0]), + ).toBeLessThan(0.0002); + expectValue( + Math.abs(followed.center[1] - followed.coordinate[1]), + ).toBeLessThan(0.0002); + const headingError = Math.abs( + ((followed.heading - followed.targetHeading + 540) % 360) - 180, + ); + expectValue(headingError).toBeLessThan(20); + }); }); diff --git a/example/src/examples/E2E/LocationPuckLifecycle.js b/example/src/examples/E2E/LocationPuckLifecycle.js new file mode 100644 index 000000000..2f9e13fee --- /dev/null +++ b/example/src/examples/E2E/LocationPuckLifecycle.js @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button, Text, View } from 'react-native'; +import Mapbox from '@rnmapbox/maps'; + +const origin = [-71.107, 42.337]; +const camera = { centerCoordinate: origin, zoomLevel: 16 }; +const styleJSON = JSON.stringify({ + version: 8, + sources: {}, + layers: [ + { + id: 'background', + type: 'background', + paint: { 'background-color': '#d9e5da' }, + }, + ], +}); +export default function LocationPuckLifecycle() { + const map = useRef(null); + const frames = useRef(0); + const heading = useRef(0); + const ready = useRef(false); + const [follow, setFollow] = useState(false); + const [tick, setTick] = useState(0); + const [state, setState] = useState('{}'); + const coordinate = [origin[0] + tick * 0.00004, origin[1]]; + const targetHeading = follow ? 90 : (tick * 10) % 360; + const current = useRef(null); + current.current = { follow, locations: tick, coordinate, targetHeading }; + + useEffect(() => { + const timer = setInterval(() => setTick((value) => value + 1), 1000); + return () => clearInterval(timer); + }, []); + + useEffect(() => { + let mounted = true; + const timer = setInterval(async () => { + if (!ready.current || !map.current) return; + const center = await map.current.getCenter(); + if (mounted) { + setState( + JSON.stringify({ + ...current.current, + ready: true, + frames: frames.current, + center, + heading: heading.current, + }), + ); + } + }, 500); + return () => { + mounted = false; + clearInterval(timer); + }; + }, []); + + return ( + +