diff --git a/src/viewer/PlayService.ts b/src/viewer/PlayService.ts index 6cbbcd76..798ba2a0 100644 --- a/src/viewer/PlayService.ts +++ b/src/viewer/PlayService.ts @@ -2,6 +2,7 @@ import { combineLatest as observableCombineLatest, empty as observableEmpty, from as observableFrom, + merge as observableMerge, of as observableOf, zip as observableZip, Observable, @@ -160,9 +161,8 @@ export class PlayService { return observableOf<[Sequence, NavigationDirection]>([undefined, direction]); } - const sequence$: Observable = (mode === GraphMode.Sequence ? - this._graphService.cacheSequenceImages$(sequenceId, imageId) : - this._graphService.cacheSequence$(sequenceId)).pipe( + const sequence$: Observable = this._graphService + .cacheSequence$(sequenceId).pipe( retry(3), catchError( (error: Error): Observable => { @@ -171,8 +171,29 @@ export class PlayService { return observableOf(undefined); })); + // Caching all images of the sequence is a batching + // optimization for the sequence graph mode. It must + // run alongside image caching instead of gating it: + // it only completes once every batch has been + // retrieved, which for long sequences takes long + // enough to starve playback of images while running. + const sequenceImages$: Observable = + mode === GraphMode.Sequence ? + this._graphService + .cacheSequenceImages$(sequenceId, imageId) + .pipe( + retry(3), + catchError( + (error: Error): Observable => { + console.error(error); + + return observableEmpty(); + })) : + observableEmpty(); + return observableCombineLatest( - sequence$, + observableMerge(sequence$, sequenceImages$).pipe( + distinctUntilChanged()), observableOf(direction)); }), switchMap( @@ -186,6 +207,21 @@ export class PlayService { imageIds.reverse(); } + // Positions are resolved on every animation frame, + // linear lookups would scale with the sequence length. + const imageIndices: Map = + new Map(); + for (let i: number = 0; i < imageIds.length; i++) { + imageIndices.set(imageIds[i], i); + } + + const indexOf: (id: string) => number = + (id: string): number => { + const index: number = imageIndices.get(id); + + return index === undefined ? -1 : index; + }; + return this._stateService.currentState$.pipe( map( (frame: AnimationFrame): [string, number] => { @@ -197,8 +233,12 @@ export class PlayService { [lastTrajectoryKey, imagesAhead]: [string, number]): [string, string[]] => { - if (lastRequestKey === undefined) { - lastRequestKey = lastTrajectoryKey; + // The trajectory reaches beyond the + // sequence when traversing into another + // one, there is nothing to request here. + const current: number = indexOf(lastTrajectoryKey); + if (current === -1) { + return [lastRequestKey, []]; } const lastIndex: number = imageIds.length - 1; @@ -206,8 +246,12 @@ export class PlayService { return [lastRequestKey, []]; } - const current: number = imageIds.indexOf(lastTrajectoryKey); - const start: number = imageIds.indexOf(lastRequestKey) + 1; + const lastRequestIndex: number = + lastRequestKey === undefined ? + -1 : indexOf(lastRequestKey); + const start: number = + (lastRequestIndex === -1 ? + current : lastRequestIndex) + 1; const end: number = Math.min(lastIndex, current + this._imagesAhead - imagesAhead) + 1; if (end <= start) { diff --git a/test/viewer/PlayService.test.ts b/test/viewer/PlayService.test.ts index 14f381e4..19951056 100644 --- a/test/viewer/PlayService.test.ts +++ b/test/viewer/PlayService.test.ts @@ -1,977 +1,279 @@ -import { bootstrap } from "../Bootstrap"; -bootstrap(); - import { - of as observableOf, - zip as observableZip, + BehaviorSubject, Observable, + of as observableOf, Subject, } from "rxjs"; -import { take, first, skip } from "rxjs/operators"; -import { MockCreator } from "../helper/MockCreator"; -import { ImageHelper } from "../helper/ImageHelper"; -import { StateServiceMockCreator } from "../helper/StateServiceMockCreator"; -import { FrameHelper } from "../helper/FrameHelper"; -import { Image } from "../../src/graph/Image"; -import { APIWrapper } from "../../src/api/APIWrapper"; -import { ImageEnt } from "../../src/api/ents/ImageEnt"; -import { Graph } from "../../src/graph/Graph"; + import { GraphMode } from "../../src/graph/GraphMode"; import { GraphService } from "../../src/graph/GraphService"; -import { NavigationEdgeStatus } from "../../src/graph/interfaces/NavigationEdgeStatus"; -import { ImageCache } from "../../src/graph/ImageCache"; +import { Image } from "../../src/graph/Image"; import { Sequence } from "../../src/graph/Sequence"; -import { IAnimationState } from "../../src/state/interfaces/IAnimationState"; -import { AnimationFrame } from "../../src/state/interfaces/AnimationFrame"; +import { NavigationDirection } from "../../src/graph/edge/NavigationDirection"; +import { NavigationEdgeStatus } from "../../src/graph/interfaces/NavigationEdgeStatus"; import { State } from "../../src/state/State"; import { StateService } from "../../src/state/StateService"; +import { AnimationFrame } from "../../src/state/interfaces/AnimationFrame"; +import { IAnimationState } from "../../src/state/interfaces/IAnimationState"; import { PlayService } from "../../src/viewer/PlayService"; -import { NavigationDirection } from "../../src/graph/edge/NavigationDirection"; -import { DataProvider } from "../helper/ProviderHelper"; -import { S2GeometryProvider } from "../../src/api/S2GeometryProvider"; - -describe("PlayService.ctor", () => { - it("should be defined when constructed", () => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); - - const playService: PlayService = new PlayService(graphService, stateService); - - expect(playService).toBeDefined(); - }); - - it("should emit default values", (done: () => void) => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); - - const playService: PlayService = new PlayService(graphService, stateService); - - observableZip( - playService.direction$, - playService.playing$, - playService.speed$).pipe( - first()) - .subscribe( - ([d, p, s]: [NavigationDirection, boolean, number]): void => { - expect(d).toBe(NavigationDirection.Next); - expect(p).toBe(false); - expect(s).toBe(0.5); - - done(); - }); - }); -}); - -describe("PlayService.playing", () => { - it("should be playing after calling play", (done: () => void) => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); - - const playService: PlayService = new PlayService(graphService, stateService); - - playService.play(); - - expect(playService.playing).toBe(true); - - playService.playing$ - .subscribe( - (playing: boolean): void => { - expect(playing).toBe(true); - - done(); - }); - }); - it("should not be playing after calling stop", (done: () => void) => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); +/** + * Edge targets are deliberately outside the sequence so that the images the + * trajectory append loop requests can be told apart from the ones the + * prefetch window requests. + */ +const EDGE_SUFFIX: string = "__edge"; - const playService: PlayService = new PlayService(graphService, stateService); +function createEdgeStatus(id: string): NavigationEdgeStatus { + const target: string = `${id}${EDGE_SUFFIX}`; - playService.play(); - - const setGraphModeSpy: jasmine.Spy = spyOn(graphService, "setGraphMode").and.stub(); - const cutImagesSpy: jasmine.Spy = spyOn(stateService, "cutImages").and.stub(); - const setSpeedSpy: jasmine.Spy = spyOn(stateService, "setSpeed").and.stub(); - - playService.stop(); - - expect(setGraphModeSpy.calls.count()).toBe(1); - expect(setGraphModeSpy.calls.argsFor(0)[0]).toBe(GraphMode.Spatial); - - expect(cutImagesSpy.calls.count()).toBe(1); - - expect(setSpeedSpy.calls.count()).toBe(1); - expect(setSpeedSpy.calls.argsFor(0)[0]).toBe(1); - - expect(playService.playing).toBe(false); - - playService.playing$ - .subscribe( - (playing: boolean): void => { - expect(playing).toBe(false); - - done(); - }); - }); -}); - -describe("PlayService.speed$", () => { - it("should emit when changing speed", (done: () => void) => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); - - const playService: PlayService = new PlayService(graphService, stateService); - - playService.speed$.pipe( - skip(1)) - .subscribe( - (speed: number): void => { - expect(speed).toBe(0); - - done(); - }); - - playService.setSpeed(0); - }); - - it("should not emit when setting current speed", () => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); - - const playService: PlayService = new PlayService(graphService, stateService); - - playService.setSpeed(1); - - let speedEmitCount: number = 0; - let firstEmit: boolean = true; - playService.speed$.pipe( - skip(1)) - .subscribe( - (speed: number): void => { - speedEmitCount++; - - if (firstEmit) { - expect(speed).toBe(0); - firstEmit = false; - } else { - expect(speed).toBe(1); - } - }); - - playService.setSpeed(0); - playService.setSpeed(0); - playService.setSpeed(1); - playService.setSpeed(1); - - expect(speedEmitCount).toBe(2); - }); - - it("should clamp speed values to 0, 1 interval", (done: () => void) => { - const api: APIWrapper = new APIWrapper(new DataProvider()); - const graphService: GraphService = new GraphService(new Graph(api)); - const stateService: StateService = new StateService(State.Traversing, new S2GeometryProvider()); - - const playService: PlayService = new PlayService(graphService, stateService); - - let firstEmit: boolean = true; - playService.speed$.pipe( - skip(1)) - .subscribe( - (speed: number): void => { - if (firstEmit) { - expect(speed).toBe(0); - firstEmit = false; - } else { - expect(speed).toBe(1); - - done(); - } - }); - - playService.setSpeed(-1); - playService.setSpeed(2); - }); -}); - -let createState: () => IAnimationState = (): IAnimationState => { return { - alpha: 0, - camera: null, - currentCamera: null, - currentIndex: 0, - currentImage: null, - currentTransform: null, - lastImage: null, - motionless: false, - imagesAhead: 0, - previousCamera: null, - previousImage: null, - previousTransform: null, - reference: null, - state: State.Traversing, - stateTransitionAlpha: 0, - trajectory: null, - zoom: 0, + cached: true, + edges: [ + { + data: { + direction: NavigationDirection.Next, + worldMotionAzimuth: 0, + }, + source: id, + target, + }, + { + data: { + direction: NavigationDirection.Prev, + worldMotionAzimuth: 0, + }, + source: id, + target, + }, + ], + }; +} + +function createImage(id: string, sequenceId: string): Image { + return { + assetsCached: true, + capturedAt: 0, + id: id, + sequenceEdges$: observableOf(createEdgeStatus(id)), + sequenceId: sequenceId, + spatialEdges$: observableOf(createEdgeStatus(id)), + }; +} + +function createSequence(sequenceId: string, count: number): Sequence { + const imageIds: string[] = []; + for (let i: number = 0; i < count; i++) { + imageIds.push(`${sequenceId}_${i}`); + } + + return new Sequence({ id: sequenceId, image_ids: imageIds }); +} + +interface Harness { + cacheSequence$: Subject; + cacheSequenceImages$: Subject; + cacheSequenceImagesSpy: jest.Mock; + currentImage$: Subject; + frame: (lastId: string, imagesAhead: number) => void; + graphMode$: BehaviorSubject; + playService: PlayService; + prefetched: () => string[]; +} + +function createHarness(): Harness { + const currentImage$: Subject = new Subject(); + const currentState$: Subject = new Subject(); + const state$: BehaviorSubject = + new BehaviorSubject(State.Traversing); + + const cacheSequenceSubject$: Subject = new Subject(); + const cacheSequenceImagesSubject$: Subject = + new Subject(); + + const graphMode$: BehaviorSubject = + new BehaviorSubject(GraphMode.Spatial); + + const requestedIds: string[] = []; + + const cacheSequenceImagesSpy: jest.Mock = jest.fn( + (): Observable => cacheSequenceImagesSubject$); + + const graphService: unknown = { + cacheImage$: (id: string): Observable => { + requestedIds.push(id); + + return observableOf(createImage(id, "seq")); + }, + cacheSequence$: (): Observable => cacheSequenceSubject$, + cacheSequenceImages$: cacheSequenceImagesSpy, + graphMode$: graphMode$, + setGraphMode: (mode: GraphMode): void => { graphMode$.next(mode); }, }; -}; - -describe("PlayService.play", () => { - let imageHelper: ImageHelper; - - let api: APIWrapper; - let graphService: GraphService; - let stateService: StateService; - - beforeEach(() => { - imageHelper = new ImageHelper(); - - api = new APIWrapper(new DataProvider()); - graphService = new GraphService(new Graph(api)); - stateService = new StateServiceMockCreator().create(); - }); - - it("should set graph mode when passing speed threshold", () => { - const playService: PlayService = new PlayService(graphService, stateService); - - const setGraphModeSpy: jasmine.Spy = spyOn(graphService, "setGraphMode").and.stub(); - - playService.setSpeed(0); - - playService.play(); - - playService.setSpeed(1); - playService.setSpeed(0); - - expect(setGraphModeSpy.calls.count()).toBe(3); - expect(setGraphModeSpy.calls.argsFor(0)[0]).toBe(GraphMode.Spatial); - expect(setGraphModeSpy.calls.argsFor(1)[0]).toBe(GraphMode.Sequence); - expect(setGraphModeSpy.calls.argsFor(2)[0]).toBe(GraphMode.Spatial); - }); - - it("should stop immediately if image does not have an edge in current direction and no bridge", () => { - const playService: PlayService = new PlayService(graphService, stateService); - - const stopSpy: jasmine.Spy = spyOn(playService, "stop").and.callThrough(); - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(observableOf([])); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - frame.state.currentImage.initializeCache(new ImageCache(undefined)); - (>stateService.currentState$).next(frame); - - frame.state.currentImage.cacheSequenceEdges([]); - - expect(stopSpy.calls.count()).toBe(1); - }); - - it("should stop if earth mode is emitted", () => { - const playService: PlayService = new PlayService(graphService, stateService); - - const stopSpy: jasmine.Spy = spyOn(playService, "stop").and.callThrough(); - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(observableOf([])); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - (>stateService.state$).next(State.Earth); - - expect(stopSpy.calls.count()).toBe(1); - }); - - it("should stop if error occurs", () => { - spyOn(console, "error").and.stub(); - - const playService: PlayService = new PlayService(graphService, stateService); - - const stopSpy: jasmine.Spy = spyOn(playService, "stop").and.callThrough(); - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(new Subject()); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - - const image: Image = frame.state.currentImage; - const sequenceEdgesSubject: Subject = new Subject(); - new MockCreator().mockProperty(image, "sequenceEdges$", sequenceEdgesSubject); - - (>stateService.currentState$).next(frame); - - sequenceEdgesSubject.error(new Error()); - - expect(stopSpy.calls.count()).toBe(1); - }); - - it("should emit in correct order if stopping immediately", (done: () => void) => { - const playService: PlayService = new PlayService(graphService, stateService); - - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(observableOf([])); - - playService.setDirection(NavigationDirection.Next); - - let firstEmit: boolean = true; - playService.playing$.pipe( - skip(1), - take(2)) - .subscribe( - (playing: boolean): void => { - expect(playing).toBe(playService.playing); - - if (firstEmit) { - expect(playing).toBe(true); - firstEmit = false; - } else { - expect(playing).toBe(false); - done(); - } - }); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - frame.state.currentImage.initializeCache(new ImageCache(undefined)); - (>stateService.currentState$).next(frame); - - frame.state.currentImage.cacheSequenceEdges([]); - }); - - it("should not stop if images are not cached", () => { - const playService: PlayService = new PlayService(graphService, stateService); - - const stopSpy: jasmine.Spy = spyOn(playService, "stop").and.callThrough(); - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(observableOf([])); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - const image: Image = frame.state.currentImage; - image.initializeCache(new ImageCache(undefined)); - const sequenceEdgesSubject: Subject = new Subject(); - new MockCreator().mockProperty(image, "sequenceEdges$", sequenceEdgesSubject); - - (>stateService.currentState$).next(frame); - - sequenceEdgesSubject.next({ cached: false, edges: [] }); - - expect(stopSpy.calls.count()).toBe(0); - - sequenceEdgesSubject.next({ cached: true, edges: [] }); - - expect(stopSpy.calls.count()).toBe(1); - }); - - it("should stop if no more images", () => { - const playService: PlayService = new PlayService(graphService, stateService); - - const stopSpy: jasmine.Spy = spyOn(playService, "stop").and.callThrough(); - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - const image: Image = frame.state.currentImage; - image.initializeCache(new ImageCache(undefined)); - const sequenceEdgesSubject: Subject = new Subject(); - - const prevFullImage: ImageEnt = new ImageHelper().createImageEnt(); - prevFullImage.captured_at = -1; - const prevImage: Image = new Image(prevFullImage); - prevImage.makeComplete(prevFullImage); - frame.state.trajectory.splice(0, 0, prevImage); - frame.state.currentIndex = 1; - - new MockCreator().mockProperty(image, "sequenceEdges$", sequenceEdgesSubject); - - (>stateService.currentState$).next(frame); - - sequenceEdgesSubject.next({ cached: false, edges: [] }); - - expect(stopSpy.calls.count()).toBe(0); - - sequenceEdgesSubject.next({ cached: true, edges: [] }); - - expect(stopSpy.calls.count()).toBe(1); - }); - - it("should append image when cached", () => { - const playService: PlayService = new PlayService(graphService, stateService); - - const appendImagesSpy: jasmine.Spy = stateService.appendImagess; - appendImagesSpy.and.callThrough(); - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); - - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(new Subject()); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - const image: Image = frame.state.currentImage; - image.initializeCache(new ImageCache(undefined)); - const sequenceEdgesSubject: Subject = new Subject(); - new MockCreator().mockProperty(image, "sequenceEdges$", sequenceEdgesSubject); - - (>stateService.currentState$).next(frame); - - const fullToImage: ImageEnt = imageHelper.createImageEnt(); - fullToImage.id = "toKey"; - const toImage: Image = new Image(fullToImage); - - sequenceEdgesSubject.next({ - cached: true, - edges: [{ - data: { direction: NavigationDirection.Next, worldMotionAzimuth: 0 }, - source: image.id, - target: toImage.id, - }], - }); - - cacheImageSubject.next(toImage); - - expect(cacheImageSpy.calls.count()).toBe(1); - expect(cacheImageSpy.calls.argsFor(0)[0]).toBe(toImage.id); - - expect(appendImagesSpy.calls.count()).toBe(1); - expect(appendImagesSpy.calls.argsFor(0)[0].length).toBe(1); - expect(appendImagesSpy.calls.argsFor(0)[0][0].id).toBe(toImage.id); - }); - - it("should stop on image caching error", () => { - spyOn(console, "error").and.stub(); - - const playService: PlayService = new PlayService(graphService, stateService); - - const appendImagesSpy: jasmine.Spy = stateService.appendImagess; - appendImagesSpy.and.callThrough(); - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); - - const stopSpy: jasmine.Spy = spyOn(playService, "stop").and.callThrough(); - - spyOn(graphService, "cacheSequence$").and.returnValue(new Subject()); - spyOn(graphService, "cacheSequenceImages$").and.returnValue(new Subject()); - spyOn(graphService, "cacheBoundingBox$").and.returnValue(new Subject()); - - playService.setDirection(NavigationDirection.Next); - - playService.play(); - - const frame: AnimationFrame = new FrameHelper().createFrame(); - const image: Image = frame.state.currentImage; - image.initializeCache(new ImageCache(undefined)); - const sequenceEdgesSubject: Subject = new Subject(); - new MockCreator().mockProperty(image, "sequenceEdges$", sequenceEdgesSubject); - (>stateService.currentState$).next(frame); + const stateService: unknown = { + appendImagess: (): void => { /*noop*/ }, + clearPriorImages: (): void => { /*noop*/ }, + currentImage$: currentImage$, + currentState$: currentState$, + cutImages: (): void => { /*noop*/ }, + setSpeed: (): void => { /*noop*/ }, + state$: state$, + }; - const fullToImage: ImageEnt = imageHelper.createImageEnt(); - fullToImage.id = "toKey"; - const toImage: Image = new Image(fullToImage); + const playService: PlayService = new PlayService( + graphService, + stateService); + + const frame = (lastId: string, imagesAhead: number): void => { + const lastImage: Image = createImage(lastId, "seq"); + const state: unknown = { + currentImage: lastImage, + currentIndex: 0, + imagesAhead: imagesAhead, + lastImage: lastImage, + trajectory: [lastImage], + }; + + currentState$.next({ fps: 60, id: 0, state: state }); + }; - sequenceEdgesSubject.next({ - cached: true, - edges: [{ - data: { direction: NavigationDirection.Next, worldMotionAzimuth: 0 }, - source: image.id, - target: toImage.id, - }], + const prefetched = (): string[] => + requestedIds.filter((id: string): boolean => { + return !id.endsWith(EDGE_SUFFIX); }); - cacheImageSubject.error(new Error()); - - expect(cacheImageSpy.calls.count()).toBe(1); - expect(cacheImageSpy.calls.argsFor(0)[0]).toBe(toImage.id); - - expect(appendImagesSpy.calls.count()).toBe(0); - - expect(stopSpy.calls.count()).toBe(1); - }); - - it("should cache sequence when in spatial graph mode", () => { - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - // Set speed to zero so that graph mode is set to spatial when calling play - playService.setSpeed(0); - - const cacheSequenceSpy: jasmine.Spy = spyOn(graphService, "cacheSequence$"); - cacheSequenceSpy.and.returnValue(new Subject()); - const cacheSequenceImagesSpy: jasmine.Spy = spyOn(graphService, "cacheSequenceImages$"); - cacheSequenceImagesSpy.and.returnValue(new Subject()); - - playService.play(); - - const currentImage: Image = imageHelper.createImage(); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); - - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); - - expect(cacheSequenceSpy.calls.count()).toBe(1); - expect(cacheSequenceSpy.calls.argsFor(0)[0]).toBe(currentImage.sequenceId); - - expect(cacheSequenceImagesSpy.calls.count()).toBe(0); - - playService.stop(); - }); - - it("should cache sequence images when in sequence graph mode", () => { - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - // Set speed to one so that graph mode is set to sequence when calling play - playService.setSpeed(1); - - const cacheSequenceSpy: jasmine.Spy = spyOn(graphService, "cacheSequence$"); - cacheSequenceSpy.and.returnValue(new Subject()); - const cacheSequenceImagesSpy: jasmine.Spy = spyOn(graphService, "cacheSequenceImages$"); - cacheSequenceImagesSpy.and.returnValue(new Subject()); + return { + cacheSequence$: cacheSequenceSubject$, + cacheSequenceImages$: cacheSequenceImagesSubject$, + cacheSequenceImagesSpy: cacheSequenceImagesSpy, + currentImage$: currentImage$, + frame: frame, + graphMode$: graphMode$, + playService: playService, + prefetched: prefetched, + }; +} - playService.play(); +describe("PlayService.play", () => { + it("should cache images before the sequence images request resolves", () => { + const harness: Harness = createHarness(); - const currentImage: Image = imageHelper.createImage(); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); + // Above PlayService.sequenceSpeed, so the graph mode is sequence. + harness.playService.setSpeed(1); + harness.playService.play(); - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); + expect(harness.graphMode$.value).toBe(GraphMode.Sequence); - expect(cacheSequenceSpy.calls.count()).toBe(0); + harness.currentImage$.next(createImage("seq_100", "seq")); - expect(cacheSequenceImagesSpy.calls.count()).toBe(1); - expect(cacheSequenceImagesSpy.calls.argsFor(0)[0]).toBe(currentImage.sequenceId); + // The sequence request resolves while the sequence images request is + // still in flight, as it is for seconds on a long sequence. + harness.cacheSequence$.next(createSequence("seq", 1000)); + harness.frame("seq_100", 0); - playService.stop(); + expect(harness.cacheSequenceImagesSpy).toHaveBeenCalledTimes(1); + expect(harness.prefetched().length).toBeGreaterThan(0); + expect(harness.prefetched()[0]).toBe("seq_101"); }); - it("should not pre-cache if current image is last sequence image", () => { - graphService.setGraphMode(GraphMode.Spatial); - - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - - const cacheSequenceSubject: Subject = new Subject(); - spyOn(graphService, "cacheSequence$").and.returnValue(cacheSequenceSubject); - - playService.play(); - - const sequenceKey: string = "sequenceId"; + it("should request a window of images ahead of the trajectory", () => { + const harness: Harness = createHarness(); - const currentFullImage: ImageEnt = new ImageHelper().createImageEnt(); - currentFullImage.sequence.id = sequenceKey; - currentFullImage.id = "image0"; - const currentImage: Image = new Image(currentFullImage); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); + harness.playService.setSpeed(1); + harness.playService.play(); + harness.currentImage$.next(createImage("seq_100", "seq")); + harness.cacheSequence$.next(createSequence("seq", 1000)); + harness.frame("seq_100", 0); - const prevImageKey: string = "image1"; + // Images ahead is 50 at maximum speed. + expect(harness.prefetched().length).toBe(50); + expect(harness.prefetched()[0]).toBe("seq_101"); + expect(harness.prefetched()[49]).toBe("seq_150"); - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); + const requested: number = harness.prefetched().length; + harness.frame("seq_150", 50); - const sequence: Sequence = new Sequence({ id: sequenceKey, image_ids: [prevImageKey, currentImage.id] }); - cacheSequenceSubject.next(sequence); + expect(harness.prefetched().length).toBe(requested); - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); + harness.frame("seq_150", 45); - const state: IAnimationState = createState(); - state.trajectory = [currentImage]; - state.lastImage = currentImage; - state.currentImage = currentImage; - state.imagesAhead = 0; - - (>stateService.currentState$).next({ fps: 60, id: 0, state: state }); - - expect(cacheImageSpy.calls.count()).toBe(0); - - playService.stop(); + expect(harness.prefetched().slice(requested)) + .toEqual([ + "seq_151", + "seq_152", + "seq_153", + "seq_154", + "seq_155", + ]); }); - it("should pre-cache one trajectory image", () => { - graphService.setGraphMode(GraphMode.Spatial); - - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - - const cacheSequenceSubject: Subject = new Subject(); - spyOn(graphService, "cacheSequence$").and.returnValue(cacheSequenceSubject); - - playService.play(); - - const sequenceKey: string = "sequenceId"; + it("should not request from the start of another sequence", () => { + const harness: Harness = createHarness(); - const currentFullImage: ImageEnt = new ImageHelper().createImageEnt(); - currentFullImage.sequence.id = sequenceKey; - currentFullImage.id = "image0"; - const currentImage: Image = new Image(currentFullImage); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); + // Spatial mode, so the request window is exercised on its own. + harness.playService.setSpeed(0.2); + harness.playService.play(); + harness.currentImage$.next(createImage("seq_100", "seq")); + harness.cacheSequence$.next(createSequence("seq", 1000)); - const nextImageKey: string = "image1"; + harness.frame("other_7", 0); - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); + expect(harness.prefetched()).toEqual([]); - const sequence: Sequence = new Sequence({ id: sequenceKey, image_ids: [currentImage.id, nextImageKey] }); - cacheSequenceSubject.next(sequence); + harness.frame("seq_100", 0); - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); - - const state: IAnimationState = createState(); - state.trajectory = [currentImage]; - state.lastImage = currentImage; - state.currentImage = currentImage; - state.imagesAhead = 0; - - const currentStateSubject$: Subject = >stateService.currentState$; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - cacheImageSubject.next(new ImageHelper().createImage()); - - expect(cacheImageSpy.calls.count()).toBe(1); - expect(cacheImageSpy.calls.argsFor(0)[0]).toBe(nextImageKey); - - playService.stop(); + expect(harness.prefetched()[0]).toBe("seq_101"); }); - it("should pre-cache one trajectory image in prev direction", () => { - graphService.setGraphMode(GraphMode.Spatial); - - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Prev); - - const cacheSequenceSubject: Subject = new Subject(); - spyOn(graphService, "cacheSequence$").and.returnValue(cacheSequenceSubject); - - playService.play(); - - const sequenceKey: string = "sequenceId"; - - const currentFullImage: ImageEnt = new ImageHelper().createImageEnt(); - currentFullImage.sequence.id = sequenceKey; - currentFullImage.id = "image0"; - const currentImage: Image = new Image(currentFullImage); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); - - const prevImageKey: string = "image1"; - - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); - - const sequence: Sequence = new Sequence({ id: sequenceKey, image_ids: [prevImageKey, currentImage.id] }); - cacheSequenceSubject.next(sequence); + it("should cache images when the sequence images request fails", () => { + const harness: Harness = createHarness(); - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); + harness.playService.setSpeed(1); + harness.playService.play(); + harness.currentImage$.next(createImage("seq_100", "seq")); + harness.cacheSequence$.next(createSequence("seq", 1000)); + harness.frame("seq_100", 0); - const state: IAnimationState = createState(); - state.trajectory = [currentImage]; - state.lastImage = currentImage; - state.currentImage = currentImage; - state.imagesAhead = 0; + const requested: number = harness.prefetched().length; + expect(requested).toBe(50); - const currentStateSubject$: Subject = >stateService.currentState$; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); + jest.spyOn(console, "error").mockImplementation((): void => { /*noop*/ }); + harness.cacheSequenceImages$.error(new Error("Failed")); - cacheImageSubject.next(new ImageHelper().createImage()); + harness.frame("seq_150", 45); - expect(cacheImageSpy.calls.count()).toBe(1); - expect(cacheImageSpy.calls.argsFor(0)[0]).toBe(prevImageKey); - - // Sequence should not have changed because of internal reversing - expect(sequence.imageIds[0]).toBe(prevImageKey); - expect(sequence.imageIds[1]).toBe(currentImage.id); - - playService.stop(); + expect(harness.prefetched().length).toBeGreaterThan(requested); }); - it("should not pre-cache the same image twice", () => { - graphService.setGraphMode(GraphMode.Spatial); - - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - - const cacheSequenceSubject: Subject = new Subject(); - spyOn(graphService, "cacheSequence$").and.returnValue(cacheSequenceSubject); - - playService.play(); - - const sequenceKey: string = "sequenceId"; - - const currentFullImage: ImageEnt = new ImageHelper().createImageEnt(); - currentFullImage.sequence.id = sequenceKey; - currentFullImage.id = "image0"; - const currentImage: Image = new Image(currentFullImage); - currentImage.makeComplete(currentFullImage); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); - - const nextImageKey: string = "image1"; - - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); - - const sequence: Sequence = new Sequence({ id: sequenceKey, image_ids: [currentImage.id, nextImageKey] }); - cacheSequenceSubject.next(sequence); - - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); - - const state: IAnimationState = createState(); - state.trajectory = [currentImage]; - state.lastImage = currentImage; - state.currentImage = currentImage; - state.imagesAhead = 0; - - const currentStateSubject$: Subject = >stateService.currentState$; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - const nextFullImage: ImageEnt = new ImageHelper().createImageEnt(); - nextFullImage.sequence.id = sequenceKey; - nextFullImage.id = nextImageKey; - const nextImage: Image = new Image(nextFullImage); - nextImage.makeComplete(nextFullImage); - cacheImageSubject.next(nextImage); + it("should not request sequence images in spatial mode", () => { + const harness: Harness = createHarness(); - expect(cacheImageSpy.calls.count()).toBe(1); - expect(cacheImageSpy.calls.argsFor(0)[0]).toBe(nextImageKey); + // Below PlayService.sequenceSpeed, so the graph mode is spatial. + harness.playService.setSpeed(0.2); + harness.playService.play(); - currentStateSubject$.next({ fps: 60, id: 0, state: state }); + expect(harness.graphMode$.value).toBe(GraphMode.Spatial); - expect(cacheImageSpy.calls.count()).toBe(1); + harness.currentImage$.next(createImage("seq_100", "seq")); + harness.cacheSequence$.next(createSequence("seq", 1000)); + harness.frame("seq_100", 0); - playService.stop(); + expect(harness.cacheSequenceImagesSpy).not.toHaveBeenCalled(); + expect(harness.prefetched().length).toBeGreaterThan(0); }); - it("should not pre-cache if all sequence images in trajectory", () => { - graphService.setGraphMode(GraphMode.Spatial); + it("should reverse the request window when playing backwards", () => { + const harness: Harness = createHarness(); - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - - const cacheSequenceSubject: Subject = new Subject(); - spyOn(graphService, "cacheSequence$").and.returnValue(cacheSequenceSubject); - - playService.play(); - - const sequenceKey: string = "sequenceId"; - - const currentFullImage: ImageEnt = new ImageHelper().createImageEnt(); - currentFullImage.sequence.id = sequenceKey; - currentFullImage.id = "image0"; - const currentImage: Image = new Image(currentFullImage); - currentImage.makeComplete(currentFullImage); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); - - const nextImageKey: string = "image1"; - const nextFullImage: ImageEnt = new ImageHelper().createImageEnt(); - nextFullImage.sequence.id = sequenceKey; - nextFullImage.id = nextImageKey; - const nextImage: Image = new Image(nextFullImage); - nextImage.makeComplete(nextFullImage); - new MockCreator().mockProperty(nextImage, "sequenceEdges$", new Subject()); - - const currentImageSubject: Subject = >stateService.currentImage$; - currentImageSubject.next(currentImage); - - const sequence: Sequence = new Sequence({ id: sequenceKey, image_ids: [currentImage.id, nextImageKey] }); - cacheSequenceSubject.next(sequence); - - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$"); - const cacheImageSubject: Subject = new Subject(); - cacheImageSpy.and.returnValue(cacheImageSubject); - - const state: IAnimationState = createState(); - state.trajectory = [currentImage, nextImage]; - state.lastImage = nextImage; - state.currentImage = currentImage; - state.imagesAhead = 0; - - const currentStateSubject$: Subject = >stateService.currentState$; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - expect(cacheImageSpy.calls.count()).toBe(0); - - playService.stop(); - }); + harness.playService.setSpeed(1); + harness.playService.setDirection(NavigationDirection.Prev); + harness.playService.play(); + harness.currentImage$.next(createImage("seq_100", "seq")); + harness.cacheSequence$.next(createSequence("seq", 1000)); + harness.frame("seq_100", 0); - it("should pre-cache up to specified images ahead", () => { - graphService.setGraphMode(GraphMode.Spatial); - - const playService: PlayService = new PlayService(graphService, stateService); - playService.setDirection(NavigationDirection.Next); - // Zero speed means max ten images ahead - playService.setSpeed(0); - - const cacheSequenceSubject: Subject = new Subject(); - spyOn(graphService, "cacheSequence$").and.returnValue(cacheSequenceSubject); - - playService.play(); - - const sequenceKey: string = "sequenceId"; - - const currentFullImage: ImageEnt = new ImageHelper().createImageEnt(); - currentFullImage.sequence.id = sequenceKey; - currentFullImage.id = "currentImageKey"; - const currentImage: Image = new Image(currentFullImage); - currentImage.makeComplete(currentFullImage); - new MockCreator().mockProperty(currentImage, "sequenceEdges$", new Subject()); - - const sequence: Sequence = new Sequence({ id: sequenceKey, image_ids: [currentImage.id] }); - const sequenceImages: Image[] = []; - - for (let i: number = 0; i < 20; i++) { - const sequenceImageKey: string = `image${i}`; - const sequenceFullImage: ImageEnt = new ImageHelper().createImageEnt(); - sequenceFullImage.sequence.id = sequenceKey; - sequenceFullImage.id = sequenceImageKey; - const sequenceImage: Image = new Image(sequenceFullImage); - sequenceImage.makeComplete(sequenceFullImage); - new MockCreator().mockProperty(sequenceImage, "sequenceEdges$", new Subject()); - - sequence.imageIds.push(sequenceImage.id); - sequenceImages.push(sequenceImage); - } - - const currentImageSubject: Subject = >stateService.currentImage$; - - currentImageSubject.next(currentImage); - cacheSequenceSubject.next(sequence); - - const cacheImageSpy: jasmine.Spy = spyOn(graphService, "cacheImage$").and.callFake( - (key: string): Observable => { - const fullImage: ImageEnt = new ImageHelper().createImageEnt(); - fullImage.sequence.id = sequenceKey; - fullImage.id = key; - const image: Image = new Image(fullImage); - image.makeComplete(fullImage); - - return observableOf(image); - }); - - const state: IAnimationState = createState(); - state.trajectory = [currentImage]; - state.lastImage = currentImage; - state.currentImage = currentImage; - state.currentIndex = 0; - state.imagesAhead = 0; - - // Cache ten images immediately - const currentStateSubject$: Subject = >stateService.currentState$; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - let cachedCount: number = 10; - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - // Add one image to trajectory before current image has moved - state.trajectory = state.trajectory.concat(sequenceImages.splice(0, 1)); - state.lastImage = state.trajectory[state.trajectory.length - 1]; - state.imagesAhead = 1; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - // No new images should be cached - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - // Current image has moved one step in trajectory to the last image, images ahead - // is zero and one new image should be cached - state.currentIndex += 1; - state.currentImage = state.trajectory[state.currentIndex]; - state.imagesAhead = 0; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - cachedCount += 1; - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - // Add 5 images to trajectory and move current image 3 steps - state.trajectory = state.trajectory.concat(sequenceImages.splice(0, 5)); - state.currentIndex += 3; - state.currentImage = state.trajectory[state.currentIndex]; - state.lastImage = state.trajectory[state.trajectory.length - 1]; - state.imagesAhead = 2; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - // Three new images should be cached - cachedCount += 3; - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - // Add all 14 images cached so far to trajectory and move current image to last - // trajectory image - state.trajectory = state.trajectory.concat(sequenceImages.splice(0, 8)); - state.currentIndex = state.trajectory.length - 1; - expect(state.currentIndex).toBe(14); - state.currentImage = state.trajectory[state.currentIndex]; - state.lastImage = state.trajectory[state.trajectory.length - 1]; - state.imagesAhead = 0; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - // Six last images should be cached - cachedCount += 6; - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - // No new images should be cached - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - // Add all remaining images to trajectory and move current image one step - state.trajectory = state.trajectory.concat(sequenceImages.splice(0, sequenceImages.length)); - state.currentIndex += 1; - state.currentImage = state.trajectory[state.currentIndex]; - state.lastImage = state.trajectory[state.trajectory.length - 1]; - state.imagesAhead = 5; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - // No new images should be cached - expect(cacheImageSpy.calls.count()).toBe(cachedCount); - - // Move current image to last trajectory image - state.trajectory = state.trajectory.concat(sequenceImages.splice(0, sequenceImages.length)); - state.currentIndex = state.trajectory.length - 1; - state.currentImage = state.trajectory[state.currentIndex]; - state.lastImage = state.trajectory[state.trajectory.length - 1]; - state.imagesAhead = 0; - currentStateSubject$.next({ fps: 60, id: 0, state: state }); - - // No new images should be cached - expect(cacheImageSpy.calls.count()).toBe(20); - - for (let i: number = 0; i < 20; i++) { - expect(cacheImageSpy.calls.argsFor(i)[0]).toBe(sequence.imageIds[i + 1]); - } - - playService.stop(); + expect(harness.prefetched()[0]).toBe("seq_99"); + expect(harness.prefetched()[49]).toBe("seq_50"); }); });