From e9b2aea97bdb6fe1092074396eeaa2aba2b30e18 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 28 Mar 2022 12:48:38 +0200 Subject: [PATCH] Live location sharing - send geolocation beacon events - happy path (#8127) * geolocation utilities Signed-off-by: Kerry Archibald * messy send events Signed-off-by: Kerry Archibald * add geolocation services Signed-off-by: Kerry Archibald * geolocation tests Signed-off-by: Kerry Archibald * debounce with backup emit every 30s Signed-off-by: Kerry Archibald * import reorder Signed-off-by: Kerry Archibald * some more working tests Signed-off-by: Kerry Archibald * complicated timeout testing Signed-off-by: Kerry Archibald * publish first location immediately Signed-off-by: Kerry Archibald * move advanceDateAndTime to utils, tidy Signed-off-by: Kerry Archibald * typos Signed-off-by: Kerry Archibald * types and lint Signed-off-by: Kerry Archibald --- .../views/location/LocationPicker.tsx | 16 +- src/stores/OwnBeaconStore.ts | 155 ++++++++++- .../beacon/RoomLiveShareWarning-test.tsx | 20 +- .../RoomLiveShareWarning-test.tsx.snap | 190 +------------ test/stores/OwnBeaconStore-test.ts | 250 +++++++++++++++--- test/utils/beacon/geolocation-test.ts | 2 - 6 files changed, 378 insertions(+), 255 deletions(-) diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index bd93225953..4eaef0365b 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -21,22 +21,22 @@ import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; import { ClientEvent, IClientWellKnown } from 'matrix-js-sdk/src/client'; import classNames from 'classnames'; +import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg'; import { _t } from '../../../languageHandler'; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import MemberAvatar from '../avatars/MemberAvatar'; import MatrixClientContext from '../../../contexts/MatrixClientContext'; import Modal from '../../../Modal'; -import ErrorDialog from '../dialogs/ErrorDialog'; +import SdkConfig from '../../../SdkConfig'; import { tileServerFromWellKnown } from '../../../utils/WellKnownUtils'; -import { LocationShareType, ShareLocationFn } from './shareLocation'; -import { Icon as LocationIcon } from '../../../../res/img/element-icons/location.svg'; +import { getUserNameColorClass } from '../../../utils/FormattingUtils'; +import { GenericPosition, genericPositionFromGeolocation, getGeoUri } from '../../../utils/beacon'; +import { LocationShareError, findMapStyleUrl } from '../../../utils/location'; +import MemberAvatar from '../avatars/MemberAvatar'; +import ErrorDialog from '../dialogs/ErrorDialog'; import AccessibleButton from '../elements/AccessibleButton'; import { MapError } from './MapError'; -import { getUserNameColorClass } from '../../../utils/FormattingUtils'; import LiveDurationDropdown, { DEFAULT_DURATION_MS } from './LiveDurationDropdown'; -import { GenericPosition, genericPositionFromGeolocation, getGeoUri } from '../../../utils/beacon'; -import SdkConfig from '../../../SdkConfig'; -import { LocationShareError, findMapStyleUrl } from '../../../utils/location'; +import { LocationShareType, ShareLocationFn } from './shareLocation'; export interface ILocationPickerProps { sender: RoomMember; diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index 4ac2bcadde..a0ec82c4b0 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { debounce } from "lodash"; import { Beacon, BeaconEvent, @@ -21,13 +22,23 @@ import { Room, } from "matrix-js-sdk/src/matrix"; import { - BeaconInfoState, makeBeaconInfoContent, + BeaconInfoState, makeBeaconContent, makeBeaconInfoContent, } from "matrix-js-sdk/src/content-helpers"; +import { M_BEACON } from "matrix-js-sdk/src/@types/beacon"; +import { logger } from "matrix-js-sdk/src/logger"; import defaultDispatcher from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; -import { arrayHasDiff } from "../utils/arrays"; +import { arrayDiff } from "../utils/arrays"; +import { + ClearWatchCallback, + GeolocationError, + mapGeolocationPositionToTimedGeo, + TimedGeoUri, + watchPosition, +} from "../utils/beacon"; +import { getCurrentPosition } from "../utils/beacon/geolocation"; const isOwnBeacon = (beacon: Beacon, userId: string): boolean => beacon.beaconInfoOwner === userId; @@ -35,6 +46,9 @@ export enum OwnBeaconStoreEvent { LivenessChange = 'OwnBeaconStore.LivenessChange', } +const MOVING_UPDATE_INTERVAL = 2000; +const STATIC_UPDATE_INTERVAL = 30000; + type OwnBeaconStoreState = { beacons: Map; beaconsByRoomId: Map>; @@ -46,6 +60,15 @@ export class OwnBeaconStore extends AsyncStoreWithClient { public readonly beacons = new Map(); public readonly beaconsByRoomId = new Map>(); private liveBeaconIds = []; + private locationInterval: number; + private geolocationError: GeolocationError | undefined; + private clearPositionWatch: ClearWatchCallback | undefined; + /** + * Track when the last position was published + * So we can manually get position on slow interval + * when the target is stationary + */ + private lastPublishedPositionTimestamp: number | undefined; public constructor() { super(defaultDispatcher); @@ -55,12 +78,21 @@ export class OwnBeaconStore extends AsyncStoreWithClient { return OwnBeaconStore.internalInstance; } + /** + * True when we have live beacons + * and geolocation.watchPosition is active + */ + public get isMonitoringLiveLocation(): boolean { + return !!this.clearPositionWatch; + } + protected async onNotReady() { this.matrixClient.removeListener(BeaconEvent.LivenessChange, this.onBeaconLiveness); this.matrixClient.removeListener(BeaconEvent.New, this.onNewBeacon); this.beacons.forEach(beacon => beacon.destroy()); + this.stopPollingLocation(); this.beacons.clear(); this.beaconsByRoomId.clear(); this.liveBeaconIds = []; @@ -117,21 +149,12 @@ export class OwnBeaconStore extends AsyncStoreWithClient { return; } - if (!isLive && this.liveBeaconIds.includes(beacon.identifier)) { - this.liveBeaconIds = - this.liveBeaconIds.filter(beaconId => beaconId !== beacon.identifier); - } - - if (isLive && !this.liveBeaconIds.includes(beacon.identifier)) { - this.liveBeaconIds.push(beacon.identifier); - } - // beacon expired, update beacon to un-alive state if (!isLive) { this.stopBeacon(beacon.identifier); } - // TODO start location polling here + this.checkLiveness(); this.emit(OwnBeaconStoreEvent.LivenessChange, this.getLiveBeaconIds()); }; @@ -169,9 +192,29 @@ export class OwnBeaconStore extends AsyncStoreWithClient { .filter(beacon => beacon.isLive) .map(beacon => beacon.identifier); - if (arrayHasDiff(prevLiveBeaconIds, this.liveBeaconIds)) { + const diff = arrayDiff(prevLiveBeaconIds, this.liveBeaconIds); + + if (diff.added.length || diff.removed.length) { this.emit(OwnBeaconStoreEvent.LivenessChange, this.liveBeaconIds); } + + // publish current location immediately + // when there are new live beacons + // and we already have a live monitor + // so first position is published quickly + // even when target is stationary + // + // when there is no existing live monitor + // it will be created below by togglePollingLocation + // and publish first position quickly + if (diff.added.length && this.isMonitoringLiveLocation) { + this.publishCurrentLocationToBeacons(); + } + + // if overall liveness changed + if (!!prevLiveBeaconIds?.length !== !!this.liveBeaconIds.length) { + this.togglePollingLocation(); + } }; private updateBeaconEvent = async (beacon: Beacon, update: Partial): Promise => { @@ -188,4 +231,90 @@ export class OwnBeaconStore extends AsyncStoreWithClient { await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, beacon.beaconInfoEventType, updateContent); }; + + private togglePollingLocation = async (): Promise => { + if (!!this.liveBeaconIds.length) { + return this.startPollingLocation(); + } + return this.stopPollingLocation(); + }; + + private startPollingLocation = async () => { + // clear any existing interval + this.stopPollingLocation(); + + this.clearPositionWatch = await watchPosition(this.onWatchedPosition, this.onWatchedPositionError); + + this.locationInterval = setInterval(() => { + if (!this.lastPublishedPositionTimestamp) { + return; + } + // if position was last updated STATIC_UPDATE_INTERVAL ms ago or more + // get our position and publish it + if (this.lastPublishedPositionTimestamp <= Date.now() - STATIC_UPDATE_INTERVAL) { + this.publishCurrentLocationToBeacons(); + } + }, STATIC_UPDATE_INTERVAL); + }; + + private onWatchedPosition = (position: GeolocationPosition) => { + const timedGeoPosition = mapGeolocationPositionToTimedGeo(position); + + // if this is our first position, publish immediateley + if (!this.lastPublishedPositionTimestamp) { + this.publishLocationToBeacons(timedGeoPosition); + } else { + this.debouncedPublishLocationToBeacons(timedGeoPosition); + } + }; + + private onWatchedPositionError = (error: GeolocationError) => { + this.geolocationError = error; + logger.error(this.geolocationError); + }; + + private stopPollingLocation = () => { + clearInterval(this.locationInterval); + this.locationInterval = undefined; + this.lastPublishedPositionTimestamp = undefined; + this.geolocationError = undefined; + + if (this.clearPositionWatch) { + this.clearPositionWatch(); + this.clearPositionWatch = undefined; + } + }; + + /** + * Sends m.location events to all live beacons + * Sets last published beacon + */ + private publishLocationToBeacons = async (position: TimedGeoUri) => { + this.lastPublishedPositionTimestamp = Date.now(); + // TODO handle failure in individual beacon without rejecting rest + await Promise.all(this.liveBeaconIds.map(beaconId => + this.sendLocationToBeacon(this.beacons.get(beaconId), position)), + ); + }; + + private debouncedPublishLocationToBeacons = debounce(this.publishLocationToBeacons, MOVING_UPDATE_INTERVAL); + + /** + * Sends m.location event to referencing given beacon + */ + private sendLocationToBeacon = async (beacon: Beacon, { geoUri, timestamp }: TimedGeoUri) => { + const content = makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId); + await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content); + }; + + /** + * Gets the current location + * (as opposed to using watched location) + * and publishes it to all live beacons + */ + private publishCurrentLocationToBeacons = async () => { + const position = await getCurrentPosition(); + // TODO error handling + this.publishLocationToBeacons(mapGeolocationPositionToTimedGeo(position)); + }; } diff --git a/test/components/views/beacon/RoomLiveShareWarning-test.tsx b/test/components/views/beacon/RoomLiveShareWarning-test.tsx index 23c3e40e18..ad3cbdf5ff 100644 --- a/test/components/views/beacon/RoomLiveShareWarning-test.tsx +++ b/test/components/views/beacon/RoomLiveShareWarning-test.tsx @@ -23,14 +23,17 @@ import '../../../skinned-sdk'; import RoomLiveShareWarning from '../../../../src/components/views/beacon/RoomLiveShareWarning'; import { OwnBeaconStore } from '../../../../src/stores/OwnBeaconStore'; import { + advanceDateAndTime, findByTestId, getMockClientWithEventEmitter, makeBeaconInfoEvent, + mockGeolocation, resetAsyncStoreWithClient, setupAsyncStoreWithClient, } from '../../../test-utils'; jest.useFakeTimers(); +mockGeolocation(); describe('', () => { const aliceId = '@alice:server.org'; const room1Id = '$room1:server.org'; @@ -40,6 +43,7 @@ describe('', () => { getVisibleRooms: jest.fn().mockReturnValue([]), getUserId: jest.fn().mockReturnValue(aliceId), unstable_setLiveBeacon: jest.fn().mockResolvedValue({ event_id: '1' }), + sendEvent: jest.fn(), }); // 14.03.2022 16:15 @@ -69,14 +73,6 @@ describe('', () => { return [room1, room2]; }; - const advanceDateAndTime = (ms: number) => { - // bc liveness check uses Date.now we have to advance this mock - jest.spyOn(global.Date, 'now').mockReturnValue(Date.now() + ms); - - // then advance time for the interval by the same amount - jest.advanceTimersByTime(ms); - }; - const makeOwnBeaconStore = async () => { const store = OwnBeaconStore.instance; @@ -137,12 +133,16 @@ describe('', () => { it('renders correctly with one live beacon in room', () => { const component = getComponent({ roomId: room1Id }); - expect(component).toMatchSnapshot(); + // beacons have generated ids that break snapshots + // assert on html + expect(component.html()).toMatchSnapshot(); }); it('renders correctly with two live beacons in room', () => { const component = getComponent({ roomId: room2Id }); - expect(component).toMatchSnapshot(); + // beacons have generated ids that break snapshots + // assert on html + expect(component.html()).toMatchSnapshot(); // later expiry displayed expect(getExpiryText(component)).toEqual('12h left'); }); diff --git a/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap b/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap index d656d57898..73a4c47816 100644 --- a/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap @@ -1,191 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` when user has live beacons renders correctly with one live beacon in room 1`] = ` - -
- -
- - - You are sharing your live location - - - - 1h left - - - - - -
- -`; +exports[` when user has live beacons renders correctly with one live beacon in room 1`] = `"
You are sharing your live location1h left
"`; -exports[` when user has live beacons renders correctly with two live beacons in room 1`] = ` - -
- -
- - - You are sharing your live location - - - - 12h left - - - - - -
- -`; +exports[` when user has live beacons renders correctly with two live beacons in room 1`] = `"
You are sharing your live location12h left
"`; diff --git a/test/stores/OwnBeaconStore-test.ts b/test/stores/OwnBeaconStore-test.ts index 14b87d822d..55da90a1b7 100644 --- a/test/stores/OwnBeaconStore-test.ts +++ b/test/stores/OwnBeaconStore-test.ts @@ -14,17 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Room, Beacon, BeaconEvent } from "matrix-js-sdk/src/matrix"; -import { M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon"; +import { Room, Beacon, BeaconEvent, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { makeBeaconContent } from "matrix-js-sdk/src/content-helpers"; +import { M_BEACON, M_BEACON_INFO } from "matrix-js-sdk/src/@types/beacon"; import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../src/stores/OwnBeaconStore"; -import { resetAsyncStoreWithClient, setupAsyncStoreWithClient } from "../test-utils"; -import { makeBeaconInfoEvent } from "../test-utils/beacon"; +import { + advanceDateAndTime, + flushPromisesWithFakeTimers, + resetAsyncStoreWithClient, + setupAsyncStoreWithClient, +} from "../test-utils"; +import { + makeBeaconInfoEvent, + makeGeolocationPosition, + mockGeolocation, + watchPositionMockImplementation, +} from "../test-utils/beacon"; import { getMockClientWithEventEmitter } from "../test-utils/client"; +// modern fake timers and lodash.debounce are a faff +// short circuit it +jest.mock("lodash", () => ({ + debounce: jest.fn().mockImplementation(callback => callback), +})); + jest.useFakeTimers(); describe('OwnBeaconStore', () => { + let geolocation; // 14.03.2022 16:15 const now = 1647270879403; const HOUR_MS = 3600000; @@ -35,10 +53,15 @@ describe('OwnBeaconStore', () => { getUserId: jest.fn().mockReturnValue(aliceId), getVisibleRooms: jest.fn().mockReturnValue([]), unstable_setLiveBeacon: jest.fn().mockResolvedValue({ event_id: '1' }), + sendEvent: jest.fn().mockResolvedValue({ event_id: '1' }), }); const room1Id = '$room1:server.org'; const room2Id = '$room2:server.org'; + // returned by default geolocation mocks + const defaultLocation = makeGeolocationPosition({}); + const defaultLocationUri = 'geo:54.001927,-8.253491;u=1'; + // beacon_info events // created 'an hour ago' // with timeout of 3 hours @@ -89,13 +112,6 @@ describe('OwnBeaconStore', () => { return [room1, room2]; }; - const advanceDateAndTime = (ms: number) => { - // bc liveness check uses Date.now we have to advance this mock - jest.spyOn(global.Date, 'now').mockReturnValue(now + ms); - // then advance time for the interval by the same amount - jest.advanceTimersByTime(ms); - }; - const makeOwnBeaconStore = async () => { const store = OwnBeaconStore.instance; @@ -103,20 +119,53 @@ describe('OwnBeaconStore', () => { return store; }; + const expireBeaconAndEmit = (store, beaconInfoEvent: MatrixEvent): void => { + const beacon = store.getBeaconById(beaconInfoEvent.getType()); + // time travel until beacon is expired + advanceDateAndTime(beacon.beaconInfo.timeout + 100); + + // force an update on the beacon + // @ts-ignore + beacon.setBeaconInfo(beaconInfoEvent); + + mockClient.emit(BeaconEvent.LivenessChange, false, beacon); + }; + + const updateBeaconLivenessAndEmit = (store, beaconInfoEvent: MatrixEvent, isLive: boolean): void => { + const beacon = store.getBeaconById(beaconInfoEvent.getType()); + // matches original state of event content + // except for live property + const updateEvent = makeBeaconInfoEvent( + beaconInfoEvent.getSender(), + beaconInfoEvent.getRoomId(), + { isLive, timeout: beacon.beaconInfo.timeout }, + undefined, + ); + updateEvent.event.type = beaconInfoEvent.getType(); + beacon.update(updateEvent); + + mockClient.emit(BeaconEvent.Update, beaconInfoEvent, beacon); + mockClient.emit(BeaconEvent.LivenessChange, false, beacon); + }; + + const addNewBeaconAndEmit = (beaconInfoEvent: MatrixEvent): void => { + const beacon = new Beacon(beaconInfoEvent); + mockClient.emit(BeaconEvent.New, beaconInfoEvent, beacon); + }; + beforeEach(() => { + geolocation = mockGeolocation(); mockClient.getVisibleRooms.mockReturnValue([]); mockClient.unstable_setLiveBeacon.mockClear().mockResolvedValue({ event_id: '1' }); + mockClient.sendEvent.mockClear().mockResolvedValue({ event_id: '1' }); jest.spyOn(global.Date, 'now').mockReturnValue(now); jest.spyOn(OwnBeaconStore.instance, 'emit').mockRestore(); }); afterEach(async () => { await resetAsyncStoreWithClient(OwnBeaconStore.instance); - }); - it('works', async () => { - const store = await makeOwnBeaconStore(); - expect(store.hasLiveBeacons()).toBe(false); + jest.clearAllTimers(); }); describe('onReady()', () => { @@ -149,6 +198,38 @@ describe('OwnBeaconStore', () => { alicesRoom2BeaconInfo.getType(), ]); }); + + it('does not do any geolocation when user has no live beacons', async () => { + makeRoomsWithStateEvents([bobsRoom1BeaconInfo, bobsOldRoom1BeaconInfo]); + const store = await makeOwnBeaconStore(); + expect(store.hasLiveBeacons()).toBe(false); + + await flushPromisesWithFakeTimers(); + + expect(geolocation.watchPosition).not.toHaveBeenCalled(); + expect(mockClient.sendEvent).not.toHaveBeenCalled(); + }); + + it('does geolocation and sends location immediatley when user has live beacons', async () => { + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + alicesRoom2BeaconInfo, + ]); + await makeOwnBeaconStore(); + await flushPromisesWithFakeTimers(); + + expect(geolocation.watchPosition).toHaveBeenCalled(); + expect(mockClient.sendEvent).toHaveBeenCalledWith( + room1Id, + M_BEACON.name, + makeBeaconContent(defaultLocationUri, defaultLocation.timestamp, alicesRoom1BeaconInfo.getId()), + ); + expect(mockClient.sendEvent).toHaveBeenCalledWith( + room2Id, + M_BEACON.name, + makeBeaconContent(defaultLocationUri, defaultLocation.timestamp, alicesRoom2BeaconInfo.getId()), + ); + }); }); describe('onNotReady()', () => { @@ -372,12 +453,8 @@ describe('OwnBeaconStore', () => { // live before expect(store.hasLiveBeacons()).toBe(true); const emitSpy = jest.spyOn(store, 'emit'); - const alicesBeacon = new Beacon(alicesRoom1BeaconInfo); - // time travel until beacon is expired - advanceDateAndTime(HOUR_MS * 3); - - mockClient.emit(BeaconEvent.LivenessChange, false, alicesBeacon); + await expireBeaconAndEmit(store, alicesRoom1BeaconInfo); expect(store.hasLiveBeacons()).toBe(false); expect(store.hasLiveBeacons(room1Id)).toBe(false); @@ -388,14 +465,10 @@ describe('OwnBeaconStore', () => { makeRoomsWithStateEvents([ alicesRoom1BeaconInfo, ]); - await makeOwnBeaconStore(); - const alicesBeacon = new Beacon(alicesRoom1BeaconInfo); + const store = await makeOwnBeaconStore(); const prevEventContent = alicesRoom1BeaconInfo.getContent(); - // time travel until beacon is expired - advanceDateAndTime(HOUR_MS * 3); - - mockClient.emit(BeaconEvent.LivenessChange, false, alicesBeacon); + await expireBeaconAndEmit(store, alicesRoom1BeaconInfo); // matches original state of event content // except for live property @@ -422,15 +495,8 @@ describe('OwnBeaconStore', () => { // not live before expect(store.hasLiveBeacons()).toBe(false); const emitSpy = jest.spyOn(store, 'emit'); - const alicesBeacon = new Beacon(alicesOldRoomIdBeaconInfo); - const liveUpdate = makeBeaconInfoEvent( - aliceId, room1Id, { isLive: true }, alicesOldRoomIdBeaconInfo.getId(), '$alice-room1-2', - ); - // bring the beacon back to life - alicesBeacon.update(liveUpdate); - - mockClient.emit(BeaconEvent.LivenessChange, true, alicesBeacon); + updateBeaconLivenessAndEmit(store, alicesOldRoomIdBeaconInfo, true); expect(store.hasLiveBeacons()).toBe(true); expect(store.hasLiveBeacons(room1Id)).toBe(true); @@ -512,4 +578,120 @@ describe('OwnBeaconStore', () => { ); }); }); + + describe('sending positions', () => { + it('stops watching position when user has no more live beacons', async () => { + // geolocation is only going to emit 1 position + geolocation.watchPosition.mockImplementation( + watchPositionMockImplementation([0]), + ); + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + const store = await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + // two locations were published + expect(mockClient.sendEvent).toHaveBeenCalledTimes(1); + + // expire the beacon + // user now has no live beacons + await expireBeaconAndEmit(store, alicesRoom1BeaconInfo); + + // stop watching location + expect(geolocation.clearWatch).toHaveBeenCalled(); + }); + + it('starts watching position when user starts having live beacons', async () => { + makeRoomsWithStateEvents([]); + await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + addNewBeaconAndEmit(alicesRoom1BeaconInfo); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + expect(geolocation.watchPosition).toHaveBeenCalled(); + }); + + it('publishes position for new beacon immediately', async () => { + makeRoomsWithStateEvents([]); + await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + addNewBeaconAndEmit(alicesRoom1BeaconInfo); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + expect(mockClient.sendEvent).toHaveBeenCalled(); + }); + + it('publishes subsequent positions', async () => { + // modern fake timers + debounce + promises are not friends + // just testing that positions are published + // not that the debounce works + + geolocation.watchPosition.mockImplementation( + watchPositionMockImplementation([0, 1000, 3000]), + ); + + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + expect(mockClient.sendEvent).toHaveBeenCalledTimes(0); + await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + jest.advanceTimersByTime(5000); + + expect(mockClient.sendEvent).toHaveBeenCalledTimes(3); + }); + + it('publishes last known position after 30s of inactivity', async () => { + geolocation.watchPosition.mockImplementation( + watchPositionMockImplementation([0]), + ); + + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + // published first location + expect(mockClient.sendEvent).toHaveBeenCalledTimes(1); + + advanceDateAndTime(31000); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + // republished latest location + expect(mockClient.sendEvent).toHaveBeenCalledTimes(2); + }); + + it('does not try to publish anything if there is no known position after 30s of inactivity', async () => { + // no position ever returned from geolocation + geolocation.watchPosition.mockImplementation( + watchPositionMockImplementation([]), + ); + geolocation.getCurrentPosition.mockImplementation( + watchPositionMockImplementation([]), + ); + + makeRoomsWithStateEvents([ + alicesRoom1BeaconInfo, + ]); + await makeOwnBeaconStore(); + // wait for store to settle + await flushPromisesWithFakeTimers(); + + advanceDateAndTime(31000); + + // no locations published + expect(mockClient.sendEvent).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/utils/beacon/geolocation-test.ts b/test/utils/beacon/geolocation-test.ts index 0b2ad4930f..7ae430fad4 100644 --- a/test/utils/beacon/geolocation-test.ts +++ b/test/utils/beacon/geolocation-test.ts @@ -229,8 +229,6 @@ describe('geolocation utilities', () => { }); it('resolves with current location', async () => { - jest.spyOn(logger, 'error').mockImplementation(() => { }); - geolocation.getCurrentPosition.mockImplementation((callback, error) => callback(defaultPosition)); const result = await getCurrentPosition();