From fc248e79161912c69c39678a6334ef4fea4bdecf Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 15:45:36 +0100 Subject: [PATCH] Switch Time Travel comparisons in a single frame Opening or changing a slider comparison could show the original alone with the curtain hidden, the previous comparison at the next framing, a black frame or a first frame with the default curtain while the next pair loaded. - SliderComponent.preload(ids) caches a pair, including image assets, without showing it, so a following configure with the same ids switches within one frame. - Pair options (viewAligned, mode, viewScale, viewCorrection, visibility) apply only once the state shows the configured pair. - A cached pair swaps before the next frame (StateService.lastState$). - The slider leaves traversal before changing the trajectory, and a newly activated slider applies its curtain together with its first GL update. - SliderGLRenderer clears before drawing, so it never composites over another background renderer active in the same frame. - A disposed TextureProvider ignores late region-of-interest updates and tiles instead of throwing on its released renderer. Regression tests: SliderPreload, SliderPairConfiguration, SliderExclusiveBackground, TextureProviderDisposed and updated SliderSelectionFraming, SliderAutomaticMotion and SliderViewAligned. --- src/component/slider/SliderComponent.ts | 181 ++++++++++++++---- src/component/slider/SliderGLRenderer.ts | 4 + src/state/StateService.ts | 8 + src/tile/TextureProvider.ts | 6 +- .../slider/SliderAutomaticMotion.test.ts | 22 ++- .../slider/SliderExclusiveBackground.test.ts | 15 ++ .../slider/SliderPairConfiguration.test.ts | 79 ++++++++ test/component/slider/SliderPreload.test.ts | 54 ++++++ .../slider/SliderSelectionFraming.test.ts | 13 +- .../slider/SliderViewAligned.test.ts | 2 +- test/helper/StateServiceMockCreator.ts | 8 +- test/tile/TextureProviderDisposed.test.ts | 52 +++++ 12 files changed, 390 insertions(+), 54 deletions(-) create mode 100644 test/component/slider/SliderExclusiveBackground.test.ts create mode 100644 test/component/slider/SliderPairConfiguration.test.ts create mode 100644 test/component/slider/SliderPreload.test.ts create mode 100644 test/tile/TextureProviderDisposed.test.ts diff --git a/src/component/slider/SliderComponent.ts b/src/component/slider/SliderComponent.ts index 7b73a3b6f..6dba89150 100644 --- a/src/component/slider/SliderComponent.ts +++ b/src/component/slider/SliderComponent.ts @@ -56,6 +56,7 @@ import { TextureProvider } from "../../tile/TextureProvider"; import { Component } from "../Component"; import { SliderConfiguration, + SliderConfigurationIds, SliderConfigurationMode, } from "../interfaces/SliderConfiguration"; import { Transform } from "../../geo/Transform"; @@ -167,6 +168,32 @@ export class SliderComponent extends Component { this._position$.next(position); } + /** + * Cache a comparison pair, including its image assets, without showing it. + * + * @description A following `configure` with the same `ids` then + * switches to the pair within one frame, so the previous pair stays + * on screen while the next one loads instead of the viewer showing a + * partially configured state. + * + * @param {SliderConfigurationIds} ids Background and foreground ids. + * @returns {Promise} Resolves when both images are cached and + * rejects when either fails to load. + */ + public preload(ids: SliderConfigurationIds): Promise { + if (!ids || !ids.background || !ids.foreground) { + return Promise.reject(new Error("Slider preload requires background and foreground ids")); + } + const graphService = this._navigator.graphService; + return new Promise((resolve, reject) => { + observableZip( + graphService.cacheImage$(ids.background), + graphService.cacheImage$(ids.foreground)).pipe( + first()) + .subscribe(() => resolve(), reject); + }); + } + protected _activate(): void { const subs = this._subscriptions; subs.push(this._domRenderer.mode$ @@ -200,6 +227,29 @@ export class SliderComponent extends Component { observableMerge(this._domRenderer.position$, this._position$)); const safety = new SliderAutomaticMotion(); subs.push(new Subscription(() => safety.dispose())); + // Pair options (alignment, motion, scale, correction, visibility) + // describe the configured ids. Until the state shows that pair, keep + // the options the shown pair was configured with, so selecting a pair + // that is still loading never re-renders the old one with the new + // options (a hidden slider or a different framing for some frames). + // Frames, not the replayed last one: a render between frames would use + // the previous render camera with the new pair and options. + const pairState$ = observableCombineLatest( + this.configuration$, + this._navigator.stateService.currentState$).pipe( + scan( + (previous: SliderPairState, [configuration, frame]: [SliderConfiguration, AnimationFrame]): + SliderPairState => ({ + // Component configuration is one mutated object: keep a copy. + configuration: previous == null || sliderShowsPair(configuration, frame.state) ? + { ...configuration } : previous.configuration, + frame, + }), + null), + publishReplay(1), + refCount()); + const pairConfiguration$ = pairState$.pipe( + map((pair: SliderPairState): SliderConfiguration => pair.configuration)); // Snapshot only changed views. Immediately revoke coverage, then check // after 100ms of stability: never raycast on each animation/drag frame. const coverageView$ = this.configuration$.pipe( @@ -229,11 +279,10 @@ export class SliderComponent extends Component { // One decision (including its exact frame/config) is shared by DOM and // GL. In particular, a new ids selection can never enable the old pair. const decision$ = observableCombineLatest( - this.configuration$, - this._navigator.stateService.currentState$, + pairState$, coverageView$, this._container.renderService.size$.pipe(startWith(null))).pipe( - map(([configuration, frame, view, size]) => { + map(([{ configuration, frame }, view, size]) => { const automatic = configuration.automaticMotion === true && configuration.viewAligned === true; const stationaryOnly = sliderViewAlignedStationary(frame.state, configuration.viewAligned === true); const currentView = view && size && view.size.width === size.width && view.size.height === size.height; @@ -247,12 +296,12 @@ export class SliderComponent extends Component { }), publishReplay(1), refCount()); - const viewAligned$ = this.configuration$.pipe( + const viewAligned$ = pairConfiguration$.pipe( map( (configuration: SliderConfiguration): boolean => configuration.viewAligned === true), distinctUntilChanged()); - const screenSpace$ = this.configuration$.pipe( + const screenSpace$ = pairConfiguration$.pipe( map((configuration: SliderConfiguration): boolean => configuration.screenSpace === true), distinctUntilChanged()); @@ -278,23 +327,9 @@ export class SliderComponent extends Component { return isSpherical(frame.state.currentImage.cameraType); }), distinctUntilChanged()); - const sliderVisible$ = observableCombineLatest( - this._configuration$.pipe( - map( - (configuration: SliderConfiguration): boolean => { - return configuration.sliderVisible; - })), - this._navigator.stateService.currentState$, - viewAligned$).pipe( - map( - ([sliderVisible, frame, viewAligned]: - [boolean, AnimationFrame, boolean]): boolean => { - return sliderVisible && !(frame.state.currentImage == null || - frame.state.previousImage == null || - (!viewAligned && isSpherical( - frame.state.currentImage.cameraType) && - !isSpherical(frame.state.previousImage.cameraType))); - }), + const sliderVisible$ = pairState$.pipe( + map(({ configuration, frame }: SliderPairState): boolean => + sliderPairVisible(configuration, frame.state)), distinctUntilChanged()); this._waitSubscription = observableCombineLatest( interactionDecision$, @@ -345,19 +380,8 @@ export class SliderComponent extends Component { screenSpace$).pipe( map( ([position, spherical, visible, render, transform, screenSpace]: - [number, boolean, boolean, RenderCamera, Transform, boolean]): [number, boolean] => { - if (screenSpace) { - return [visible ? position : 1, true]; - } - if (!spherical) { - return [visible ? position : 1, false]; - } - const basicMin: number[] = this._viewportCoords.viewportToBasic(-1.15, 0, transform, render.perspective); - const basicMax: number[] = this._viewportCoords.viewportToBasic(1.15, 0, transform, render.perspective); - const shiftedMax: number = basicMax[0] < basicMin[0] ? basicMax[0] + 1 : basicMax[0]; - const basicPosition: number = basicMin[0] + position * (shiftedMax - basicMin[0]); - return [basicPosition > 1 ? basicPosition - 1 : basicPosition, false]; - }), + [number, boolean, boolean, RenderCamera, Transform, boolean]): [number, boolean] => + this._curtain(position, spherical, visible, render, transform, screenSpace)), map( ([position, screenSpace]: [number, boolean]): GLRendererOperation => { return (glRenderer: SliderGLRenderer): SliderGLRenderer => { @@ -366,7 +390,7 @@ export class SliderComponent extends Component { }; })) .subscribe(this._glRendererOperation$)); - subs.push(this._configuration$.pipe( + subs.push(pairConfiguration$.pipe( map((configuration: SliderConfiguration): number[] => configuration.viewCorrection ?? null), distinctUntilChanged((a: number[], b: number[]): boolean => JSON.stringify(a) === JSON.stringify(b)), map((correction: number[]): GLRendererOperation => (glRenderer): SliderGLRenderer => { @@ -374,7 +398,7 @@ export class SliderComponent extends Component { return glRenderer; })) .subscribe(this._glRendererOperation$)); - subs.push(this._configuration$.pipe( + subs.push(pairConfiguration$.pipe( map((configuration: SliderConfiguration): number => configuration.viewScale ?? 1), distinctUntilChanged(), map((scale: number): GLRendererOperation => (glRenderer): SliderGLRenderer => { @@ -383,8 +407,23 @@ export class SliderComponent extends Component { })) .subscribe(this._glRendererOperation$)); subs.push(decision$.pipe( - map((decision): GLRendererOperation => (glRenderer): SliderGLRenderer => { + // The first frame of a newly activated slider renders as soon as + // this update lands, before the curtain stream sees that frame. + // Apply its curtain here too, or it renders with the default one. + withLatestFrom( + position$, + this._container.renderService.renderCamera$.pipe(startWith(null)), + this._navigator.stateService.currentTransform$.pipe(startWith(null))), + map(([decision, position, render, transform]): GLRendererOperation => (glRenderer): SliderGLRenderer => { const c = decision.configuration; + const state = decision.frame.state; + const spherical = !!state.currentImage && isSpherical(state.currentImage.cameraType); + const screenSpace = c.screenSpace === true; + if (screenSpace || !spherical || render != null && transform != null) { + const visible = sliderPairVisible(c, state); + const [curtain, inScreen] = this._curtain(position, spherical, visible, render, transform, screenSpace); + glRenderer.updateCurtain(curtain, inScreen); + } glRenderer.updateCurtainFeather(c.curtainFeather); if (decision.automatic) { glRenderer.update(decision.frame, decision.mode, @@ -402,23 +441,30 @@ export class SliderComponent extends Component { return configuration.ids != null; }), switchMap( - (configuration: SliderConfiguration): Observable => { + (configuration: SliderConfiguration): Observable => { return observableZip( this._catchCacheImage$(configuration.ids.background), this._catchCacheImage$(configuration.ids.foreground)).pipe( // Read the live source view after hydration, so wheel // zoom while the new background loads is not undone. + // The latest frame's state is the live context: a + // cached pair then switches before the next frame + // renders, together with any view change made in + // the same task (no frame of old pair at new view). switchMap((images: [Image, Image]) => - this._navigator.stateService.currentState$.pipe( + this._navigator.stateService.lastState$.pipe( first(), - map((frame: AnimationFrame): SliderCombination & { viewAligned: boolean } => ({ + map((frame: AnimationFrame): SliderPairLoad => ({ images: { background: images[0], foreground: images[1] }, state: frame.state, viewAligned: configuration.viewAligned === true, + interactive: configuration.viewAligned === true || + configuration.mode === SliderConfigurationMode.Stationary || + images.some((i: Image) => isSpherical(i.cameraType)), }))))); })) .subscribe( - (co: SliderCombination & { viewAligned: boolean }): void => { + (co: SliderPairLoad): void => { if (co.state.currentImage != null && co.state.previousImage != null && co.state.currentImage.id === co.images.foreground.id && @@ -427,6 +473,15 @@ export class SliderComponent extends Component { } const center = co.viewAligned && co.state.currentImage.id === co.images.foreground.id ? co.state.currentTransform.projectBasic(co.state.camera.lookat.toArray()) : null; + // Leave traversal before changing the trajectory: a + // traversing state animates its first frame from the + // comparison's viewpoint (a flash when the slider opens). + // Same rule as the wait subscription; a no-op when waiting. + if (co.interactive) { + this._navigator.stateService.waitInteractively(); + } else { + this._navigator.stateService.wait(); + } if (co.state.currentImage.id === co.images.background.id) { this._navigator.stateService.setImages([co.images.foreground]); } else if (co.state.currentImage.id === co.images.foreground.id && @@ -868,6 +923,26 @@ export class SliderComponent extends Component { sliderVisible: true, }; } + private _curtain( + position: number, + spherical: boolean, + visible: boolean, + render: RenderCamera, + transform: Transform, + screenSpace: boolean): [number, boolean] { + if (screenSpace) { + return [visible ? position : 1, true]; + } + if (!spherical) { + return [visible ? position : 1, false]; + } + const basicMin: number[] = this._viewportCoords.viewportToBasic(-1.15, 0, transform, render.perspective); + const basicMax: number[] = this._viewportCoords.viewportToBasic(1.15, 0, transform, render.perspective); + const shiftedMax: number = basicMax[0] < basicMin[0] ? basicMax[0] + 1 : basicMax[0]; + const basicPosition: number = basicMin[0] + position * (shiftedMax - basicMin[0]); + return [basicPosition > 1 ? basicPosition - 1 : basicPosition, false]; + } + private _catchCacheImage$(imageId: string): Observable { return this._navigator.graphService.cacheImage$(imageId).pipe( catchError( @@ -922,3 +997,25 @@ export class SliderComponent extends Component { bbox.maxY = Math.max(0, Math.min(1, bbox.maxY)); } } + +interface SliderPairState { + configuration: SliderConfiguration; + frame: AnimationFrame; +} + +/** Whether the state shows the pair the configuration selects (or none is selected). */ +export function sliderShowsPair(configuration: SliderConfiguration, state: IAnimationState): boolean { + const ids = configuration.ids; + return ids == null || (!!state.currentImage && !!state.previousImage && + state.currentImage.id === ids.foreground && state.previousImage.id === ids.background); +} + +type SliderPairLoad = SliderCombination & { viewAligned: boolean; interactive: boolean }; + +/** Whether the curtain shows for this pair (both images, renderable combination). */ +function sliderPairVisible(configuration: SliderConfiguration, state: IAnimationState): boolean { + return configuration.sliderVisible && !(state.currentImage == null || + state.previousImage == null || + (configuration.viewAligned !== true && isSpherical(state.currentImage.cameraType) && + !isSpherical(state.previousImage.cameraType))); +} diff --git a/src/component/slider/SliderGLRenderer.ts b/src/component/slider/SliderGLRenderer.ts index b49ca5705..e5dbe4a19 100644 --- a/src/component/slider/SliderGLRenderer.ts +++ b/src/component/slider/SliderGLRenderer.ts @@ -205,6 +205,10 @@ export class SliderGLRenderer { public render( perspectiveCamera: THREE.PerspectiveCamera, renderer: THREE.WebGLRenderer): void { + // The comparison owns the background: never composite it over another + // background renderer still active in the same frame (e.g. the plain + // image while it hands over), which shows a mix of both views. + renderer.clear(); if (!this.disabled) { renderer.render( this._scene.sceneOld, diff --git a/src/state/StateService.ts b/src/state/StateService.ts index 005947557..1d5b6f571 100644 --- a/src/state/StateService.ts +++ b/src/state/StateService.ts @@ -322,6 +322,14 @@ export class StateService { return this._currentState$; } + /** + * The latest animation frame, replayed to new subscribers. Its state is + * the live context, so reading it never waits for the next frame. + */ + public get lastState$(): Observable { + return this._lastState$; + } + public get currentImage$(): Observable { return this._currentImage$; } diff --git a/src/tile/TextureProvider.ts b/src/tile/TextureProvider.ts index 3b7896212..b6739146e 100644 --- a/src/tile/TextureProvider.ts +++ b/src/tile/TextureProvider.ts @@ -244,7 +244,8 @@ export class TextureProvider { * @param {TileRegionOfInterest} roi - Spatial edges to cache. */ public setRegionOfInterest(roi: TileRegionOfInterest): void { - if (!verifySize(this._size)) { return; } + // A disposed provider has no renderer; late requests must not fetch. + if (this._disposed || !verifySize(this._size)) { return; } const virtualWidth = 1 / roi.pixelWidth; const virtualHeight = 1 / roi.pixelHeight; @@ -303,6 +304,7 @@ export class TextureProvider { const subscription = tile$.subscribe( (image: HTMLImageElement): void => { + if (this._disposed) { return; } const pixels = tileToPixelCoords2D( tile, this._size, @@ -358,7 +360,7 @@ export class TextureProvider { const subscription = urls$.subscribe( (): void => { - if (level !== this._level.z) { return; } + if (this._disposed || level !== this._level.z) { return; } for (const tile of tiles) { const ent: ImageTileEnt = { x: tile.x, diff --git a/test/component/slider/SliderAutomaticMotion.test.ts b/test/component/slider/SliderAutomaticMotion.test.ts index 9f605a786..e2992459e 100644 --- a/test/component/slider/SliderAutomaticMotion.test.ts +++ b/test/component/slider/SliderAutomaticMotion.test.ts @@ -117,7 +117,7 @@ describe("Slider automatic motion safety", () => { s.dispose(); }); } - describe("relaxed policy", () => { + describe("relaxed policy (prototype)", () => { function relaxed(): any { const f = fixture(); f.configuration.automaticMotionPolicy = "relaxed"; @@ -436,7 +436,7 @@ describe("Slider automatic rendering", () => { for (const position of [0, 0.25, 0.5, 0.75, 1]) { r.updateCurtain(position, true); const render = jasmine.createSpy("render"); - r.render(f.camera, { render }); + r.render(f.camera, { render, clear: jasmine.createSpy("clear") }); const cameras = render.calls.allArgs().map(args => args[1]); expect(cameras.length).toBe(2); cameras.forEach(camera => { @@ -761,11 +761,25 @@ describe("Slider automatic shared decision and interactive state", () => { expect(f.navigator.stateService.wait).not.toHaveBeenCalled(); f.component.deactivate(); }); - it("never enables a stale pair and mode button cannot override an unsafe rejection", () => { + it("keeps the shown pair's checked decision while a new pair loads, then checks the new pair", () => { const f = componentFixture(); jest.advanceTimersByTime(100); + expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Motion); + const covered = spyOn(SliderAutomaticMotion.prototype as any, "_covered").and.callThrough(); + // Selecting a pair that is still loading must not re-render the shown pair differently. f.component.configure({ ids: { background: "replacement", foreground: "current" } }); + f.emitFrame(); + expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Motion); + expect(covered).not.toHaveBeenCalled(); + // The new pair is never treated as checked: it is evaluated once shown. + const replacement = image("replacement"); + f.state.previousImage = replacement; + f.state.previousTransform = transform(replacement, 0); + f.state.trajectory = [replacement, f.state.currentImage]; + f.emitFrame(); + expect(covered).toHaveBeenCalled(); + f.state.currentImage.mergeId = "different-component"; + f.emitFrame(); expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Stationary); - expect(f.dom.calls.mostRecent().args[2]).toBe(true); (f.component as any)._domRenderer._notifyModeChanged$.next(Mode.Motion); expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Stationary); f.component.deactivate(); diff --git a/test/component/slider/SliderExclusiveBackground.test.ts b/test/component/slider/SliderExclusiveBackground.test.ts new file mode 100644 index 000000000..60e708ab4 --- /dev/null +++ b/test/component/slider/SliderExclusiveBackground.test.ts @@ -0,0 +1,15 @@ +import * as THREE from "three"; +import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; + +describe("SliderGLRenderer background ownership", () => { + it("clears earlier background renders before drawing the comparison", () => { + const calls: string[] = []; + const renderer = { + clear: () => calls.push("clear"), + render: () => calls.push("render"), + }; + new SliderGLRenderer().render(new THREE.PerspectiveCamera(), renderer); + expect(calls[0]).toBe("clear"); + expect(calls.slice(1).every(call => call === "render")).toBe(true); + }); +}); diff --git a/test/component/slider/SliderPairConfiguration.test.ts b/test/component/slider/SliderPairConfiguration.test.ts new file mode 100644 index 000000000..f2dc0ee0c --- /dev/null +++ b/test/component/slider/SliderPairConfiguration.test.ts @@ -0,0 +1,79 @@ +import { Subject } from "rxjs"; +import { SliderComponent, sliderShowsPair } from "../../../src/component/slider/SliderComponent"; +import { SliderDOMRenderer } from "../../../src/component/slider/SliderDOMRenderer"; +import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; +import { ContainerMockCreator } from "../../helper/ContainerMockCreator"; +import { NavigatorMockCreator } from "../../helper/NavigatorMockCreator"; +import { MockCreator } from "../../helper/MockCreator"; + +describe("SliderComponent pair configuration", () => { + const image = (id: string, cameraType: string) => { id, cameraType }; + function frame(foreground: any, background: any): any { + return { state: { currentImage: foreground, previousImage: background, motionless: true } }; + } + function fixture(): any { + const container = new ContainerMockCreator().create(); + const navigator = new NavigatorMockCreator().create(); + new MockCreator().mockProperty(container, "domContainer", document.createElement("div")); + (navigator.graphService.cacheImage$).and.returnValue(new Subject()); + const render = spyOn(SliderDOMRenderer.prototype, "render").and.returnValue({}); + const update = spyOn(SliderGLRenderer.prototype, "update"); + const component = new SliderComponent("slider", container, navigator); + const original = image("original", "spherical"); + const photo = image("photo", "perspective"); + component.configure({ initialPosition: 0.5, viewAligned: true, ids: { foreground: "original", background: "photo" } }); + component.activate(); + (>container.renderService.size$).next({ width: 1440, height: 900 }); + const state$ = >navigator.stateService.currentState$; + state$.next(frame(original, photo)); + return { component, render, update, state$, original, photo }; + } + + it("keeps the shown pair's options while the selected pair loads", () => { + const f = fixture(); + expect(f.render.calls.mostRecent().args[4]).toBe(true); + // A 360° comparison: not view aligned, which would hide the curtain + // for the shown 360°-to-photo pair if applied before it loads. + f.component.configure({ viewAligned: false, ids: { foreground: "original", background: "pano" } }); + f.state$.next(frame(f.original, f.photo)); + expect(f.render.calls.mostRecent().args[4]).toBe(true); + expect(f.update.calls.mostRecent().args[3]).toBe(true); + f.state$.next(frame(f.original, image("pano", "spherical"))); + expect(f.render.calls.mostRecent().args[4]).toBe(true); + expect(f.update.calls.mostRecent().args[3]).toBe(false); + }); + + it("applies options for the shown pair immediately", () => { + const f = fixture(); + f.component.configure({ sliderVisible: false }); + expect(f.render.calls.mostRecent().args[4]).toBe(false); + }); + + it("applies the configured curtain before the first frame of a new activation renders", () => { + const container = new ContainerMockCreator().create(); + const navigator = new NavigatorMockCreator().create(); + new MockCreator().mockProperty(container, "domContainer", document.createElement("div")); + (navigator.graphService.cacheImage$).and.returnValue(new Subject()); + spyOn(SliderDOMRenderer.prototype, "render").and.returnValue({}); + const order: any[] = []; + spyOn(SliderGLRenderer.prototype, "update").and.callFake(() => order.push("update")); + spyOn(SliderGLRenderer.prototype, "updateCurtain").and.callFake((c: number, s: boolean) => order.push([c, s])); + const component = new SliderComponent("slider", container, navigator); + component.configure({ initialPosition: 0.3, screenSpace: true, viewAligned: true, + ids: { foreground: "original", background: "photo" } }); + component.activate(); + (>navigator.stateService.currentState$).next( + frame(image("original", "spherical"), image("photo", "spherical"))); + const first = order.indexOf("update"); + expect(first).toBeGreaterThan(0); + expect(order.slice(0, first)).toContainEqual([0.3, true]); + component.deactivate(); + }); + it("matches only the configured foreground and background", () => { + const ids = { foreground: "a", background: "b" }; + expect(sliderShowsPair({}, {})).toBe(true); + expect(sliderShowsPair({ ids }, { currentImage: { id: "a" }, previousImage: { id: "b" } })).toBe(true); + expect(sliderShowsPair({ ids }, { currentImage: { id: "b" }, previousImage: { id: "a" } })).toBe(false); + expect(sliderShowsPair({ ids }, { currentImage: { id: "a" }, previousImage: null })).toBe(false); + }); +}); diff --git a/test/component/slider/SliderPreload.test.ts b/test/component/slider/SliderPreload.test.ts new file mode 100644 index 000000000..3309c9b7b --- /dev/null +++ b/test/component/slider/SliderPreload.test.ts @@ -0,0 +1,54 @@ +import { of as observableOf, Subject, throwError } from "rxjs"; +import { SliderComponent } from "../../../src/component/slider/SliderComponent"; +import { ContainerMockCreator } from "../../helper/ContainerMockCreator"; +import { NavigatorMockCreator } from "../../helper/NavigatorMockCreator"; + +describe("SliderComponent.preload", () => { + function fixture(): any { + const container = new ContainerMockCreator().create(); + const navigator = new NavigatorMockCreator().create(); + const component = new SliderComponent("slider", container, navigator); + return { component, navigator }; + } + + it("caches both images without activating or configuring the slider", async () => { + const f = fixture(); + const cache = (f.navigator.graphService.cacheImage$) + .and.callFake((id: string) => observableOf({ id })); + const configure = spyOn(f.component, "configure").and.callThrough(); + await f.component.preload({ background: "bg", foreground: "fg" }); + expect(cache.calls.allArgs()).toEqual([["bg"], ["fg"]]); + expect(configure).not.toHaveBeenCalled(); + expect(f.component.activated).toBe(false); + }); + + it("resolves only after both images are cached", async () => { + const f = fixture(); + const background = new Subject(); + const foreground = new Subject(); + (f.navigator.graphService.cacheImage$) + .and.callFake((id: string) => id === "bg" ? background : foreground); + let resolved = false; + const done = f.component.preload({ background: "bg", foreground: "fg" }).then(() => resolved = true); + foreground.next({ id: "fg" }); + await Promise.resolve(); + expect(resolved).toBe(false); + background.next({ id: "bg" }); + await done; + expect(resolved).toBe(true); + }); + + it("rejects when an image fails to cache", async () => { + const f = fixture(); + (f.navigator.graphService.cacheImage$) + .and.callFake((id: string) => id === "bg" ? throwError(new Error("missing")) : observableOf({ id })); + await expect(f.component.preload({ background: "bg", foreground: "fg" })).rejects.toThrow("missing"); + }); + + it("rejects an incomplete pair without caching", async () => { + const f = fixture(); + const cache = (f.navigator.graphService.cacheImage$); + await expect(f.component.preload({ background: "bg" })).rejects.toThrow(); + expect(cache).not.toHaveBeenCalled(); + }); +}); diff --git a/test/component/slider/SliderSelectionFraming.test.ts b/test/component/slider/SliderSelectionFraming.test.ts index 1a3d612f8..91b8ef7a2 100644 --- a/test/component/slider/SliderSelectionFraming.test.ts +++ b/test/component/slider/SliderSelectionFraming.test.ts @@ -52,17 +52,24 @@ describe("Slider comparison selection framing", () => { expect(f.navigator.stateService.setZoom).not.toHaveBeenCalled(); f.component.deactivate(); }); - it("does not drop synchronously cached pairs before a new state frame arrives", () => { + it("switches a synchronously cached pair before the next state frame renders", () => { const f = fixture(); const cache = f.component._catchCacheImage$; cache.and.callFake(id => of(id === "new" ? { id: "new" } : f.original)); f.component.configure({ ids: { foreground: "original", background: "new" }, viewAligned: true }); - expect(f.navigator.stateService.setImages).not.toHaveBeenCalled(); - f.frame(); expect(f.navigator.stateService.setImages).toHaveBeenCalledTimes(2); expect(f.navigator.stateService.setCenter).toHaveBeenCalledWith([0.51, 0.49]); f.component.deactivate(); }); + it("leaves traversal before the trajectory changes so the first frame is not animated", () => { + const f = fixture(true, true); + const order: string[] = []; + f.navigator.stateService.waitInteractively.and.callFake(() => order.push("waitInteractively")); + f.navigator.stateService.prependImages.and.callFake(() => order.push("prepend")); + f.background$.next({ id: "new" }); f.foreground$.next(f.original); + expect(order).toEqual(["waitInteractively", "prepend"]); + f.component.deactivate(); + }); it("does not change legacy non-view-aligned pair handling", () => { const f = fixture(false); f.background$.next({ id: "new" }); f.foreground$.next(f.original); f.frame(); diff --git a/test/component/slider/SliderViewAligned.test.ts b/test/component/slider/SliderViewAligned.test.ts index 27bba01a6..9f7d7e54f 100644 --- a/test/component/slider/SliderViewAligned.test.ts +++ b/test/component/slider/SliderViewAligned.test.ts @@ -171,7 +171,7 @@ describe("Slider opt-in common-world view", () => { r.update(frame(), SliderConfigurationMode.Stationary, false, true); const live = liveCamera(); const render = jasmine.createSpy("render"); - r.render(live, { render }); + r.render(live, { render, clear: jasmine.createSpy("clear") }); const calls = render.calls.allArgs(); expect(calls[0][0]).toBe(r._scene.sceneOld); expectSameView(calls[0][1], live); diff --git a/test/helper/StateServiceMockCreator.ts b/test/helper/StateServiceMockCreator.ts index 1e88d27ff..7df3ee70b 100644 --- a/test/helper/StateServiceMockCreator.ts +++ b/test/helper/StateServiceMockCreator.ts @@ -1,4 +1,4 @@ -import { Subject } from "rxjs"; +import { ReplaySubject, Subject } from "rxjs"; import { MockCreator } from "./MockCreator"; import { MockCreatorBase } from "./MockCreatorBase"; @@ -18,7 +18,11 @@ export class StateServiceMockCreator extends MockCreatorBase { this._mockProperty(mock, "currentId$", new Subject()); this._mockProperty(mock, "currentImage$", new Subject()); this._mockProperty(mock, "currentImageExternal$", new Subject()); - this._mockProperty(mock, "currentState$", new Subject()); + const currentState$ = new Subject(); + const lastState$ = new ReplaySubject(1); + currentState$.subscribe(lastState$); + this._mockProperty(mock, "currentState$", currentState$); + this._mockProperty(mock, "lastState$", lastState$); this._mockProperty(mock, "currentTransform$", new Subject()); this._mockProperty(mock, "inMotion$", new Subject()); this._mockProperty(mock, "inTranslation$", new Subject()); diff --git a/test/tile/TextureProviderDisposed.test.ts b/test/tile/TextureProviderDisposed.test.ts new file mode 100644 index 000000000..8f7166fe6 --- /dev/null +++ b/test/tile/TextureProviderDisposed.test.ts @@ -0,0 +1,52 @@ +import * as THREE from "three"; +import { Subject } from "rxjs"; + +import { TextureProvider } from "../../src/tile/TextureProvider"; +import { TileLoader } from "../../src/tile/TileLoader"; +import { TileStore } from "../../src/tile/TileStore"; +import { TILE_SIZE } from "../../src/tile/interfaces/TileTypes"; +import { ImageTileEnt } from "../../src/api/ents/ImageTileEnt"; +import { MockCreator } from "../helper/MockCreator"; +import { RendererMock } from "../helper/WebGLRenderer"; + +describe("TextureProvider after dispose", () => { + function fixture(): any { + spyOn(console, "warn").and.stub(); + const mockCreator = new MockCreator(); + const loader = mockCreator.create(TileLoader, "TileLoader"); + const store = mockCreator.create(TileStore, "TileStore"); + const renderer = new RendererMock(); + spyOn(renderer, "getContext").and.returnValue( + { getParameter: () => 2048 }); + const width = TILE_SIZE + 1; + const height = TILE_SIZE; + const provider = new TextureProvider("image-id", width, height, new Image(), loader, store, renderer); + const roi = { + bbox: { maxX: 0.75, maxY: 0.75, minX: 0.25, minY: 0.25 }, + pixelHeight: 1 / height, + pixelWidth: 1 / width, + }; + return { loader, store, provider, roi }; + } + + it("ignores region of interest updates", () => { + const f = fixture(); + f.provider.dispose(); + f.provider.setRegionOfInterest(f.roi); + expect(f.loader.getURLs$).not.toHaveBeenCalled(); + }); + + it("never renders a tile that arrives after dispose", () => { + const f = fixture(); + const urls = new Subject(); + const image = new Subject(); + (f.loader.getURLs$).and.returnValue(urls); + (f.loader.getImage$).and.returnValue([image, (): void => { /* noop */ }]); + (f.store.getURL).and.returnValue("url"); + f.provider.setRegionOfInterest(f.roi); + urls.next([{ url: "url", x: 0, y: 0, z: 11 }]); + f.provider.dispose(); + expect(() => image.next(new Image())).not.toThrow(); + expect(f.store.add).not.toHaveBeenCalled(); + }); +});