diff --git a/extensions/dicom-video/jest.config.js b/extensions/dicom-video/jest.config.js new file mode 100644 index 00000000000..0411b1f8307 --- /dev/null +++ b/extensions/dicom-video/jest.config.js @@ -0,0 +1,14 @@ +const base = require('../../jest.config.base.js'); + +module.exports = { + ...base, + moduleNameMapper: { + ...base.moduleNameMapper, + '@ohif/(.*)': '/../../platform/$1/src', + }, + // rootDir: "../.." + // testMatch: [ + // //`/platform/${pack.name}/**/*.spec.js` + // "/platform/app/**/*.test.js" + // ] +}; diff --git a/extensions/dicom-video/src/index.tsx b/extensions/dicom-video/src/index.tsx index 202a9eb44ed..308782fabb0 100644 --- a/extensions/dicom-video/src/index.tsx +++ b/extensions/dicom-video/src/index.tsx @@ -1,6 +1,19 @@ +import React from 'react'; import getSopClassHandlerModule from './getSopClassHandlerModule'; import { id } from './id'; +const Component = React.lazy(() => { + return import(/* webpackPrefetch: true */ './viewports/OHIFDicomVideoViewport'); +}); + +const OHIFDicomVideoViewport = props => { + return ( + Loading...}> + + + ); +}; + /** * */ @@ -10,6 +23,13 @@ const dicomVideoExtension = { */ id, + /** + * A plain HTML5 video viewport, for modes that do not use the Cornerstone + * viewport (which plays video through its own VIDEO viewport type). + */ + getViewportModule() { + return [{ name: 'dicom-video', component: OHIFDicomVideoViewport }]; + }, getSopClassHandlerModule, }; diff --git a/extensions/dicom-video/src/viewports/OHIFDicomVideoViewport.test.ts b/extensions/dicom-video/src/viewports/OHIFDicomVideoViewport.test.ts new file mode 100644 index 00000000000..d436ef95609 --- /dev/null +++ b/extensions/dicom-video/src/viewports/OHIFDicomVideoViewport.test.ts @@ -0,0 +1,104 @@ +import React from 'react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import OHIFDicomVideoViewport from './OHIFDicomVideoViewport'; + +const mockViewportRef = { register: jest.fn(), unregister: jest.fn() }; + +jest.mock('@ohif/core', () => ({ + useViewportElementRegistration: () => mockViewportRef, +})); + +const renderViewport = displaySet => + render( + React.createElement(OHIFDicomVideoViewport, { + displaySets: [displaySet], + viewportId: 'viewport-1', + }) + ); + +const getVideo = container => container.querySelector('video'); + +describe('OHIFDicomVideoViewport', () => { + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + it('plays the URL returned by getVideoUrl and revokes it on unmount', async () => { + const revoke = jest.fn(); + const getVideoUrl = jest.fn().mockResolvedValue({ url: 'blob:video-1', revoke }); + + const { container, unmount } = renderViewport({ + videoUrl: 'https://wado.example/video', + getVideoUrl, + }); + + await waitFor(() => expect(getVideo(container)).not.toBeNull()); + const video = getVideo(container); + expect(video.getAttribute('src')).toBe('blob:video-1'); + expect(video.hasAttribute('controls')).toBe(true); + expect(getVideoUrl).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) }); + expect(mockViewportRef.register).toHaveBeenCalled(); + + unmount(); + expect(revoke).toHaveBeenCalledTimes(1); + expect(mockViewportRef.unregister).toHaveBeenCalled(); + }); + + it('falls back to videoUrl when the data source has no rendered URL', async () => { + const { container } = renderViewport({ videoUrl: 'https://wado.example/video' }); + + await waitFor(() => expect(getVideo(container)).not.toBeNull()); + expect(getVideo(container).getAttribute('src')).toBe('https://wado.example/video'); + }); + + it('shows a message when the video cannot be fetched', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const getVideoUrl = jest.fn().mockRejectedValue(new Error('network')); + + const { container } = renderViewport({ getVideoUrl }); + + expect(await screen.findByText('Video could not be loaded')).toBeTruthy(); + expect(getVideo(container)).toBeNull(); + warn.mockRestore(); + }); + + it('shows a message when the data source returns no URL', async () => { + renderViewport({ getVideoUrl: jest.fn().mockResolvedValue({ url: null }) }); + + expect(await screen.findByText('Video could not be loaded')).toBeTruthy(); + }); + + it('revokes a URL that resolves after unmount', async () => { + const revoke = jest.fn(); + let resolveUrl; + const getVideoUrl = jest.fn( + () => + new Promise(resolve => { + resolveUrl = resolve; + }) + ); + + const { unmount } = renderViewport({ getVideoUrl }); + const [{ signal }] = getVideoUrl.mock.calls[0]; + unmount(); + expect(signal.aborted).toBe(true); + + await act(async () => { + resolveUrl({ url: 'blob:late', revoke }); + }); + expect(revoke).toHaveBeenCalledTimes(1); + }); + + it('rejects more than one display set', () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => + render( + React.createElement(OHIFDicomVideoViewport, { + displaySets: [{ videoUrl: 'a' }, { videoUrl: 'b' }], + }) + ) + ).toThrow('only one display set'); + error.mockRestore(); + }); +}); diff --git a/extensions/dicom-video/src/viewports/OHIFDicomVideoViewport.tsx b/extensions/dicom-video/src/viewports/OHIFDicomVideoViewport.tsx new file mode 100644 index 00000000000..4ede1553faa --- /dev/null +++ b/extensions/dicom-video/src/viewports/OHIFDicomVideoViewport.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState } from 'react'; +import { useViewportElementRegistration } from '@ohif/core'; + +type OHIFDicomVideoViewportProps = { + displaySets: AppTypes.DisplaySet[]; + viewportId?: string; +}; + +type LoadedVideo = { + // The display set the result was loaded for; a result for any other display + // set is stale and is ignored until the new load finishes. + displaySet: AppTypes.DisplaySet; + url: string | null; + failed: boolean; +}; + +/** + * getVideoUrl fetches with the data source's auth headers and returns an + * object URL; without it the direct WADO URL is used as-is. + */ +async function fetchVideoUrl( + videoUrl, + getVideoUrl, + signal: AbortSignal +): Promise<{ url?: string; revoke?: () => void } | undefined> { + if (getVideoUrl) { + return getVideoUrl({ signal }); + } + return { url: await videoUrl }; +} + +/** + * Plays a DICOM video display set in a native HTML5