diff --git a/e2e/src/navigating/changeTemplate.e2e-spec.js b/e2e/src/navigating/changeTemplate.e2e-spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a1585c415462e383540698f8dd349f032fff3e9d --- /dev/null +++ b/e2e/src/navigating/changeTemplate.e2e-spec.js @@ -0,0 +1,33 @@ +const { AtlasPage } = require('../util') + +describe('trans template navigation', () => { + let iavPage + + beforeEach(async () => { + iavPage = new AtlasPage() + await iavPage.init() + + }) + + it('when user moves from template a -> template b, a xhr call is made', async () => { + + const searchParam = new URLSearchParams() + searchParam.set('templateSelected', 'MNI 152 ICBM 2009c Nonlinear Asymmetric') + searchParam.set('parcellationSelected', 'JuBrain Cytoarchitectonic Atlas') + await iavPage.goto(`/?${searchParam.toString()}`, { interceptHttp: true, doNotAutomate: true }) + await iavPage.wait(200) + await iavPage.dismissModal() + await iavPage.waitUntilAllChunksLoaded() + + await iavPage.selectDropdownTemplate('Big Brain (Histology)') + await iavPage.wait(2000) + const interceptedCalls = await iavPage.getInterceptedHttpCalls() + + const found = interceptedCalls.find(({ method, url }) => { + return method === 'POST' && /transform-points/.test(url) + }) + expect(!!found).toBe(true) + }) + + // TODO test that nav/zoom/orientation are actually preserved +}) diff --git a/e2e/src/util.js b/e2e/src/util.js index 7baa48efaf3a26b3a1e1028e9b36ee99480b4b95..454b8c706917d6a7c683ff5e6a631e6ea83643b6 100644 --- a/e2e/src/util.js +++ b/e2e/src/util.js @@ -96,6 +96,8 @@ class WdBase{ return window['__interceptedXhr__'] }) } + + // it seems if you set intercept http to be true, you might also want ot set do not automat to be true async goto(url = '/', { interceptHttp, doNotAutomate } = {}){ const actualUrl = getActualUrl(url) if (interceptHttp) { diff --git a/package.json b/package.json index 69cd4628c3087ae7e0a45c66171ff9fa6f8f43f5..6332a0cce1b6e06ed950246bc04893cd1b9d8b3c 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "@angular/core": "^9.0.0", "@angular/elements": "^9.0.0", "@angular/forms": "^9.0.0", - "@angular/http": "^7.2.15", "@angular/language-service": "^9.0.0", "@angular/material": "^9.0.0", "@angular/platform-browser": "^9.0.0", diff --git a/src/services/effect/effect.spec.ts b/src/services/effect/effect.spec.ts index bf8dab818ed67a8852ee2f055dffd2afdb3c208c..c8bb115fca5e4d06deba3e657583e2443f6a52e7 100644 --- a/src/services/effect/effect.spec.ts +++ b/src/services/effect/effect.spec.ts @@ -55,14 +55,10 @@ describe('effect.ts', () => { provideMockStore({ initialState: defaultRootState }) ] }) - - const useEffectsInstance: UseEffects = TestBed.get(UseEffects) - useEffectsInstance.onParcellationSelected$.subscribe(console.log) - }) it('both SELECT_PARCELLATION and NEWVIEWER actions should trigger onParcellationSelected$', () => { - const useEffectsInstance: UseEffects = TestBed.get(UseEffects) + const useEffectsInstance: UseEffects = TestBed.inject(UseEffects) actions$ = hot( 'ab', { diff --git a/src/services/effect/effect.ts b/src/services/effect/effect.ts index 313663300fc99e13f3ae79486c49dc9da17b114a..16f5a3b1e9aebd4a41030d888c5aa829083a730f 100644 --- a/src/services/effect/effect.ts +++ b/src/services/effect/effect.ts @@ -4,7 +4,7 @@ import { select, Store } from "@ngrx/store"; import { merge, Observable, Subscription } from "rxjs"; import { filter, map, shareReplay, switchMap, take, withLatestFrom, mapTo } from "rxjs/operators"; import { LoggingService } from "../logging.service"; -import { ADD_TO_REGIONS_SELECTION_WITH_IDS, DESELECT_REGIONS, NEWVIEWER, SELECT_PARCELLATION, SELECT_REGIONS, SELECT_REGIONS_WITH_ID } from "../state/viewerState.store"; +import { ADD_TO_REGIONS_SELECTION_WITH_IDS, DESELECT_REGIONS, NEWVIEWER, CHANGE_NAVIGATION, SELECT_PARCELLATION, SELECT_REGIONS, SELECT_REGIONS_WITH_ID, SELECT_LANDMARKS } from "../state/viewerState.store"; import { generateLabelIndexId, getNgIdLabelIndexFromId, IavRootStoreInterface, recursiveFindRegionWithLabelIndexId, getMultiNgIdsRegionsLabelIndexMap, GENERAL_ACTION_TYPES } from '../stateStore.service'; @Injectable({ @@ -189,6 +189,29 @@ export class UseEffects implements OnDestroy { selectRegions: [], }) ) + + /** + * side effects of loading a new template space + * reset navigation (navigation translation is done by a different service) + * Landmarks will no longer be accurate (differente template space) + */ + @Effect() + public onNewViewerResetNavigation$ = this.actions$.pipe( + ofType(NEWVIEWER), + mapTo({ + type: CHANGE_NAVIGATION, + navigation: {} + }) + ) + + @Effect() + public onNewViewerResetLandmarkSelected$ = this.actions$.pipe( + ofType(NEWVIEWER), + mapTo({ + type: SELECT_LANDMARKS, + landmarks: [] + }) + ) } export const getGetRegionFromLabelIndexId = ({ parcellation }) => { diff --git a/src/services/state/viewerState.store.ts b/src/services/state/viewerState.store.ts index 4310e2185d1af89421ee4711e0cc3ba847a79fbe..6c0ac15d27a0017143f0b5220e94f3e2f47850e0 100644 --- a/src/services/state/viewerState.store.ts +++ b/src/services/state/viewerState.store.ts @@ -94,7 +94,9 @@ export const getStateStore = ({ state = defaultState } = {}) => (prevState: Part parcellationSelected : parcellation, // taken care of by effect.ts // regionsSelected : [], - landmarksSelected : [], + + // taken care of by effect.ts + // landmarksSelected : [], // navigation : {}, dedicatedView : null, } diff --git a/src/services/templateCoordinatesTransformation.service.spec.ts b/src/services/templateCoordinatesTransformation.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..08906f068f5d49f38d65d599e6bfe8a099545751 --- /dev/null +++ b/src/services/templateCoordinatesTransformation.service.spec.ts @@ -0,0 +1,112 @@ +import { TemplateCoordinatesTransformation } from './templateCoordinatesTransformation.service' +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing' +import { TestBed, fakeAsync, tick } from '@angular/core/testing' + +describe('templateCoordinatesTransformation.service.spec.ts', () => { + describe('TemplateCoordinatesTransformation', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule + ], + providers: [ + TemplateCoordinatesTransformation + ] + }) + }) + + afterEach(() => { + const ctrl = TestBed.inject(HttpTestingController) + ctrl.verify() + }) + + describe('getPointCoordinatesForTemplate', () => { + it('should instantiate service properly', () => { + const service = TestBed.inject(TemplateCoordinatesTransformation) + expect(service).toBeTruthy() + expect(service.getPointCoordinatesForTemplate).toBeTruthy() + }) + + it('should transform argument properly', () => { + const service = TestBed.inject(TemplateCoordinatesTransformation) + const httpTestingController = TestBed.inject(HttpTestingController) + + // subscriptions are necessary for http fetch to occur + service.getPointCoordinatesForTemplate( + 'from', + 'target', + [1,2,3] + ).subscribe((_ev) => { + + }) + const req = httpTestingController.expectOne(service.url) + expect(req.request.method).toEqual('POST') + expect( + JSON.parse(req.request.body) + ).toEqual({ + 'source_space': 'from', + 'target_space': 'target', + 'source_points': [ + [1e-6, 2e-6, 3e-6] + ] + }) + req.flush({}) + }) + + it('should transform response properly', () => { + + const service = TestBed.inject(TemplateCoordinatesTransformation) + const httpTestingController = TestBed.inject(HttpTestingController) + + service.getPointCoordinatesForTemplate( + 'from', + 'target', + [1,2,3] + ).subscribe(({ status, result }) => { + expect(status).toEqual('completed') + expect(result).toEqual([1e6, 2e6, 3e6]) + }) + const req = httpTestingController.expectOne(service.url) + req.flush({ + 'target_points':[ + [1, 2, 3] + ] + }) + }) + + it('if server returns >=400, fallback gracefully', () => { + const service = TestBed.inject(TemplateCoordinatesTransformation) + const httpTestingController = TestBed.inject(HttpTestingController) + + service.getPointCoordinatesForTemplate( + 'from', + 'target', + [1,2,3] + ).subscribe(({ status }) => { + expect(status).toEqual('error') + }) + const req = httpTestingController.expectOne(service.url) + + req.flush('intercepted', { status: 500, statusText: 'internal server error' }) + }) + + it('if server does not respond after 3s, fallback gracefully', fakeAsync(() => { + + const service = TestBed.inject(TemplateCoordinatesTransformation) + const httpTestingController = TestBed.inject(HttpTestingController) + + service.getPointCoordinatesForTemplate( + 'from', + 'target', + [1,2,3] + ).subscribe(({ status, statusText }) => { + expect(status).toEqual('error') + expect(statusText).toEqual(`Timeout after 3s`) + }) + const req = httpTestingController.expectOne(service.url) + tick(4000) + expect(req.cancelled).toBe(true) + })) + }) + }) +}) diff --git a/src/services/templateCoordinatesTransformation.service.ts b/src/services/templateCoordinatesTransformation.service.ts index 5eb89e6eafe31608fbe96b6a20745805bc31fc9b..1329fe9422cf3d46e3fcf32d83018c9d74068701 100644 --- a/src/services/templateCoordinatesTransformation.service.ts +++ b/src/services/templateCoordinatesTransformation.service.ts @@ -1,5 +1,13 @@ import {Injectable} from "@angular/core"; -import {HttpClient, HttpHeaders} from "@angular/common/http"; +import {HttpClient, HttpHeaders, HttpErrorResponse} from "@angular/common/http"; +import { catchError, timeout, map } from "rxjs/operators"; +import { of, Observable } from "rxjs"; + +export interface ITemplateCoordXformResp{ + status: 'pending' | 'error' | 'completed' + statusText?: string + result? : [number, number, number] +} @Injectable({ providedIn: 'root', @@ -8,39 +16,41 @@ export class TemplateCoordinatesTransformation { constructor(private httpClient: HttpClient) {} - getPointCoordinatesForTemplate(sourceTemplateName, targetTemplateName, coordinates) { - const url = 'https://hbp-spatial-backend.apps-dev.hbp.eu/v1/transform-points' + public url = 'https://hbp-spatial-backend.apps-dev.hbp.eu/v1/transform-points' + + // jasmine marble cannot test promise properly + // see https://github.com/ngrx/platform/issues/498#issuecomment-337465179 + // in order to properly test with marble, use obs instead of promise + getPointCoordinatesForTemplate(sourceTemplateName: string, targetTemplateName: string, coordinatesInNm: [number, number, number]): Observable<ITemplateCoordXformResp> { const httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json' }) } - - const convertedPoints = new Promise((resolve, reject) => { - let timeOut = true - setTimeout(() => { - if (timeOut) reject('Timed out') - },3000) - - this.httpClient.post( - url, - JSON.stringify({ - 'source_points': [[...coordinates.map(c => c/1000000)]], - 'source_space': sourceTemplateName, - 'target_space': targetTemplateName - }), - httpOptions - ).toPromise().then( - res => { - timeOut = false - resolve(res['target_points'][0].map(r=> r*1000000)) - }, - msg => { - timeOut = false - reject(msg) + return this.httpClient.post( + this.url, + JSON.stringify({ + 'source_points': [[...coordinatesInNm.map(c => c/1e6)]], + 'source_space': sourceTemplateName, + 'target_space': targetTemplateName + }), + httpOptions + ).pipe( + map(resp => { + return { + status: 'completed', + result: resp['target_points'][0].map((r: number)=> r * 1e6) + } as ITemplateCoordXformResp + }), + catchError(err => { + if (err instanceof HttpErrorResponse) { + return of(({ status: 'error', statusText: err.message } as ITemplateCoordXformResp)) + } else { + return of(({ status: 'error', statusText: err.toString() } as ITemplateCoordXformResp)) } - ) - }) - return convertedPoints + }), + timeout(3000), + catchError(() => of(({ status: 'error', statusText: `Timeout after 3s` } as ITemplateCoordXformResp))), + ) } } \ No newline at end of file diff --git a/src/ui/viewerStateController/viewerState.useEffect.spec.ts b/src/ui/viewerStateController/viewerState.useEffect.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8fc55ccd958cf7bcc5414662b822cf971022af17 --- /dev/null +++ b/src/ui/viewerStateController/viewerState.useEffect.spec.ts @@ -0,0 +1,162 @@ +import { ViewerStateControllerUseEffect } from './viewerState.useEffect' +import { Observable, of } from 'rxjs' +import { TestBed } from '@angular/core/testing' +import { provideMockActions } from '@ngrx/effects/testing' +import { provideMockStore } from '@ngrx/store/testing' +import { defaultRootState, NEWVIEWER } from 'src/services/stateStore.service' +import { Injectable } from '@angular/core' +import { TemplateCoordinatesTransformation, ITemplateCoordXformResp } from 'src/services/templateCoordinatesTransformation.service' +import { hot } from 'jasmine-marbles' +import { VIEWERSTATE_CONTROLLER_ACTION_TYPES } from './viewerState.base' +import { AngularMaterialModule } from '../sharedModules/angularMaterial.module' +import { HttpClientModule } from '@angular/common/http' + +const bigbrainJson = require('!json-loader!src/res/ext/bigbrain.json') +const colinJson = require('!json-loader!src/res/ext/colin.json') +const colinJsonNehubaConfig = require('!json-loader!src/res/ext/colinNehubaConfig.json') +const reconstitutedColin = JSON.parse(JSON.stringify( + { + ...colinJson, + nehubaConfig: colinJsonNehubaConfig + } +)) +let returnPosition = null +@Injectable() +class MockCoordXformService{ + getPointCoordinatesForTemplate(src:string, tgt: string, pos: [number, number, number]): Observable<ITemplateCoordXformResp>{ + return returnPosition + ? of({ status: 'completed', result: returnPosition } as ITemplateCoordXformResp) + : of({ status: 'error', statusText: 'Failing query' } as ITemplateCoordXformResp) + } +} + +const initialState = JSON.parse(JSON.stringify( defaultRootState )) +initialState.viewerState.fetchedTemplates = [ + bigbrainJson, + reconstitutedColin +] +initialState.viewerState.templateSelected = initialState.viewerState.fetchedTemplates[0] +const currentNavigation = { + position: [4, 5, 6], + orientation: [0, 0, 0, 1], + perspectiveOrientation: [ 0, 0, 0, 1], + perspectiveZoom: 2e5, + zoom: 1e5 +} +initialState.viewerState.navigation = currentNavigation + +describe('viewerState.useEffect.ts', () => { + describe('ViewerStateControllerUseEffect', () => { + let actions$: Observable<any> + let spy: any + beforeEach(() => { + + const mock = new MockCoordXformService() + spy = spyOn(mock, 'getPointCoordinatesForTemplate').and.callThrough() + returnPosition = null + actions$ = hot( + 'a', + { + a: { + type: VIEWERSTATE_CONTROLLER_ACTION_TYPES.SELECT_TEMPLATE_WITH_NAME, + payload: reconstitutedColin + } + } + ) + + TestBed.configureTestingModule({ + imports: [ + AngularMaterialModule, + HttpClientModule + ], + providers: [ + ViewerStateControllerUseEffect, + provideMockActions(() => actions$), + provideMockStore({ initialState }), + { + provide: TemplateCoordinatesTransformation, + useValue: mock + } + ] + }) + }) + + describe('selectTemplateWithName$', () => { + + it('if coordXform returns error', () => { + + const viewerStateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) + expect( + viewerStateCtrlEffect.selectTemplateWithName$ + ).toBeObservable( + hot( + 'a', + { + a: { + type: NEWVIEWER, + selectTemplate: reconstitutedColin, + selectParcellation: reconstitutedColin.parcellations[0] + } + } + ) + ) + }) + + it('calls with correct param', () => { + + // necessary for observable to fire + const viewerStateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) + expect( + viewerStateCtrlEffect.selectTemplateWithName$ + ).toBeObservable( + hot( + 'a', + { + a: { + type: NEWVIEWER, + selectTemplate: reconstitutedColin, + selectParcellation: reconstitutedColin.parcellations[0] + } + } + ) + ) + expect(spy).toHaveBeenCalledWith( + bigbrainJson.name, + reconstitutedColin.name, + initialState.viewerState.navigation.position + ) + }) + + it('if coordXform returns complete', () => { + returnPosition = [ 1.11e6, 2.22e6, 3.33e6 ] + + const viewerStateCtrlEffect = TestBed.inject(ViewerStateControllerUseEffect) + const updatedColin = JSON.parse( JSON.stringify( reconstitutedColin ) ) + const updatedColinNavigation = updatedColin.nehubaConfig.dataset.initialNgState.navigation + + const { zoom, orientation, perspectiveOrientation, position, perspectiveZoom } = currentNavigation + + for (const idx of [0, 1, 2]) { + updatedColinNavigation.pose.position.voxelCoordinates[idx] = returnPosition[idx] / updatedColinNavigation.pose.position.voxelSize[idx] + } + updatedColinNavigation.zoomFactor = zoom + updatedColinNavigation.pose.orientation = orientation + + expect( + viewerStateCtrlEffect.selectTemplateWithName$ + ).toBeObservable( + hot( + 'a', + { + a: { + type: NEWVIEWER, + selectTemplate: updatedColin, + selectParcellation: updatedColin.parcellations[0] + } + } + ) + ) + }) + }) + }) +}) \ No newline at end of file diff --git a/src/ui/viewerStateController/viewerState.useEffect.ts b/src/ui/viewerStateController/viewerState.useEffect.ts index fddfc289321c62ce9c41bea08f83b91a5288f7e6..f849f6f24f62219970e5fe62c901e4cc8d287af9 100644 --- a/src/ui/viewerStateController/viewerState.useEffect.ts +++ b/src/ui/viewerStateController/viewerState.useEffect.ts @@ -1,8 +1,8 @@ import { Injectable, OnDestroy, OnInit } from "@angular/core"; import { Actions, Effect, ofType } from "@ngrx/effects"; import { Action, select, Store } from "@ngrx/store"; -import { Observable, Subscription } from "rxjs"; -import {distinctUntilChanged, filter, map, mergeMap, shareReplay, withLatestFrom} from "rxjs/operators"; +import { Observable, Subscription, of } from "rxjs"; +import {distinctUntilChanged, filter, map, mergeMap, shareReplay, withLatestFrom, switchMap} from "rxjs/operators"; import { AtlasViewerConstantsServices } from "src/atlasViewer/atlasViewer.constantService.service"; import { CHANGE_NAVIGATION, FETCHED_TEMPLATE, GENERAL_ACTION_TYPES, IavRootStoreInterface, isDefined, NEWVIEWER, SELECT_PARCELLATION, SELECT_REGIONS } from "src/services/stateStore.service"; import { UIService } from "src/services/uiService.service"; @@ -117,45 +117,36 @@ export class ViewerStateControllerUseEffect implements OnInit, OnDestroy { return name }), filter(name => !!name), - withLatestFrom(viewerState$.pipe( - select('templateSelected'), - )), - filter(([name, templateSelected]) => { - if (templateSelected && templateSelected.name === name) { return false } - return true - }), withLatestFrom( - viewerState$.pipe( - select('fetchedTemplates'), - ), - viewerState$.pipe( - select('navigation'), - ), + viewerState$ ), - mergeMap(([[name, templateSelected], availableTemplates, navigation]) => - this.coordinatesTransformation.getPointCoordinatesForTemplate(templateSelected.name, name, navigation.position) - .then(res => { - navigation.position = res - return { - name: name, - templateSelected: templateSelected, - availableTemplates: availableTemplates, - coordinates: res, - navigation: navigation + switchMap(([newTemplateName, { templateSelected, fetchedTemplates, navigation }]) => { + const position = (navigation && navigation.position) || [0, 0, 0] + if (newTemplateName === templateSelected.name) return of(null) + return this.coordinatesTransformation.getPointCoordinatesForTemplate(templateSelected.name, newTemplateName, position).pipe( + map(({ status, statusText, result }) => { + if (status === 'error') { + return { + newTemplateName, + templateSelected: templateSelected, + fetchedTemplates, + translatedCoordinate: null, + navigation + } } - }) - .catch(() => { return { - name: name, + newTemplateName, templateSelected: templateSelected, - availableTemplates: availableTemplates, - coordinates: null, - navigation: null + fetchedTemplates, + translatedCoordinate: result, + navigation } }) - ), - map(({name, templateSelected, availableTemplates, coordinates, navigation}) => { - const newTemplateTobeSelected = availableTemplates.find(t => t.name === name) + ) + }), + filter(v => !!v), + map(({ newTemplateName, templateSelected, fetchedTemplates, translatedCoordinate, navigation }) => { + const newTemplateTobeSelected = fetchedTemplates.find(t => t.name === newTemplateName) if (!newTemplateTobeSelected) { return { type: GENERAL_ACTION_TYPES.ERROR, @@ -165,19 +156,23 @@ export class ViewerStateControllerUseEffect implements OnInit, OnDestroy { } } - if (!coordinates && !navigation) + if (!translatedCoordinate) { return { type: NEWVIEWER, selectTemplate: newTemplateTobeSelected, selectParcellation: newTemplateTobeSelected.parcellations[0], } - + } const deepCopiedState = JSON.parse(JSON.stringify(newTemplateTobeSelected)) const initNavigation = deepCopiedState.nehubaConfig.dataset.initialNgState.navigation - initNavigation.zoomFactor = navigation.zoom - initNavigation.pose.position.voxelCoordinates = coordinates.map((c, i) => c/initNavigation.pose.position.voxelSize[i]) - initNavigation.pose.orientation = navigation.orientation + const { zoom = null, orientation = null } = navigation || {} + if (zoom) initNavigation.zoomFactor = zoom + if (orientation) initNavigation.pose.orientation = orientation + + for (const idx of [0, 1, 2]) { + initNavigation.pose.position.voxelCoordinates[idx] = translatedCoordinate[idx] / initNavigation.pose.position.voxelSize[idx] + } return { type: NEWVIEWER,