From 36a574a14fa75f7349add5ac0693d26a0f9872d3 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 7 Nov 2022 15:19:49 +0100 Subject: [PATCH] Use server side relations for voice broadcasts (#9534) --- src/events/RelationsHelper.ts | 45 ++- .../models/VoiceBroadcastPlayback.ts | 57 ++-- test/@types/common.ts | 19 ++ test/events/RelationsHelper-test.ts | 84 +++++- test/test-utils/audio.ts | 5 +- test/test-utils/index.ts | 1 + test/test-utils/relations.ts | 35 +++ .../components/VoiceBroadcastBody-test.tsx | 46 ++- .../VoiceBroadcastPlaybackBody-test.tsx | 3 + .../models/VoiceBroadcastPlayback-test.ts | 274 +++++++++--------- .../VoiceBroadcastPlaybacksStore-test.ts | 19 +- 11 files changed, 396 insertions(+), 192 deletions(-) create mode 100644 test/@types/common.ts create mode 100644 test/test-utils/relations.ts diff --git a/src/events/RelationsHelper.ts b/src/events/RelationsHelper.ts index b211d03862..2c89983bb7 100644 --- a/src/events/RelationsHelper.ts +++ b/src/events/RelationsHelper.ts @@ -38,6 +38,8 @@ export class RelationsHelper extends TypedEventEmitter implements IDestroyable { private relations?: Relations; + private eventId: string; + private roomId: string; public constructor( private event: MatrixEvent, @@ -46,6 +48,21 @@ export class RelationsHelper private client: MatrixClient, ) { super(); + + const eventId = event.getId(); + + if (!eventId) { + throw new Error("unable to create RelationsHelper: missing event ID"); + } + + const roomId = event.getRoomId(); + + if (!roomId) { + throw new Error("unable to create RelationsHelper: missing room ID"); + } + + this.eventId = eventId; + this.roomId = roomId; this.setUpRelations(); } @@ -73,7 +90,7 @@ export class RelationsHelper private setRelations(): void { const room = this.client.getRoom(this.event.getRoomId()); this.relations = room?.getUnfilteredTimelineSet()?.relations?.getChildEventsForEvent( - this.event.getId(), + this.eventId, this.relationType, this.relationEventType, ); @@ -87,6 +104,32 @@ export class RelationsHelper this.relations?.getRelations()?.forEach(e => this.emit(RelationsHelperEvent.Add, e)); } + public getCurrent(): MatrixEvent[] { + return this.relations?.getRelations() || []; + } + + /** + * Fetches all related events from the server and emits them. + */ + public async emitFetchCurrent(): Promise { + let nextBatch: string | undefined = undefined; + + do { + const response = await this.client.relations( + this.roomId, + this.eventId, + this.relationType, + this.relationEventType, + { + from: nextBatch, + limit: 50, + }, + ); + nextBatch = response?.nextBatch; + response?.events.forEach(e => this.emit(RelationsHelperEvent.Add, e)); + } while (nextBatch); + } + public destroy(): void { this.removeAllListeners(); this.event.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); diff --git a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts index 203805f393..38a65caf7a 100644 --- a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts +++ b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts @@ -32,7 +32,6 @@ import { MediaEventHelper } from "../../utils/MediaEventHelper"; import { IDestroyable } from "../../utils/IDestroyable"; import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; -import { getReferenceRelationsForEvent } from "../../events"; import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents"; export enum VoiceBroadcastPlaybackState { @@ -89,15 +88,27 @@ export class VoiceBroadcastPlayback this.setUpRelationsHelper(); } - private setUpRelationsHelper(): void { + private async setUpRelationsHelper(): Promise { this.infoRelationHelper = new RelationsHelper( this.infoEvent, RelationType.Reference, VoiceBroadcastInfoEventType, this.client, ); - this.infoRelationHelper.on(RelationsHelperEvent.Add, this.addInfoEvent); - this.infoRelationHelper.emitCurrent(); + this.infoRelationHelper.getCurrent().forEach(this.addInfoEvent); + + if (this.infoState !== VoiceBroadcastInfoState.Stopped) { + // Only required if not stopped. Stopped is the final state. + this.infoRelationHelper.on(RelationsHelperEvent.Add, this.addInfoEvent); + + try { + await this.infoRelationHelper.emitFetchCurrent(); + } catch (err) { + logger.warn("error fetching server side relation for voice broadcast info", err); + // fall back to local events + this.infoRelationHelper.emitCurrent(); + } + } this.chunkRelationHelper = new RelationsHelper( this.infoEvent, @@ -106,7 +117,15 @@ export class VoiceBroadcastPlayback this.client, ); this.chunkRelationHelper.on(RelationsHelperEvent.Add, this.addChunkEvent); - this.chunkRelationHelper.emitCurrent(); + + try { + // TODO Michael W: only fetch events if needed, blocked by PSF-1708 + await this.chunkRelationHelper.emitFetchCurrent(); + } catch (err) { + logger.warn("error fetching server side relation for voice broadcast chunks", err); + // fall back to local events + this.chunkRelationHelper.emitCurrent(); + } } private addChunkEvent = async (event: MatrixEvent): Promise => { @@ -150,23 +169,18 @@ export class VoiceBroadcastPlayback this.setInfoState(state); }; - private async loadChunks(): Promise { - const relations = getReferenceRelationsForEvent(this.infoEvent, EventType.RoomMessage, this.client); - const chunkEvents = relations?.getRelations(); + private async enqueueChunks(): Promise { + const promises = this.chunkEvents.getEvents().reduce((promises, event: MatrixEvent) => { + if (!this.playbacks.has(event.getId() || "")) { + promises.push(this.enqueueChunk(event)); + } + return promises; + }, [] as Promise[]); - if (!chunkEvents) { - return; - } - - this.chunkEvents.addEvents(chunkEvents); - this.setDuration(this.chunkEvents.getLength()); - - for (const chunkEvent of chunkEvents) { - await this.enqueueChunk(chunkEvent); - } + await Promise.all(promises); } - private async enqueueChunk(chunkEvent: MatrixEvent) { + private async enqueueChunk(chunkEvent: MatrixEvent): Promise { const eventId = chunkEvent.getId(); if (!eventId) { @@ -317,10 +331,7 @@ export class VoiceBroadcastPlayback } public async start(): Promise { - if (this.playbacks.size === 0) { - await this.loadChunks(); - } - + await this.enqueueChunks(); const chunkEvents = this.chunkEvents.getEvents(); const toPlay = this.getInfoState() === VoiceBroadcastInfoState.Stopped diff --git a/test/@types/common.ts b/test/@types/common.ts new file mode 100644 index 0000000000..2df182a949 --- /dev/null +++ b/test/@types/common.ts @@ -0,0 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export type PublicInterface = { + [P in keyof T]: T[P]; +}; diff --git a/test/events/RelationsHelper-test.ts b/test/events/RelationsHelper-test.ts index 3d9c256216..8b6a891834 100644 --- a/test/events/RelationsHelper-test.ts +++ b/test/events/RelationsHelper-test.ts @@ -28,13 +28,15 @@ import { Relations } from "matrix-js-sdk/src/models/relations"; import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container"; import { RelationsHelper, RelationsHelperEvent } from "../../src/events/RelationsHelper"; -import { mkEvent, mkStubRoom, stubClient } from "../test-utils"; +import { mkEvent, mkRelationsContainer, mkStubRoom, stubClient } from "../test-utils"; describe("RelationsHelper", () => { const roomId = "!room:example.com"; + let userId: string; let event: MatrixEvent; let relatedEvent1: MatrixEvent; let relatedEvent2: MatrixEvent; + let relatedEvent3: MatrixEvent; let room: Room; let client: MatrixClient; let relationsHelper: RelationsHelper; @@ -46,47 +48,81 @@ describe("RelationsHelper", () => { beforeEach(() => { client = stubClient(); + userId = client.getUserId() || ""; + mocked(client.relations).mockClear(); room = mkStubRoom(roomId, "test room", client); - mocked(client.getRoom).mockImplementation((getRoomId: string) => { + mocked(client.getRoom).mockImplementation((getRoomId?: string) => { if (getRoomId === roomId) { return room; } + + return null; }); event = mkEvent({ event: true, type: EventType.RoomMessage, room: roomId, - user: client.getUserId(), + user: userId, content: {}, }); relatedEvent1 = mkEvent({ event: true, type: EventType.RoomMessage, room: roomId, - user: client.getUserId(), - content: {}, + user: userId, + content: { relatedEvent: 1 }, }); relatedEvent2 = mkEvent({ event: true, type: EventType.RoomMessage, room: roomId, - user: client.getUserId(), - content: {}, + user: userId, + content: { relatedEvent: 2 }, + }); + relatedEvent3 = mkEvent({ + event: true, + type: EventType.RoomMessage, + room: roomId, + user: userId, + content: { relatedEvent: 3 }, }); onAdd = jest.fn(); + relationsContainer = mkRelationsContainer(); // TODO Michael W: create test utils, remove casts - relationsContainer = { - getChildEventsForEvent: jest.fn(), - } as unknown as RelationsContainer; relations = { getRelations: jest.fn(), on: jest.fn().mockImplementation((type, l) => relationsOnAdd = l), + off: jest.fn(), } as unknown as Relations; timelineSet = { relations: relationsContainer, } as unknown as EventTimelineSet; }); + afterEach(() => { + relationsHelper?.destroy(); + }); + + describe("when there is an event without ID", () => { + it("should raise an error", () => { + jest.spyOn(event, "getId").mockReturnValue(undefined); + + expect(() => { + new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client); + }).toThrowError("unable to create RelationsHelper: missing event ID"); + }); + }); + + describe("when there is an event without room ID", () => { + it("should raise an error", () => { + jest.spyOn(event, "getRoomId").mockReturnValue(undefined); + + expect(() => { + new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client); + }).toThrowError("unable to create RelationsHelper: missing room ID"); + }); + }); + describe("when there is an event without relations", () => { beforeEach(() => { relationsHelper = new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client); @@ -118,6 +154,34 @@ describe("RelationsHelper", () => { }); }); + describe("when there is an event with two pages server side relations", () => { + beforeEach(() => { + mocked(client.relations) + .mockResolvedValueOnce({ + events: [relatedEvent1, relatedEvent2], + nextBatch: "next", + }) + .mockResolvedValueOnce({ + events: [relatedEvent3], + nextBatch: null, + }); + relationsHelper = new RelationsHelper(event, RelationType.Reference, EventType.RoomMessage, client); + relationsHelper.on(RelationsHelperEvent.Add, onAdd); + }); + + describe("emitFetchCurrent", () => { + beforeEach(async () => { + await relationsHelper.emitFetchCurrent(); + }); + + it("should emit the server side events", () => { + expect(onAdd).toHaveBeenCalledWith(relatedEvent1); + expect(onAdd).toHaveBeenCalledWith(relatedEvent2); + expect(onAdd).toHaveBeenCalledWith(relatedEvent3); + }); + }); + }); + describe("when there is an event with relations", () => { beforeEach(() => { mocked(room.getUnfilteredTimelineSet).mockReturnValue(timelineSet); diff --git a/test/test-utils/audio.ts b/test/test-utils/audio.ts index 8996d97b53..212ef51362 100644 --- a/test/test-utils/audio.ts +++ b/test/test-utils/audio.ts @@ -20,10 +20,7 @@ import { SimpleObservable } from "matrix-widget-api"; import { Playback, PlaybackState } from "../../src/audio/Playback"; import { PlaybackClock } from "../../src/audio/PlaybackClock"; import { UPDATE_EVENT } from "../../src/stores/AsyncStore"; - -type PublicInterface = { - [P in keyof T]: T[P]; -}; +import { PublicInterface } from "../@types/common"; export const createTestPlayback = (): Playback => { const eventEmitter = new EventEmitter(); diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 2fa87e1e67..17e8ad10c1 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -25,3 +25,4 @@ export * from './call'; export * from './wrappers'; export * from './utilities'; export * from './date'; +export * from './relations'; diff --git a/test/test-utils/relations.ts b/test/test-utils/relations.ts new file mode 100644 index 0000000000..5918750c2f --- /dev/null +++ b/test/test-utils/relations.ts @@ -0,0 +1,35 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Relations } from "matrix-js-sdk/src/models/relations"; +import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container"; + +import { PublicInterface } from "../@types/common"; + +export const mkRelations = (): Relations => { + return { + + } as PublicInterface as Relations; +}; + +export const mkRelationsContainer = (): RelationsContainer => { + return { + aggregateChildEvent: jest.fn(), + aggregateParentEvent: jest.fn(), + getAllChildEventsForEvent: jest.fn(), + getChildEventsForEvent: jest.fn(), + } as PublicInterface as RelationsContainer; +}; diff --git a/test/voice-broadcast/components/VoiceBroadcastBody-test.tsx b/test/voice-broadcast/components/VoiceBroadcastBody-test.tsx index ff47ab4c20..0e80d48075 100644 --- a/test/voice-broadcast/components/VoiceBroadcastBody-test.tsx +++ b/test/voice-broadcast/components/VoiceBroadcastBody-test.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ReactElement } from "react"; import { act, render, screen } from "@testing-library/react"; import { mocked } from "jest-mock"; import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; @@ -31,6 +31,8 @@ import { } from "../../../src/voice-broadcast"; import { stubClient } from "../../test-utils"; import { mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils"; +import { MediaEventHelper } from "../../../src/utils/MediaEventHelper"; +import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks"; jest.mock("../../../src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody", () => ({ VoiceBroadcastRecordingBody: jest.fn(), @@ -40,8 +42,13 @@ jest.mock("../../../src/voice-broadcast/components/molecules/VoiceBroadcastPlayb VoiceBroadcastPlaybackBody: jest.fn(), })); +jest.mock("../../../src/utils/permalinks/Permalinks"); +jest.mock("../../../src/utils/MediaEventHelper"); + describe("VoiceBroadcastBody", () => { const roomId = "!room:example.com"; + let userId: string; + let deviceId: string; let client: MatrixClient; let room: Room; let infoEvent: MatrixEvent; @@ -52,62 +59,75 @@ describe("VoiceBroadcastBody", () => { const renderVoiceBroadcast = () => { render( {}} onMessageAllowed={() => {}} - permalinkCreator={null} + permalinkCreator={new RoomPermalinkCreator(room)} />); testRecording = VoiceBroadcastRecordingsStore.instance().getByInfoEvent(infoEvent, client); }; beforeEach(() => { client = stubClient(); - room = new Room(roomId, client, client.getUserId()); - mocked(client.getRoom).mockImplementation((getRoomId: string) => { + userId = client.getUserId() || ""; + deviceId = client.getDeviceId() || ""; + mocked(client.relations).mockClear(); + mocked(client.relations).mockResolvedValue({ events: [] }); + room = new Room(roomId, client, userId); + mocked(client.getRoom).mockImplementation((getRoomId?: string) => { if (getRoomId === roomId) return room; + return null; }); infoEvent = mkVoiceBroadcastInfoStateEvent( roomId, VoiceBroadcastInfoState.Started, - client.getUserId(), - client.getDeviceId(), + userId, + deviceId, ); stoppedEvent = mkVoiceBroadcastInfoStateEvent( roomId, VoiceBroadcastInfoState.Stopped, - client.getUserId(), - client.getDeviceId(), + userId, + deviceId, infoEvent, ); room.addEventsToTimeline([infoEvent], true, room.getLiveTimeline()); testRecording = new VoiceBroadcastRecording(infoEvent, client); testPlayback = new VoiceBroadcastPlayback(infoEvent, client); - mocked(VoiceBroadcastRecordingBody).mockImplementation(({ recording }) => { + mocked(VoiceBroadcastRecordingBody).mockImplementation(({ recording }): ReactElement | null => { if (testRecording === recording) { return
; } + + return null; }); - mocked(VoiceBroadcastPlaybackBody).mockImplementation(({ playback }) => { + mocked(VoiceBroadcastPlaybackBody).mockImplementation(({ playback }): ReactElement | null => { if (testPlayback === playback) { return
; } + + return null; }); jest.spyOn(VoiceBroadcastRecordingsStore.instance(), "getByInfoEvent").mockImplementation( - (getEvent: MatrixEvent, getClient: MatrixClient) => { + (getEvent: MatrixEvent, getClient: MatrixClient): VoiceBroadcastRecording => { if (getEvent === infoEvent && getClient === client) { return testRecording; } + + throw new Error("unexpected event"); }, ); jest.spyOn(VoiceBroadcastPlaybacksStore.instance(), "getByInfoEvent").mockImplementation( - (getEvent: MatrixEvent) => { + (getEvent: MatrixEvent): VoiceBroadcastPlayback => { if (getEvent === infoEvent) { return testPlayback; } + + throw new Error("unexpected event"); }, ); }); diff --git a/test/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody-test.tsx b/test/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody-test.tsx index 27e693aed1..8cf5a94e26 100644 --- a/test/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody-test.tsx +++ b/test/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody-test.tsx @@ -48,6 +48,9 @@ describe("VoiceBroadcastPlaybackBody", () => { beforeAll(() => { client = stubClient(); + mocked(client.relations).mockClear(); + mocked(client.relations).mockResolvedValue({ events: [] }); + infoEvent = mkVoiceBroadcastInfoStateEvent( roomId, VoiceBroadcastInfoState.Started, diff --git a/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts b/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts index f9eb203ef4..4113595c2d 100644 --- a/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts +++ b/test/voice-broadcast/models/VoiceBroadcastPlayback-test.ts @@ -15,24 +15,21 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { EventType, MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; -import { Relations } from "matrix-js-sdk/src/models/relations"; +import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { Playback, PlaybackState } from "../../../src/audio/Playback"; import { PlaybackManager } from "../../../src/audio/PlaybackManager"; -import { getReferenceRelationsForEvent } from "../../../src/events"; import { RelationsHelperEvent } from "../../../src/events/RelationsHelper"; import { MediaEventHelper } from "../../../src/utils/MediaEventHelper"; import { - VoiceBroadcastInfoEventType, VoiceBroadcastInfoState, VoiceBroadcastPlayback, VoiceBroadcastPlaybackEvent, VoiceBroadcastPlaybackState, } from "../../../src/voice-broadcast"; -import { mkEvent, stubClient } from "../../test-utils"; +import { flushPromises, stubClient } from "../../test-utils"; import { createTestPlayback } from "../../test-utils/audio"; -import { mkVoiceBroadcastChunkEvent } from "../utils/test-utils"; +import { mkVoiceBroadcastChunkEvent, mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils"; jest.mock("../../../src/events/getReferenceRelationsForEvent", () => ({ getReferenceRelationsForEvent: jest.fn(), @@ -44,6 +41,7 @@ jest.mock("../../../src/utils/MediaEventHelper", () => ({ describe("VoiceBroadcastPlayback", () => { const userId = "@user:example.com"; + let deviceId: string; const roomId = "!room:example.com"; let client: MatrixClient; let infoEvent: MatrixEvent; @@ -98,7 +96,7 @@ describe("VoiceBroadcastPlayback", () => { const mkChunkHelper = (data: ArrayBuffer): MediaEventHelper => { return { sourceBlob: { - cachedValue: null, + cachedValue: new Blob(), done: false, value: { // @ts-ignore @@ -109,32 +107,31 @@ describe("VoiceBroadcastPlayback", () => { }; const mkInfoEvent = (state: VoiceBroadcastInfoState) => { - return mkEvent({ - event: true, - type: VoiceBroadcastInfoEventType, - user: userId, - room: roomId, - content: { - state, - }, - }); + return mkVoiceBroadcastInfoStateEvent( + roomId, + state, + userId, + deviceId, + ); }; - const mkPlayback = () => { + const mkPlayback = async () => { const playback = new VoiceBroadcastPlayback(infoEvent, client); jest.spyOn(playback, "removeAllListeners"); playback.on(VoiceBroadcastPlaybackEvent.StateChanged, onStateChanged); + await flushPromises(); return playback; }; const setUpChunkEvents = (chunkEvents: MatrixEvent[]) => { - const relations = new Relations(RelationType.Reference, EventType.RoomMessage, client); - jest.spyOn(relations, "getRelations").mockReturnValue(chunkEvents); - mocked(getReferenceRelationsForEvent).mockReturnValue(relations); + mocked(client.relations).mockResolvedValueOnce({ + events: chunkEvents, + }); }; beforeAll(() => { client = stubClient(); + deviceId = client.getDeviceId() || ""; chunk1Event = mkVoiceBroadcastChunkEvent(userId, roomId, chunk1Length, 1); chunk2Event = mkVoiceBroadcastChunkEvent(userId, roomId, chunk2Length, 2); @@ -153,6 +150,8 @@ describe("VoiceBroadcastPlayback", () => { if (buffer === chunk1Data) return chunk1Playback; if (buffer === chunk2Data) return chunk2Playback; if (buffer === chunk3Data) return chunk3Playback; + + throw new Error("unexpected buffer"); }, ); @@ -168,11 +167,17 @@ describe("VoiceBroadcastPlayback", () => { onStateChanged = jest.fn(); }); + afterEach(() => { + playback.destroy(); + }); + describe(`when there is a ${VoiceBroadcastInfoState.Resumed} broadcast without chunks yet`, () => { - beforeEach(() => { - infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Resumed); - playback = mkPlayback(); + beforeEach(async () => { + // info relation + mocked(client.relations).mockResolvedValueOnce({ events: [] }); setUpChunkEvents([]); + infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Resumed); + playback = await mkPlayback(); }); describe("and calling start", () => { @@ -227,10 +232,12 @@ describe("VoiceBroadcastPlayback", () => { }); describe(`when there is a ${VoiceBroadcastInfoState.Resumed} voice broadcast with some chunks`, () => { - beforeEach(() => { - infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Resumed); - playback = mkPlayback(); + beforeEach(async () => { + // info relation + mocked(client.relations).mockResolvedValueOnce({ events: [] }); setUpChunkEvents([chunk2Event, chunk1Event]); + infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Resumed); + playback = await mkPlayback(); }); describe("and calling start", () => { @@ -267,158 +274,153 @@ describe("VoiceBroadcastPlayback", () => { }); describe("when there is a stopped voice broadcast", () => { - beforeEach(() => { + beforeEach(async () => { + setUpChunkEvents([chunk2Event, chunk1Event]); infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Stopped); - playback = mkPlayback(); + playback = await mkPlayback(); }); - describe("and there are some chunks", () => { - beforeEach(() => { - setUpChunkEvents([chunk2Event, chunk1Event]); + it("should expose the info event", () => { + expect(playback.infoEvent).toBe(infoEvent); + }); + + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); + + describe("and calling start", () => { + startPlayback(); + + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); + + it("should play the chunks beginning with the first one", () => { + // assert that the first chunk is being played + expect(chunk1Playback.play).toHaveBeenCalled(); + expect(chunk2Playback.play).not.toHaveBeenCalled(); }); - it("should expose the info event", () => { - expect(playback.infoEvent).toBe(infoEvent); + describe("and the chunk playback progresses", () => { + beforeEach(() => { + chunk1Playback.clockInfo.liveData.update([11]); + }); + + it("should update the time", () => { + expect(playback.timeSeconds).toBe(11); + }); }); - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); + describe("and skipping to the middle of the second chunk", () => { + const middleOfSecondChunk = (chunk1Length + (chunk2Length / 2)) / 1000; - describe("and calling start", () => { - startPlayback(); - - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); - - it("should play the chunks beginning with the first one", () => { - // assert that the first chunk is being played - expect(chunk1Playback.play).toHaveBeenCalled(); - expect(chunk2Playback.play).not.toHaveBeenCalled(); + beforeEach(async () => { + await playback.skipTo(middleOfSecondChunk); }); - describe("and the chunk playback progresses", () => { - beforeEach(() => { - chunk1Playback.clockInfo.liveData.update([11]); - }); - - it("should update the time", () => { - expect(playback.timeSeconds).toBe(11); - }); + it("should play the second chunk", () => { + expect(chunk1Playback.stop).toHaveBeenCalled(); + expect(chunk2Playback.play).toHaveBeenCalled(); }); - describe("and skipping to the middle of the second chunk", () => { - const middleOfSecondChunk = (chunk1Length + (chunk2Length / 2)) / 1000; + it("should update the time", () => { + expect(playback.timeSeconds).toBe(middleOfSecondChunk); + }); + describe("and skipping to the start", () => { beforeEach(async () => { - await playback.skipTo(middleOfSecondChunk); + await playback.skipTo(0); }); it("should play the second chunk", () => { - expect(chunk1Playback.stop).toHaveBeenCalled(); - expect(chunk2Playback.play).toHaveBeenCalled(); + expect(chunk1Playback.play).toHaveBeenCalled(); + expect(chunk2Playback.stop).toHaveBeenCalled(); }); it("should update the time", () => { - expect(playback.timeSeconds).toBe(middleOfSecondChunk); - }); - - describe("and skipping to the start", () => { - beforeEach(async () => { - await playback.skipTo(0); - }); - - it("should play the second chunk", () => { - expect(chunk1Playback.play).toHaveBeenCalled(); - expect(chunk2Playback.stop).toHaveBeenCalled(); - }); - - it("should update the time", () => { - expect(playback.timeSeconds).toBe(0); - }); - }); - }); - - describe("and the first chunk ends", () => { - beforeEach(() => { - chunk1Playback.emit(PlaybackState.Stopped); - }); - - it("should play until the end", () => { - // assert that the second chunk is being played - expect(chunk2Playback.play).toHaveBeenCalled(); - - // simulate end of second chunk - chunk2Playback.emit(PlaybackState.Stopped); - - // assert that the entire playback is now in stopped state - expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Stopped); - }); - }); - - describe("and calling pause", () => { - pausePlayback(); - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); - itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Paused); - }); - - describe("and calling stop", () => { - stopPlayback(); - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); - }); - - describe("and calling destroy", () => { - beforeEach(() => { - playback.destroy(); - }); - - it("should call removeAllListeners", () => { - expect(playback.removeAllListeners).toHaveBeenCalled(); - }); - - it("should call destroy on the playbacks", () => { - expect(chunk1Playback.destroy).toHaveBeenCalled(); - expect(chunk2Playback.destroy).toHaveBeenCalled(); + expect(playback.timeSeconds).toBe(0); }); }); }); - describe("and calling toggle for the first time", () => { - beforeEach(async () => { - await playback.toggle(); + describe("and the first chunk ends", () => { + beforeEach(() => { + chunk1Playback.emit(PlaybackState.Stopped); }); - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); + it("should play until the end", () => { + // assert that the second chunk is being played + expect(chunk2Playback.play).toHaveBeenCalled(); - describe("and calling toggle a second time", () => { - beforeEach(async () => { - await playback.toggle(); - }); + // simulate end of second chunk + chunk2Playback.emit(PlaybackState.Stopped); - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); - - describe("and calling toggle a third time", () => { - beforeEach(async () => { - await playback.toggle(); - }); - - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); - }); + // assert that the entire playback is now in stopped state + expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Stopped); }); }); + describe("and calling pause", () => { + pausePlayback(); + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); + itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Paused); + }); + describe("and calling stop", () => { stopPlayback(); - itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); + }); - describe("and calling toggle", () => { + describe("and calling destroy", () => { + beforeEach(() => { + playback.destroy(); + }); + + it("should call removeAllListeners", () => { + expect(playback.removeAllListeners).toHaveBeenCalled(); + }); + + it("should call destroy on the playbacks", () => { + expect(chunk1Playback.destroy).toHaveBeenCalled(); + expect(chunk2Playback.destroy).toHaveBeenCalled(); + }); + }); + }); + + describe("and calling toggle for the first time", () => { + beforeEach(async () => { + await playback.toggle(); + }); + + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); + + describe("and calling toggle a second time", () => { + beforeEach(async () => { + await playback.toggle(); + }); + + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); + + describe("and calling toggle a third time", () => { beforeEach(async () => { - mocked(onStateChanged).mockReset(); await playback.toggle(); }); itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); - itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Playing); }); }); }); + + describe("and calling stop", () => { + stopPlayback(); + + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); + + describe("and calling toggle", () => { + beforeEach(async () => { + mocked(onStateChanged).mockReset(); + await playback.toggle(); + }); + + itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); + itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Playing); + }); + }); }); }); diff --git a/test/voice-broadcast/stores/VoiceBroadcastPlaybacksStore-test.ts b/test/voice-broadcast/stores/VoiceBroadcastPlaybacksStore-test.ts index 07c7e2fe63..d234f37637 100644 --- a/test/voice-broadcast/stores/VoiceBroadcastPlaybacksStore-test.ts +++ b/test/voice-broadcast/stores/VoiceBroadcastPlaybacksStore-test.ts @@ -35,6 +35,8 @@ import { mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils"; describe("VoiceBroadcastPlaybacksStore", () => { const roomId = "!room:example.com"; let client: MatrixClient; + let userId: string; + let deviceId: string; let room: Room; let infoEvent1: MatrixEvent; let infoEvent2: MatrixEvent; @@ -45,24 +47,31 @@ describe("VoiceBroadcastPlaybacksStore", () => { beforeEach(() => { client = stubClient(); + userId = client.getUserId() || ""; + deviceId = client.getDeviceId() || ""; + mocked(client.relations).mockClear(); + mocked(client.relations).mockResolvedValue({ events: [] }); + room = mkStubRoom(roomId, "test room", client); - mocked(client.getRoom).mockImplementation((roomId: string) => { + mocked(client.getRoom).mockImplementation((roomId: string): Room | null => { if (roomId === room.roomId) { return room; } + + return null; }); infoEvent1 = mkVoiceBroadcastInfoStateEvent( roomId, VoiceBroadcastInfoState.Started, - client.getUserId(), - client.getDeviceId(), + userId, + deviceId, ); infoEvent2 = mkVoiceBroadcastInfoStateEvent( roomId, VoiceBroadcastInfoState.Started, - client.getUserId(), - client.getDeviceId(), + userId, + deviceId, ); playback1 = new VoiceBroadcastPlayback(infoEvent1, client); jest.spyOn(playback1, "off");