diff --git a/extensions/cornerstone/src/utils/findNearbyToolData.test.ts b/extensions/cornerstone/src/utils/findNearbyToolData.test.ts new file mode 100644 index 000000000..fc25f737c --- /dev/null +++ b/extensions/cornerstone/src/utils/findNearbyToolData.test.ts @@ -0,0 +1,125 @@ +import { findNearbyToolData } from './findNearbyToolData'; + +describe('findNearbyToolData', () => { + const mockCommandsManager = { + runCommand: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return undefined when event is null', () => { + const result = findNearbyToolData(mockCommandsManager, null); + + expect(result).toBeUndefined(); + expect(mockCommandsManager.runCommand).not.toHaveBeenCalled(); + }); + + it('should return undefined when event is undefined', () => { + const result = findNearbyToolData(mockCommandsManager, undefined); + + expect(result).toBeUndefined(); + expect(mockCommandsManager.runCommand).not.toHaveBeenCalled(); + }); + + it('should return undefined when event has no detail', () => { + const event = {}; + const result = findNearbyToolData(mockCommandsManager, event); + + expect(result).toBeUndefined(); + expect(mockCommandsManager.runCommand).not.toHaveBeenCalled(); + }); + + it('should return undefined when event detail is null', () => { + const event = { detail: null }; + const result = findNearbyToolData(mockCommandsManager, event); + + expect(result).toBeUndefined(); + expect(mockCommandsManager.runCommand).not.toHaveBeenCalled(); + }); + + it('should call runCommand with correct parameters when event has valid detail', () => { + const mockElement = document.createElement('div'); + const mockCurrentPoints = { canvas: { x: 100, y: 200 } }; + const mockToolData = { id: 'annotation-1' }; + const event = { + detail: { + element: mockElement, + currentPoints: mockCurrentPoints, + }, + }; + + mockCommandsManager.runCommand.mockReturnValue(mockToolData); + + const result = findNearbyToolData(mockCommandsManager, event); + + expect(mockCommandsManager.runCommand).toHaveBeenCalledWith( + 'getNearbyAnnotation', + { + element: mockElement, + canvasCoordinates: mockCurrentPoints.canvas, + }, + 'CORNERSTONE' + ); + expect(result).toBe(mockToolData); + }); + + it('should handle event with element but no currentPoints', () => { + const mockElement = document.createElement('div'); + const event = { + detail: { + element: mockElement, + }, + }; + + findNearbyToolData(mockCommandsManager, event); + + expect(mockCommandsManager.runCommand).toHaveBeenCalledWith( + 'getNearbyAnnotation', + { + element: mockElement, + canvasCoordinates: undefined, + }, + 'CORNERSTONE' + ); + }); + + it('should handle event with currentPoints but no canvas coordinates', () => { + const mockElement = document.createElement('div'); + const mockCurrentPoints = {}; + const event = { + detail: { + element: mockElement, + currentPoints: mockCurrentPoints, + }, + }; + + findNearbyToolData(mockCommandsManager, event); + + expect(mockCommandsManager.runCommand).toHaveBeenCalledWith( + 'getNearbyAnnotation', + { + element: mockElement, + canvasCoordinates: undefined, + }, + 'CORNERSTONE' + ); + }); + + it('should return result from runCommand', () => { + const mockToolData = { id: 'test-annotation', data: 'test-data' }; + const event = { + detail: { + element: document.createElement('div'), + currentPoints: { canvas: { x: 50, y: 75 } }, + }, + }; + + mockCommandsManager.runCommand.mockReturnValue(mockToolData); + + const result = findNearbyToolData(mockCommandsManager, event); + + expect(result).toBe(mockToolData); + }); +}); diff --git a/extensions/cornerstone/src/utils/generateSegmentationCSVReport.test.ts b/extensions/cornerstone/src/utils/generateSegmentationCSVReport.test.ts new file mode 100644 index 000000000..c27404777 --- /dev/null +++ b/extensions/cornerstone/src/utils/generateSegmentationCSVReport.test.ts @@ -0,0 +1,319 @@ +import { generateSegmentationCSVReport } from './generateSegmentationCSVReport'; + +// Mock DOM APIs +Object.defineProperty(global, 'Blob', { + writable: true, + value: jest.fn().mockImplementation((content, options) => ({ + content, + options, + })), +}); + +Object.defineProperty(global, 'URL', { + writable: true, + value: { + createObjectURL: jest.fn().mockReturnValue('mock-url'), + }, +}); + +const mockDocument = { + addEventListener: jest.fn(), + createElement: jest.fn().mockReturnValue({ + setAttribute: jest.fn(), + click: jest.fn(), + style: {}, + }), + body: { + appendChild: jest.fn(), + removeChild: jest.fn(), + }, +}; + +Object.defineProperty(global, 'document', { + writable: true, + value: mockDocument, +}); + +describe('generateSegmentationCSVReport', () => { + const mockInfo = { + reference: { + SeriesNumber: '1', + SeriesInstanceUID: 'series-uid-123', + StudyInstanceUID: 'study-uid-456', + SeriesDate: '20231201', + SeriesTime: '120000', + SeriesDescription: 'Test Series', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should generate CSV with basic segmentation data', () => { + const segmentationData = { + segmentationId: 'seg-123', + label: 'Test Segmentation', + }; + + generateSegmentationCSVReport(segmentationData, mockInfo); + + expect(global.Blob).toHaveBeenCalledWith([expect.stringContaining('Segmentation ID,seg-123')], { + type: 'text/csv;charset=utf-8;', + }); + }); + + it('should handle segmentation data without id and label', () => { + const segmentationData = {}; + + generateSegmentationCSVReport(segmentationData, mockInfo); + + expect(global.Blob).toHaveBeenCalledWith( + [expect.stringContaining('Segmentation ID,\nSegmentation Label,')], + { type: 'text/csv;charset=utf-8;' } + ); + }); + + it('should include reference information when provided', () => { + const segmentationData = { + segmentationId: 'seg-123', + label: 'Test Segmentation', + }; + + generateSegmentationCSVReport(segmentationData, mockInfo); + + expect(global.Blob).toHaveBeenCalledWith( + [expect.stringContaining('reference Series Number,1')], + { type: 'text/csv;charset=utf-8;' } + ); + }); + + it('should skip empty reference values', () => { + const segmentationData = { + segmentationId: 'seg-123', + label: 'Test Segmentation', + }; + const infoWithEmptyValues = { + reference: { + SeriesNumber: '1', + SeriesInstanceUID: '', + StudyInstanceUID: null, + SeriesDate: undefined, + SeriesTime: '120000', + SeriesDescription: 'Test Series', + }, + }; + + generateSegmentationCSVReport(segmentationData, infoWithEmptyValues); + + const csvContent = (global.Blob as jest.Mock).mock.calls[0][0][0]; + expect(csvContent).toContain('reference Series Number,1'); + expect(csvContent).toContain('reference Series Time,120000'); + expect(csvContent).not.toContain('reference Series Instance UID'); + expect(csvContent).not.toContain('reference Study Instance UID'); + expect(csvContent).not.toContain('reference Series Date'); + }); + + it('should handle segments with basic properties', () => { + const segmentationData = { + segmentationId: 'seg-123', + label: 'Test Segmentation', + segments: { + '1': { + segmentIndex: 1, + label: 'Segment 1', + locked: true, + active: false, + }, + '2': { + segmentIndex: 2, + label: 'Segment 2', + locked: false, + active: true, + }, + }, + }; + + generateSegmentationCSVReport(segmentationData, mockInfo); + + const csvContent = (global.Blob as jest.Mock).mock.calls[0][0][0]; + expect(csvContent).toContain('Label,Segment 1,Segment 2'); + expect(csvContent).toContain('Segment Index,1,2'); + expect(csvContent).toContain('Locked,Yes,No'); + expect(csvContent).toContain('Active,No,Yes'); + }); + + it('should handle segments with statistics', () => { + const segmentationData = { + segmentationId: 'seg-123', + label: 'Test Segmentation', + segments: { + '1': { + segmentIndex: 1, + label: 'Segment 1', + locked: false, + active: true, + cachedStats: { + namedStats: { + mean: { + name: 'mean', + label: 'Mean', + value: 100.5, + unit: 'HU', + }, + volume: { + name: 'volume', + label: 'Volume', + value: 250.75, + unit: 'mm³', + }, + }, + }, + }, + }, + }; + + generateSegmentationCSVReport(segmentationData, mockInfo); + + const csvContent = (global.Blob as jest.Mock).mock.calls[0][0][0]; + expect(csvContent).toContain('Mean (HU),100.5'); + expect(csvContent).toContain('Volume (mm³),250.75'); + }); + + it('should handle segments with statistics without units', () => { + const segmentationData = { + segmentationId: 'seg-123', + segments: { + '1': { + segmentIndex: 1, + label: 'Segment 1', + cachedStats: { + namedStats: { + count: { + name: 'count', + label: 'Count', + value: 42, + }, + }, + }, + }, + }, + }; + + generateSegmentationCSVReport(segmentationData, mockInfo); + + const csvContent = (global.Blob as jest.Mock).mock.calls[0][0][0]; + expect(csvContent).toContain('Count,42'); + }); + + it('should handle segments without cachedStats', () => { + const segmentationData = { + segmentationId: 'seg-123', + segments: { + '1': { + segmentIndex: 1, + label: 'Segment 1', + locked: false, + active: true, + }, + }, + }; + + generateSegmentationCSVReport(segmentationData, mockInfo); + + expect(global.Blob).toHaveBeenCalled(); + }); + + it('should handle segments with empty namedStats', () => { + const segmentationData = { + segmentationId: 'seg-123', + segments: { + '1': { + segmentIndex: 1, + label: 'Segment 1', + cachedStats: { + namedStats: {}, + }, + }, + }, + }; + + generateSegmentationCSVReport(segmentationData, mockInfo); + + expect(global.Blob).toHaveBeenCalled(); + }); + + it('should escape CSV special characters', () => { + const segmentationData = { + segmentationId: 'seg,with,commas', + label: 'Test "quoted" label', + segments: { + '1': { + segmentIndex: 1, + label: 'Segment\nwith\nnewlines', + cachedStats: { + namedStats: { + test: { + name: 'test', + label: 'Test,Value', + value: 'value"with"quotes', + }, + }, + }, + }, + }, + }; + + generateSegmentationCSVReport(segmentationData, mockInfo); + + const csvContent = (global.Blob as jest.Mock).mock.calls[0][0][0]; + expect(csvContent).toContain('"seg,with,commas"'); + expect(csvContent).toContain('"Test ""quoted"" label"'); + expect(csvContent).toContain('"Segment\nwith\nnewlines"'); + }); + + it('should create download link with correct attributes', () => { + const mockLink = { + setAttribute: jest.fn(), + click: jest.fn(), + style: {}, + }; + mockDocument.createElement.mockReturnValue(mockLink); + + const segmentationData = { + segmentationId: 'seg-123', + label: 'Test Segmentation', + }; + + generateSegmentationCSVReport(segmentationData, mockInfo); + + expect(mockLink.setAttribute).toHaveBeenCalledWith('href', 'mock-url'); + expect(mockLink.setAttribute).toHaveBeenCalledWith( + 'download', + expect.stringMatching(/Test Segmentation_Report_\d{4}-\d{2}-\d{2}\.csv/) + ); + expect(mockLink.click).toHaveBeenCalled(); + expect(mockDocument.body.appendChild).toHaveBeenCalledWith(mockLink); + expect(mockDocument.body.removeChild).toHaveBeenCalledWith(mockLink); + }); + + it('should use default filename when label is missing', () => { + const mockLink = { + setAttribute: jest.fn(), + click: jest.fn(), + style: {}, + }; + mockDocument.createElement.mockReturnValue(mockLink); + + const segmentationData = { + segmentationId: 'seg-123', + }; + + generateSegmentationCSVReport(segmentationData, mockInfo); + + expect(mockLink.setAttribute).toHaveBeenCalledWith( + 'download', + expect.stringMatching(/Segmentation_Report_\d{4}-\d{2}-\d{2}\.csv/) + ); + }); +}); diff --git a/extensions/cornerstone/src/utils/getActiveViewportEnabledElement.test.ts b/extensions/cornerstone/src/utils/getActiveViewportEnabledElement.test.ts new file mode 100644 index 000000000..fd3da66bc --- /dev/null +++ b/extensions/cornerstone/src/utils/getActiveViewportEnabledElement.test.ts @@ -0,0 +1,70 @@ +import { getViewportEnabledElement } from './getViewportEnabledElement'; +import getActiveViewportEnabledElement from './getActiveViewportEnabledElement'; + +jest.mock('./getViewportEnabledElement', () => ({ + getViewportEnabledElement: jest.fn(), +})); + +describe('getActiveViewportEnabledElement', () => { + const mockViewportGridService = { + getState: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return enabled element for active viewport', () => { + const mockEnabledElement = { viewport: 'test-viewport' }; + mockViewportGridService.getState.mockReturnValue({ activeViewportId: 'viewport-1' }); + (getViewportEnabledElement as jest.Mock).mockReturnValue(mockEnabledElement); + + const result = getActiveViewportEnabledElement(mockViewportGridService); + + expect(mockViewportGridService.getState).toHaveBeenCalledTimes(1); + expect(getViewportEnabledElement).toHaveBeenCalledWith('viewport-1'); + expect(result).toBe(mockEnabledElement); + }); + + it('should handle null activeViewportId', () => { + const mockEnabledElement = null; + mockViewportGridService.getState.mockReturnValue({ activeViewportId: null }); + (getViewportEnabledElement as jest.Mock).mockReturnValue(mockEnabledElement); + + const result = getActiveViewportEnabledElement(mockViewportGridService); + + expect(getViewportEnabledElement).toHaveBeenCalledWith(null); + expect(result).toBe(null); + }); + + it('should handle undefined activeViewportId', () => { + const mockEnabledElement = undefined; + mockViewportGridService.getState.mockReturnValue({ activeViewportId: undefined }); + (getViewportEnabledElement as jest.Mock).mockReturnValue(mockEnabledElement); + + const result = getActiveViewportEnabledElement(mockViewportGridService); + + expect(getViewportEnabledElement).toHaveBeenCalledWith(undefined); + expect(result).toBe(undefined); + }); + + it('should handle empty state object', () => { + const mockEnabledElement = undefined; + mockViewportGridService.getState.mockReturnValue({}); + (getViewportEnabledElement as jest.Mock).mockReturnValue(mockEnabledElement); + + const result = getActiveViewportEnabledElement(mockViewportGridService); + + expect(getViewportEnabledElement).toHaveBeenCalledWith(undefined); + expect(result).toBe(undefined); + }); + + it('should handle getViewportEnabledElement returning null', () => { + mockViewportGridService.getState.mockReturnValue({ activeViewportId: 'viewport-1' }); + (getViewportEnabledElement as jest.Mock).mockReturnValue(null); + + const result = getActiveViewportEnabledElement(mockViewportGridService); + + expect(result).toBe(null); + }); +}); diff --git a/extensions/cornerstone/src/utils/getCenterExtent.test.ts b/extensions/cornerstone/src/utils/getCenterExtent.test.ts new file mode 100644 index 000000000..72c06a6db --- /dev/null +++ b/extensions/cornerstone/src/utils/getCenterExtent.test.ts @@ -0,0 +1,313 @@ +import { getCenterExtent } from './getCenterExtent'; + +describe('getCenterExtent', () => { + it('should return default values when points is undefined', () => { + const measurement = {}; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [0, 0, 0], + extent: { + min: [0, 0, 0], + max: [0, 0, 0], + }, + }); + }); + + it('should return default values when points is null', () => { + const measurement = { points: null }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [0, 0, 0], + extent: { + min: [0, 0, 0], + max: [0, 0, 0], + }, + }); + }); + + it('should return default values when points is not an array', () => { + const measurement = { points: 'invalid' }; + + // @ts-expect-error - purposely passing an invalid type + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [0, 0, 0], + extent: { + min: [0, 0, 0], + max: [0, 0, 0], + }, + }); + }); + + it('should return default values when points array is empty', () => { + const measurement = { points: [] }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [0, 0, 0], + extent: { + min: [0, 0, 0], + max: [0, 0, 0], + }, + }); + }); + + it('should handle single point correctly', () => { + const measurement = { + points: [[5, 10, 15]], + }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [5, 10, 15], + extent: { + min: [5, 10, 15], + max: [5, 10, 15], + }, + }); + }); + + it('should calculate center and extent for two points', () => { + const measurement = { + points: [ + [0, 0, 0], + [10, 20, 30], + ], + }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [5, 10, 15], + extent: { + min: [0, 0, 0], + max: [10, 20, 30], + }, + }); + }); + + it('should calculate center and extent for multiple points', () => { + const measurement = { + points: [ + [0, 0, 0], + [10, 20, 30], + [5, 5, 5], + [-5, 15, 25], + ], + }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [2.5, 10, 15], + extent: { + min: [-5, 0, 0], + max: [10, 20, 30], + }, + }); + }); + + it('should handle negative coordinates', () => { + const measurement = { + points: [ + [-10, -20, -30], + [-5, -15, -25], + ], + }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [-7.5, -17.5, -27.5], + extent: { + min: [-10, -20, -30], + max: [-5, -15, -25], + }, + }); + }); + + it('should handle mixed positive and negative coordinates', () => { + const measurement = { + points: [ + [-10, -5, 0], + [10, 5, 20], + [0, 0, -10], + ], + }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [0, 0, 5], + extent: { + min: [-10, -5, -10], + max: [10, 5, 20], + }, + }); + }); + + it('should handle floating point coordinates', () => { + const measurement = { + points: [ + [1.5, 2.3, 3.1], + [4.2, 8.9, 6.3], + ], + }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [2.85, 5.6, 4.7], + extent: { + min: [1.5, 2.3, 3.1], + max: [4.2, 8.9, 6.3], + }, + }); + }); + + it('should handle points with same coordinates', () => { + const measurement = { + points: [ + [5, 5, 5], + [5, 5, 5], + [5, 5, 5], + ], + }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [5, 5, 5], + extent: { + min: [5, 5, 5], + max: [5, 5, 5], + }, + }); + }); + + it('should handle zero coordinates', () => { + const measurement = { + points: [ + [0, 0, 0], + [0, 0, 0], + ], + }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [0, 0, 0], + extent: { + min: [0, 0, 0], + max: [0, 0, 0], + }, + }); + }); + + it('should handle large coordinate values', () => { + const measurement = { + points: [ + [1000000, 2000000, 3000000], + [1000001, 2000001, 3000001], + ], + }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [1000000.5, 2000000.5, 3000000.5], + extent: { + min: [1000000, 2000000, 3000000], + max: [1000001, 2000001, 3000001], + }, + }); + }); + + it('should handle points in different order', () => { + const measurement = { + points: [ + [10, 20, 30], + [0, 0, 0], + [5, 10, 15], + ], + }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [5, 10, 15], + extent: { + min: [0, 0, 0], + max: [10, 20, 30], + }, + }); + }); + + it('should handle points where min and max are not the first point', () => { + const measurement = { + points: [ + [5, 5, 5], + [0, 0, 0], + [10, 10, 10], + [3, 3, 3], + ], + }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [5, 5, 5], + extent: { + min: [0, 0, 0], + max: [10, 10, 10], + }, + }); + }); + + it('should handle asymmetric extent distribution', () => { + const measurement = { + points: [ + [0, 0, 0], + [100, 10, 1], + [50, 50, 50], + ], + }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [50, 25, 25], + extent: { + min: [0, 0, 0], + max: [100, 50, 50], + }, + }); + }); + + it('should handle points with different ranges per dimension', () => { + const measurement = { + points: [ + [0, 100, 1000], + [1, 101, 1001], + [0.5, 100.5, 1000.5], + ], + }; + + const result = getCenterExtent(measurement); + + expect(result).toEqual({ + center: [0.5, 100.5, 1000.5], + extent: { + min: [0, 100, 1000], + max: [1, 101, 1001], + }, + }); + }); +}); diff --git a/extensions/cornerstone/src/utils/getCornerstoneBlendMode.test.ts b/extensions/cornerstone/src/utils/getCornerstoneBlendMode.test.ts new file mode 100644 index 000000000..dc92713f3 --- /dev/null +++ b/extensions/cornerstone/src/utils/getCornerstoneBlendMode.test.ts @@ -0,0 +1,59 @@ +import { Enums } from '@cornerstonejs/core'; +import getCornerstoneBlendMode from './getCornerstoneBlendMode'; + +jest.mock('@cornerstonejs/core', () => ({ + Enums: { + BlendModes: { + COMPOSITE: 'composite', + MAXIMUM_INTENSITY_BLEND: 'mip', + MINIMUM_INTENSITY_BLEND: 'minip', + AVERAGE_INTENSITY_BLEND: 'avg', + }, + }, +})); + +describe('getCornerstoneBlendMode', () => { + it('should return COMPOSITE when blendMode is null', () => { + const result = getCornerstoneBlendMode(null); + expect(result).toBe(Enums.BlendModes.COMPOSITE); + }); + + it('should return COMPOSITE when blendMode is undefined', () => { + const result = getCornerstoneBlendMode(undefined); + expect(result).toBe(Enums.BlendModes.COMPOSITE); + }); + + it('should return COMPOSITE when blendMode is empty string', () => { + const result = getCornerstoneBlendMode(''); + expect(result).toBe(Enums.BlendModes.COMPOSITE); + }); + + it('should return MAXIMUM_INTENSITY_BLEND when blendMode is mip', () => { + const result = getCornerstoneBlendMode('mip'); + expect(result).toBe(Enums.BlendModes.MAXIMUM_INTENSITY_BLEND); + }); + + it('should return MAXIMUM_INTENSITY_BLEND when blendMode is MIP (uppercase)', () => { + const result = getCornerstoneBlendMode('MIP'); + expect(result).toBe(Enums.BlendModes.MAXIMUM_INTENSITY_BLEND); + }); + + it('should return MINIMUM_INTENSITY_BLEND when blendMode is MINIP (uppercase)', () => { + const result = getCornerstoneBlendMode('MINIP'); + expect(result).toBe(Enums.BlendModes.MINIMUM_INTENSITY_BLEND); + }); + + it('should return AVERAGE_INTENSITY_BLEND when blendMode is avg', () => { + const result = getCornerstoneBlendMode('avg'); + expect(result).toBe(Enums.BlendModes.AVERAGE_INTENSITY_BLEND); + }); + + it('should throw error for unsupported blend mode', () => { + expect(() => getCornerstoneBlendMode('invalid')).toThrow('Unsupported blend mode: invalid'); + }); + + it('should handle mixed case blend mode', () => { + const result = getCornerstoneBlendMode('MiP'); + expect(result).toBe(Enums.BlendModes.MAXIMUM_INTENSITY_BLEND); + }); +}); diff --git a/extensions/cornerstone/src/utils/getCornerstoneOrientation.test.ts b/extensions/cornerstone/src/utils/getCornerstoneOrientation.test.ts new file mode 100644 index 000000000..0e8e18d5b --- /dev/null +++ b/extensions/cornerstone/src/utils/getCornerstoneOrientation.test.ts @@ -0,0 +1,60 @@ +import { Enums } from '@cornerstonejs/core'; +import getCornerstoneOrientation from './getCornerstoneOrientation'; + +jest.mock('@cornerstonejs/core', () => ({ + Enums: { + OrientationAxis: { + AXIAL: 'axial', + SAGITTAL: 'sagittal', + CORONAL: 'coronal', + ACQUISITION: 'acquisition', + }, + }, +})); + +describe('getCornerstoneOrientation', () => { + it('should return AXIAL when orientation is axial', () => { + const result = getCornerstoneOrientation('axial'); + expect(result).toBe(Enums.OrientationAxis.AXIAL); + }); + + it('should return AXIAL when orientation is AXIAL (uppercase)', () => { + const result = getCornerstoneOrientation('AXIAL'); + expect(result).toBe(Enums.OrientationAxis.AXIAL); + }); + + it('should return SAGITTAL when orientation is sagittal', () => { + const result = getCornerstoneOrientation('sagittal'); + expect(result).toBe(Enums.OrientationAxis.SAGITTAL); + }); + + it('should return CORONAL when orientation is coronal', () => { + const result = getCornerstoneOrientation('coronal'); + expect(result).toBe(Enums.OrientationAxis.CORONAL); + }); + + it('should return ACQUISITION for unknown orientation', () => { + const result = getCornerstoneOrientation('unknown'); + expect(result).toBe(Enums.OrientationAxis.ACQUISITION); + }); + + it('should return ACQUISITION when orientation is null', () => { + const result = getCornerstoneOrientation(null); + expect(result).toBe(Enums.OrientationAxis.ACQUISITION); + }); + + it('should return ACQUISITION when orientation is undefined', () => { + const result = getCornerstoneOrientation(undefined); + expect(result).toBe(Enums.OrientationAxis.ACQUISITION); + }); + + it('should return ACQUISITION when orientation is empty string', () => { + const result = getCornerstoneOrientation(''); + expect(result).toBe(Enums.OrientationAxis.ACQUISITION); + }); + + it('should handle mixed case orientation', () => { + const result = getCornerstoneOrientation('CoRoNaL'); + expect(result).toBe(Enums.OrientationAxis.CORONAL); + }); +}); diff --git a/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts b/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts new file mode 100644 index 000000000..7afd41f68 --- /dev/null +++ b/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts @@ -0,0 +1,91 @@ +import type { Types } from '@ohif/core'; +import { Enums } from '@cornerstonejs/core'; +import getCornerstoneViewportType from './getCornerstoneViewportType'; + +jest.mock('@cornerstonejs/core', () => ({ + Enums: { + ViewportType: { + STACK: 'stack', + VIDEO: 'video', + WHOLE_SLIDE: 'wholeslide', + ORTHOGRAPHIC: 'orthographic', + VOLUME_3D: 'volume3d', + }, + }, +})); + +describe('getCornerstoneViewportType', () => { + it('should return STACK when viewportType is stack', () => { + const result = getCornerstoneViewportType('stack'); + expect(result).toBe(Enums.ViewportType.STACK); + }); + + it('should return STACK when viewportType is STACK (uppercase)', () => { + const result = getCornerstoneViewportType('STACK'); + expect(result).toBe(Enums.ViewportType.STACK); + }); + + it('should return VIDEO when viewportType is video', () => { + const result = getCornerstoneViewportType('video'); + expect(result).toBe(Enums.ViewportType.VIDEO); + }); + + it('should return WHOLE_SLIDE when viewportType is wholeslide', () => { + const result = getCornerstoneViewportType('wholeslide'); + expect(result).toBe(Enums.ViewportType.WHOLE_SLIDE); + }); + + it('should return ORTHOGRAPHIC when viewportType is volume', () => { + const result = getCornerstoneViewportType('volume'); + expect(result).toBe(Enums.ViewportType.ORTHOGRAPHIC); + }); + + it('should return ORTHOGRAPHIC when viewportType is orthographic', () => { + const result = getCornerstoneViewportType('orthographic'); + expect(result).toBe(Enums.ViewportType.ORTHOGRAPHIC); + }); + + it('should return VOLUME_3D when viewportType is volume3d', () => { + const result = getCornerstoneViewportType('volume3d'); + expect(result).toBe(Enums.ViewportType.VOLUME_3D); + }); + + it('should throw error for invalid viewport type', () => { + expect(() => getCornerstoneViewportType('invalid')).toThrow( + 'Invalid viewport type: invalid. Valid types are: stack, volume, video, wholeslide' + ); + }); + + it('should use displaySet viewportType when provided', () => { + const displaySets = [{ viewportType: 'stack' }] as Types.DisplaySet[]; + const result = getCornerstoneViewportType('volume', displaySets); + expect(result).toBe(Enums.ViewportType.STACK); + }); + + it('should use displaySet viewportType with case insensitive matching', () => { + const displaySets = [{ viewportType: 'VIDEO' }] as Types.DisplaySet[]; + const result = getCornerstoneViewportType('stack', displaySets); + expect(result).toBe(Enums.ViewportType.VIDEO); + }); + + it('should fallback to viewportType when displaySet has no viewportType', () => { + const displaySets = [{}] as Types.DisplaySet[]; + const result = getCornerstoneViewportType('volume', displaySets); + expect(result).toBe(Enums.ViewportType.ORTHOGRAPHIC); + }); + + it('should handle empty displaySets array', () => { + const result = getCornerstoneViewportType('stack', []); + expect(result).toBe(Enums.ViewportType.STACK); + }); + + it('should handle null displaySets', () => { + const result = getCornerstoneViewportType('video', null); + expect(result).toBe(Enums.ViewportType.VIDEO); + }); + + it('should handle undefined displaySets', () => { + const result = getCornerstoneViewportType('wholeslide', undefined); + expect(result).toBe(Enums.ViewportType.WHOLE_SLIDE); + }); +}); diff --git a/extensions/cornerstone/src/utils/getInterleavedFrames.test.ts b/extensions/cornerstone/src/utils/getInterleavedFrames.test.ts new file mode 100644 index 000000000..62899043a --- /dev/null +++ b/extensions/cornerstone/src/utils/getInterleavedFrames.test.ts @@ -0,0 +1,85 @@ +import getInterleavedFrames from './getInterleavedFrames'; + +describe('getInterleavedFrames', () => { + it('should return single element when input has one element', () => { + const imageIds = ['image-1']; + const result = getInterleavedFrames(imageIds); + + expect(result).toEqual([{ imageId: 'image-1', imageIdIndex: 0 }]); + }); + + it('should return correct order for three elements', () => { + const imageIds = ['image-1', 'image-2', 'image-3']; + const result = getInterleavedFrames(imageIds); + + expect(result).toEqual([ + { imageId: 'image-2', imageIdIndex: 1 }, + { imageId: 'image-1', imageIdIndex: 0 }, + { imageId: 'image-3', imageIdIndex: 2 }, + ]); + }); + + it('should start with middle element', () => { + const imageIds = ['image-1', 'image-2', 'image-3', 'image-4', 'image-5']; + const result = getInterleavedFrames(imageIds); + + expect(result[0]).toEqual({ imageId: 'image-3', imageIdIndex: 2 }); + }); + + it('should interleave elements correctly for even length array', () => { + const imageIds = ['image-1', 'image-2', 'image-3', 'image-4', 'image-5', 'image-6']; + const result = getInterleavedFrames(imageIds); + + expect(result).toEqual([ + { imageId: 'image-4', imageIdIndex: 3 }, + { imageId: 'image-3', imageIdIndex: 2 }, + { imageId: 'image-5', imageIdIndex: 4 }, + { imageId: 'image-2', imageIdIndex: 1 }, + { imageId: 'image-6', imageIdIndex: 5 }, + { imageId: 'image-1', imageIdIndex: 0 }, + ]); + }); + + it('should interleave elements correctly for odd length array', () => { + const imageIds = ['image-1', 'image-2', 'image-3', 'image-4', 'image-5', 'image-6', 'image-7']; + const result = getInterleavedFrames(imageIds); + + expect(result).toEqual([ + { imageId: 'image-4', imageIdIndex: 3 }, + { imageId: 'image-3', imageIdIndex: 2 }, + { imageId: 'image-5', imageIdIndex: 4 }, + { imageId: 'image-2', imageIdIndex: 1 }, + { imageId: 'image-6', imageIdIndex: 5 }, + { imageId: 'image-1', imageIdIndex: 0 }, + { imageId: 'image-7', imageIdIndex: 6 }, + ]); + }); + + it('should handle large array correctly', () => { + const imageIds = Array.from({ length: 10 }, (_, i) => `image-${i + 1}`); + const result = getInterleavedFrames(imageIds); + + expect(result).toHaveLength(10); + expect(result[0]).toEqual({ imageId: 'image-6', imageIdIndex: 5 }); + expect(result[8]).toEqual({ imageId: 'image-10', imageIdIndex: 9 }); + expect(result[9]).toEqual({ imageId: 'image-1', imageIdIndex: 0 }); + }); + + it('should handle empty array', () => { + const imageIds = []; + const result = getInterleavedFrames(imageIds); + + expect(result).toEqual([]); + }); + + it('should handle duplicate imageIds with different indices', () => { + const imageIds = ['duplicate', 'unique', 'duplicate']; + const result = getInterleavedFrames(imageIds); + + expect(result).toEqual([ + { imageId: 'unique', imageIdIndex: 1 }, + { imageId: 'duplicate', imageIdIndex: 0 }, + { imageId: 'duplicate', imageIdIndex: 2 }, + ]); + }); +}); diff --git a/extensions/cornerstone/src/utils/getInterleavedFrames.js b/extensions/cornerstone/src/utils/getInterleavedFrames.ts similarity index 82% rename from extensions/cornerstone/src/utils/getInterleavedFrames.js rename to extensions/cornerstone/src/utils/getInterleavedFrames.ts index b380a9350..f37a6bbe7 100644 --- a/extensions/cornerstone/src/utils/getInterleavedFrames.js +++ b/extensions/cornerstone/src/utils/getInterleavedFrames.ts @@ -1,4 +1,17 @@ -export default function getInterleavedFrames(imageIds) { +interface ImageIdToPrefetch { + imageId: string; + imageIdIndex: number; +} + +export default function getInterleavedFrames(imageIds: string[]): ImageIdToPrefetch[] { + if (imageIds.length === 0) { + return []; + } + + if (imageIds.length === 1) { + return [{ imageId: imageIds[0], imageIdIndex: 0 }]; + } + const minImageIdIndex = 0; const maxImageIdIndex = imageIds.length - 1; @@ -8,7 +21,7 @@ export default function getInterleavedFrames(imageIds) { let upperImageIdIndex = middleImageIdIndex; // Build up an array of images to prefetch, starting with the current image. - const imageIdsToPrefetch = [ + const imageIdsToPrefetch: ImageIdToPrefetch[] = [ { imageId: imageIds[middleImageIdIndex], imageIdIndex: middleImageIdIndex }, ]; diff --git a/extensions/cornerstone/src/utils/getNthFrames.test.ts b/extensions/cornerstone/src/utils/getNthFrames.test.ts new file mode 100644 index 000000000..414ac81f4 --- /dev/null +++ b/extensions/cornerstone/src/utils/getNthFrames.test.ts @@ -0,0 +1,145 @@ +import getNthFrames from './getNthFrames'; + +describe('getNthFrames', () => { + const createMockImageLoadRequest = (imageId: string, imageIdIndex: number) => ({ + imageId, + callLoadImage: jest.fn(), + additionalDetails: 'test-details', + imageIdIndex, + options: { test: 'option' }, + }); + + it('should return empty array when input is empty', () => { + const result = getNthFrames([]); + expect(result).toEqual([]); + }); + + it('should return same array when input has one element', () => { + const imageIds = [createMockImageLoadRequest('image-1', 0)]; + const result = getNthFrames(imageIds); + expect(result).toEqual(imageIds); + }); + + it('should return same array when input has two elements', () => { + const imageIds = [ + createMockImageLoadRequest('image-1', 0), + createMockImageLoadRequest('image-2', 1), + ]; + const result = getNthFrames(imageIds); + expect(result).toEqual(imageIds); + }); + + it('should prioritize first two elements', () => { + const imageIds = Array.from({ length: 10 }, (_, i) => + createMockImageLoadRequest(`image-${i}`, i) + ); + const result = getNthFrames(imageIds); + + expect(result[0]).toBe(imageIds[0]); + expect(result[1]).toBe(imageIds[1]); + }); + + it('should prioritize last three elements', () => { + const imageIds = Array.from({ length: 10 }, (_, i) => + createMockImageLoadRequest(`image-${i}`, i) + ); + const result = getNthFrames(imageIds); + + expect(result).toContain(imageIds[7]); + expect(result).toContain(imageIds[8]); + expect(result).toContain(imageIds[9]); + }); + + it('should include center elements', () => { + const imageIds = Array.from({ length: 20 }, (_, i) => + createMockImageLoadRequest(`image-${i}`, i) + ); + const result = getNthFrames(imageIds); + const centerStart = imageIds.length / 2 - 3; + const centerEnd = centerStart + 6; + + for (let i = Math.ceil(centerStart); i < centerEnd; i++) { + if (i >= 0 && i < imageIds.length) { + expect(result).toContain(imageIds[i]); + } + } + }); + + it('should include nth elements where i % 7 === 2', () => { + const imageIds = Array.from({ length: 30 }, (_, i) => + createMockImageLoadRequest(`image-${i}`, i) + ); + const result = getNthFrames(imageIds); + + for (let i = 0; i < imageIds.length; i++) { + if (i % 7 === 2 && i >= 2 && i <= imageIds.length - 4) { + const centerStart = imageIds.length / 2 - 3; + const centerEnd = centerStart + 6; + if (!(i > centerStart && i < centerEnd)) { + expect(result).toContain(imageIds[i]); + } + } + } + }); + + it('should include nth elements where i % 7 === 5', () => { + const imageIds = Array.from({ length: 30 }, (_, i) => + createMockImageLoadRequest(`image-${i}`, i) + ); + const result = getNthFrames(imageIds); + + for (let i = 0; i < imageIds.length; i++) { + if (i % 7 === 5 && i >= 2 && i <= imageIds.length - 4) { + const centerStart = imageIds.length / 2 - 3; + const centerEnd = centerStart + 6; + if (!(i > centerStart && i < centerEnd)) { + expect(result).toContain(imageIds[i]); + } + } + } + }); + + it('should handle large arrays correctly', () => { + const imageIds = Array.from({ length: 100 }, (_, i) => + createMockImageLoadRequest(`image-${i}`, i) + ); + const result = getNthFrames(imageIds); + + expect(result.length).toBe(100); + expect(result[0]).toBe(imageIds[0]); + expect(result[1]).toBe(imageIds[1]); + expect(result).toContain(imageIds[97]); + expect(result).toContain(imageIds[98]); + expect(result).toContain(imageIds[99]); + }); + + it('should handle arrays where centerStart is negative', () => { + const imageIds = Array.from({ length: 3 }, (_, i) => + createMockImageLoadRequest(`image-${i}`, i) + ); + const result = getNthFrames(imageIds); + + expect(result).toEqual(imageIds); + }); + + it('should handle arrays where centerEnd exceeds length', () => { + const imageIds = Array.from({ length: 4 }, (_, i) => + createMockImageLoadRequest(`image-${i}`, i) + ); + const result = getNthFrames(imageIds); + + expect(result).toEqual(expect.arrayContaining(imageIds)); + expect(result.length).toBe(4); + }); + + it('should preserve object references', () => { + const imageIds = Array.from({ length: 10 }, (_, i) => + createMockImageLoadRequest(`image-${i}`, i) + ); + const result = getNthFrames(imageIds); + + result.forEach(item => { + expect(imageIds).toContain(item); + }); + }); +}); diff --git a/extensions/cornerstone/src/utils/getNthFrames.js b/extensions/cornerstone/src/utils/getNthFrames.ts similarity index 85% rename from extensions/cornerstone/src/utils/getNthFrames.js rename to extensions/cornerstone/src/utils/getNthFrames.ts index 38df66d95..24e498777 100644 --- a/extensions/cornerstone/src/utils/getNthFrames.js +++ b/extensions/cornerstone/src/utils/getNthFrames.ts @@ -9,12 +9,9 @@ * What this does is return the first/center/start objects, as those * are often used first, then a selection of objects scattered over the * instances in order to allow making requests over a set of image instances. - * - * @param {[]} imageIds - * @returns [] reordered to be an nth selection */ -export default function getNthFrames(imageIds) { - const frames = [[], [], [], [], []]; +export default function getNthFrames(imageIds: T[]): T[] { + const frames: T[][] = [[], [], [], [], []]; const centerStart = imageIds.length / 2 - 3; const centerEnd = centerStart + 6; diff --git a/extensions/cornerstone/src/utils/getViewportEnabledElement.test.ts b/extensions/cornerstone/src/utils/getViewportEnabledElement.test.ts new file mode 100644 index 000000000..e1496ffab --- /dev/null +++ b/extensions/cornerstone/src/utils/getViewportEnabledElement.test.ts @@ -0,0 +1,83 @@ +import { getEnabledElement } from '@cornerstonejs/core'; +import { getEnabledElement as OHIFgetEnabledElement } from '../state'; +import { getViewportEnabledElement } from './getViewportEnabledElement'; + +jest.mock('@cornerstonejs/core', () => ({ + getEnabledElement: jest.fn(), +})); + +jest.mock('../state', () => ({ + getEnabledElement: jest.fn(), +})); + +describe('getViewportEnabledElement', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return enabled element when OHIF getEnabledElement returns element', () => { + const mockElement = document.createElement('div'); + const mockEnabledElement = { viewport: 'test-viewport' }; + + (OHIFgetEnabledElement as jest.Mock).mockReturnValue({ element: mockElement }); + (getEnabledElement as jest.Mock).mockReturnValue(mockEnabledElement); + + const result = getViewportEnabledElement('test-viewport-id'); + + expect(OHIFgetEnabledElement).toHaveBeenCalledWith('test-viewport-id'); + expect(getEnabledElement).toHaveBeenCalledWith(mockElement); + expect(result).toBe(mockEnabledElement); + }); + + it('should return enabled element when OHIF getEnabledElement returns null', () => { + const mockEnabledElement = { viewport: 'test-viewport' }; + + (OHIFgetEnabledElement as jest.Mock).mockReturnValue(null); + (getEnabledElement as jest.Mock).mockReturnValue(mockEnabledElement); + + const result = getViewportEnabledElement('test-viewport-id'); + + expect(OHIFgetEnabledElement).toHaveBeenCalledWith('test-viewport-id'); + expect(getEnabledElement).toHaveBeenCalledWith(undefined); + expect(result).toBe(mockEnabledElement); + }); + + it('should return enabled element when OHIF getEnabledElement returns object without element', () => { + const mockEnabledElement = { viewport: 'test-viewport' }; + + (OHIFgetEnabledElement as jest.Mock).mockReturnValue({}); + (getEnabledElement as jest.Mock).mockReturnValue(mockEnabledElement); + + const result = getViewportEnabledElement('test-viewport-id'); + + expect(OHIFgetEnabledElement).toHaveBeenCalledWith('test-viewport-id'); + expect(getEnabledElement).toHaveBeenCalledWith(undefined); + expect(result).toBe(mockEnabledElement); + }); + + it('should return null when cornerstone getEnabledElement returns null', () => { + const mockElement = document.createElement('div'); + + (OHIFgetEnabledElement as jest.Mock).mockReturnValue({ element: mockElement }); + (getEnabledElement as jest.Mock).mockReturnValue(null); + + const result = getViewportEnabledElement('test-viewport-id'); + + expect(OHIFgetEnabledElement).toHaveBeenCalledWith('test-viewport-id'); + expect(getEnabledElement).toHaveBeenCalledWith(mockElement); + expect(result).toBe(null); + }); + + it('should return undefined when cornerstone getEnabledElement returns undefined', () => { + const mockElement = document.createElement('div'); + + (OHIFgetEnabledElement as jest.Mock).mockReturnValue({ element: mockElement }); + (getEnabledElement as jest.Mock).mockReturnValue(undefined); + + const result = getViewportEnabledElement('test-viewport-id'); + + expect(OHIFgetEnabledElement).toHaveBeenCalledWith('test-viewport-id'); + expect(getEnabledElement).toHaveBeenCalledWith(mockElement); + expect(result).toBe(undefined); + }); +}); diff --git a/extensions/cornerstone/src/utils/getViewportOrientationFromImageOrientationPatient.test.ts b/extensions/cornerstone/src/utils/getViewportOrientationFromImageOrientationPatient.test.ts new file mode 100644 index 000000000..153baabcc --- /dev/null +++ b/extensions/cornerstone/src/utils/getViewportOrientationFromImageOrientationPatient.test.ts @@ -0,0 +1,50 @@ +import { getViewportOrientationFromImageOrientationPatient } from './getViewportOrientationFromImageOrientationPatient'; + +describe('getViewportOrientationFromImageOrientationPatient', () => { + it('should return undefined when imageOrientationPatient is null', () => { + const result = getViewportOrientationFromImageOrientationPatient(null); + expect(result).toBeUndefined(); + }); + + it('should return undefined when imageOrientationPatient is undefined', () => { + const result = getViewportOrientationFromImageOrientationPatient(undefined); + expect(result).toBeUndefined(); + }); + + it('should return undefined when imageOrientationPatient has length less than 6', () => { + const result = getViewportOrientationFromImageOrientationPatient([1, 0, 0, 0, 1]); + expect(result).toBeUndefined(); + }); + + it('should return undefined when imageOrientationPatient has length greater than 6', () => { + const result = getViewportOrientationFromImageOrientationPatient([1, 0, 0, 0, 1, 0, 0]); + expect(result).toBeUndefined(); + }); + + it('should return undefined when imageOrientationPatient is empty array', () => { + const result = getViewportOrientationFromImageOrientationPatient([]); + expect(result).toBeUndefined(); + }); + + it('should return "axial" when imageOrientationPatient matches axial orientation', () => { + const result = getViewportOrientationFromImageOrientationPatient([1, 0, 0, 0, 1, 0]); + expect(result).toBe('axial'); + }); + + it('should return "sagittal" when imageOrientationPatient matches sagittal orientation', () => { + const result = getViewportOrientationFromImageOrientationPatient([0, 1, 0, 0, 0, -1]); + expect(result).toBe('sagittal'); + }); + + it('should return "coronal" when imageOrientationPatient matches coronal orientation', () => { + const result = getViewportOrientationFromImageOrientationPatient([1, 0, 0, 0, 0, -1]); + expect(result).toBe('coronal'); + }); + + it('should return undefined when no orientation matches', () => { + const result = getViewportOrientationFromImageOrientationPatient([ + 0.5, 0.5, 0.7, 0.3, 0.8, 0.5, + ]); + expect(result).toBeUndefined(); + }); +}); diff --git a/extensions/cornerstone/src/utils/hydrationUtils.test.ts b/extensions/cornerstone/src/utils/hydrationUtils.test.ts new file mode 100644 index 000000000..e961d8676 --- /dev/null +++ b/extensions/cornerstone/src/utils/hydrationUtils.test.ts @@ -0,0 +1,387 @@ +import { getUpdatedViewportsForSegmentation } from './hydrationUtils'; + +describe('getUpdatedViewportsForSegmentation', () => { + const mockHangingProtocolService = { + getViewportsRequireUpdate: jest.fn(), + }; + + const mockViewportGridService = { + getState: jest.fn(), + }; + + const mockServicesManager = { + services: { + hangingProtocolService: mockHangingProtocolService, + viewportGridService: mockViewportGridService, + }, + }; + + const mockViewport = { + viewportOptions: { + viewportId: 'target-viewport-id', + }, + }; + + const mockViewports = new Map([ + ['viewport-1', mockViewport], + ['active-viewport-id', mockViewport], + ]); + + const defaultParameters = { + viewportId: 'viewport-1', + servicesManager: mockServicesManager as unknown as AppTypes.ServicesManager, + displaySetInstanceUIDs: ['display-set-1'], + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockViewportGridService.getState.mockReturnValue({ + isHangingProtocolLayout: true, + viewports: mockViewports, + activeViewportId: 'active-viewport-id', + }); + mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue([]); + }); + + it('should get updated viewports for segmentation', () => { + const mockUpdatedViewports = [ + { + viewportOptions: { + viewportType: 'stack', + }, + }, + { + viewportOptions: { + viewportType: 'volume', + }, + }, + ]; + + mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue(mockUpdatedViewports); + + const result = getUpdatedViewportsForSegmentation(defaultParameters); + + expect(mockViewportGridService.getState).toHaveBeenCalled(); + expect(mockHangingProtocolService.getViewportsRequireUpdate).toHaveBeenCalledWith( + mockViewport.viewportOptions.viewportId, + defaultParameters.displaySetInstanceUIDs[0], + true + ); + expect(result).toEqual(mockUpdatedViewports); + }); + + it('should filter out volume3d viewports', () => { + const mockUpdatedViewports = [ + { + viewportOptions: { + viewportType: 'stack', + }, + }, + { + viewportOptions: { + viewportType: 'volume3d', + }, + }, + { + viewportOptions: { + viewportType: 'volume', + }, + }, + ]; + + mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue(mockUpdatedViewports); + + const result = getUpdatedViewportsForSegmentation(defaultParameters); + + expect(result).toEqual([ + { + viewportOptions: { + viewportType: 'stack', + }, + }, + { + viewportOptions: { + viewportType: 'volume', + }, + }, + ]); + }); + + it('should handle viewports without viewportOptions', () => { + const mockUpdatedViewports = [ + { + viewportOptions: { + viewportType: 'stack', + }, + }, + { + someOtherProperty: 'value', + }, + { + viewportOptions: null, + }, + ]; + + mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue(mockUpdatedViewports); + + const result = getUpdatedViewportsForSegmentation(defaultParameters); + + expect(result).toEqual([ + { + viewportOptions: { + viewportType: 'stack', + }, + }, + { + someOtherProperty: 'value', + }, + { + viewportOptions: null, + }, + ]); + }); + + it('should use activeViewportId when viewportId is not provided', () => { + const result = getUpdatedViewportsForSegmentation({ + ...defaultParameters, + viewportId: null, + }); + + expect(mockHangingProtocolService.getViewportsRequireUpdate).toHaveBeenCalledWith( + mockViewport.viewportOptions.viewportId, + defaultParameters.displaySetInstanceUIDs[0], + true + ); + expect(result).toEqual([]); + }); + + it('should use activeViewportId when viewportId is undefined', () => { + const result = getUpdatedViewportsForSegmentation({ + ...defaultParameters, + viewportId: undefined, + }); + + expect(mockHangingProtocolService.getViewportsRequireUpdate).toHaveBeenCalledWith( + mockViewport.viewportOptions.viewportId, + defaultParameters.displaySetInstanceUIDs[0], + true + ); + expect(result).toEqual([]); + }); + + it('should handle isHangingProtocolLayout false', () => { + mockViewportGridService.getState.mockReturnValue({ + isHangingProtocolLayout: false, + viewports: mockViewports, + activeViewportId: 'active-viewport-id', + }); + + const result = getUpdatedViewportsForSegmentation(defaultParameters); + + expect(mockHangingProtocolService.getViewportsRequireUpdate).toHaveBeenCalledWith( + mockViewport.viewportOptions.viewportId, + defaultParameters.displaySetInstanceUIDs[0], + false + ); + expect(result).toEqual([]); + }); + + it('should handle multiple displaySetInstanceUIDs by using first one', () => { + const result = getUpdatedViewportsForSegmentation({ + ...defaultParameters, + displaySetInstanceUIDs: ['display-set-1', 'display-set-2', 'display-set-3'], + }); + + expect(mockHangingProtocolService.getViewportsRequireUpdate).toHaveBeenCalledWith( + mockViewport.viewportOptions.viewportId, + 'display-set-1', + true + ); + expect(result).toEqual([]); + }); + + it('should handle empty displaySetInstanceUIDs array', () => { + const result = getUpdatedViewportsForSegmentation({ + ...defaultParameters, + displaySetInstanceUIDs: [], + }); + + expect(mockHangingProtocolService.getViewportsRequireUpdate).toHaveBeenCalledWith( + mockViewport.viewportOptions.viewportId, + undefined, + true + ); + expect(result).toEqual([]); + }); + + it('should handle viewport not found in viewports map', () => { + mockViewportGridService.getState.mockReturnValue({ + isHangingProtocolLayout: true, + viewports: new Map(), + activeViewportId: 'non-existent-viewport', + }); + + expect(() => getUpdatedViewportsForSegmentation(defaultParameters)).toThrow(); + }); + + it('should handle viewport with missing viewportOptions', () => { + const viewportWithoutOptions = {}; + const viewportsMap = new Map([['viewport-1', viewportWithoutOptions]]); + + mockViewportGridService.getState.mockReturnValue({ + isHangingProtocolLayout: true, + viewports: viewportsMap, + activeViewportId: 'active-viewport-id', + }); + + expect(() => getUpdatedViewportsForSegmentation(defaultParameters)).toThrow(); + }); + + it('should handle viewport with null viewportOptions', () => { + const viewportWithNullOptions = { + viewportOptions: null, + }; + const viewportsMap = new Map([['viewport-1', viewportWithNullOptions]]); + + mockViewportGridService.getState.mockReturnValue({ + isHangingProtocolLayout: true, + viewports: viewportsMap, + activeViewportId: 'active-viewport-id', + }); + + expect(() => getUpdatedViewportsForSegmentation(defaultParameters)).toThrow(); + }); + + it('should handle getViewportsRequireUpdate returning null', () => { + mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue(null); + + expect(() => getUpdatedViewportsForSegmentation(defaultParameters)).toThrow(); + }); + + it('should handle mixed viewport types including volume3d', () => { + const mockUpdatedViewports = [ + { + viewportOptions: { + viewportType: 'stack', + }, + }, + { + viewportOptions: { + viewportType: 'volume3d', + }, + }, + { + viewportOptions: { + viewportType: 'volume3d', + }, + }, + { + viewportOptions: { + viewportType: 'orthogonal', + }, + }, + ]; + + mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue(mockUpdatedViewports); + + const result = getUpdatedViewportsForSegmentation(defaultParameters); + + expect(result).toEqual([ + { + viewportOptions: { + viewportType: 'stack', + }, + }, + { + viewportOptions: { + viewportType: 'orthogonal', + }, + }, + ]); + }); + + it('should handle all volume3d viewports', () => { + const mockUpdatedViewports = [ + { + viewportOptions: { + viewportType: 'volume3d', + }, + }, + { + viewportOptions: { + viewportType: 'volume3d', + }, + }, + ]; + + mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue(mockUpdatedViewports); + + const result = getUpdatedViewportsForSegmentation(defaultParameters); + + expect(result).toEqual([]); + }); + + it('should handle viewports with undefined viewportType', () => { + const mockUpdatedViewports = [ + { + viewportOptions: { + viewportType: undefined, + }, + }, + { + viewportOptions: { + someOtherProperty: 'value', + }, + }, + ]; + + mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue(mockUpdatedViewports); + + const result = getUpdatedViewportsForSegmentation(defaultParameters); + + expect(result).toEqual(mockUpdatedViewports); + }); + + it('should handle complex viewport structure', () => { + const complexViewport = { + viewportOptions: { + viewportId: 'complex-viewport-id', + viewportType: 'stack', + orientation: 'axial', + initialImageOptions: { + index: 0, + }, + }, + displaySetOptions: { + displaySetInstanceUID: 'display-set-1', + }, + }; + + const viewportsMap = new Map([['viewport-1', complexViewport]]); + + mockViewportGridService.getState.mockReturnValue({ + isHangingProtocolLayout: true, + viewports: viewportsMap, + activeViewportId: 'active-viewport-id', + }); + + const mockUpdatedViewports = [ + { + viewportOptions: { + viewportType: 'stack', + }, + }, + ]; + + mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue(mockUpdatedViewports); + + const result = getUpdatedViewportsForSegmentation(defaultParameters); + + expect(mockHangingProtocolService.getViewportsRequireUpdate).toHaveBeenCalledWith( + 'complex-viewport-id', + defaultParameters.displaySetInstanceUIDs[0], + true + ); + expect(result).toEqual(mockUpdatedViewports); + }); +}); diff --git a/extensions/cornerstone/src/utils/initViewTiming.test.ts b/extensions/cornerstone/src/utils/initViewTiming.test.ts new file mode 100644 index 000000000..e6adabbe2 --- /dev/null +++ b/extensions/cornerstone/src/utils/initViewTiming.test.ts @@ -0,0 +1,342 @@ +import { log, Enums } from '@ohif/core'; +import { EVENTS } from '@cornerstonejs/core'; +import initViewTiming from './initViewTiming'; + +jest.mock('@ohif/core', () => ({ + log: { + timingKeys: {}, + timeEnd: jest.fn(), + }, + Enums: { + TimingEnum: { + DISPLAY_SETS_TO_ALL_IMAGES: 'DISPLAY_SETS_TO_ALL_IMAGES', + DISPLAY_SETS_TO_FIRST_IMAGE: 'DISPLAY_SETS_TO_FIRST_IMAGE', + STUDY_TO_FIRST_IMAGE: 'STUDY_TO_FIRST_IMAGE', + SCRIPT_TO_VIEW: 'SCRIPT_TO_VIEW', + }, + }, +})); + +jest.mock('@cornerstonejs/core', () => ({ + EVENTS: { + IMAGE_RENDERED: 'IMAGE_RENDERED', + }, +})); + +describe('initViewTiming', () => { + const mockElement = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + const defaultParameters = { + element: mockElement, + }; + + const clearUnevenlyHandledViewportState = () => { + const imageRenderedListener = mockElement.addEventListener.mock.calls[0][1]; + imageRenderedListener({ + detail: { + viewportStatus: 'rendered', + element: mockElement, + }, + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + // @ts-expect-error - restarting the object that will have values set by each test + log.timingKeys = {}; + }); + + it('should return early when no timing keys are set', () => { + // @ts-expect-error - the idea is to test an invalid state + log.timingKeys = {}; + + initViewTiming(defaultParameters); + + expect(mockElement.addEventListener).not.toHaveBeenCalled(); + }); + + it('should add event listener when timing keys are present', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + initViewTiming(defaultParameters); + + expect(mockElement.addEventListener).toHaveBeenCalledWith( + EVENTS.IMAGE_RENDERED, + expect.any(Function) + ); + + clearUnevenlyHandledViewportState(); + }); + + it('should handle multiple timing keys', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES] = true; + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + initViewTiming(defaultParameters); + + expect(mockElement.addEventListener).toHaveBeenCalledWith( + EVENTS.IMAGE_RENDERED, + expect.any(Function) + ); + + clearUnevenlyHandledViewportState(); + }); + + it('should return early in imageRenderedListener when viewportStatus is preRender', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + initViewTiming(defaultParameters); + + const imageRenderedListener = mockElement.addEventListener.mock.calls[0][1]; + const evt = { + detail: { + viewportStatus: 'preRender', + element: mockElement, + }, + }; + + imageRenderedListener(evt); + + expect(log.timeEnd).not.toHaveBeenCalled(); + expect(mockElement.removeEventListener).not.toHaveBeenCalled(); + + clearUnevenlyHandledViewportState(); + }); + + it('should call timeEnd for timing keys when image is rendered', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + initViewTiming(defaultParameters); + + const imageRenderedListener = mockElement.addEventListener.mock.calls[0][1]; + const evt = { + detail: { + viewportStatus: 'rendered', + element: mockElement, + }, + }; + + imageRenderedListener(evt); + + expect(log.timeEnd).toHaveBeenCalledWith(Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE); + expect(log.timeEnd).toHaveBeenCalledWith(Enums.TimingEnum.STUDY_TO_FIRST_IMAGE); + expect(log.timeEnd).toHaveBeenCalledWith(Enums.TimingEnum.SCRIPT_TO_VIEW); + }); + + it('should remove event listener after image is rendered', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + initViewTiming(defaultParameters); + + const imageRenderedListener = mockElement.addEventListener.mock.calls[0][1]; + const evt = { + detail: { + viewportStatus: 'rendered', + element: mockElement, + }, + }; + + imageRenderedListener(evt); + + expect(mockElement.removeEventListener).toHaveBeenCalledWith( + EVENTS.IMAGE_RENDERED, + imageRenderedListener + ); + }); + + it('should not call timeEnd for ALL_IMAGES when viewports are still waiting', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + initViewTiming(defaultParameters); + initViewTiming(defaultParameters); + + const imageRenderedListener = mockElement.addEventListener.mock.calls[0][1]; + const evt = { + detail: { + viewportStatus: 'rendered', + element: mockElement, + }, + }; + + imageRenderedListener(evt); + + expect(log.timeEnd).toHaveBeenCalledWith(Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE); + expect(log.timeEnd).toHaveBeenCalledWith(Enums.TimingEnum.STUDY_TO_FIRST_IMAGE); + expect(log.timeEnd).toHaveBeenCalledWith(Enums.TimingEnum.SCRIPT_TO_VIEW); + expect(log.timeEnd).not.toHaveBeenCalledWith(Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES); + + clearUnevenlyHandledViewportState(); + }); + + it('should handle multiple viewports finishing in sequence', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + const mockElement2 = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + initViewTiming(defaultParameters); + initViewTiming({ element: mockElement2 }); + + const imageRenderedListener1 = mockElement.addEventListener.mock.calls[0][1]; + const imageRenderedListener2 = mockElement2.addEventListener.mock.calls[0][1]; + + const evt1 = { + detail: { + viewportStatus: 'rendered', + element: mockElement, + }, + }; + + const evt2 = { + detail: { + viewportStatus: 'rendered', + element: mockElement2, + }, + }; + + imageRenderedListener1(evt1); + + expect(log.timeEnd).not.toHaveBeenCalledWith(Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES); + + imageRenderedListener2(evt2); + + expect(log.timeEnd).toHaveBeenCalledWith(Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES); + }); + + it('should call timeEnd for ALL_IMAGES when last viewport finishes', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + initViewTiming(defaultParameters); + + const imageRenderedListener = mockElement.addEventListener.mock.calls[0][1]; + const evt = { + detail: { + viewportStatus: 'rendered', + element: mockElement, + }, + }; + + imageRenderedListener(evt); + + expect(log.timeEnd).toHaveBeenCalledWith(Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES); + }); + + it('should handle different viewportStatus values', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + initViewTiming(defaultParameters); + + const imageRenderedListener = mockElement.addEventListener.mock.calls[0][1]; + const evt = { + detail: { + viewportStatus: 'loading', + element: mockElement, + }, + }; + + imageRenderedListener(evt); + + expect(log.timeEnd).toHaveBeenCalledWith(Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE); + expect(mockElement.removeEventListener).toHaveBeenCalled(); + }); + + it('should handle undefined viewportStatus', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + initViewTiming(defaultParameters); + + const imageRenderedListener = mockElement.addEventListener.mock.calls[0][1]; + const evt = { + detail: { + element: mockElement, + }, + }; + + imageRenderedListener(evt); + + expect(log.timeEnd).toHaveBeenCalledWith(Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE); + expect(mockElement.removeEventListener).toHaveBeenCalled(); + }); + + it('should handle missing detail object', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + initViewTiming(defaultParameters); + + const imageRenderedListener = mockElement.addEventListener.mock.calls[0][1]; + const evt = {}; + + expect(() => imageRenderedListener(evt)).toThrow(); + }); + + it('should handle timing keys with falsy values', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = false; + log.timingKeys[Enums.TimingEnum.STUDY_TO_FIRST_IMAGE] = null; + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES] = undefined; + + initViewTiming(defaultParameters); + + expect(mockElement.addEventListener).not.toHaveBeenCalled(); + }); + + it('should handle timing keys with truthy values', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = 'some value'; + log.timingKeys[Enums.TimingEnum.STUDY_TO_FIRST_IMAGE] = 1; + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES] = {}; + + initViewTiming(defaultParameters); + + expect(mockElement.addEventListener).toHaveBeenCalledWith( + EVENTS.IMAGE_RENDERED, + expect.any(Function) + ); + }); + + it('should initialize IMAGE_TIMING_KEYS on first call', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + initViewTiming(defaultParameters); + + expect(mockElement.addEventListener).toHaveBeenCalled(); + }); + + it('should handle empty element object', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + const emptyElement = {}; + + expect(() => initViewTiming({ element: emptyElement })).toThrow(); + }); + + it('should handle null element', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + expect(() => initViewTiming({ element: null })).toThrow(); + }); + + it('should handle multiple calls with same element', () => { + log.timingKeys[Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE] = true; + + initViewTiming(defaultParameters); + initViewTiming(defaultParameters); + initViewTiming(defaultParameters); + + expect(mockElement.addEventListener).toHaveBeenCalledTimes(3); + }); + + it('should handle case where no timing keys match', () => { + // @ts-expect-error - the idea is to test an invalid state + log.timingKeys = { + someOtherKey: true, + anotherKey: 'value', + }; + + initViewTiming(defaultParameters); + + expect(mockElement.addEventListener).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/cornerstone/src/utils/initWebWorkerProgressHandler.test.ts b/extensions/cornerstone/src/utils/initWebWorkerProgressHandler.test.ts new file mode 100644 index 000000000..400d5a632 --- /dev/null +++ b/extensions/cornerstone/src/utils/initWebWorkerProgressHandler.test.ts @@ -0,0 +1,381 @@ +import { eventTarget, EVENTS } from '@cornerstonejs/core'; +import * as cornerstoneTools from '@cornerstonejs/tools'; +import { initializeWebWorkerProgressHandler } from './initWebWorkerProgressHandler'; + +jest.mock('@cornerstonejs/core', () => ({ + eventTarget: { + addEventListener: jest.fn(), + }, + EVENTS: { + WEB_WORKER_PROGRESS: 'WEB_WORKER_PROGRESS', + }, +})); + +jest.mock('@cornerstonejs/tools', () => ({ + Enums: { + WorkerTypes: { + COMPUTE_STATISTICS: 'COMPUTE_STATISTICS', + }, + }, +})); + +describe('initializeWebWorkerProgressHandler', () => { + const mockUINotificationService = { + show: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'error').mockImplementation(); + jest.spyOn(console, 'debug').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should register event listener for WEB_WORKER_PROGRESS', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + expect(eventTarget.addEventListener).toHaveBeenCalledWith( + EVENTS.WEB_WORKER_PROGRESS, + expect.any(Function) + ); + }); + + it('should skip notifications for COMPUTE_STATISTICS worker type', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + const detail = { + progress: 0, + type: cornerstoneTools.Enums.WorkerTypes.COMPUTE_STATISTICS, + id: 'test-id', + }; + + eventHandler({ detail }); + + expect(mockUINotificationService.show).not.toHaveBeenCalled(); + }); + + it('should show notification when progress is 0 for new task', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + const detail = { + progress: 0, + type: 'TEST_WORKER', + id: 'test-id', + }; + + eventHandler({ detail }); + + expect(mockUINotificationService.show).toHaveBeenCalledWith({ + id: 'worker-task-test_worker', + title: 'TEST_WORKER', + message: 'Computing...', + autoClose: false, + allowDuplicates: false, + deduplicationInterval: 60000, + promise: expect.any(Promise), + promiseMessages: { + loading: 'Computing...', + success: 'Completed successfully', + error: 'Web Worker failed', + }, + }); + }); + + it('should not show duplicate notification for same task type', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + const detail = { + progress: 0, + type: 'TEST_WORKER', + id: 'test-id-1', + }; + + eventHandler({ detail }); + eventHandler({ detail: { ...detail, id: 'test-id-2' } }); + + expect(mockUINotificationService.show).toHaveBeenCalledTimes(1); + expect(console.debug).toHaveBeenCalledWith( + 'Already tracking a "TEST_WORKER" task, skipping duplicate notification' + ); + }); + + it('should resolve promise when progress is 100', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + + const startDetail = { + progress: 0, + type: 'TEST_WORKER', + id: 'test-id', + }; + + eventHandler({ detail: startDetail }); + + const completeDetail = { + progress: 100, + type: 'TEST_WORKER', + id: 'test-id', + }; + + eventHandler({ detail: completeDetail }); + + expect(console.debug).toHaveBeenCalledWith('Worker task "TEST_WORKER" completed successfully'); + }); + + it('should handle completion for non-tracked task gracefully', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + const detail = { + progress: 100, + type: 'UNKNOWN_WORKER', + id: 'test-id', + }; + + eventHandler({ detail }); + + expect(console.debug).not.toHaveBeenCalledWith( + 'Worker task "UNKNOWN_WORKER" completed successfully' + ); + }); + + it('should normalize task keys correctly', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + const detail = { + progress: 0, + type: 'Test Worker Type', + id: 'test-id', + }; + + eventHandler({ detail }); + + expect(mockUINotificationService.show).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'worker-task-test-worker-type', + }) + ); + }); + + it('should handle error when setting active worker task', () => { + jest.spyOn(global, 'Promise').mockImplementationOnce(() => { + throw new Error('Promise creation failed'); + }); + + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + const detail = { + progress: 0, + type: 'TEST_WORKER', + id: 'test-id', + }; + + expect(() => eventHandler({ detail })).not.toThrow(); + expect(console.error).toHaveBeenCalledWith( + 'Error in web worker progress handler for type "TEST_WORKER":', + expect.any(Error) + ); + + jest.restoreAllMocks(); + }); + + it('should handle error when showing notification', () => { + mockUINotificationService.show.mockImplementationOnce(() => { + throw new Error('Notification service failed'); + }); + + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + const detail = { + progress: 0, + type: 'TEST_WORKER', + id: 'test-id', + }; + + expect(() => eventHandler({ detail })).not.toThrow(); + expect(console.error).toHaveBeenCalledWith( + 'Error showing web worker notification for type "TEST_WORKER":', + expect.any(Error) + ); + }); + + it('should handle missing detail object', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + + expect(() => eventHandler({})).not.toThrow(); + expect(console.error).toHaveBeenCalledWith( + 'Error in web worker progress handler for type "undefined":', + expect.any(Error) + ); + }); + + it('should handle undefined detail', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + + expect(() => eventHandler({ detail: undefined })).not.toThrow(); + expect(console.error).toHaveBeenCalledWith( + 'Error in web worker progress handler for type "undefined":', + expect.any(Error) + ); + }); + + it('should handle cleanup error gracefully', () => { + const mockMap = new Map(); + mockMap.delete = jest.fn(() => { + throw new Error('Delete failed'); + }); + + jest.spyOn(global, 'Map').mockImplementationOnce(() => mockMap); + + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + const detail = { + progress: 0, + type: 'TEST_WORKER', + id: 'test-id', + }; + + mockUINotificationService.show.mockImplementationOnce(() => { + throw new Error('Notification failed'); + }); + + expect(() => eventHandler({ detail })).not.toThrow(); + expect(console.error).toHaveBeenCalledWith( + 'Error cleaning up active worker task for type "TEST_WORKER":', + expect.any(Error) + ); + + jest.restoreAllMocks(); + }); + + it('should handle intermediate progress values', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + + const startDetail = { + progress: 0, + type: 'TEST_WORKER', + id: 'test-id', + }; + + eventHandler({ detail: startDetail }); + + const intermediateDetail = { + progress: 50, + type: 'TEST_WORKER', + id: 'test-id', + }; + + eventHandler({ detail: intermediateDetail }); + + expect(mockUINotificationService.show).toHaveBeenCalledTimes(1); + }); + + it('should handle multiple different worker types simultaneously', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + + const detail1 = { + progress: 0, + type: 'WORKER_TYPE_1', + id: 'test-id-1', + }; + + const detail2 = { + progress: 0, + type: 'WORKER_TYPE_2', + id: 'test-id-2', + }; + + eventHandler({ detail: detail1 }); + eventHandler({ detail: detail2 }); + + expect(mockUINotificationService.show).toHaveBeenCalledTimes(2); + expect(mockUINotificationService.show).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + id: 'worker-task-worker_type_1', + title: 'WORKER_TYPE_1', + }) + ); + expect(mockUINotificationService.show).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + id: 'worker-task-worker_type_2', + title: 'WORKER_TYPE_2', + }) + ); + }); + + it('should complete multiple worker types independently', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + + eventHandler({ detail: { progress: 0, type: 'WORKER_1', id: 'id-1' } }); + eventHandler({ detail: { progress: 0, type: 'WORKER_2', id: 'id-2' } }); + eventHandler({ detail: { progress: 100, type: 'WORKER_1', id: 'id-1' } }); + + expect(console.debug).toHaveBeenCalledWith('Worker task "WORKER_1" completed successfully'); + + eventHandler({ detail: { progress: 100, type: 'WORKER_2', id: 'id-2' } }); + + expect(console.debug).toHaveBeenCalledWith('Worker task "WORKER_2" completed successfully'); + }); + + it('should handle special characters in worker type names', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + const detail = { + progress: 0, + type: 'Test-Worker_Type@123', + id: 'test-id', + }; + + eventHandler({ detail }); + + expect(mockUINotificationService.show).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'worker-task-test-worker_type@123', + }) + ); + }); + + it('should handle empty worker type', () => { + initializeWebWorkerProgressHandler(mockUINotificationService); + + const eventHandler = (eventTarget.addEventListener as jest.Mock).mock.calls[0][1]; + const detail = { + progress: 0, + type: '', + id: 'test-id', + }; + + eventHandler({ detail }); + + expect(mockUINotificationService.show).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'worker-task-', + title: '', + }) + ); + }); +}); diff --git a/extensions/cornerstone/src/utils/interleave.test.ts b/extensions/cornerstone/src/utils/interleave.test.ts new file mode 100644 index 000000000..59db7c06f --- /dev/null +++ b/extensions/cornerstone/src/utils/interleave.test.ts @@ -0,0 +1,304 @@ +import interleave from './interleave'; + +describe('interleave', () => { + beforeEach(() => { + jest.spyOn(console, 'time').mockImplementation(); + jest.spyOn(console, 'timeEnd').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return empty array when input is null', () => { + const result = interleave(null); + + expect(result).toEqual([]); + }); + + it('should return empty array when input is undefined', () => { + const result = interleave(undefined); + + expect(result).toEqual([]); + }); + + it('should return empty array when input is empty array', () => { + const result = interleave([]); + + expect(result).toEqual([]); + }); + + it('should return same array when only one list is provided', () => { + const input = [[1, 2, 3, 4]]; + + const result = interleave(input); + + expect(result).toEqual([1, 2, 3, 4]); + expect(result).toBe(input[0]); + }); + + it('should interleave two arrays of equal length', () => { + const input = [ + [1, 3, 5], + [2, 4, 6], + ]; + + const result = interleave(input); + + expect(result).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('should interleave three arrays of equal length', () => { + const input = [ + [1, 4, 7], + [2, 5, 8], + [3, 6, 9], + ]; + + const result = interleave(input); + + expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it('should handle arrays of different lengths', () => { + const input = [ + [1, 4], + [2, 5, 7, 8], + [3, 6], + ]; + + const result = interleave(input); + + expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + }); + + it('should handle first array being longer', () => { + const input = [ + [1, 3, 5, 7, 9, 11], + [2, 4, 6], + ]; + + const result = interleave(input); + + expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 9, 11]); + }); + + it('should handle second array being longer', () => { + const input = [ + [1, 3, 5], + [2, 4, 6, 8, 10, 12], + ]; + + const result = interleave(input); + + expect(result).toEqual([1, 2, 3, 4, 5, 6, 8, 10, 12]); + }); + + it('should handle empty arrays in the input', () => { + const input = [[1, 3, 5], [], [2, 4, 6]]; + + const result = interleave(input); + + expect(result).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('should handle all empty arrays', () => { + const input = [[], [], []]; + + const result = interleave(input); + + expect(result).toEqual([]); + }); + + it('should handle single element arrays', () => { + const input = [[1], [2], [3], [4]]; + + const result = interleave(input); + + expect(result).toEqual([1, 2, 3, 4]); + }); + + it('should handle mixed single and multi-element arrays', () => { + const input = [[1], [2, 5, 8], [3], [4, 6, 7, 9]]; + + const result = interleave(input); + + expect(result).toEqual([1, 2, 3, 4, 5, 6, 8, 7, 9]); + }); + + it('should handle string arrays', () => { + const input = [ + ['a', 'c', 'e'], + ['b', 'd', 'f'], + ]; + + const result = interleave(input); + + expect(result).toEqual(['a', 'b', 'c', 'd', 'e', 'f']); + }); + + it('should handle object arrays', () => { + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + const obj3 = { id: 3 }; + const obj4 = { id: 4 }; + + const input = [ + [obj1, obj3], + [obj2, obj4], + ]; + + const result = interleave(input); + + expect(result).toEqual([obj1, obj2, obj3, obj4]); + }); + + it('should handle large arrays efficiently', () => { + const array1 = Array.from({ length: 1000 }, (_, i) => i * 2); + const array2 = Array.from({ length: 1000 }, (_, i) => i * 2 + 1); + const input = [array1, array2]; + + const startTime = performance.now(); + const result = interleave(input); + const endTime = performance.now(); + + expect(result).toHaveLength(2000); + expect(result[0]).toBe(0); + expect(result[1]).toBe(1); + expect(result[2]).toBe(2); + expect(result[3]).toBe(3); + expect(endTime - startTime).toBeLessThan(100); + }); + + it('should handle very large number of arrays', () => { + const input = Array.from({ length: 100 }, (_, i) => [i, i + 100, i + 200]); + + const result = interleave(input); + + expect(result).toHaveLength(300); + expect(result.slice(0, 100)).toEqual(Array.from({ length: 100 }, (_, i) => i)); + }); + + it('should handle arrays with different data types', () => { + const input = [ + [1, 'a', true], + [2, 'b', false], + [3, 'c', null], + ]; + + const result = interleave(input); + + expect(result).toEqual([1, 2, 3, 'a', 'b', 'c', true, false, null]); + }); + + it('should not mutate original arrays', () => { + const array1 = [1, 3, 5]; + const array2 = [2, 4, 6]; + const input = [array1, array2]; + const originalArray1 = [...array1]; + const originalArray2 = [...array2]; + + interleave(input); + + expect(array1).toEqual(originalArray1); + expect(array2).toEqual(originalArray2); + }); + + it('should handle nested arrays', () => { + const input = [ + [ + [1, 2], + [5, 6], + ], + [ + [3, 4], + [7, 8], + ], + ]; + + const result = interleave(input); + + expect(result).toEqual([ + [1, 2], + [3, 4], + [5, 6], + [7, 8], + ]); + }); + + it('should handle arrays with undefined and null values', () => { + const input = [ + [1, undefined, 3], + [null, 2, 4], + ]; + + const result = interleave(input); + + expect(result).toEqual([1, null, undefined, 2, 3, 4]); + }); + + it('should handle extremely unbalanced arrays', () => { + const input = [[1], Array.from({ length: 1000 }, (_, i) => i + 2)]; + + const result = interleave(input); + + expect(result).toHaveLength(1001); + expect(result[0]).toBe(1); + expect(result[1]).toBe(2); + expect(result[2]).toBe(3); + }); + + it('should handle performance with many small arrays', () => { + const input = Array.from({ length: 1000 }, (_, i) => [i]); + + const startTime = performance.now(); + const result = interleave(input); + const endTime = performance.now(); + + expect(result).toHaveLength(1000); + expect(result).toEqual(Array.from({ length: 1000 }, (_, i) => i)); + expect(endTime - startTime).toBeLessThan(50); + }); + + it('should handle zero values correctly', () => { + const input = [ + [0, 2, 4], + [1, 3, 5], + ]; + + const result = interleave(input); + + expect(result).toEqual([0, 1, 2, 3, 4, 5]); + }); + + it('should handle boolean arrays', () => { + const input = [ + [true, false, true], + [false, true, false], + ]; + + const result = interleave(input); + + expect(result).toEqual([true, false, false, true, true, false]); + }); + + it('should handle single empty array with non-empty arrays', () => { + const input = [[], [1, 2, 3], [4, 5, 6]]; + + const result = interleave(input); + + expect(result).toEqual([1, 4, 2, 5, 3, 6]); + }); + + it('should maintain order within each array', () => { + const input = [ + [10, 20, 30, 40, 50], + [15, 25], + [12, 22, 32], + ]; + + const result = interleave(input); + + expect(result).toEqual([10, 15, 12, 20, 25, 22, 30, 32, 40, 50]); + }); +}); diff --git a/extensions/cornerstone/src/utils/interleave.js b/extensions/cornerstone/src/utils/interleave.ts similarity index 59% rename from extensions/cornerstone/src/utils/interleave.js rename to extensions/cornerstone/src/utils/interleave.ts index a21d3fb7e..a3a10cff0 100644 --- a/extensions/cornerstone/src/utils/interleave.js +++ b/extensions/cornerstone/src/utils/interleave.ts @@ -3,28 +3,30 @@ * in the returned list, the second items are next etc. * Does this in a O(n) fashion, and return lists[0] if there is only one list. * - * @param {[]} lists - * @returns [] reordered to be breadth first traversal of lists + * @param lists - Array of arrays to interleave + * @returns Array reordered to be breadth first traversal of lists */ -export default function interleave(lists) { +export default function interleave(lists: T[][]): T[] { if (!lists || !lists.length) { return []; } if (lists.length === 1) { return lists[0]; } - console.time('interleave'); + const useLists = [...lists]; const ret = []; for (let i = 0; useLists.length > 0; i++) { - for (const list of useLists) { + for (let j = 0; j < useLists.length; j++) { + const list = useLists[j]; if (i >= list.length) { - useLists.splice(useLists.indexOf(list), 1); + useLists.splice(j, 1); + j--; // Adjust index after removal to avoid iterator skipping continue; } ret.push(list[i]); } } - console.timeEnd('interleave'); + return ret; } diff --git a/extensions/cornerstone/src/utils/interleaveCenterLoader.test.ts b/extensions/cornerstone/src/utils/interleaveCenterLoader.test.ts new file mode 100644 index 000000000..0d91f04d5 --- /dev/null +++ b/extensions/cornerstone/src/utils/interleaveCenterLoader.test.ts @@ -0,0 +1,547 @@ +import { cache, imageLoadPoolManager, Enums } from '@cornerstonejs/core'; +import getInterleavedFrames from './getInterleavedFrames'; +import zip from 'lodash.zip'; +import compact from 'lodash.compact'; +import flatten from 'lodash.flatten'; +import interleaveCenterLoader from './interleaveCenterLoader'; + +jest.mock('@cornerstonejs/core', () => ({ + cache: { + getVolume: jest.fn(), + }, + imageLoadPoolManager: { + addRequest: jest.fn(), + }, + Enums: { + RequestType: { + Prefetch: 'Prefetch', + }, + }, +})); + +jest.mock('./getInterleavedFrames', () => jest.fn()); +jest.mock('lodash.zip', () => jest.fn()); +jest.mock('lodash.compact', () => jest.fn()); +jest.mock('lodash.flatten', () => jest.fn()); + +describe('interleaveCenterLoader', () => { + const mockVolumeInput = { + volumeId: 'test-volume-id', + }; + + const mockVolume = { + metadata: { + SeriesInstanceUID: 'test-series-uid', + }, + getImageLoadRequests: jest.fn(), + }; + + const mockImageLoadRequest = { + imageId: 'test-image-id', + callLoadImage: jest.fn(), + additionalDetails: 'test-details', + imageIdIndex: 0, + options: { test: 'option' }, + }; + + const mockDisplaySetsInfo = [ + { + displaySetInstanceUID: 'test-volume-id', + displaySetOptions: {}, + }, + ]; + + const mockMatchDetails = new Map([ + [ + 'viewport-1', + { + displaySetsInfo: mockDisplaySetsInfo, + }, + ], + ]); + + const defaultParameters = { + data: { + viewportId: 'test-viewport-id', + volumeInputArray: [mockVolumeInput], + }, + displaySetsMatchDetails: {}, + viewportMatchDetails: mockMatchDetails, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (cache.getVolume as jest.Mock).mockReturnValue(mockVolume); + mockVolume.getImageLoadRequests.mockReturnValue([mockImageLoadRequest]); + (getInterleavedFrames as jest.Mock).mockReturnValue([{ imageId: 'test-image-id' }]); + (zip as jest.Mock).mockImplementation((...args) => args[0]); + (compact as jest.Mock).mockImplementation(input => input?.filter(Boolean) ?? []); + (flatten as jest.Mock).mockImplementation(input => input?.flat() ?? []); + }); + + it('should store viewport and volume input array mapping on first call', () => { + interleaveCenterLoader(defaultParameters); + + expect(cache.getVolume).toHaveBeenCalledWith(mockVolumeInput.volumeId); + }); + + it('should return early when volume is not found', () => { + (cache.getVolume as jest.Mock).mockReturnValue(null); + + const result = interleaveCenterLoader(defaultParameters); + + expect(result).toBeUndefined(); + expect(mockVolume.getImageLoadRequests).not.toHaveBeenCalled(); + }); + + it('should return early when volume input array size does not match display set UIDs', () => { + const mockMatchDetailsWithDifferentSize = new Map([ + [ + 'viewport-1', + { + displaySetsInfo: [ + { displaySetInstanceUID: 'display-set-1' }, + { displaySetInstanceUID: 'display-set-2' }, + ], + }, + ], + ]); + + const result = interleaveCenterLoader({ + ...defaultParameters, + viewportMatchDetails: mockMatchDetailsWithDifferentSize, + }); + + expect(result).toBeUndefined(); + }); + + it('should process volume and create interleaved requests', () => { + const result = interleaveCenterLoader(defaultParameters); + + expect(cache.getVolume).toHaveBeenCalledWith(mockVolumeInput.volumeId); + expect(mockVolume.getImageLoadRequests).toHaveBeenCalled(); + expect(getInterleavedFrames).toHaveBeenCalledWith(['test-image-id']); + expect(zip).toHaveBeenCalled(); + expect(compact).toHaveBeenCalled(); + expect(flatten).toHaveBeenCalled(); + expect(result).toBeInstanceOf(Map); + }); + + it('should add requests to imageLoadPoolManager with correct parameters', () => { + interleaveCenterLoader(defaultParameters); + + expect(imageLoadPoolManager.addRequest).toHaveBeenCalledWith( + expect.any(Function), + Enums.RequestType.Prefetch, + mockImageLoadRequest.additionalDetails, + 0 + ); + }); + + it('should bind callLoadImage with correct parameters', () => { + interleaveCenterLoader(defaultParameters); + + const addRequestCall = (imageLoadPoolManager.addRequest as jest.Mock).mock.calls[0]; + const boundFunction = addRequestCall[0]; + + boundFunction(); + + expect(mockImageLoadRequest.callLoadImage).toHaveBeenCalledWith( + mockImageLoadRequest.imageId, + mockImageLoadRequest.imageIdIndex, + mockImageLoadRequest.options + ); + }); + + it('should handle volumes without image load requests', () => { + mockVolume.getImageLoadRequests.mockReturnValue([]); + + const result = interleaveCenterLoader(defaultParameters); + + expect(zip).toHaveBeenCalledWith(); + expect(result).toBeInstanceOf(Map); + }); + + it('should handle volumes with empty requests array', () => { + mockVolume.getImageLoadRequests.mockReturnValue([]); + + const result = interleaveCenterLoader(defaultParameters); + + expect(getInterleavedFrames).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(Map); + }); + + it('should handle volumes with null first request', () => { + mockVolume.getImageLoadRequests.mockReturnValue([null]); + + const result = interleaveCenterLoader(defaultParameters); + + expect(getInterleavedFrames).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(Map); + }); + + it('should handle volumes with requests without imageId', () => { + const requestWithoutImageId = { + callLoadImage: jest.fn(), + additionalDetails: 'test-details', + }; + + mockVolume.getImageLoadRequests.mockReturnValue([requestWithoutImageId]); + + const result = interleaveCenterLoader(defaultParameters); + + expect(getInterleavedFrames).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(Map); + }); + + it('should handle multiple volume inputs', () => { + const mockVolumeInput2 = { + volumeId: 'test-volume-id-2', + }; + + const mockVolume2 = { + metadata: { + SeriesInstanceUID: 'test-series-uid-2', + }, + getImageLoadRequests: jest.fn().mockReturnValue([mockImageLoadRequest]), + }; + + (cache.getVolume as jest.Mock) + .mockReturnValueOnce(mockVolume) + .mockReturnValueOnce(mockVolume2) + .mockReturnValueOnce(mockVolume) + .mockReturnValueOnce(mockVolume2); + + const mockMatchDetailsWithMultipleVolumes = new Map([ + [ + 'viewport-1', + { + displaySetsInfo: [ + { displaySetInstanceUID: 'test-volume-id' }, + { displaySetInstanceUID: 'test-volume-id-2' }, + ], + }, + ], + ]); + + interleaveCenterLoader({ + ...defaultParameters, + data: { + ...defaultParameters.data, + volumeInputArray: [mockVolumeInput, mockVolumeInput2], + }, + viewportMatchDetails: mockMatchDetailsWithMultipleVolumes, + }); + + expect(cache.getVolume).toHaveBeenCalledWith(mockVolumeInput.volumeId); + expect(cache.getVolume).toHaveBeenCalledWith(mockVolumeInput2.volumeId); + }); + + it('should handle duplicate volume IDs by not adding them again', () => { + const duplicateVolumeInput = { + volumeId: mockVolumeInput.volumeId, + }; + + interleaveCenterLoader({ + ...defaultParameters, + data: { + ...defaultParameters.data, + volumeInputArray: [mockVolumeInput, duplicateVolumeInput], + }, + }); + + expect(cache.getVolume).toHaveBeenCalledTimes(3); + }); + + it('should clear internal maps after processing', () => { + const firstResult = interleaveCenterLoader(defaultParameters); + const secondResult = interleaveCenterLoader(defaultParameters); + + expect(firstResult).toBeInstanceOf(Map); + expect(secondResult).toBeInstanceOf(Map); + }); + + it('should handle multiple image load requests', () => { + const mockImageLoadRequest2 = { + imageId: 'test-image-id-2', + callLoadImage: jest.fn(), + additionalDetails: 'test-details-2', + imageIdIndex: 1, + options: { test: 'option2' }, + }; + + mockVolume.getImageLoadRequests.mockReturnValue([mockImageLoadRequest, mockImageLoadRequest2]); + + (getInterleavedFrames as jest.Mock).mockReturnValue([ + { imageId: 'test-image-id' }, + { imageId: 'test-image-id-2' }, + ]); + + (compact as jest.Mock).mockReturnValue([mockImageLoadRequest, mockImageLoadRequest2]); + (flatten as jest.Mock).mockReturnValue([mockImageLoadRequest, mockImageLoadRequest2]); + + interleaveCenterLoader(defaultParameters); + + expect(getInterleavedFrames).toHaveBeenCalledWith(['test-image-id', 'test-image-id-2']); + expect(imageLoadPoolManager.addRequest).toHaveBeenCalledTimes(2); + }); + + it('should handle getInterleavedFrames returning empty array', () => { + (getInterleavedFrames as jest.Mock).mockReturnValue([]); + + const result = interleaveCenterLoader(defaultParameters); + + expect(imageLoadPoolManager.addRequest).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(Map); + }); + + it('should handle zip returning empty result', () => { + (zip as jest.Mock).mockReturnValue([]); + + const result = interleaveCenterLoader(defaultParameters); + + expect(compact).toHaveBeenCalledWith([]); + expect(result).toBeInstanceOf(Map); + }); + + it('should handle compact filtering out null values', () => { + (compact as jest.Mock).mockReturnValue([]); + + const result = interleaveCenterLoader(defaultParameters); + + expect(imageLoadPoolManager.addRequest).not.toHaveBeenCalled(); + expect(result).toBeInstanceOf(Map); + }); + + it('should map imageIds to requests correctly', () => { + const mockImageLoadRequest2 = { + imageId: 'test-image-id-2', + callLoadImage: jest.fn(), + }; + + mockVolume.getImageLoadRequests.mockReturnValue([mockImageLoadRequest, mockImageLoadRequest2]); + + (getInterleavedFrames as jest.Mock).mockReturnValue([ + { imageId: 'test-image-id-2' }, + { imageId: 'test-image-id' }, + ]); + + interleaveCenterLoader(defaultParameters); + + expect(getInterleavedFrames).toHaveBeenCalledWith(['test-image-id', 'test-image-id-2']); + }); + + it('should return copy of viewport volume input array map', () => { + const result = interleaveCenterLoader(defaultParameters); + + expect(result.has(defaultParameters.data.viewportId)).toBe(true); + expect(result.get(defaultParameters.data.viewportId)).toEqual( + defaultParameters.data.volumeInputArray + ); + }); + + it('should handle multiple match details with different displaySets', () => { + const mockMultipleMatchDetails = new Map([ + [ + 'viewport-1', + { + displaySetsInfo: [{ displaySetInstanceUID: 'display-set-1' }], + }, + ], + [ + 'viewport-2', + { + displaySetsInfo: [{ displaySetInstanceUID: 'display-set-2' }], + }, + ], + ]); + + const result = interleaveCenterLoader({ + ...defaultParameters, + viewportMatchDetails: mockMultipleMatchDetails, + }); + + expect(result).toBeUndefined(); + }); + + it('should handle empty match details', () => { + const emptyMatchDetails = new Map(); + + const result = interleaveCenterLoader({ + ...defaultParameters, + viewportMatchDetails: emptyMatchDetails, + }); + + expect(result).toBeUndefined(); + }); + + it('should handle empty volumeInputArray', () => { + const result = interleaveCenterLoader({ + ...defaultParameters, + data: { + ...defaultParameters.data, + volumeInputArray: [], + }, + }); + + expect(result).toBeUndefined(); + }); + + it('should handle requests with missing properties gracefully', () => { + const incompleteRequest = { + imageId: 'test-image-id', + callLoadImage: jest.fn(), + }; + + mockVolume.getImageLoadRequests.mockReturnValue([incompleteRequest]); + (getInterleavedFrames as jest.Mock).mockReturnValue([{ imageId: 'test-image-id' }]); + (compact as jest.Mock).mockReturnValue([incompleteRequest]); + (flatten as jest.Mock).mockReturnValue([incompleteRequest]); + + interleaveCenterLoader(defaultParameters); + + expect(imageLoadPoolManager.addRequest).toHaveBeenCalledWith( + expect.any(Function), + Enums.RequestType.Prefetch, + undefined, + 0 + ); + }); + + it('should process different viewport IDs separately', () => { + const firstResult = interleaveCenterLoader({ + ...defaultParameters, + data: { + viewportId: 'viewport-1', + volumeInputArray: [mockVolumeInput], + }, + }); + + const secondResult = interleaveCenterLoader({ + ...defaultParameters, + data: { + viewportId: 'viewport-2', + volumeInputArray: [mockVolumeInput], + }, + }); + + expect(firstResult.has('viewport-1')).toBe(true); + expect(secondResult.has('viewport-2')).toBe(true); + }); + + it('should accumulate state across multiple calls before processing', () => { + const mockVolumeInput2 = { + volumeId: 'test-volume-id-2', + }; + + const mockVolume2 = { + metadata: { + SeriesInstanceUID: 'test-series-uid-2', + }, + getImageLoadRequests: jest.fn().mockReturnValue([mockImageLoadRequest]), + }; + + (cache.getVolume as jest.Mock) + .mockReturnValueOnce(mockVolume) + .mockReturnValueOnce(mockVolume) + .mockReturnValueOnce(mockVolume2); + + const firstCall = interleaveCenterLoader({ + ...defaultParameters, + data: { + viewportId: 'viewport-1', + volumeInputArray: [mockVolumeInput], + }, + viewportMatchDetails: new Map([ + [ + 'viewport-1', + { + displaySetsInfo: [ + { displaySetInstanceUID: 'test-volume-id' }, + { displaySetInstanceUID: 'missing-volume-id' }, + ], + }, + ], + ]), + }); + + expect(firstCall).toBeUndefined(); + + const secondCall = interleaveCenterLoader({ + ...defaultParameters, + data: { + viewportId: 'viewport-2', + volumeInputArray: [mockVolumeInput2], + }, + viewportMatchDetails: new Map([ + [ + 'viewport-1', + { + displaySetsInfo: [{ displaySetInstanceUID: 'test-volume-id' }], + }, + ], + [ + 'viewport-2', + { + displaySetsInfo: [{ displaySetInstanceUID: 'test-volume-id-2' }], + }, + ], + ]), + }); + + expect(secondCall).toBeInstanceOf(Map); + expect(secondCall.has('viewport-1')).toBe(true); + expect(secondCall.has('viewport-2')).toBe(true); + }); + + it('should handle volume with undefined metadata', () => { + const mockVolumeWithoutMetadata = { + getImageLoadRequests: jest.fn().mockReturnValue([]), + }; + + (cache.getVolume as jest.Mock).mockReturnValue(mockVolumeWithoutMetadata); + + expect(() => interleaveCenterLoader(defaultParameters)).toThrow(); + }); + + it('should handle multiple volumes with same image requests', () => { + const mockVolume2 = { + metadata: { SeriesInstanceUID: 'test-series-uid-2' }, + getImageLoadRequests: jest.fn().mockReturnValue([mockImageLoadRequest]), + }; + + const mockVolumeInput2 = { volumeId: 'test-volume-id-2' }; + + (cache.getVolume as jest.Mock) + .mockReturnValueOnce(mockVolume) + .mockReturnValueOnce(mockVolume2) + .mockReturnValueOnce(mockVolume) + .mockReturnValueOnce(mockVolume2); + + const mockMatchDetailsWithBothVolumes = new Map([ + [ + 'viewport-1', + { + displaySetsInfo: [ + { displaySetInstanceUID: 'test-volume-id' }, + { displaySetInstanceUID: 'test-volume-id-2' }, + ], + }, + ], + ]); + + (compact as jest.Mock).mockReturnValue([mockImageLoadRequest]); + (flatten as jest.Mock).mockReturnValue([mockImageLoadRequest]); + + interleaveCenterLoader({ + ...defaultParameters, + data: { + ...defaultParameters.data, + volumeInputArray: [mockVolumeInput, mockVolumeInput2], + }, + viewportMatchDetails: mockMatchDetailsWithBothVolumes, + }); + + expect(imageLoadPoolManager.addRequest).toHaveBeenCalledTimes(2); + }); +}); diff --git a/extensions/cornerstone/src/utils/interleaveTopToBottom.test.ts b/extensions/cornerstone/src/utils/interleaveTopToBottom.test.ts new file mode 100644 index 000000000..71afa71a3 --- /dev/null +++ b/extensions/cornerstone/src/utils/interleaveTopToBottom.test.ts @@ -0,0 +1,487 @@ +import { cache, imageLoadPoolManager, Enums } from '@cornerstonejs/core'; +import zip from 'lodash.zip'; +import compact from 'lodash.compact'; +import flatten from 'lodash.flatten'; +import interleaveTopToBottom from './interleaveTopToBottom'; + +jest.mock('@cornerstonejs/core', () => ({ + cache: { + getVolume: jest.fn(), + }, + imageLoadPoolManager: { + addRequest: jest.fn(), + }, + Enums: { + RequestType: { + Prefetch: 'Prefetch', + }, + }, +})); + +jest.mock('lodash.zip', () => jest.fn()); +jest.mock('lodash.compact', () => jest.fn()); +jest.mock('lodash.flatten', () => jest.fn()); + +describe('interleaveTopToBottom', () => { + const mockVolumeInput = { + volumeId: 'test-volume-id', + }; + + const mockVolume = { + metadata: { + SeriesInstanceUID: 'test-series-uid', + }, + getImageLoadRequests: jest.fn(), + }; + + const mockImageLoadRequest = { + imageId: 'test-image-id', + callLoadImage: jest.fn(), + additionalDetails: 'test-details', + imageIdIndex: 0, + options: { test: 'option' }, + }; + + const mockDisplaySetsInfo = [ + { + displaySetInstanceUID: 'display-set-1', + displaySetOptions: {}, + }, + ]; + + const mockMatchDetails = new Map([ + [ + 'viewport-1', + { + displaySetsInfo: mockDisplaySetsInfo, + }, + ], + ]); + + const defaultParameters = { + data: { + viewportId: 'test-viewport-id', + volumeInputArray: [mockVolumeInput], + }, + displaySetsMatchDetails: {}, + viewportMatchDetails: mockMatchDetails, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (cache.getVolume as jest.Mock).mockReturnValue(mockVolume); + mockVolume.getImageLoadRequests.mockReturnValue([mockImageLoadRequest]); + (zip as jest.Mock).mockImplementation((...args) => args[0]); + (compact as jest.Mock).mockImplementation(input => input?.filter(Boolean) ?? []); + (flatten as jest.Mock).mockImplementation(input => input?.flat() ?? []); + }); + + it('should store viewport and volume input array mapping', () => { + interleaveTopToBottom(defaultParameters); + + expect(cache.getVolume).toHaveBeenCalledWith(mockVolumeInput.volumeId); + }); + + it('should return early when volume is not found', () => { + (cache.getVolume as jest.Mock).mockReturnValue(null); + + const result = interleaveTopToBottom(defaultParameters); + + expect(result).toBeUndefined(); + expect(mockVolume.getImageLoadRequests).not.toHaveBeenCalled(); + }); + + it('should process volume input array and create image load requests', () => { + const result = interleaveTopToBottom(defaultParameters); + + expect(cache.getVolume).toHaveBeenCalledWith(mockVolumeInput.volumeId); + expect(mockVolume.getImageLoadRequests).toHaveBeenCalled(); + expect(zip).toHaveBeenCalled(); + expect(compact).toHaveBeenCalled(); + expect(flatten).toHaveBeenCalled(); + expect(result).toBeInstanceOf(Map); + }); + + it('should process displaySets without skipLoading option', () => { + const mockMatchDetailsWithoutSkipLoading = new Map([ + [ + 'viewport-1', + { + displaySetsInfo: [ + { + displaySetInstanceUID: 'display-set-1', + displaySetOptions: { options: { skipLoading: false } }, + }, + ], + }, + ], + ]); + + const result = interleaveTopToBottom({ + ...defaultParameters, + viewportMatchDetails: mockMatchDetailsWithoutSkipLoading, + }); + + expect(result).toBeInstanceOf(Map); + }); + + it('should return early when viewport volume display set UIDs size does not match', () => { + const mockMatchDetailsWithDifferentSize = new Map([ + [ + 'viewport-1', + { + displaySetsInfo: [ + { + displaySetInstanceUID: 'display-set-1', + }, + { + displaySetInstanceUID: 'display-set-2', + }, + ], + }, + ], + ]); + + const result = interleaveTopToBottom({ + ...defaultParameters, + viewportMatchDetails: mockMatchDetailsWithDifferentSize, + }); + + expect(result).toBeUndefined(); + }); + + it('should handle multiple volume inputs', () => { + const mockVolumeInput2 = { + volumeId: 'test-volume-id-2', + }; + + const mockVolume2 = { + metadata: { + SeriesInstanceUID: 'test-series-uid-2', + }, + getImageLoadRequests: jest.fn().mockReturnValue([mockImageLoadRequest]), + }; + + (cache.getVolume as jest.Mock) + .mockReturnValueOnce(mockVolume) + .mockReturnValueOnce(mockVolume2) + .mockReturnValueOnce(mockVolume) + .mockReturnValueOnce(mockVolume2); + + const mockMatchDetailsWithMultipleVolumes = new Map([ + [ + 'viewport-1', + { + displaySetsInfo: [ + { displaySetInstanceUID: 'test-volume-id' }, + { displaySetInstanceUID: 'test-volume-id-2' }, + ], + }, + ], + ]); + + interleaveTopToBottom({ + ...defaultParameters, + data: { + ...defaultParameters.data, + volumeInputArray: [mockVolumeInput, mockVolumeInput2], + }, + viewportMatchDetails: mockMatchDetailsWithMultipleVolumes, + }); + + expect(cache.getVolume).toHaveBeenCalledWith(mockVolumeInput.volumeId); + expect(cache.getVolume).toHaveBeenCalledWith(mockVolumeInput2.volumeId); + }); + + it('should add requests to imageLoadPoolManager with correct parameters', () => { + interleaveTopToBottom(defaultParameters); + + expect(imageLoadPoolManager.addRequest).toHaveBeenCalledWith( + expect.any(Function), + Enums.RequestType.Prefetch, + mockImageLoadRequest.additionalDetails, + 0 + ); + }); + + it('should bind callLoadImage with correct parameters', () => { + interleaveTopToBottom(defaultParameters); + + const addRequestCall = (imageLoadPoolManager.addRequest as jest.Mock).mock.calls[0]; + const boundFunction = addRequestCall[0]; + + boundFunction(); + + expect(mockImageLoadRequest.callLoadImage).toHaveBeenCalledWith( + mockImageLoadRequest.imageId, + mockImageLoadRequest.imageIdIndex, + mockImageLoadRequest.options + ); + }); + + it('should handle volumes without image load requests', () => { + mockVolume.getImageLoadRequests.mockReturnValue(null); + + const result = interleaveTopToBottom(defaultParameters); + + expect(zip).toHaveBeenCalledWith(); + expect(result).toBeInstanceOf(Map); + }); + + it('should handle volumes with empty image load requests', () => { + mockVolume.getImageLoadRequests.mockReturnValue([]); + + const result = interleaveTopToBottom(defaultParameters); + + expect(zip).toHaveBeenCalledWith(); + expect(result).toBeInstanceOf(Map); + }); + + it('should handle volumes with requests without imageId', () => { + const requestWithoutImageId = { + callLoadImage: jest.fn(), + additionalDetails: 'test-details', + }; + + mockVolume.getImageLoadRequests.mockReturnValue([requestWithoutImageId]); + + const result = interleaveTopToBottom(defaultParameters); + + expect(zip).toHaveBeenCalledWith(); + expect(result).toBeInstanceOf(Map); + }); + + it('should reverse the requests before adding to AllRequests', () => { + const mockRequests = [ + { imageId: 'image-1', callLoadImage: jest.fn() }, + { imageId: 'image-2', callLoadImage: jest.fn() }, + ]; + + mockVolume.getImageLoadRequests.mockReturnValue(mockRequests); + + interleaveTopToBottom(defaultParameters); + + expect(mockVolume.getImageLoadRequests).toHaveBeenCalled(); + }); + + it('should handle duplicate volume IDs by not adding them again', () => { + const duplicateVolumeInput = { + volumeId: mockVolumeInput.volumeId, + }; + + interleaveTopToBottom({ + ...defaultParameters, + data: { + ...defaultParameters.data, + volumeInputArray: [mockVolumeInput, duplicateVolumeInput], + }, + }); + + expect(cache.getVolume).toHaveBeenCalledTimes(3); + }); + + it('should clear internal maps after processing', () => { + const firstResult = interleaveTopToBottom(defaultParameters); + const secondResult = interleaveTopToBottom(defaultParameters); + + expect(firstResult).toBeInstanceOf(Map); + expect(secondResult).toBeInstanceOf(Map); + }); + + it('should handle multiple match details with different displaySets', () => { + const mockMultipleMatchDetails = new Map([ + [ + 'viewport-1', + { + displaySetsInfo: [{ displaySetInstanceUID: 'display-set-1' }], + }, + ], + [ + 'viewport-2', + { + displaySetsInfo: [{ displaySetInstanceUID: 'display-set-2' }], + }, + ], + ]); + + const result = interleaveTopToBottom({ + ...defaultParameters, + viewportMatchDetails: mockMultipleMatchDetails, + }); + + expect(result).toBeUndefined(); + }); + + it('should handle undefined displaySetOptions', () => { + const mockMatchDetailsWithUndefinedOptions = new Map([ + [ + 'viewport-1', + { + displaySetsInfo: [ + { + displaySetInstanceUID: 'display-set-1', + displaySetOptions: undefined, + }, + ], + }, + ], + ]); + + const result = interleaveTopToBottom({ + ...defaultParameters, + viewportMatchDetails: mockMatchDetailsWithUndefinedOptions, + }); + + expect(result).toBeInstanceOf(Map); + }); + + it('should handle displaySetOptions without options property', () => { + const mockMatchDetailsWithoutOptionsProperty = new Map([ + [ + 'viewport-1', + { + displaySetsInfo: [ + { + displaySetInstanceUID: 'display-set-1', + displaySetOptions: { someOtherProperty: 'value' }, + }, + ], + }, + ], + ]); + + const result = interleaveTopToBottom({ + ...defaultParameters, + viewportMatchDetails: mockMatchDetailsWithoutOptionsProperty, + }); + + expect(result).toBeInstanceOf(Map); + }); + + it('should handle empty match details', () => { + const emptyMatchDetails = new Map(); + + const result = interleaveTopToBottom({ + ...defaultParameters, + viewportMatchDetails: emptyMatchDetails, + }); + + expect(result).toBeUndefined(); + }); + + it('should handle empty volumeInputArray', () => { + const result = interleaveTopToBottom({ + ...defaultParameters, + data: { + ...defaultParameters.data, + volumeInputArray: [], + }, + }); + + expect(result).toBeUndefined(); + }); + + it('should handle interleavedRequests processing correctly', () => { + const mockRequest1 = { imageId: 'image-1', callLoadImage: jest.fn() }; + const mockRequest2 = { imageId: 'image-2', callLoadImage: jest.fn() }; + + mockVolume.getImageLoadRequests.mockReturnValue([mockRequest1, mockRequest2]); + (compact as jest.Mock).mockReturnValue([mockRequest1, mockRequest2]); + (flatten as jest.Mock).mockReturnValue([mockRequest1, mockRequest2]); + + interleaveTopToBottom(defaultParameters); + + expect(imageLoadPoolManager.addRequest).toHaveBeenCalledTimes(2); + }); + + it('should return copy of viewport volume input array map', () => { + const result = interleaveTopToBottom(defaultParameters); + + expect(result.has(defaultParameters.data.viewportId)).toBe(true); + expect(result.get(defaultParameters.data.viewportId)).toEqual( + defaultParameters.data.volumeInputArray + ); + }); + + it('should process different viewport IDs separately', () => { + const firstResult = interleaveTopToBottom({ + ...defaultParameters, + data: { + viewportId: 'viewport-1', + volumeInputArray: [mockVolumeInput], + }, + }); + + const secondResult = interleaveTopToBottom({ + ...defaultParameters, + data: { + viewportId: 'viewport-2', + volumeInputArray: [mockVolumeInput], + }, + }); + + expect(firstResult.has('viewport-1')).toBe(true); + expect(secondResult.has('viewport-2')).toBe(true); + }); + + it('should handle requests with missing properties gracefully', () => { + const incompleteRequest = { + imageId: 'test-image-id', + callLoadImage: jest.fn(), + }; + + mockVolume.getImageLoadRequests.mockReturnValue([incompleteRequest]); + (compact as jest.Mock).mockReturnValue([incompleteRequest]); + (flatten as jest.Mock).mockReturnValue([incompleteRequest]); + + interleaveTopToBottom(defaultParameters); + + expect(imageLoadPoolManager.addRequest).toHaveBeenCalledWith( + expect.any(Function), + Enums.RequestType.Prefetch, + undefined, + 0 + ); + }); + + it('should handle multiple volumes with same requests', () => { + const mockVolume2 = { + metadata: { SeriesInstanceUID: 'test-series-uid-2' }, + getImageLoadRequests: jest.fn().mockReturnValue([mockImageLoadRequest]), + }; + + const mockVolumeInput2 = { volumeId: 'test-volume-id-2' }; + + (cache.getVolume as jest.Mock) + .mockReturnValueOnce(mockVolume) + .mockReturnValueOnce(mockVolume2) + .mockReturnValueOnce(mockVolume) + .mockReturnValueOnce(mockVolume2); + + const mockMatchDetailsWithBothVolumes = new Map([ + [ + 'viewport-1', + { + displaySetsInfo: [ + { displaySetInstanceUID: 'test-volume-id' }, + { displaySetInstanceUID: 'test-volume-id-2' }, + ], + }, + ], + ]); + + (compact as jest.Mock).mockReturnValue([mockImageLoadRequest]); + (flatten as jest.Mock).mockReturnValue([mockImageLoadRequest]); + + interleaveTopToBottom({ + ...defaultParameters, + data: { + ...defaultParameters.data, + volumeInputArray: [mockVolumeInput, mockVolumeInput2], + }, + viewportMatchDetails: mockMatchDetailsWithBothVolumes, + }); + + expect(imageLoadPoolManager.addRequest).toHaveBeenCalledTimes(2); + }); +}); diff --git a/extensions/cornerstone/src/utils/interleaveTopToBottom.ts b/extensions/cornerstone/src/utils/interleaveTopToBottom.ts index 8ef729b00..72078d4f8 100644 --- a/extensions/cornerstone/src/utils/interleaveTopToBottom.ts +++ b/extensions/cornerstone/src/utils/interleaveTopToBottom.ts @@ -39,31 +39,6 @@ export default function interleaveTopToBottom({ } } - const filteredMatchDetails = []; - const displaySetsToLoad = new Set(); - - // Check all viewports that have a displaySet to be loaded. In some cases - // (eg: line chart viewports which is not a Cornerstone viewport) the - // displaySet is created on the client and there are no instances to be - // downloaded. For those viewports the displaySet may have the `skipLoading` - // option set to true otherwise it may block the download of all other - // instances resulting in blank viewports. - Array.from(matchDetails.values()).forEach(curMatchDetails => { - const { displaySetsInfo } = curMatchDetails; - let numDisplaySetsToLoad = 0; - - displaySetsInfo.forEach(({ displaySetInstanceUID, displaySetOptions }) => { - if (!displaySetOptions?.options?.skipLoading) { - numDisplaySetsToLoad++; - displaySetsToLoad.add(displaySetInstanceUID); - } - }); - - if (numDisplaySetsToLoad) { - filteredMatchDetails.push(curMatchDetails); - } - }); - /** * The following is checking if all the viewports that were matched in the HP has been * successfully created their cornerstone viewport or not. Todo: This can be diff --git a/extensions/cornerstone/src/utils/isMeasurementWithinViewport.test.ts b/extensions/cornerstone/src/utils/isMeasurementWithinViewport.test.ts new file mode 100644 index 000000000..4f998d812 --- /dev/null +++ b/extensions/cornerstone/src/utils/isMeasurementWithinViewport.test.ts @@ -0,0 +1,340 @@ +import { isMeasurementWithinViewport } from './isMeasurementWithinViewport'; +import { getCenterExtent } from './getCenterExtent'; + +jest.mock('./getCenterExtent', () => ({ + getCenterExtent: jest.fn(), +})); + +describe('isMeasurementWithinViewport', () => { + const mockViewport = { + getCamera: jest.fn(), + }; + + const mockMeasurement = { + points: [ + [0, 0, 0], + [10, 10, 10], + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return true when measurement is within viewport extent', () => { + const mockCamera = { + focalPoint: [5, 5, 5], + parallelScale: 15, + }; + + const mockExtent = { + extent: { + min: [0, 0, 0], + max: [10, 10, 10], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(mockViewport.getCamera).toHaveBeenCalled(); + expect(getCenterExtent).toHaveBeenCalledWith(mockMeasurement); + expect(result).toBe(true); + }); + + it('should return false when measurement min point is outside viewport extent', () => { + const mockCamera = { + focalPoint: [5, 5, 5], + parallelScale: 4, + }; + + const mockExtent = { + extent: { + min: [0, 0, 0], + max: [10, 10, 10], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(false); + }); + + it('should return false when measurement max point is outside viewport extent', () => { + const mockCamera = { + focalPoint: [5, 5, 5], + parallelScale: 4, + }; + + const mockExtent = { + extent: { + min: [1, 1, 1], + max: [15, 15, 15], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(false); + }); + + it('should return false when min point exceeds parallelScale in first dimension', () => { + const mockCamera = { + focalPoint: [10, 5, 5], + parallelScale: 5, + }; + + const mockExtent = { + extent: { + min: [0, 0, 0], + max: [8, 8, 8], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(false); + }); + + it('should return false when max point exceeds parallelScale in second dimension', () => { + const mockCamera = { + focalPoint: [5, 5, 5], + parallelScale: 5, + }; + + const mockExtent = { + extent: { + min: [3, 3, 3], + max: [7, 12, 7], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(false); + }); + + it('should return false when max point exceeds parallelScale in third dimension', () => { + const mockCamera = { + focalPoint: [5, 5, 5], + parallelScale: 5, + }; + + const mockExtent = { + extent: { + min: [3, 3, 3], + max: [7, 7, 12], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(false); + }); + + it('should return true when measurement extent exactly matches parallelScale', () => { + const mockCamera = { + focalPoint: [5, 5, 5], + parallelScale: 5, + }; + + const mockExtent = { + extent: { + min: [0, 0, 0], + max: [10, 10, 10], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(true); + }); + + it('should handle negative coordinates correctly', () => { + const mockCamera = { + focalPoint: [-5, -5, -5], + parallelScale: 10, + }; + + const mockExtent = { + extent: { + min: [-10, -10, -10], + max: [0, 0, 0], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(true); + }); + + it('should handle zero parallelScale', () => { + const mockCamera = { + focalPoint: [0, 0, 0], + parallelScale: 0, + }; + + const mockExtent = { + extent: { + min: [0, 0, 0], + max: [0, 0, 0], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(true); + }); + + it('should handle zero parallelScale with non-zero extent', () => { + const mockCamera = { + focalPoint: [0, 0, 0], + parallelScale: 0, + }; + + const mockExtent = { + extent: { + min: [0, 0, 0], + max: [1, 1, 1], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(false); + }); + + it('should handle large parallelScale values', () => { + const mockCamera = { + focalPoint: [0, 0, 0], + parallelScale: 1000000, + }; + + const mockExtent = { + extent: { + min: [-500, -500, -500], + max: [500, 500, 500], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(true); + }); + + it('should handle floating point coordinates', () => { + const mockCamera = { + focalPoint: [1.5, 2.7, 3.1], + parallelScale: 5.5, + }; + + const mockExtent = { + extent: { + min: [0.1, 0.2, 0.3], + max: [2.9, 5.2, 5.9], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(true); + }); + + it('should fail when min point distance equals parallelScale plus epsilon', () => { + const mockCamera = { + focalPoint: [5, 5, 5], + parallelScale: 5, + }; + + const mockExtent = { + extent: { + min: [-0.1, 0, 0], + max: [10, 10, 10], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(false); + }); + + it('should pass when extent is within parallelScale boundary', () => { + const mockCamera = { + focalPoint: [5, 5, 5], + parallelScale: 6, + }; + + const mockExtent = { + extent: { + min: [-1, -1, -1], + max: [11, 11, 11], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(true); + }); + + it('should handle asymmetric extent around focal point', () => { + const mockCamera = { + focalPoint: [10, 10, 10], + parallelScale: 8, + }; + + const mockExtent = { + extent: { + min: [5, 5, 5], + max: [18, 18, 18], + }, + }; + + mockViewport.getCamera.mockReturnValue(mockCamera); + (getCenterExtent as jest.Mock).mockReturnValue(mockExtent); + + const result = isMeasurementWithinViewport(mockViewport, mockMeasurement); + + expect(result).toBe(true); + }); +}); diff --git a/extensions/cornerstone/src/utils/isReferenceViewable.test.ts b/extensions/cornerstone/src/utils/isReferenceViewable.test.ts new file mode 100644 index 000000000..6e9d0a101 --- /dev/null +++ b/extensions/cornerstone/src/utils/isReferenceViewable.test.ts @@ -0,0 +1,581 @@ +import { vec3 } from 'gl-matrix'; +import { Enums } from '@cornerstonejs/core'; +import getClosestOrientationFromIOP, { isReferenceViewable } from './isReferenceViewable'; + +jest.mock('gl-matrix', () => ({ + vec3: { + fromValues: jest.fn(), + cross: jest.fn(), + dot: jest.fn(), + create: jest.fn(), + }, +})); + +jest.mock('@cornerstonejs/core', () => ({ + Enums: { + OrientationAxis: { + AXIAL: 'axial', + CORONAL: 'coronal', + SAGITTAL: 'sagittal', + }, + }, +})); + +describe('isReferenceViewable', () => { + const mockCornerstoneViewportService = { + getCornerstoneViewport: jest.fn(), + }; + + const mockDisplaySetService = { + getDisplaySetByUID: jest.fn(), + }; + + const mockServicesManager = { + services: { + cornerstoneViewportService: mockCornerstoneViewportService, + displaySetService: mockDisplaySetService, + }, + }; + + const mockViewport = { + isReferenceViewable: jest.fn(), + }; + + const mockReference = { + displaySetInstanceUID: 'test-display-set-uid', + referencedImageId: 'test-image-id', + }; + + const defaultParameters = { + servicesManager: mockServicesManager, + viewportId: 'test-viewport-id', + reference: mockReference, + viewportOptions: undefined, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockCornerstoneViewportService.getCornerstoneViewport.mockReturnValue(mockViewport); + mockViewport.isReferenceViewable.mockReturnValue(true); + }); + + it('should return viewport isReferenceViewable result when viewportOptions is undefined', () => { + mockViewport.isReferenceViewable.mockReturnValue(true); + + const result = isReferenceViewable( + defaultParameters.servicesManager, + defaultParameters.viewportId, + defaultParameters.reference + ); + + expect(mockCornerstoneViewportService.getCornerstoneViewport).toHaveBeenCalledWith( + defaultParameters.viewportId + ); + expect(mockViewport.isReferenceViewable).toHaveBeenCalledWith(mockReference, { + withNavigation: true, + asVolume: true, + }); + expect(result).toBe(true); + }); + + it('should return false when viewport isReferenceViewable returns false', () => { + mockViewport.isReferenceViewable.mockReturnValue(false); + + const result = isReferenceViewable( + defaultParameters.servicesManager, + defaultParameters.viewportId, + defaultParameters.reference + ); + + expect(result).toBe(false); + }); + + it('should check imageIds inclusion for stack viewport type', () => { + const mockDisplaySet = { + instances: [{ imageId: 'image-1' }, { imageId: 'test-image-id' }, { imageId: 'image-3' }], + }; + + const viewportOptions = { + viewportType: 'stack', + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const result = isReferenceViewable( + defaultParameters.servicesManager, + defaultParameters.viewportId, + defaultParameters.reference, + viewportOptions + ); + + expect(mockDisplaySetService.getDisplaySetByUID).toHaveBeenCalledWith( + mockReference.displaySetInstanceUID + ); + expect(result).toBe(true); + }); + + it('should return false when imageId is not in stack', () => { + const mockDisplaySet = { + instances: [{ imageId: 'image-1' }, { imageId: 'image-2' }, { imageId: 'image-3' }], + }; + + const viewportOptions = { + viewportType: 'stack', + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const result = isReferenceViewable( + defaultParameters.servicesManager, + defaultParameters.viewportId, + defaultParameters.reference, + viewportOptions + ); + + expect(result).toBe(false); + }); + + it('should check orientation for volume viewport type', () => { + const mockDisplaySet = { + instances: [ + { + ImageOrientationPatient: [1, 0, 0, 0, 1, 0], + }, + ], + }; + + const viewportOptions = { + viewportType: 'volume', + orientation: 'axial', + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const mockRowCosineVec = [1, 0, 0]; + const mockColCosineVec = [0, 1, 0]; + const mockScanAxisNormal = [0, 0, 1]; + const mockAxialVector = [0, 0, 1]; + + (vec3.fromValues as jest.Mock) + .mockReturnValueOnce(mockRowCosineVec) + .mockReturnValueOnce(mockColCosineVec) + .mockReturnValueOnce(mockAxialVector); + + (vec3.create as jest.Mock).mockReturnValue([]); + (vec3.cross as jest.Mock).mockReturnValue(mockScanAxisNormal); + (vec3.dot as jest.Mock).mockReturnValue(1); + + const result = isReferenceViewable( + defaultParameters.servicesManager, + defaultParameters.viewportId, + defaultParameters.reference, + viewportOptions + ); + + expect(result).toBe(true); + }); + + it('should return false when orientation does not match', () => { + const mockDisplaySet = { + instances: [ + { + ImageOrientationPatient: [1, 0, 0, 0, 1, 0], + }, + ], + }; + + const viewportOptions = { + viewportType: 'volume', + orientation: 'coronal', + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const mockRowCosineVec = [1, 0, 0]; + const mockColCosineVec = [0, 1, 0]; + const mockScanAxisNormal = [0, 0, 1]; + const mockAxialVector = [0, 0, 1]; + + (vec3.fromValues as jest.Mock) + .mockReturnValueOnce(mockRowCosineVec) + .mockReturnValueOnce(mockColCosineVec) + .mockReturnValueOnce(mockAxialVector); + + (vec3.create as jest.Mock).mockReturnValue([]); + (vec3.cross as jest.Mock).mockReturnValue(mockScanAxisNormal); + (vec3.dot as jest.Mock).mockReturnValue(1); + + const result = isReferenceViewable( + defaultParameters.servicesManager, + defaultParameters.viewportId, + defaultParameters.reference, + viewportOptions + ); + + expect(result).toBe(false); + }); + + it('should handle empty instances array for stack viewport', () => { + const mockDisplaySet = { + instances: [], + }; + + const viewportOptions = { + viewportType: 'stack', + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const result = isReferenceViewable( + defaultParameters.servicesManager, + defaultParameters.viewportId, + defaultParameters.reference, + viewportOptions + ); + + expect(result).toBe(false); + }); +}); + +describe('getClosestOrientationFromIOP', () => { + const mockDisplaySetService = { + getDisplaySetByUID: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return undefined when ImageOrientationPatient is not length 6', () => { + const mockDisplaySet = { + instances: [ + { + ImageOrientationPatient: [1, 0, 0, 0, 1], + }, + ], + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const result = getClosestOrientationFromIOP(mockDisplaySetService, 'test-display-set-uid'); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when ImageOrientationPatient is undefined', () => { + const mockDisplaySet = { + instances: [ + { + ImageOrientationPatient: undefined, + }, + ], + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const result = getClosestOrientationFromIOP(mockDisplaySetService, 'test-display-set-uid'); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when ImageOrientationPatient is null', () => { + const mockDisplaySet = { + instances: [ + { + ImageOrientationPatient: null, + }, + ], + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const result = getClosestOrientationFromIOP(mockDisplaySetService, 'test-display-set-uid'); + + expect(result).toBeUndefined(); + }); + + it('should return axial orientation for axial-aligned IOP', () => { + const mockDisplaySet = { + instances: [ + { + ImageOrientationPatient: [1, 0, 0, 0, 1, 0], + }, + ], + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const mockRowCosineVec = [1, 0, 0]; + const mockColCosineVec = [0, 1, 0]; + const mockScanAxisNormal = [0, 0, 1]; + const mockAxialVector = [0, 0, 1]; + const mockCoronalVector = [0, 1, 0]; + const mockSagittalVector = [1, 0, 0]; + + (vec3.fromValues as jest.Mock) + .mockReturnValueOnce(mockRowCosineVec) + .mockReturnValueOnce(mockColCosineVec) + .mockReturnValueOnce(mockAxialVector) + .mockReturnValueOnce(mockCoronalVector) + .mockReturnValueOnce(mockSagittalVector); + + (vec3.create as jest.Mock).mockReturnValue([]); + (vec3.cross as jest.Mock).mockReturnValue(mockScanAxisNormal); + (vec3.dot as jest.Mock) + .mockReturnValueOnce(1.0) + .mockReturnValueOnce(0.0) + .mockReturnValueOnce(0.0); + + const result = getClosestOrientationFromIOP(mockDisplaySetService, 'test-display-set-uid'); + + expect(result).toBe(Enums.OrientationAxis.AXIAL); + }); + + it('should return coronal orientation for coronal-aligned IOP', () => { + const mockDisplaySet = { + instances: [ + { + ImageOrientationPatient: [1, 0, 0, 0, 0, -1], + }, + ], + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const mockRowCosineVec = [1, 0, 0]; + const mockColCosineVec = [0, 0, -1]; + const mockScanAxisNormal = [0, 1, 0]; + const mockAxialVector = [0, 0, 1]; + const mockCoronalVector = [0, 1, 0]; + const mockSagittalVector = [1, 0, 0]; + + (vec3.fromValues as jest.Mock) + .mockReturnValueOnce(mockRowCosineVec) + .mockReturnValueOnce(mockColCosineVec) + .mockReturnValueOnce(mockAxialVector) + .mockReturnValueOnce(mockCoronalVector) + .mockReturnValueOnce(mockSagittalVector); + + (vec3.create as jest.Mock).mockReturnValue([]); + (vec3.cross as jest.Mock).mockReturnValue(mockScanAxisNormal); + (vec3.dot as jest.Mock) + .mockReturnValueOnce(0.0) + .mockReturnValueOnce(1.0) + .mockReturnValueOnce(0.0); + + const result = getClosestOrientationFromIOP(mockDisplaySetService, 'test-display-set-uid'); + + expect(result).toBe(Enums.OrientationAxis.CORONAL); + }); + + it('should return sagittal orientation for sagittal-aligned IOP', () => { + const mockDisplaySet = { + instances: [ + { + ImageOrientationPatient: [0, 1, 0, 0, 0, -1], + }, + ], + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const mockRowCosineVec = [0, 1, 0]; + const mockColCosineVec = [0, 0, -1]; + const mockScanAxisNormal = [1, 0, 0]; + const mockAxialVector = [0, 0, 1]; + const mockCoronalVector = [0, 1, 0]; + const mockSagittalVector = [1, 0, 0]; + + (vec3.fromValues as jest.Mock) + .mockReturnValueOnce(mockRowCosineVec) + .mockReturnValueOnce(mockColCosineVec) + .mockReturnValueOnce(mockAxialVector) + .mockReturnValueOnce(mockCoronalVector) + .mockReturnValueOnce(mockSagittalVector); + + (vec3.create as jest.Mock).mockReturnValue([]); + (vec3.cross as jest.Mock).mockReturnValue(mockScanAxisNormal); + (vec3.dot as jest.Mock) + .mockReturnValueOnce(0.0) + .mockReturnValueOnce(0.0) + .mockReturnValueOnce(1.0); + + const result = getClosestOrientationFromIOP(mockDisplaySetService, 'test-display-set-uid'); + + expect(result).toBe(Enums.OrientationAxis.SAGITTAL); + }); + + it('should handle negative dot products and return correct orientation', () => { + const mockDisplaySet = { + instances: [ + { + ImageOrientationPatient: [1, 0, 0, 0, -1, 0], + }, + ], + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const mockRowCosineVec = [1, 0, 0]; + const mockColCosineVec = [0, -1, 0]; + const mockScanAxisNormal = [0, 0, -1]; + const mockAxialVector = [0, 0, 1]; + const mockCoronalVector = [0, 1, 0]; + const mockSagittalVector = [1, 0, 0]; + + (vec3.fromValues as jest.Mock) + .mockReturnValueOnce(mockRowCosineVec) + .mockReturnValueOnce(mockColCosineVec) + .mockReturnValueOnce(mockAxialVector) + .mockReturnValueOnce(mockCoronalVector) + .mockReturnValueOnce(mockSagittalVector); + + (vec3.create as jest.Mock).mockReturnValue([]); + (vec3.cross as jest.Mock).mockReturnValue(mockScanAxisNormal); + (vec3.dot as jest.Mock) + .mockReturnValueOnce(-1.0) + .mockReturnValueOnce(0.0) + .mockReturnValueOnce(0.0); + + const result = getClosestOrientationFromIOP(mockDisplaySetService, 'test-display-set-uid'); + + expect(result).toBe(Enums.OrientationAxis.AXIAL); + }); + + it('should handle oblique orientations and return closest match', () => { + const mockDisplaySet = { + instances: [ + { + ImageOrientationPatient: [0.7071, 0.7071, 0, 0, 0, 1], + }, + ], + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const mockRowCosineVec = [0.7071, 0.7071, 0]; + const mockColCosineVec = [0, 0, 1]; + const mockScanAxisNormal = [0.7071, -0.7071, 0]; + const mockAxialVector = [0, 0, 1]; + const mockCoronalVector = [0, 1, 0]; + const mockSagittalVector = [1, 0, 0]; + + (vec3.fromValues as jest.Mock) + .mockReturnValueOnce(mockRowCosineVec) + .mockReturnValueOnce(mockColCosineVec) + .mockReturnValueOnce(mockAxialVector) + .mockReturnValueOnce(mockCoronalVector) + .mockReturnValueOnce(mockSagittalVector); + + (vec3.create as jest.Mock).mockReturnValue([]); + (vec3.cross as jest.Mock).mockReturnValue(mockScanAxisNormal); + (vec3.dot as jest.Mock) + .mockReturnValueOnce(0.0) + .mockReturnValueOnce(0.7071) + .mockReturnValueOnce(0.7071); + + const result = getClosestOrientationFromIOP(mockDisplaySetService, 'test-display-set-uid'); + + expect(result).toBe(Enums.OrientationAxis.CORONAL); + }); + + it('should handle zero dot products', () => { + const mockDisplaySet = { + instances: [ + { + ImageOrientationPatient: [1, 0, 0, 0, 1, 0], + }, + ], + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const mockRowCosineVec = [1, 0, 0]; + const mockColCosineVec = [0, 1, 0]; + const mockScanAxisNormal = [0, 0, 1]; + const mockAxialVector = [0, 0, 1]; + const mockCoronalVector = [0, 1, 0]; + const mockSagittalVector = [1, 0, 0]; + + (vec3.fromValues as jest.Mock) + .mockReturnValueOnce(mockRowCosineVec) + .mockReturnValueOnce(mockColCosineVec) + .mockReturnValueOnce(mockAxialVector) + .mockReturnValueOnce(mockCoronalVector) + .mockReturnValueOnce(mockSagittalVector); + + (vec3.create as jest.Mock).mockReturnValue([]); + (vec3.cross as jest.Mock).mockReturnValue(mockScanAxisNormal); + (vec3.dot as jest.Mock) + .mockReturnValueOnce(0.0) + .mockReturnValueOnce(0.0) + .mockReturnValueOnce(0.0); + + const result = getClosestOrientationFromIOP(mockDisplaySetService, 'test-display-set-uid'); + + expect(result).toBe(''); + }); + + it('should call vec3 functions with correct parameters', () => { + const mockDisplaySet = { + instances: [ + { + ImageOrientationPatient: [1, 0, 0, 0, 1, 0], + }, + ], + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const mockRowCosineVec = [1, 0, 0]; + const mockColCosineVec = [0, 1, 0]; + const mockCreatedVec = []; + + (vec3.fromValues as jest.Mock) + .mockReturnValueOnce(mockRowCosineVec) + .mockReturnValueOnce(mockColCosineVec); + + (vec3.create as jest.Mock).mockReturnValue(mockCreatedVec); + (vec3.cross as jest.Mock).mockReturnValue([0, 0, 1]); + (vec3.dot as jest.Mock).mockReturnValue(1.0); + + getClosestOrientationFromIOP(mockDisplaySetService, 'test-display-set-uid'); + + expect(vec3.fromValues).toHaveBeenNthCalledWith(1, 1, 0, 0); + expect(vec3.fromValues).toHaveBeenNthCalledWith(2, 0, 1, 0); + expect(vec3.cross).toHaveBeenCalledWith(mockCreatedVec, mockRowCosineVec, mockColCosineVec); + }); + + it('should handle floating point IOP values', () => { + const mockDisplaySet = { + instances: [ + { + ImageOrientationPatient: [0.866, 0.5, 0, -0.5, 0.866, 0], + }, + ], + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + const mockRowCosineVec = [0.866, 0.5, 0]; + const mockColCosineVec = [-0.5, 0.866, 0]; + const mockScanAxisNormal = [0, 0, 1]; + + (vec3.fromValues as jest.Mock) + .mockReturnValueOnce(mockRowCosineVec) + .mockReturnValueOnce(mockColCosineVec); + + (vec3.create as jest.Mock).mockReturnValue([]); + (vec3.cross as jest.Mock).mockReturnValue(mockScanAxisNormal); + (vec3.dot as jest.Mock) + .mockReturnValueOnce(1.0) + .mockReturnValueOnce(0.0) + .mockReturnValueOnce(0.0); + + const result = getClosestOrientationFromIOP(mockDisplaySetService, 'test-display-set-uid'); + + expect(vec3.fromValues).toHaveBeenNthCalledWith(1, 0.866, 0.5, 0); + expect(vec3.fromValues).toHaveBeenNthCalledWith(2, -0.5, 0.866, 0); + expect(result).toBe(Enums.OrientationAxis.AXIAL); + }); +}); diff --git a/extensions/cornerstone/src/utils/nthLoader.test.ts b/extensions/cornerstone/src/utils/nthLoader.test.ts new file mode 100644 index 000000000..f1d0dcc37 --- /dev/null +++ b/extensions/cornerstone/src/utils/nthLoader.test.ts @@ -0,0 +1,317 @@ +import { cache, imageLoadPoolManager, Enums as csEnums } from '@cornerstonejs/core'; +import interleaveNthLoader from './nthLoader'; +import getNthFrames from './getNthFrames'; +import interleave from './interleave'; + +jest.mock('@cornerstonejs/core', () => ({ + ...jest.requireActual('@cornerstonejs/core'), + cache: { + getVolume: jest.fn(), + }, + imageLoadPoolManager: { + addRequest: jest.fn(), + }, +})); + +jest.mock('./getNthFrames', () => jest.fn()); +jest.mock('./interleave', () => jest.fn()); + +describe('interleaveNthLoader', () => { + const mockVolumeInput = { + volumeId: 'test-volume-id', + }; + + const mockVolume = { + metadata: { + SeriesInstanceUID: 'test-series-uid', + }, + getImageLoadRequests: jest.fn(), + }; + + const mockImageLoadRequest = { + imageId: 'test-image-id', + callLoadImage: jest.fn(), + additionalDetails: 'test-details', + imageIdIndex: 0, + options: { test: 'option' }, + }; + + const defaultParameters = { + data: { + viewportId: 'test-viewport-id', + volumeInputArray: [mockVolumeInput], + }, + displaySetsMatchDetails: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (cache.getVolume as jest.Mock).mockReturnValue(mockVolume); + mockVolume.getImageLoadRequests.mockReturnValue([mockImageLoadRequest]); + (getNthFrames as jest.Mock).mockImplementation(input => input); + (interleave as jest.Mock).mockImplementation(input => input.flat()); + }); + + it('should store viewport and volume input array mapping', () => { + interleaveNthLoader(defaultParameters); + + expect(cache.getVolume).toHaveBeenCalledWith(mockVolumeInput.volumeId); + }); + + it('should return early when volume is not found', () => { + (cache.getVolume as jest.Mock).mockReturnValue(null); + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + const result = interleaveNthLoader(defaultParameters); + + expect(consoleSpy).toHaveBeenCalledWith("interleaveNthLoader::No volume, can't load it"); + expect(result).toBeUndefined(); + expect(mockVolume.getImageLoadRequests).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should process volume input array and create image load requests', () => { + const result = interleaveNthLoader(defaultParameters); + + expect(cache.getVolume).toHaveBeenCalledWith(mockVolumeInput.volumeId); + expect(mockVolume.getImageLoadRequests).toHaveBeenCalled(); + expect(getNthFrames).toHaveBeenCalledWith([mockImageLoadRequest]); + expect(interleave).toHaveBeenCalledWith([[mockImageLoadRequest]]); + expect(result).toBeInstanceOf(Map); + }); + + it('should add requests to imageLoadPoolManager with correct parameters', () => { + interleaveNthLoader(defaultParameters); + + expect(imageLoadPoolManager.addRequest).toHaveBeenCalledWith( + expect.any(Function), + csEnums.RequestType.Prefetch, + mockImageLoadRequest.additionalDetails, + 0 + ); + }); + + it('should handle multiple volume inputs', () => { + const mockVolumeInput2 = { + volumeId: 'test-volume-id-2', + }; + + const mockVolume2 = { + metadata: { + SeriesInstanceUID: 'test-series-uid-2', + }, + getImageLoadRequests: jest.fn().mockReturnValue([mockImageLoadRequest]), + }; + + (cache.getVolume as jest.Mock) + .mockReturnValueOnce(mockVolume) + .mockReturnValueOnce(mockVolume2) + .mockReturnValueOnce(mockVolume) + .mockReturnValueOnce(mockVolume2); + + interleaveNthLoader({ + ...defaultParameters, + data: { + ...defaultParameters.data, + volumeInputArray: [mockVolumeInput, mockVolumeInput2], + }, + }); + + expect(cache.getVolume).toHaveBeenCalledWith(mockVolumeInput.volumeId); + expect(cache.getVolume).toHaveBeenCalledWith(mockVolumeInput2.volumeId); + expect(mockVolume.getImageLoadRequests).toHaveBeenCalled(); + expect(mockVolume2.getImageLoadRequests).toHaveBeenCalled(); + }); + + it('should handle volumes without getImageLoadRequests method', () => { + const mockVolumeWithoutMethod = { + metadata: { + SeriesInstanceUID: 'test-series-uid', + }, + }; + + (cache.getVolume as jest.Mock) + .mockReturnValueOnce(mockVolumeWithoutMethod) + .mockReturnValueOnce(mockVolumeWithoutMethod); + + interleaveNthLoader(defaultParameters); + + expect(getNthFrames).not.toHaveBeenCalled(); + expect(interleave).toHaveBeenCalledWith([]); + }); + + it('should handle volumes with null getImageLoadRequests return', () => { + mockVolume.getImageLoadRequests.mockReturnValue(null); + + interleaveNthLoader(defaultParameters); + + expect(getNthFrames).not.toHaveBeenCalled(); + expect(interleave).toHaveBeenCalledWith([]); + }); + + it('should filter out requests without imageId in first request', () => { + const requestWithoutImageId = { + callLoadImage: jest.fn(), + additionalDetails: 'test-details', + }; + + mockVolume.getImageLoadRequests + .mockReturnValueOnce([requestWithoutImageId, mockImageLoadRequest]) + .mockReturnValueOnce([mockImageLoadRequest]); + + interleaveNthLoader({ + ...defaultParameters, + data: { + ...defaultParameters.data, + volumeInputArray: [mockVolumeInput, { volumeId: 'test-volume-id-2' }], + }, + }); + + expect(getNthFrames).toHaveBeenCalledWith([mockImageLoadRequest]); + }); + + it('should handle empty image load requests', () => { + mockVolume.getImageLoadRequests.mockReturnValue([]); + + interleaveNthLoader(defaultParameters); + + expect(getNthFrames).not.toHaveBeenCalled(); + expect(interleave).toHaveBeenCalledWith([]); + }); + + it('should bind callLoadImage with correct parameters', () => { + interleaveNthLoader(defaultParameters); + + const addRequestCall = (imageLoadPoolManager.addRequest as jest.Mock).mock.calls[0]; + const boundFunction = addRequestCall[0]; + + boundFunction(); + + expect(mockImageLoadRequest.callLoadImage).toHaveBeenCalledWith( + mockImageLoadRequest.imageId, + mockImageLoadRequest.imageIdIndex, + mockImageLoadRequest.options + ); + }); + + it('should handle duplicate volume IDs by not adding them again', () => { + const duplicateVolumeInput = { + volumeId: mockVolumeInput.volumeId, + }; + + interleaveNthLoader({ + ...defaultParameters, + data: { + ...defaultParameters.data, + volumeInputArray: [mockVolumeInput, duplicateVolumeInput], + }, + }); + + expect(cache.getVolume).toHaveBeenCalledTimes(3); + expect(cache.getVolume).toHaveBeenCalledWith(mockVolumeInput.volumeId); + }); + + it('should clear internal maps after processing', () => { + const firstResult = interleaveNthLoader(defaultParameters); + const secondResult = interleaveNthLoader(defaultParameters); + + expect(firstResult).toBeInstanceOf(Map); + expect(secondResult).toBeInstanceOf(Map); + expect(firstResult.get(defaultParameters.data.viewportId)).toEqual( + defaultParameters.data.volumeInputArray + ); + }); + + it('should handle multiple image load requests', () => { + const mockImageLoadRequest2 = { + imageId: 'test-image-id-2', + callLoadImage: jest.fn(), + additionalDetails: 'test-details-2', + imageIdIndex: 1, + options: { test: 'option2' }, + }; + + mockVolume.getImageLoadRequests.mockReturnValue([mockImageLoadRequest, mockImageLoadRequest2]); + + (interleave as jest.Mock).mockReturnValue([mockImageLoadRequest, mockImageLoadRequest2]); + + interleaveNthLoader(defaultParameters); + + expect(imageLoadPoolManager.addRequest).toHaveBeenCalledTimes(2); + expect(imageLoadPoolManager.addRequest).toHaveBeenNthCalledWith( + 1, + expect.any(Function), + csEnums.RequestType.Prefetch, + mockImageLoadRequest.additionalDetails, + 0 + ); + expect(imageLoadPoolManager.addRequest).toHaveBeenNthCalledWith( + 2, + expect.any(Function), + csEnums.RequestType.Prefetch, + mockImageLoadRequest2.additionalDetails, + 0 + ); + }); + + it('should handle volume with undefined metadata', () => { + const mockVolumeWithoutMetadata = { + getImageLoadRequests: jest.fn().mockReturnValue([]), + }; + + (cache.getVolume as jest.Mock).mockReturnValueOnce(mockVolumeWithoutMetadata); + + expect(() => interleaveNthLoader(defaultParameters)).toThrow(); + }); + + it('should return copy of viewport volume input array map', () => { + const result = interleaveNthLoader(defaultParameters); + + expect(result.has(defaultParameters.data.viewportId)).toBe(true); + expect(result.get(defaultParameters.data.viewportId)).toEqual( + defaultParameters.data.volumeInputArray + ); + }); + + it('should handle requests with missing properties gracefully', () => { + const incompleteRequest = { + imageId: 'test-image-id', + callLoadImage: jest.fn(), + }; + + mockVolume.getImageLoadRequests.mockReturnValue([incompleteRequest]); + (interleave as jest.Mock).mockReturnValue([incompleteRequest]); + + interleaveNthLoader(defaultParameters); + + expect(imageLoadPoolManager.addRequest).toHaveBeenCalledWith( + expect.any(Function), + csEnums.RequestType.Prefetch, + undefined, + 0 + ); + }); + + it('should process different viewport IDs separately', () => { + const firstResult = interleaveNthLoader({ + ...defaultParameters, + data: { + viewportId: 'viewport-1', + volumeInputArray: [mockVolumeInput], + }, + }); + + const secondResult = interleaveNthLoader({ + ...defaultParameters, + data: { + viewportId: 'viewport-2', + volumeInputArray: [mockVolumeInput], + }, + }); + + expect(firstResult.has('viewport-1')).toBe(true); + expect(secondResult.has('viewport-2')).toBe(true); + expect(firstResult.has('viewport-2')).toBe(false); + }); +}); diff --git a/extensions/cornerstone/src/utils/promptHydrationDialog.test.ts b/extensions/cornerstone/src/utils/promptHydrationDialog.test.ts new file mode 100644 index 000000000..73daa2f8e --- /dev/null +++ b/extensions/cornerstone/src/utils/promptHydrationDialog.test.ts @@ -0,0 +1,490 @@ +import promptHydrationDialog, { HydrationType } from './promptHydrationDialog'; + +describe('promptHydrationDialog', () => { + const mockUIViewportDialogService = { + show: jest.fn(), + hide: jest.fn(), + }; + + const mockCustomizationService = { + getCustomization: jest.fn(), + }; + + const mockExtensionManager = { + _appConfig: { + measurementTrackingMode: 'standard', + disableConfirmationPrompts: false, + }, + }; + + const mockServicesManager = { + services: { + uiViewportDialogService: mockUIViewportDialogService, + customizationService: mockCustomizationService, + }, + _extensionManager: mockExtensionManager, + }; + + const mockDisplaySet = { + displaySetInstanceUID: 'test-display-set-uid', + SeriesInstanceUID: 'test-series-uid', + }; + + const mockHydrateCallback = jest.fn(); + const mockPreHydrateCallback = jest.fn(); + + const defaultParameters = { + servicesManager: mockServicesManager as unknown as AppTypes.ServicesManager, + viewportId: 'test-viewport-id', + displaySet: mockDisplaySet as unknown as AppTypes.DisplaySet, + preHydrateCallbacks: [mockPreHydrateCallback], + hydrateCallback: mockHydrateCallback, + type: HydrationType.SEG, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockCustomizationService.getCustomization.mockReturnValue('Test message'); + mockHydrateCallback.mockResolvedValue(true); + jest.spyOn(window, 'setTimeout').mockImplementation((callback: () => void) => { + callback(); + return 0 as unknown as NodeJS.Timeout; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return HYDRATE response when disableConfirmationPrompts is true for non-SR types', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = true; + + const result = await promptHydrationDialog(defaultParameters); + + expect(mockUIViewportDialogService.show).not.toHaveBeenCalled(); + expect(mockPreHydrateCallback).toHaveBeenCalled(); + expect(mockHydrateCallback).toHaveBeenCalledWith({ + segDisplaySet: mockDisplaySet, + viewportId: defaultParameters.viewportId, + }); + expect(result).toBe(true); + }); + + it('should show dialog when disableConfirmationPrompts is false for non-SR types', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = false; + + const promise = promptHydrationDialog(defaultParameters); + + expect(mockUIViewportDialogService.show).toHaveBeenCalledWith({ + id: 'promptHydrateSEG', + viewportId: defaultParameters.viewportId, + type: 'info', + message: 'Test message', + actions: [ + { + id: 'no-hydrate', + type: 'secondary', + text: 'No', + value: 0, + }, + { + id: 'yes-hydrate', + type: 'primary', + text: 'Yes', + value: 5, + }, + ], + onSubmit: expect.any(Function), + onOutsideClick: expect.any(Function), + onKeyPress: expect.any(Function), + }); + + const onSubmit = mockUIViewportDialogService.show.mock.calls[0][0].onSubmit; + onSubmit(5); + + const result = await promise; + expect(result).toBe(true); + }); + + it('should handle CANCEL response', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = false; + + const promise = promptHydrationDialog(defaultParameters); + + const onSubmit = mockUIViewportDialogService.show.mock.calls[0][0].onSubmit; + onSubmit(0); + + const result = await promise; + expect(result).toBe(false); + expect(mockHydrateCallback).not.toHaveBeenCalled(); + }); + + it('should handle onOutsideClick', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = false; + + const promise = promptHydrationDialog(defaultParameters); + + const onOutsideClick = mockUIViewportDialogService.show.mock.calls[0][0].onOutsideClick; + onOutsideClick(); + + const result = await promise; + expect(result).toBe(false); + expect(mockUIViewportDialogService.hide).toHaveBeenCalled(); + }); + + it('should handle onKeyPress Enter', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = false; + + const promise = promptHydrationDialog(defaultParameters); + + const onKeyPress = mockUIViewportDialogService.show.mock.calls[0][0].onKeyPress; + onKeyPress({ key: 'Enter' }); + + const result = await promise; + expect(result).toBe(true); + expect(mockUIViewportDialogService.hide).toHaveBeenCalled(); + }); + + it('should handle onKeyPress non-Enter key', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = false; + + const promise = promptHydrationDialog(defaultParameters); + + const onKeyPress = mockUIViewportDialogService.show.mock.calls[0][0].onKeyPress; + onKeyPress({ key: 'Escape' }); + + expect(mockUIViewportDialogService.hide).not.toHaveBeenCalled(); + + const onOutsideClick = mockUIViewportDialogService.show.mock.calls[0][0].onOutsideClick; + onOutsideClick(); + + const result = await promise; + expect(result).toBe(false); + }); + + it('should handle SEG type hydration with setTimeout', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = true; + + await promptHydrationDialog({ + ...defaultParameters, + type: HydrationType.SEG, + }); + + expect(window.setTimeout).toHaveBeenCalledWith(expect.any(Function), 0); + expect(mockHydrateCallback).toHaveBeenCalledWith({ + segDisplaySet: mockDisplaySet, + viewportId: defaultParameters.viewportId, + }); + }); + + it('should handle RTSTRUCT type hydration', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = true; + + const result = await promptHydrationDialog({ + ...defaultParameters, + type: HydrationType.RTSTRUCT, + }); + + expect(mockHydrateCallback).toHaveBeenCalledWith({ + rtDisplaySet: mockDisplaySet, + viewportId: defaultParameters.viewportId, + servicesManager: mockServicesManager, + }); + expect(result).toBe(true); + }); + + it('should handle SR type hydration when standardMode is true', async () => { + mockExtensionManager._appConfig.measurementTrackingMode = 'standard'; + const mockHydrationResult = { + StudyInstanceUID: 'test-study-uid', + SeriesInstanceUIDs: ['series-1', 'series-2'], + }; + mockHydrateCallback.mockResolvedValue(mockHydrationResult); + + const promise = promptHydrationDialog({ + ...defaultParameters, + type: HydrationType.SR, + }); + + const onSubmit = mockUIViewportDialogService.show.mock.calls[0][0].onSubmit; + onSubmit(5); + + const result = await promise; + + expect(result).toEqual({ + userResponse: 5, + displaySetInstanceUID: mockDisplaySet.displaySetInstanceUID, + srSeriesInstanceUID: mockDisplaySet.SeriesInstanceUID, + viewportId: defaultParameters.viewportId, + StudyInstanceUID: mockHydrationResult.StudyInstanceUID, + SeriesInstanceUIDs: mockHydrationResult.SeriesInstanceUIDs, + }); + }); + + it('should handle SR type hydration when standardMode is false', async () => { + mockExtensionManager._appConfig.measurementTrackingMode = 'other'; + + const result = await promptHydrationDialog({ + ...defaultParameters, + type: HydrationType.SR, + }); + + expect(mockUIViewportDialogService.show).not.toHaveBeenCalled(); + expect(result).toEqual({ + userResponse: 5, + displaySetInstanceUID: mockDisplaySet.displaySetInstanceUID, + srSeriesInstanceUID: mockDisplaySet.SeriesInstanceUID, + viewportId: defaultParameters.viewportId, + StudyInstanceUID: undefined, + SeriesInstanceUIDs: undefined, + }); + }); + + it('should handle SR type with CANCEL response', async () => { + mockExtensionManager._appConfig.measurementTrackingMode = 'standard'; + + const promise = promptHydrationDialog({ + ...defaultParameters, + type: HydrationType.SR, + }); + + const onSubmit = mockUIViewportDialogService.show.mock.calls[0][0].onSubmit; + onSubmit(0); + + const result = await promise; + + expect(result).toEqual({ + userResponse: 0, + displaySetInstanceUID: mockDisplaySet.displaySetInstanceUID, + srSeriesInstanceUID: mockDisplaySet.SeriesInstanceUID, + viewportId: defaultParameters.viewportId, + }); + }); + + it('should handle undefined preHydrateCallbacks', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = true; + + await promptHydrationDialog({ + ...defaultParameters, + preHydrateCallbacks: undefined, + }); + + expect(mockHydrateCallback).toHaveBeenCalled(); + }); + + it('should handle empty preHydrateCallbacks array', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = true; + + await promptHydrationDialog({ + ...defaultParameters, + preHydrateCallbacks: [], + }); + + expect(mockHydrateCallback).toHaveBeenCalled(); + }); + + it('should execute multiple preHydrateCallbacks', async () => { + const mockPreHydrateCallback2 = jest.fn(); + mockExtensionManager._appConfig.disableConfirmationPrompts = true; + + await promptHydrationDialog({ + ...defaultParameters, + preHydrateCallbacks: [mockPreHydrateCallback, mockPreHydrateCallback2], + }); + + expect(mockPreHydrateCallback).toHaveBeenCalled(); + expect(mockPreHydrateCallback2).toHaveBeenCalled(); + }); + + it('should get correct customization message key for SEG type', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = false; + + const promise = promptHydrationDialog({ + ...defaultParameters, + type: HydrationType.SEG, + }); + + expect(mockCustomizationService.getCustomization).toHaveBeenCalledWith( + 'viewportNotification.hydrateSEGMessage' + ); + + const onOutsideClick = mockUIViewportDialogService.show.mock.calls[0][0].onOutsideClick; + onOutsideClick(); + await promise; + }); + + it('should get correct customization message key for RTSTRUCT type', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = false; + + const promise = promptHydrationDialog({ + ...defaultParameters, + type: HydrationType.RTSTRUCT, + }); + + expect(mockCustomizationService.getCustomization).toHaveBeenCalledWith( + 'viewportNotification.hydrateRTMessage' + ); + + const onOutsideClick = mockUIViewportDialogService.show.mock.calls[0][0].onOutsideClick; + onOutsideClick(); + await promise; + }); + + it('should get correct customization message key for SR type', async () => { + mockExtensionManager._appConfig.measurementTrackingMode = 'standard'; + + const promise = promptHydrationDialog({ + ...defaultParameters, + type: HydrationType.SR, + }); + + expect(mockCustomizationService.getCustomization).toHaveBeenCalledWith( + 'viewportNotification.hydrateSRMessage' + ); + + const onOutsideClick = mockUIViewportDialogService.show.mock.calls[0][0].onOutsideClick; + onOutsideClick(); + await promise; + }); + + it('should get default customization message key for unknown type', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = false; + + const promise = promptHydrationDialog({ + ...defaultParameters, + type: 'UNKNOWN_TYPE', + }); + + expect(mockCustomizationService.getCustomization).toHaveBeenCalledWith( + 'viewportNotification.hydrateMessage' + ); + + const onOutsideClick = mockUIViewportDialogService.show.mock.calls[0][0].onOutsideClick; + onOutsideClick(); + await promise; + }); + + it('should get correct dialog id for SEG type', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = false; + + const promise = promptHydrationDialog({ + ...defaultParameters, + type: HydrationType.SEG, + }); + + expect(mockUIViewportDialogService.show).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'promptHydrateSEG', + }) + ); + + const onOutsideClick = mockUIViewportDialogService.show.mock.calls[0][0].onOutsideClick; + onOutsideClick(); + await promise; + }); + + it('should get correct dialog id for SR type', async () => { + mockExtensionManager._appConfig.measurementTrackingMode = 'standard'; + + const promise = promptHydrationDialog({ + ...defaultParameters, + type: HydrationType.SR, + }); + + expect(mockUIViewportDialogService.show).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'promptHydrateSR', + }) + ); + + const onOutsideClick = mockUIViewportDialogService.show.mock.calls[0][0].onOutsideClick; + onOutsideClick(); + await promise; + }); + + it('should get default dialog id for unknown type', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = false; + + const promise = promptHydrationDialog({ + ...defaultParameters, + type: 'UNKNOWN_TYPE', + }); + + expect(mockUIViewportDialogService.show).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'promptHydrate', + }) + ); + + const onOutsideClick = mockUIViewportDialogService.show.mock.calls[0][0].onOutsideClick; + onOutsideClick(); + await promise; + }); + + it('should handle hydrateCallback returning false for SEG', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = true; + mockHydrateCallback.mockResolvedValue(false); + + const result = await promptHydrationDialog({ + ...defaultParameters, + type: HydrationType.SEG, + }); + + expect(result).toBe(false); + }); + + it('should handle hydrateCallback returning false for RTSTRUCT', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = true; + mockHydrateCallback.mockResolvedValue(false); + + const result = await promptHydrationDialog({ + ...defaultParameters, + type: HydrationType.RTSTRUCT, + }); + + expect(result).toBe(false); + }); + + it('should handle SR hydration with null result', async () => { + mockExtensionManager._appConfig.measurementTrackingMode = 'standard'; + mockHydrateCallback.mockResolvedValue(null); + + const promise = promptHydrationDialog({ + ...defaultParameters, + type: HydrationType.SR, + }); + + const onSubmit = mockUIViewportDialogService.show.mock.calls[0][0].onSubmit; + onSubmit(5); + + const result = await promise; + + expect(result).toEqual({ + userResponse: 5, + displaySetInstanceUID: mockDisplaySet.displaySetInstanceUID, + srSeriesInstanceUID: mockDisplaySet.SeriesInstanceUID, + viewportId: defaultParameters.viewportId, + StudyInstanceUID: undefined, + SeriesInstanceUIDs: undefined, + }); + }); + + it('should handle RTSTRUCT type in getDialogId', async () => { + mockExtensionManager._appConfig.disableConfirmationPrompts = false; + + const promise = promptHydrationDialog({ + ...defaultParameters, + type: 'RTSTRUCT', + }); + + expect(mockUIViewportDialogService.show).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'promptHydrateRT', + }) + ); + + const onOutsideClick = mockUIViewportDialogService.show.mock.calls[0][0].onOutsideClick; + onOutsideClick(); + await promise; + }); +}); diff --git a/extensions/cornerstone/src/utils/promptHydrationDialog.ts b/extensions/cornerstone/src/utils/promptHydrationDialog.ts index d743f8005..0a2872419 100644 --- a/extensions/cornerstone/src/utils/promptHydrationDialog.ts +++ b/extensions/cornerstone/src/utils/promptHydrationDialog.ts @@ -49,7 +49,7 @@ function getCustomizationMessageKey(type: string): string { function getDialogId(type: string): string { switch (type) { - case HydrationType.RTSS: + case HydrationType.RTSTRUCT: return 'promptHydrateRT'; case HydrationType.SEG: return 'promptHydrateSEG'; diff --git a/extensions/cornerstone/src/utils/removeViewportSegmentationRepresentations.test.ts b/extensions/cornerstone/src/utils/removeViewportSegmentationRepresentations.test.ts new file mode 100644 index 000000000..757afb66c --- /dev/null +++ b/extensions/cornerstone/src/utils/removeViewportSegmentationRepresentations.test.ts @@ -0,0 +1,129 @@ +import { segmentation } from '@cornerstonejs/tools'; +import removeViewportSegmentationRepresentations from './removeViewportSegmentationRepresentations'; + +jest.mock('@cornerstonejs/tools', () => ({ + segmentation: { + state: { + getSegmentationRepresentations: jest.fn(), + removeSegmentationRepresentation: jest.fn(), + }, + }, +})); + +describe('removeViewportSegmentationRepresentations', () => { + const viewportId = 'viewport-1'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return early when no representations are found', () => { + (segmentation.state.getSegmentationRepresentations as jest.Mock).mockReturnValue(null); + + removeViewportSegmentationRepresentations(viewportId); + + expect(segmentation.state.getSegmentationRepresentations).toHaveBeenCalledWith(viewportId); + expect(segmentation.state.removeSegmentationRepresentation).not.toHaveBeenCalled(); + }); + + it('should return early when representations array is empty', () => { + (segmentation.state.getSegmentationRepresentations as jest.Mock).mockReturnValue([]); + + removeViewportSegmentationRepresentations(viewportId); + + expect(segmentation.state.removeSegmentationRepresentation).not.toHaveBeenCalled(); + }); + + it('should remove single segmentation representation', () => { + const representations = [ + { + segmentationRepresentationUID: 'representation-1', + }, + ]; + + (segmentation.state.getSegmentationRepresentations as jest.Mock).mockReturnValue( + representations + ); + + removeViewportSegmentationRepresentations(viewportId); + + expect(segmentation.state.removeSegmentationRepresentation).toHaveBeenCalledTimes(1); + expect(segmentation.state.removeSegmentationRepresentation).toHaveBeenCalledWith( + 'representation-1' + ); + }); + + it('should remove multiple segmentation representations', () => { + const representations = [ + { + segmentationRepresentationUID: 'representation-1', + }, + { + segmentationRepresentationUID: 'representation-2', + }, + { + segmentationRepresentationUID: 'representation-3', + }, + ]; + + (segmentation.state.getSegmentationRepresentations as jest.Mock).mockReturnValue( + representations + ); + + removeViewportSegmentationRepresentations(viewportId); + + expect(segmentation.state.removeSegmentationRepresentation).toHaveBeenCalledTimes(3); + expect(segmentation.state.removeSegmentationRepresentation).toHaveBeenNthCalledWith( + 1, + 'representation-1' + ); + expect(segmentation.state.removeSegmentationRepresentation).toHaveBeenNthCalledWith( + 2, + 'representation-2' + ); + expect(segmentation.state.removeSegmentationRepresentation).toHaveBeenNthCalledWith( + 3, + 'representation-3' + ); + }); + + it('should handle representations with additional properties', () => { + const representations = [ + { + segmentationRepresentationUID: 'representation-1', + type: 'LABELMAP', + active: true, + }, + { + segmentationRepresentationUID: 'representation-2', + type: 'CONTOUR', + active: false, + visible: true, + }, + ]; + + (segmentation.state.getSegmentationRepresentations as jest.Mock).mockReturnValue( + representations + ); + + removeViewportSegmentationRepresentations(viewportId); + + expect(segmentation.state.removeSegmentationRepresentation).toHaveBeenCalledTimes(2); + expect(segmentation.state.removeSegmentationRepresentation).toHaveBeenNthCalledWith( + 1, + 'representation-1' + ); + expect(segmentation.state.removeSegmentationRepresentation).toHaveBeenNthCalledWith( + 2, + 'representation-2' + ); + }); + + it('should handle undefined representations', () => { + (segmentation.state.getSegmentationRepresentations as jest.Mock).mockReturnValue(undefined); + + removeViewportSegmentationRepresentations(viewportId); + + expect(segmentation.state.removeSegmentationRepresentation).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/cornerstone/src/utils/segmentUtils.test.ts b/extensions/cornerstone/src/utils/segmentUtils.test.ts new file mode 100644 index 000000000..549e59fa5 --- /dev/null +++ b/extensions/cornerstone/src/utils/segmentUtils.test.ts @@ -0,0 +1,479 @@ +import { handleSegmentChange } from './segmentUtils'; + +describe('handleSegmentChange', () => { + const mockSegmentationService = { + getSegmentation: jest.fn(), + getActiveSegment: jest.fn(), + setActiveSegment: jest.fn(), + jumpToSegmentCenter: jest.fn(), + }; + + const mockSegmentation = { + segments: { + '1': { + segmentIndex: 1, + label: 'Segment 1', + }, + '2': { + segmentIndex: 2, + label: 'Segment 2', + }, + '3': { + segmentIndex: 3, + label: 'Segment 3', + }, + }, + }; + + const defaultParameters = { + direction: 1, + segmentationId: 'test-segmentation-id', + viewportId: 'test-viewport-id', + selectedSegmentObjectIndex: 0, + // @ts-expect-error - only part of the SegmentationService is needed + segmentationService: mockSegmentationService as AppTypes.SegmentationService, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + }); + + it('should move to next segment when direction is positive and activeSegment is null', () => { + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: 1, + selectedSegmentObjectIndex: 0, + }); + + expect(mockSegmentationService.getSegmentation).toHaveBeenCalledWith( + defaultParameters.segmentationId + ); + expect(mockSegmentationService.getActiveSegment).toHaveBeenCalledWith( + defaultParameters.viewportId + ); + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 2 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 2, + defaultParameters.viewportId + ); + }); + + it('should move to previous segment when direction is negative and activeSegment is null', () => { + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: -1, + selectedSegmentObjectIndex: 1, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1, + defaultParameters.viewportId + ); + }); + + it('should update selectedSegmentObjectIndex when activeSegment is found', () => { + const mockActiveSegment = { + segmentIndex: 2, + }; + mockSegmentationService.getActiveSegment.mockReturnValue(mockActiveSegment); + + handleSegmentChange({ + ...defaultParameters, + direction: 1, + selectedSegmentObjectIndex: 0, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 3 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 3, + defaultParameters.viewportId + ); + }); + + it('should loop to first segment when going beyond last segment', () => { + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: 1, + selectedSegmentObjectIndex: 2, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1, + defaultParameters.viewportId + ); + }); + + it('should loop to last segment when going before first segment', () => { + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: -1, + selectedSegmentObjectIndex: 0, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 3 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 3, + defaultParameters.viewportId + ); + }); + + it('should handle direction with value greater than 1', () => { + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: 2, + selectedSegmentObjectIndex: 0, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 3 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 3, + defaultParameters.viewportId + ); + }); + + it('should handle direction with value less than -1', () => { + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: -2, + selectedSegmentObjectIndex: 1, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 3 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 3, + defaultParameters.viewportId + ); + }); + + it('should handle single segment scenario with positive direction', () => { + const singleSegmentSegmentation = { + segments: { + '1': { + segmentIndex: 1, + label: 'Only Segment', + }, + }, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(singleSegmentSegmentation); + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: 1, + selectedSegmentObjectIndex: 0, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1, + defaultParameters.viewportId + ); + }); + + it('should handle single segment scenario with negative direction', () => { + const singleSegmentSegmentation = { + segments: { + '1': { + segmentIndex: 1, + label: 'Only Segment', + }, + }, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(singleSegmentSegmentation); + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: -1, + selectedSegmentObjectIndex: 0, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1, + defaultParameters.viewportId + ); + }); + + it('should handle activeSegment not found in segments', () => { + const mockActiveSegment = { + segmentIndex: 99, + }; + mockSegmentationService.getActiveSegment.mockReturnValue(mockActiveSegment); + + handleSegmentChange({ + ...defaultParameters, + direction: 1, + selectedSegmentObjectIndex: 0, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1, + defaultParameters.viewportId + ); + }); + + it('should handle zero direction', () => { + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: 0, + selectedSegmentObjectIndex: 1, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 2 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 2, + defaultParameters.viewportId + ); + }); + + it('should handle segments with non-sequential indices', () => { + const nonSequentialSegmentation = { + segments: { + '5': { + segmentIndex: 5, + label: 'Segment 5', + }, + '10': { + segmentIndex: 10, + label: 'Segment 10', + }, + '15': { + segmentIndex: 15, + label: 'Segment 15', + }, + }, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(nonSequentialSegmentation); + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: 1, + selectedSegmentObjectIndex: 0, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 10 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 10, + defaultParameters.viewportId + ); + }); + + it('should handle wrap around with non-sequential indices', () => { + const nonSequentialSegmentation = { + segments: { + '5': { + segmentIndex: 5, + label: 'Segment 5', + }, + '10': { + segmentIndex: 10, + label: 'Segment 10', + }, + }, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(nonSequentialSegmentation); + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: 1, + selectedSegmentObjectIndex: 1, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 5 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 5, + defaultParameters.viewportId + ); + }); + + it('should handle empty segments object', () => { + const emptySegmentation = { + segments: {}, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(emptySegmentation); + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: 1, + selectedSegmentObjectIndex: 0, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + undefined + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + undefined, + defaultParameters.viewportId + ); + }); + + it('should handle large positive direction that wraps multiple times', () => { + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: 5, + selectedSegmentObjectIndex: 1, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1, + defaultParameters.viewportId + ); + }); + + it('should handle large negative direction that wraps multiple times', () => { + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + direction: -5, + selectedSegmentObjectIndex: 1, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 3 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 3, + defaultParameters.viewportId + ); + }); + + it('should handle activeSegment with segmentIndex 0', () => { + const mockActiveSegment = { + segmentIndex: 0, + }; + mockSegmentationService.getActiveSegment.mockReturnValue(mockActiveSegment); + + handleSegmentChange({ + ...defaultParameters, + direction: 1, + selectedSegmentObjectIndex: 0, + }); + + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + defaultParameters.segmentationId, + 1, + defaultParameters.viewportId + ); + }); + + it('should use different viewport and segmentation IDs', () => { + mockSegmentationService.getActiveSegment.mockReturnValue(null); + + handleSegmentChange({ + ...defaultParameters, + segmentationId: 'different-segmentation-id', + viewportId: 'different-viewport-id', + direction: 1, + selectedSegmentObjectIndex: 0, + }); + + expect(mockSegmentationService.getSegmentation).toHaveBeenCalledWith( + 'different-segmentation-id' + ); + expect(mockSegmentationService.getActiveSegment).toHaveBeenCalledWith('different-viewport-id'); + expect(mockSegmentationService.setActiveSegment).toHaveBeenCalledWith( + 'different-segmentation-id', + 2 + ); + expect(mockSegmentationService.jumpToSegmentCenter).toHaveBeenCalledWith( + 'different-segmentation-id', + 2, + 'different-viewport-id' + ); + }); +}); diff --git a/extensions/cornerstone/src/utils/segmentationHandlers.test.ts b/extensions/cornerstone/src/utils/segmentationHandlers.test.ts new file mode 100644 index 000000000..8624ed9a0 --- /dev/null +++ b/extensions/cornerstone/src/utils/segmentationHandlers.test.ts @@ -0,0 +1,614 @@ +import * as cornerstoneTools from '@cornerstonejs/tools'; +import { + setupSegmentationDataModifiedHandler, + setupSegmentationModifiedHandler, +} from './segmentationHandlers'; +import { updateSegmentationStats } from './updateSegmentationStats'; + +jest.mock('@cornerstonejs/tools', () => ({ + annotation: { + state: { + getAllAnnotations: jest.fn(), + removeAnnotation: jest.fn(), + }, + }, + SegmentBidirectionalTool: { + toolName: 'SegmentBidirectional', + }, +})); + +jest.mock('./updateSegmentationStats', () => ({ + updateSegmentationStats: jest.fn(), +})); + +describe('setupSegmentationDataModifiedHandler', () => { + const mockSegmentationService = { + EVENTS: { + SEGMENTATION_DATA_MODIFIED: 'SEGMENTATION_DATA_MODIFIED', + }, + subscribeDebounced: jest.fn(), + getSegmentation: jest.fn(), + addOrUpdateSegmentation: jest.fn(), + }; + + const mockCustomizationService = { + getCustomization: jest.fn(), + }; + + const mockCommandsManager = { + runCommand: jest.fn(), + }; + + const mockUnsubscribeDebounced = jest.fn(); + + const defaultParameters = { + segmentationService: mockSegmentationService, + customizationService: mockCustomizationService, + commandsManager: mockCommandsManager, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSegmentationService.subscribeDebounced.mockReturnValue({ + unsubscribe: mockUnsubscribeDebounced, + }); + (updateSegmentationStats as jest.Mock).mockResolvedValue(null); + }); + + it('should subscribe to SEGMENTATION_DATA_MODIFIED event with debounce', () => { + setupSegmentationDataModifiedHandler(defaultParameters); + + expect(mockSegmentationService.subscribeDebounced).toHaveBeenCalledWith( + mockSegmentationService.EVENTS.SEGMENTATION_DATA_MODIFIED, + expect.any(Function), + 1000 + ); + }); + + it('should return unsubscribe function', () => { + const result = setupSegmentationDataModifiedHandler(defaultParameters); + + expect(result).toEqual({ + unsubscribe: expect.any(Function), + }); + + result.unsubscribe(); + + expect(mockUnsubscribeDebounced).toHaveBeenCalled(); + }); + + it('should return early when segmentation is null', async () => { + mockSegmentationService.getSegmentation.mockReturnValue(null); + + setupSegmentationDataModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribeDebounced.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(mockSegmentationService.getSegmentation).toHaveBeenCalledWith('test-id'); + expect(updateSegmentationStats).not.toHaveBeenCalled(); + expect(mockSegmentationService.addOrUpdateSegmentation).not.toHaveBeenCalled(); + }); + + it('should return early when disableUpdateSegmentationStats is true', async () => { + const mockSegmentation = { + segments: { + 1: { segmentIndex: 1 }, + }, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + mockCustomizationService.getCustomization.mockReturnValue(true); + + setupSegmentationDataModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribeDebounced.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(mockCustomizationService.getCustomization).toHaveBeenCalledWith( + 'panelSegmentation.disableUpdateSegmentationStats' + ); + expect(updateSegmentationStats).not.toHaveBeenCalled(); + expect(mockSegmentationService.addOrUpdateSegmentation).not.toHaveBeenCalled(); + }); + + it('should update segmentation stats when conditions are met', async () => { + const mockSegmentation = { + segments: { + 1: { segmentIndex: 1 }, + 2: { segmentIndex: 2 }, + }, + }; + + const mockUpdatedSegmentation = { + segments: { + 1: { segmentIndex: 1, stats: 'updated' }, + 2: { segmentIndex: 2, stats: 'updated' }, + }, + }; + + const mockReadableText = { mean: 'Mean' }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + mockCustomizationService.getCustomization + .mockReturnValueOnce(false) + .mockReturnValueOnce(mockReadableText); + (updateSegmentationStats as jest.Mock).mockResolvedValue(mockUpdatedSegmentation); + + setupSegmentationDataModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribeDebounced.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(updateSegmentationStats).toHaveBeenCalledWith({ + segmentation: mockSegmentation, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + expect(mockSegmentationService.addOrUpdateSegmentation).toHaveBeenCalledWith({ + segmentationId: 'test-id', + segments: mockUpdatedSegmentation.segments, + }); + }); + + it('should not update segmentation when unsubscribed', async () => { + const mockSegmentation = { + segments: { + 1: { segmentIndex: 1 }, + }, + }; + + const mockUpdatedSegmentation = { + segments: { + 1: { segmentIndex: 1, stats: 'updated' }, + }, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + mockCustomizationService.getCustomization.mockReturnValue(false); + (updateSegmentationStats as jest.Mock).mockResolvedValue(mockUpdatedSegmentation); + + const result = setupSegmentationDataModifiedHandler(defaultParameters); + result.unsubscribe(); + + const callback = mockSegmentationService.subscribeDebounced.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(updateSegmentationStats).toHaveBeenCalled(); + expect(mockSegmentationService.addOrUpdateSegmentation).not.toHaveBeenCalled(); + }); + + it('should not update segmentation when updateSegmentationStats returns null', async () => { + const mockSegmentation = { + segments: { + 1: { segmentIndex: 1 }, + }, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + mockCustomizationService.getCustomization.mockReturnValue(false); + (updateSegmentationStats as jest.Mock).mockResolvedValue(null); + + setupSegmentationDataModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribeDebounced.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(updateSegmentationStats).toHaveBeenCalled(); + expect(mockSegmentationService.addOrUpdateSegmentation).not.toHaveBeenCalled(); + }); + + it('should run bidirectional command for segments with bidirectional stats', async () => { + const mockSegmentation = { + segments: { + 0: { segmentIndex: 0 }, + 1: { + segmentIndex: 1, + cachedStats: { + namedStats: { + bidirectional: { data: 'test' }, + }, + }, + }, + 2: { segmentIndex: 2 }, + 3: { + segmentIndex: 3, + cachedStats: { + namedStats: { + bidirectional: { data: 'test2' }, + }, + }, + }, + }, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + mockCustomizationService.getCustomization.mockReturnValue(false); + (updateSegmentationStats as jest.Mock).mockResolvedValue(null); + + setupSegmentationDataModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribeDebounced.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(mockCommandsManager.runCommand).toHaveBeenCalledTimes(2); + expect(mockCommandsManager.runCommand).toHaveBeenNthCalledWith(1, 'runSegmentBidirectional', { + segmentationId: 'test-id', + segmentIndex: 1, + }); + expect(mockCommandsManager.runCommand).toHaveBeenNthCalledWith(2, 'runSegmentBidirectional', { + segmentationId: 'test-id', + segmentIndex: 3, + }); + }); + + it('should not run bidirectional command for segment index 0', async () => { + const mockSegmentation = { + segments: { + 0: { + segmentIndex: 0, + cachedStats: { + namedStats: { + bidirectional: { data: 'test' }, + }, + }, + }, + 1: { segmentIndex: 1 }, + }, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + mockCustomizationService.getCustomization.mockReturnValue(false); + (updateSegmentationStats as jest.Mock).mockResolvedValue(null); + + setupSegmentationDataModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribeDebounced.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(mockCommandsManager.runCommand).not.toHaveBeenCalled(); + }); + + it('should handle segment without cachedStats', async () => { + const mockSegmentation = { + segments: { + 1: { segmentIndex: 1 }, + 2: { + segmentIndex: 2, + cachedStats: undefined, + }, + 3: { + segmentIndex: 3, + cachedStats: { + namedStats: undefined, + }, + }, + 4: { + segmentIndex: 4, + cachedStats: { + namedStats: { + other: { data: 'test' }, + }, + }, + }, + }, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + mockCustomizationService.getCustomization.mockReturnValue(false); + (updateSegmentationStats as jest.Mock).mockResolvedValue(null); + + setupSegmentationDataModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribeDebounced.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(mockCommandsManager.runCommand).not.toHaveBeenCalled(); + }); +}); + +describe('setupSegmentationModifiedHandler', () => { + const mockSegmentationService = { + EVENTS: { + SEGMENTATION_MODIFIED: 'SEGMENTATION_MODIFIED', + }, + subscribe: jest.fn(), + getSegmentation: jest.fn(), + }; + + const mockUnsubscribe = jest.fn(); + + const defaultParameters = { + segmentationService: mockSegmentationService, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSegmentationService.subscribe.mockReturnValue({ + unsubscribe: mockUnsubscribe, + }); + (cornerstoneTools.annotation.state.getAllAnnotations as jest.Mock).mockReturnValue([]); + }); + + it('should subscribe to SEGMENTATION_MODIFIED event', () => { + setupSegmentationModifiedHandler(defaultParameters); + + expect(mockSegmentationService.subscribe).toHaveBeenCalledWith( + mockSegmentationService.EVENTS.SEGMENTATION_MODIFIED, + expect.any(Function) + ); + }); + + it('should return unsubscribe function', () => { + const result = setupSegmentationModifiedHandler(defaultParameters); + + expect(result).toEqual({ + unsubscribe: mockUnsubscribe, + }); + }); + + it('should return early when segmentation is null', async () => { + mockSegmentationService.getSegmentation.mockReturnValue(null); + + setupSegmentationModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribe.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(cornerstoneTools.annotation.state.getAllAnnotations).not.toHaveBeenCalled(); + expect(cornerstoneTools.annotation.state.removeAnnotation).not.toHaveBeenCalled(); + }); + + it('should remove bidirectional annotations for non-existent segments', async () => { + const mockSegmentation = { + segments: { + 1: { segmentIndex: 1 }, + 3: { segmentIndex: 3 }, + }, + }; + + const mockAnnotations = [ + { + annotationUID: 'annotation-1', + metadata: { + toolName: 'SegmentBidirectional', + segmentationId: 'test-id', + segmentIndex: 1, + }, + }, + { + annotationUID: 'annotation-2', + metadata: { + toolName: 'SegmentBidirectional', + segmentationId: 'test-id', + segmentIndex: 2, + }, + }, + { + annotationUID: 'annotation-3', + metadata: { + toolName: 'SegmentBidirectional', + segmentationId: 'other-id', + segmentIndex: 2, + }, + }, + { + annotationUID: 'annotation-4', + metadata: { + toolName: 'OtherTool', + segmentationId: 'test-id', + segmentIndex: 2, + }, + }, + ]; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + (cornerstoneTools.annotation.state.getAllAnnotations as jest.Mock).mockReturnValue( + mockAnnotations + ); + + setupSegmentationModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribe.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(cornerstoneTools.annotation.state.removeAnnotation).toHaveBeenCalledTimes(1); + expect(cornerstoneTools.annotation.state.removeAnnotation).toHaveBeenCalledWith('annotation-2'); + }); + + it('should not remove annotations for existing segments', async () => { + const mockSegmentation = { + segments: { + 1: { segmentIndex: 1 }, + 2: { segmentIndex: 2 }, + }, + }; + + const mockAnnotations = [ + { + annotationUID: 'annotation-1', + metadata: { + toolName: 'SegmentBidirectional', + segmentationId: 'test-id', + segmentIndex: 1, + }, + }, + { + annotationUID: 'annotation-2', + metadata: { + toolName: 'SegmentBidirectional', + segmentationId: 'test-id', + segmentIndex: 2, + }, + }, + ]; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + (cornerstoneTools.annotation.state.getAllAnnotations as jest.Mock).mockReturnValue( + mockAnnotations + ); + + setupSegmentationModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribe.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(cornerstoneTools.annotation.state.removeAnnotation).not.toHaveBeenCalled(); + }); + + it('should handle empty segments object', async () => { + const mockSegmentation = { + segments: {}, + }; + + const mockAnnotations = [ + { + annotationUID: 'annotation-1', + metadata: { + toolName: 'SegmentBidirectional', + segmentationId: 'test-id', + segmentIndex: 1, + }, + }, + ]; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + (cornerstoneTools.annotation.state.getAllAnnotations as jest.Mock).mockReturnValue( + mockAnnotations + ); + + setupSegmentationModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribe.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(cornerstoneTools.annotation.state.removeAnnotation).toHaveBeenCalledWith('annotation-1'); + }); + + it('should filter out segment index 0', async () => { + const mockSegmentation = { + segments: { + 0: { segmentIndex: 0 }, + 1: { segmentIndex: 1 }, + }, + }; + + const mockAnnotations = [ + { + annotationUID: 'annotation-0', + metadata: { + toolName: 'SegmentBidirectional', + segmentationId: 'test-id', + segmentIndex: 0, + }, + }, + { + annotationUID: 'annotation-1', + metadata: { + toolName: 'SegmentBidirectional', + segmentationId: 'test-id', + segmentIndex: 1, + }, + }, + ]; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + (cornerstoneTools.annotation.state.getAllAnnotations as jest.Mock).mockReturnValue( + mockAnnotations + ); + + setupSegmentationModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribe.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(cornerstoneTools.annotation.state.removeAnnotation).toHaveBeenCalledWith('annotation-0'); + }); + + it('should handle no bidirectional annotations', async () => { + const mockSegmentation = { + segments: { + 1: { segmentIndex: 1 }, + }, + }; + + const mockAnnotations = [ + { + annotationUID: 'annotation-1', + metadata: { + toolName: 'OtherTool', + segmentationId: 'test-id', + segmentIndex: 1, + }, + }, + ]; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + (cornerstoneTools.annotation.state.getAllAnnotations as jest.Mock).mockReturnValue( + mockAnnotations + ); + + setupSegmentationModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribe.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(cornerstoneTools.annotation.state.removeAnnotation).not.toHaveBeenCalled(); + }); + + it('should handle mixed annotation types and segmentation IDs', async () => { + const mockSegmentation = { + segments: { + 1: { segmentIndex: 1 }, + }, + }; + + const mockAnnotations = [ + { + annotationUID: 'keep-1', + metadata: { + toolName: 'SegmentBidirectional', + segmentationId: 'test-id', + segmentIndex: 1, + }, + }, + { + annotationUID: 'remove-1', + metadata: { + toolName: 'SegmentBidirectional', + segmentationId: 'test-id', + segmentIndex: 2, + }, + }, + { + annotationUID: 'keep-2', + metadata: { + toolName: 'SegmentBidirectional', + segmentationId: 'other-id', + segmentIndex: 2, + }, + }, + { + annotationUID: 'keep-3', + metadata: { + toolName: 'OtherTool', + segmentationId: 'test-id', + segmentIndex: 2, + }, + }, + ]; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + (cornerstoneTools.annotation.state.getAllAnnotations as jest.Mock).mockReturnValue( + mockAnnotations + ); + + setupSegmentationModifiedHandler(defaultParameters); + + const callback = mockSegmentationService.subscribe.mock.calls[0][1]; + await callback({ segmentationId: 'test-id' }); + + expect(cornerstoneTools.annotation.state.removeAnnotation).toHaveBeenCalledTimes(1); + expect(cornerstoneTools.annotation.state.removeAnnotation).toHaveBeenCalledWith('remove-1'); + }); +}); diff --git a/extensions/cornerstone/src/utils/segmentationHandlers.ts b/extensions/cornerstone/src/utils/segmentationHandlers.ts index 42b406fb7..0368356c0 100644 --- a/extensions/cornerstone/src/utils/segmentationHandlers.ts +++ b/extensions/cornerstone/src/utils/segmentationHandlers.ts @@ -16,8 +16,9 @@ export function setupSegmentationDataModifiedHandler({ const { unsubscribe: debouncedUnsubscribe } = segmentationService.subscribeDebounced( segmentationService.EVENTS.SEGMENTATION_DATA_MODIFIED, async ({ segmentationId }) => { - - const disableUpdateSegmentationStats = customizationService.getCustomization('panelSegmentation.disableUpdateSegmentationStats'); + const disableUpdateSegmentationStats = customizationService.getCustomization( + 'panelSegmentation.disableUpdateSegmentationStats' + ); const segmentation = segmentationService.getSegmentation(segmentationId); @@ -85,27 +86,21 @@ export function setupSegmentationModifiedHandler({ segmentationService }) { annotation.metadata.toolName === cornerstoneTools.SegmentBidirectionalTool.toolName ); - let toRemoveUIDs = []; - if (!segmentation) { - toRemoveUIDs = bidirectionalAnnotations.map( - annotation => annotation.metadata.segmentationId === segmentationId - ); - return; - } else { - const segmentIndices = Object.keys(segmentation.segments) - .map(index => parseInt(index)) - .filter(index => index > 0); + const segmentIndices = Object.keys(segmentation.segments) + .map(index => parseInt(index)) + .filter(index => index > 0); - // check if there is a bidirectional data that exists but the segment - // does not exists anymore we need to remove the bidirectional data - const bidirectionalAnnotationsToRemove = bidirectionalAnnotations.filter( - annotation => - annotation.metadata.segmentationId === segmentationId && - !segmentIndices.includes(annotation.metadata.segmentIndex) - ); + // check if there is a bidirectional data that exists but the segment + // does not exists anymore we need to remove the bidirectional data + const bidirectionalAnnotationsToRemove = bidirectionalAnnotations.filter( + annotation => + annotation.metadata.segmentationId === segmentationId && + !segmentIndices.includes(annotation.metadata.segmentIndex) + ); - toRemoveUIDs = bidirectionalAnnotationsToRemove.map(annotation => annotation.annotationUID); - } + const toRemoveUIDs = bidirectionalAnnotationsToRemove.map( + annotation => annotation.annotationUID + ); toRemoveUIDs.forEach(uid => { cornerstoneTools.annotation.state.removeAnnotation(uid); diff --git a/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.test.ts b/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.test.ts new file mode 100644 index 000000000..5503b5d43 --- /dev/null +++ b/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.test.ts @@ -0,0 +1,348 @@ +import { setUpSegmentationEventHandlers } from './setUpSegmentationEventHandlers'; +import { + setupSegmentationDataModifiedHandler, + setupSegmentationModifiedHandler, +} from './segmentationHandlers'; + +jest.mock('./segmentationHandlers', () => ({ + setupSegmentationDataModifiedHandler: jest.fn(), + setupSegmentationModifiedHandler: jest.fn(), +})); + +describe('setUpSegmentationEventHandlers', () => { + const mockSegmentationService = { + EVENTS: { + SEGMENTATION_ADDED: 'SEGMENTATION_ADDED', + }, + subscribe: jest.fn(), + getSegmentation: jest.fn(), + }; + + const mockCustomizationService = {}; + + const mockDisplaySetService = { + getDisplaySetByUID: jest.fn(), + addDisplaySets: jest.fn(), + }; + + const mockCommandsManager = {}; + + const mockServicesManager = { + services: { + segmentationService: mockSegmentationService, + customizationService: mockCustomizationService, + displaySetService: mockDisplaySetService, + }, + }; + + const mockUnsubscribeDataModified = jest.fn(); + const mockUnsubscribeModified = jest.fn(); + const mockUnsubscribeCreated = jest.fn(); + + const defaultParameters = { + servicesManager: mockServicesManager as unknown as AppTypes.ServicesManager, + commandsManager: mockCommandsManager, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (setupSegmentationDataModifiedHandler as jest.Mock).mockReturnValue({ + unsubscribe: mockUnsubscribeDataModified, + }); + (setupSegmentationModifiedHandler as jest.Mock).mockReturnValue({ + unsubscribe: mockUnsubscribeModified, + }); + mockSegmentationService.subscribe.mockReturnValue({ + unsubscribe: mockUnsubscribeCreated, + }); + }); + + it('should setup segmentation data modified handler', () => { + setUpSegmentationEventHandlers(defaultParameters); + + expect(setupSegmentationDataModifiedHandler).toHaveBeenCalledWith({ + segmentationService: mockSegmentationService, + customizationService: mockCustomizationService, + commandsManager: mockCommandsManager, + }); + }); + + it('should setup segmentation modified handler', () => { + setUpSegmentationEventHandlers(defaultParameters); + + expect(setupSegmentationModifiedHandler).toHaveBeenCalledWith({ + segmentationService: mockSegmentationService, + }); + }); + + it('should subscribe to SEGMENTATION_ADDED event', () => { + setUpSegmentationEventHandlers(defaultParameters); + + expect(mockSegmentationService.subscribe).toHaveBeenCalledWith( + mockSegmentationService.EVENTS.SEGMENTATION_ADDED, + expect.any(Function) + ); + }); + + it('should return unsubscriptions object', () => { + const result = setUpSegmentationEventHandlers(defaultParameters); + + expect(result).toEqual({ + unsubscriptions: [ + mockUnsubscribeDataModified, + mockUnsubscribeModified, + mockUnsubscribeCreated, + ], + }); + }); + + it('should return early when displaySet already exists for segmentationId', () => { + const mockDisplaySet = { + displaySetInstanceUID: 'test-segmentation-id', + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + setUpSegmentationEventHandlers(defaultParameters); + + const callback = mockSegmentationService.subscribe.mock.calls[0][1]; + const evt = { segmentationId: 'test-segmentation-id' }; + + callback(evt); + + expect(mockDisplaySetService.getDisplaySetByUID).toHaveBeenCalledWith('test-segmentation-id'); + expect(mockSegmentationService.getSegmentation).not.toHaveBeenCalled(); + expect(mockDisplaySetService.addDisplaySets).not.toHaveBeenCalled(); + }); + + it('should create and add display set when segmentation is added and no displaySet exists', () => { + const mockSegmentation = { + cachedStats: { + info: 'Test Segmentation Label', + }, + representationData: { + Labelmap: { + imageIds: ['image-1', 'image-2', 'image-3'], + }, + }, + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(null); + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + setUpSegmentationEventHandlers(defaultParameters); + + const callback = mockSegmentationService.subscribe.mock.calls[0][1]; + const evt = { segmentationId: 'test-segmentation-id' }; + + callback(evt); + + expect(mockDisplaySetService.getDisplaySetByUID).toHaveBeenCalledWith('test-segmentation-id'); + expect(mockSegmentationService.getSegmentation).toHaveBeenCalledWith('test-segmentation-id'); + expect(mockDisplaySetService.addDisplaySets).toHaveBeenCalledWith({ + displaySetInstanceUID: 'test-segmentation-id', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.66.4', + SOPClassHandlerId: '@ohif/extension-cornerstone-dicom-seg.sopClassHandlerModule.dicom-seg', + SeriesDescription: mockSegmentation.cachedStats.info, + Modality: 'SEG', + numImageFrames: mockSegmentation.representationData.Labelmap.imageIds.length, + imageIds: mockSegmentation.representationData.Labelmap.imageIds, + isOverlayDisplaySet: true, + label: mockSegmentation.cachedStats.info, + madeInClient: true, + segmentationId: 'test-segmentation-id', + isDerived: true, + }); + }); + + it('should handle displaySet undefined when segmentation is added', () => { + const mockSegmentation = { + cachedStats: { + info: 'Test Segmentation Label', + }, + representationData: { + Labelmap: { + imageIds: ['image-1', 'image-2'], + }, + }, + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(undefined); + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + setUpSegmentationEventHandlers(defaultParameters); + + const callback = mockSegmentationService.subscribe.mock.calls[0][1]; + const evt = { segmentationId: 'test-segmentation-id' }; + + callback(evt); + + expect(mockSegmentationService.getSegmentation).toHaveBeenCalledWith('test-segmentation-id'); + expect(mockDisplaySetService.addDisplaySets).toHaveBeenCalledWith({ + displaySetInstanceUID: 'test-segmentation-id', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.66.4', + SOPClassHandlerId: '@ohif/extension-cornerstone-dicom-seg.sopClassHandlerModule.dicom-seg', + SeriesDescription: mockSegmentation.cachedStats.info, + Modality: 'SEG', + numImageFrames: mockSegmentation.representationData.Labelmap.imageIds.length, + imageIds: mockSegmentation.representationData.Labelmap.imageIds, + isOverlayDisplaySet: true, + label: mockSegmentation.cachedStats.info, + madeInClient: true, + segmentationId: 'test-segmentation-id', + isDerived: true, + }); + }); + + it('should handle empty imageIds array', () => { + const mockSegmentation = { + cachedStats: { + info: 'Empty Segmentation', + }, + representationData: { + Labelmap: { + imageIds: [], + }, + }, + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(null); + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + setUpSegmentationEventHandlers(defaultParameters); + + const callback = mockSegmentationService.subscribe.mock.calls[0][1]; + const evt = { segmentationId: 'empty-segmentation-id' }; + + callback(evt); + + expect(mockDisplaySetService.addDisplaySets).toHaveBeenCalledWith({ + displaySetInstanceUID: 'empty-segmentation-id', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.66.4', + SOPClassHandlerId: '@ohif/extension-cornerstone-dicom-seg.sopClassHandlerModule.dicom-seg', + SeriesDescription: mockSegmentation.cachedStats.info, + Modality: 'SEG', + numImageFrames: 0, + imageIds: [], + isOverlayDisplaySet: true, + label: mockSegmentation.cachedStats.info, + madeInClient: true, + segmentationId: 'empty-segmentation-id', + isDerived: true, + }); + }); + + it('should handle different segmentation label values', () => { + const mockSegmentation = { + cachedStats: { + info: 'Custom Label Text', + }, + representationData: { + Labelmap: { + imageIds: ['custom-image-1'], + }, + }, + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(null); + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + setUpSegmentationEventHandlers(defaultParameters); + + const callback = mockSegmentationService.subscribe.mock.calls[0][1]; + const evt = { segmentationId: 'custom-segmentation-id' }; + + callback(evt); + + expect(mockDisplaySetService.addDisplaySets).toHaveBeenCalledWith({ + displaySetInstanceUID: 'custom-segmentation-id', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.66.4', + SOPClassHandlerId: '@ohif/extension-cornerstone-dicom-seg.sopClassHandlerModule.dicom-seg', + SeriesDescription: 'Custom Label Text', + Modality: 'SEG', + numImageFrames: 1, + imageIds: ['custom-image-1'], + isOverlayDisplaySet: true, + label: 'Custom Label Text', + madeInClient: true, + segmentationId: 'custom-segmentation-id', + isDerived: true, + }); + }); + + it('should handle multiple segmentation events', () => { + const mockSegmentation1 = { + cachedStats: { + info: 'Segmentation 1', + }, + representationData: { + Labelmap: { + imageIds: ['image-1'], + }, + }, + }; + + const mockSegmentation2 = { + cachedStats: { + info: 'Segmentation 2', + }, + representationData: { + Labelmap: { + imageIds: ['image-2', 'image-3'], + }, + }, + }; + + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(null); + mockSegmentationService.getSegmentation + .mockReturnValueOnce(mockSegmentation1) + .mockReturnValueOnce(mockSegmentation2); + + setUpSegmentationEventHandlers(defaultParameters); + + const callback = mockSegmentationService.subscribe.mock.calls[0][1]; + + callback({ segmentationId: 'segmentation-1' }); + callback({ segmentationId: 'segmentation-2' }); + + expect(mockDisplaySetService.addDisplaySets).toHaveBeenCalledTimes(2); + expect(mockDisplaySetService.addDisplaySets).toHaveBeenNthCalledWith(1, { + displaySetInstanceUID: 'segmentation-1', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.66.4', + SOPClassHandlerId: '@ohif/extension-cornerstone-dicom-seg.sopClassHandlerModule.dicom-seg', + SeriesDescription: mockSegmentation1.cachedStats.info, + Modality: 'SEG', + numImageFrames: 1, + imageIds: mockSegmentation1.representationData.Labelmap.imageIds, + isOverlayDisplaySet: true, + label: mockSegmentation1.cachedStats.info, + madeInClient: true, + segmentationId: 'segmentation-1', + isDerived: true, + }); + expect(mockDisplaySetService.addDisplaySets).toHaveBeenNthCalledWith(2, { + displaySetInstanceUID: 'segmentation-2', + SOPClassUID: '1.2.840.10008.5.1.4.1.1.66.4', + SOPClassHandlerId: '@ohif/extension-cornerstone-dicom-seg.sopClassHandlerModule.dicom-seg', + SeriesDescription: mockSegmentation2.cachedStats.info, + Modality: 'SEG', + numImageFrames: 2, + imageIds: mockSegmentation2.representationData.Labelmap.imageIds, + isOverlayDisplaySet: true, + label: mockSegmentation2.cachedStats.info, + madeInClient: true, + segmentationId: 'segmentation-2', + isDerived: true, + }); + }); + + it('should call all unsubscribe functions in returned unsubscriptions array', () => { + const result = setUpSegmentationEventHandlers(defaultParameters); + + result.unsubscriptions.forEach(unsubscribe => unsubscribe()); + + expect(mockUnsubscribeDataModified).toHaveBeenCalled(); + expect(mockUnsubscribeModified).toHaveBeenCalled(); + expect(mockUnsubscribeCreated).toHaveBeenCalled(); + }); +}); diff --git a/extensions/cornerstone/src/utils/toggleVOISliceSync.test.ts b/extensions/cornerstone/src/utils/toggleVOISliceSync.test.ts new file mode 100644 index 000000000..888cbc699 --- /dev/null +++ b/extensions/cornerstone/src/utils/toggleVOISliceSync.test.ts @@ -0,0 +1,606 @@ +import toggleVOISliceSync from './toggleVOISliceSync'; + +describe('toggleVOISliceSync', () => { + const mockSyncGroupService = { + getSynchronizersForViewport: jest.fn(), + addViewportToSyncGroup: jest.fn(), + removeViewportFromSyncGroup: jest.fn(), + }; + + const mockViewportGridService = { + getState: jest.fn(), + }; + + const mockDisplaySetService = { + getDisplaySetByUID: jest.fn(), + }; + + const mockCornerstoneViewportService = { + getCornerstoneViewport: jest.fn(), + }; + + const mockServicesManager = { + services: { + syncGroupService: mockSyncGroupService, + viewportGridService: mockViewportGridService, + displaySetService: mockDisplaySetService, + cornerstoneViewportService: mockCornerstoneViewportService, + }, + }; + + const mockViewport = { + id: 'viewport-1', + getRenderingEngine: jest.fn(() => ({ id: 'rendering-engine-1' })), + }; + + const mockViewport2 = { + id: 'viewport-2', + getRenderingEngine: jest.fn(() => ({ id: 'rendering-engine-2' })), + }; + + const mockGridViewport = { + viewportOptions: { + viewportId: 'viewport-1', + }, + displaySetInstanceUIDs: ['displaySet-1'], + }; + + const mockDisplaySet = { + Modality: 'CT', + }; + + const defaultParameters = { + servicesManager: mockServicesManager as unknown as AppTypes.ServicesManager, + viewports: null, + syncId: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockCornerstoneViewportService.getCornerstoneViewport.mockReturnValue(mockViewport); + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + }); + + it('should enable sync when no sync exists for single modality', () => { + const viewports = { + CT: [mockGridViewport], + }; + + mockSyncGroupService.getSynchronizersForViewport.mockReturnValue([]); + + toggleVOISliceSync({ + ...defaultParameters, + viewports, + }); + + expect(mockSyncGroupService.getSynchronizersForViewport).toHaveBeenCalledWith('viewport-1'); + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenCalledWith( + 'viewport-1', + 'rendering-engine-1', + { + type: 'voi', + id: 'VOI_SYNC_CT', + source: true, + target: true, + } + ); + }); + + it('should disable sync when sync already exists', () => { + const viewports = { + CT: [mockGridViewport], + }; + + const mockSyncState = { + id: 'VOI_SYNC_CT', + }; + + mockSyncGroupService.getSynchronizersForViewport.mockReturnValue([mockSyncState]); + + toggleVOISliceSync({ + ...defaultParameters, + viewports, + }); + + expect(mockSyncGroupService.removeViewportFromSyncGroup).toHaveBeenCalledWith( + 'viewport-1', + 'rendering-engine-1', + 'VOI_SYNC_CT' + ); + expect(mockSyncGroupService.addViewportToSyncGroup).not.toHaveBeenCalled(); + }); + + it('should use custom syncId when provided', () => { + const viewports = { + CT: [mockGridViewport], + }; + + mockSyncGroupService.getSynchronizersForViewport.mockReturnValue([]); + + toggleVOISliceSync({ + ...defaultParameters, + viewports, + syncId: 'CUSTOM_SYNC_ID', + }); + + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenCalledWith( + 'viewport-1', + 'rendering-engine-1', + { + type: 'voi', + id: 'CUSTOM_SYNC_ID', + source: true, + target: true, + } + ); + }); + + it('should handle multiple viewports in same modality', () => { + const mockGridViewport2 = { + viewportOptions: { + viewportId: 'viewport-2', + }, + displaySetInstanceUIDs: ['displaySet-2'], + }; + + const viewports = { + CT: [mockGridViewport, mockGridViewport2], + }; + + mockCornerstoneViewportService.getCornerstoneViewport + .mockReturnValueOnce(mockViewport) + .mockReturnValueOnce(mockViewport2); + + mockSyncGroupService.getSynchronizersForViewport.mockReturnValue([]); + + toggleVOISliceSync({ + ...defaultParameters, + viewports, + }); + + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenCalledTimes(2); + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenNthCalledWith( + 1, + 'viewport-1', + 'rendering-engine-1', + { + type: 'voi', + id: 'VOI_SYNC_CT', + source: true, + target: true, + } + ); + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenNthCalledWith( + 2, + 'viewport-2', + 'rendering-engine-2', + { + type: 'voi', + id: 'VOI_SYNC_CT', + source: true, + target: true, + } + ); + }); + + it('should handle multiple modalities separately', () => { + const mockGridViewportMR = { + viewportOptions: { + viewportId: 'viewport-mr', + }, + displaySetInstanceUIDs: ['displaySet-mr'], + }; + + const mockViewportMR = { + id: 'viewport-mr', + getRenderingEngine: jest.fn(() => ({ id: 'rendering-engine-mr' })), + }; + + const viewports = { + CT: [mockGridViewport], + MR: [mockGridViewportMR], + }; + + mockCornerstoneViewportService.getCornerstoneViewport + .mockReturnValueOnce(mockViewport) + .mockReturnValueOnce(mockViewportMR); + + mockSyncGroupService.getSynchronizersForViewport.mockReturnValue([]); + + toggleVOISliceSync({ + ...defaultParameters, + viewports, + }); + + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenCalledTimes(2); + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenNthCalledWith( + 1, + 'viewport-1', + 'rendering-engine-1', + { + type: 'voi', + id: 'VOI_SYNC_CT', + source: true, + target: true, + } + ); + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenNthCalledWith( + 2, + 'viewport-mr', + 'rendering-engine-mr', + { + type: 'voi', + id: 'VOI_SYNC_MR', + source: true, + target: true, + } + ); + }); + + it('should skip viewport when cornerstone viewport is not found', () => { + const viewports = { + CT: [mockGridViewport], + }; + + mockCornerstoneViewportService.getCornerstoneViewport.mockReturnValue(null); + mockSyncGroupService.getSynchronizersForViewport.mockReturnValue([]); + + toggleVOISliceSync({ + ...defaultParameters, + viewports, + }); + + expect(mockSyncGroupService.addViewportToSyncGroup).not.toHaveBeenCalled(); + }); + + it('should generate viewports from grid service when not provided', () => { + const mockViewportsMap = new Map([ + [ + 'viewport-1', + { + ...mockGridViewport, + }, + ], + ]); + + const mockState = { + viewports: mockViewportsMap, + }; + + mockViewportGridService.getState.mockReturnValue(mockState); + mockSyncGroupService.getSynchronizersForViewport.mockReturnValue([]); + + toggleVOISliceSync({ + ...defaultParameters, + viewports: null, + }); + + expect(mockViewportGridService.getState).toHaveBeenCalled(); + expect(mockDisplaySetService.getDisplaySetByUID).toHaveBeenCalledWith('displaySet-1'); + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenCalledWith( + 'viewport-1', + 'rendering-engine-1', + { + type: 'voi', + id: 'VOI_SYNC_CT', + source: true, + target: true, + } + ); + }); + + it('should handle sync state with different id not matching', () => { + const viewports = { + CT: [mockGridViewport], + }; + + const mockSyncState = { + id: 'DIFFERENT_SYNC_ID', + }; + + mockSyncGroupService.getSynchronizersForViewport.mockReturnValue([mockSyncState]); + + toggleVOISliceSync({ + ...defaultParameters, + viewports, + }); + + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenCalledWith( + 'viewport-1', + 'rendering-engine-1', + { + type: 'voi', + id: 'VOI_SYNC_CT', + source: true, + target: true, + } + ); + expect(mockSyncGroupService.removeViewportFromSyncGroup).not.toHaveBeenCalled(); + }); + + it('should handle mixed sync states where some match and some do not', () => { + const mockGridViewport2 = { + viewportOptions: { + viewportId: 'viewport-2', + }, + displaySetInstanceUIDs: ['displaySet-2'], + }; + + const viewports = { + CT: [mockGridViewport, mockGridViewport2], + }; + + const mockSyncState = { + id: 'VOI_SYNC_CT', + }; + + mockSyncGroupService.getSynchronizersForViewport + .mockReturnValueOnce([mockSyncState]) + .mockReturnValueOnce([]); + + mockCornerstoneViewportService.getCornerstoneViewport + .mockReturnValueOnce(mockViewport) + .mockReturnValueOnce(mockViewport2); + + toggleVOISliceSync({ + ...defaultParameters, + viewports, + }); + + expect(mockSyncGroupService.removeViewportFromSyncGroup).toHaveBeenCalledWith( + 'viewport-1', + 'rendering-engine-1', + 'VOI_SYNC_CT' + ); + expect(mockSyncGroupService.removeViewportFromSyncGroup).toHaveBeenCalledWith( + 'viewport-2', + 'rendering-engine-2', + 'VOI_SYNC_CT' + ); + }); + + it('should disable sync for multiple viewports when sync exists', () => { + const mockGridViewport2 = { + viewportOptions: { + viewportId: 'viewport-2', + }, + displaySetInstanceUIDs: ['displaySet-2'], + }; + + const mockViewport2 = { + id: 'viewport-2', + getRenderingEngine: jest.fn(() => ({ id: 'rendering-engine-2' })), + }; + + const viewports = { + CT: [mockGridViewport, mockGridViewport2], + }; + + const mockSyncState = { + id: 'VOI_SYNC_CT', + }; + + mockCornerstoneViewportService.getCornerstoneViewport + .mockReturnValueOnce(mockViewport) + .mockReturnValueOnce(mockViewport2); + + mockSyncGroupService.getSynchronizersForViewport.mockReturnValue([mockSyncState]); + + toggleVOISliceSync({ + ...defaultParameters, + viewports, + }); + + expect(mockSyncGroupService.removeViewportFromSyncGroup).toHaveBeenCalledTimes(2); + expect(mockSyncGroupService.removeViewportFromSyncGroup).toHaveBeenNthCalledWith( + 1, + 'viewport-1', + 'rendering-engine-1', + 'VOI_SYNC_CT' + ); + expect(mockSyncGroupService.removeViewportFromSyncGroup).toHaveBeenNthCalledWith( + 2, + 'viewport-2', + 'rendering-engine-2', + 'VOI_SYNC_CT' + ); + }); + + it('should skip viewport during disable when cornerstone viewport is not found', () => { + const mockGridViewport2 = { + viewportOptions: { + viewportId: 'viewport-2', + }, + displaySetInstanceUIDs: ['displaySet-2'], + }; + + const viewports = { + CT: [mockGridViewport, mockGridViewport2], + }; + + const mockSyncState = { + id: 'VOI_SYNC_CT', + }; + + mockCornerstoneViewportService.getCornerstoneViewport + .mockReturnValueOnce(mockViewport) + .mockReturnValueOnce(null); + + mockSyncGroupService.getSynchronizersForViewport.mockReturnValue([mockSyncState]); + + toggleVOISliceSync({ + ...defaultParameters, + viewports, + }); + + expect(mockSyncGroupService.removeViewportFromSyncGroup).toHaveBeenCalledTimes(1); + expect(mockSyncGroupService.removeViewportFromSyncGroup).toHaveBeenCalledWith( + 'viewport-1', + 'rendering-engine-1', + 'VOI_SYNC_CT' + ); + }); + + it('should handle empty viewports object', () => { + const viewports = {}; + + toggleVOISliceSync({ + ...defaultParameters, + viewports, + }); + + expect(mockSyncGroupService.getSynchronizersForViewport).not.toHaveBeenCalled(); + expect(mockSyncGroupService.addViewportToSyncGroup).not.toHaveBeenCalled(); + expect(mockSyncGroupService.removeViewportFromSyncGroup).not.toHaveBeenCalled(); + }); + + it('should handle modality with empty viewport array', () => { + const viewports = { + CT: [], + }; + + toggleVOISliceSync({ + ...defaultParameters, + viewports, + }); + + expect(mockSyncGroupService.getSynchronizersForViewport).not.toHaveBeenCalled(); + expect(mockSyncGroupService.addViewportToSyncGroup).not.toHaveBeenCalled(); + expect(mockSyncGroupService.removeViewportFromSyncGroup).not.toHaveBeenCalled(); + }); + + it('should handle multiple displaySetInstanceUIDs by using first one', () => { + const mockGridViewportMultipleDisplaySets = { + viewportOptions: { + viewportId: 'viewport-1', + }, + displaySetInstanceUIDs: ['displaySet-1', 'displaySet-2', 'displaySet-3'], + }; + + mockViewportGridService.getState.mockReturnValue({ + viewports: new Map([['viewport-1', mockGridViewportMultipleDisplaySets]]), + }); + mockSyncGroupService.getSynchronizersForViewport.mockReturnValue([]); + mockDisplaySetService.getDisplaySetByUID.mockReturnValue(mockDisplaySet); + + toggleVOISliceSync({ + ...defaultParameters, + }); + + expect(mockDisplaySetService.getDisplaySetByUID).toHaveBeenCalledWith('displaySet-1'); + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenCalledWith( + 'viewport-1', + 'rendering-engine-1', + { + type: 'voi', + id: 'VOI_SYNC_CT', + source: true, + target: true, + } + ); + }); + + it('should group viewports by modality correctly when generating from grid', () => { + const mockViewportsMap = new Map([ + [ + 'viewport-ct-1', + { + viewportOptions: { + viewportId: 'viewport-ct-1', + }, + displaySetInstanceUIDs: ['displaySet-ct-1'], + }, + ], + [ + 'viewport-ct-2', + { + viewportOptions: { + viewportId: 'viewport-ct-2', + }, + displaySetInstanceUIDs: ['displaySet-ct-2'], + }, + ], + [ + 'viewport-mr-1', + { + viewportOptions: { + viewportId: 'viewport-mr-1', + }, + displaySetInstanceUIDs: ['displaySet-mr-1'], + }, + ], + ]); + + const mockState = { + viewports: mockViewportsMap, + }; + + const mockDisplaySetCT = { Modality: 'CT' }; + const mockDisplaySetMR = { Modality: 'MR' }; + const mockViewportCT1 = { + id: 'viewport-ct-1', + getRenderingEngine: jest.fn(() => ({ id: 'rendering-engine-ct-1' })), + }; + const mockViewportCT2 = { + id: 'viewport-ct-2', + getRenderingEngine: jest.fn(() => ({ id: 'rendering-engine-ct-2' })), + }; + const mockViewportMR1 = { + id: 'viewport-mr-1', + getRenderingEngine: jest.fn(() => ({ id: 'rendering-engine-mr-1' })), + }; + + mockViewportGridService.getState.mockReturnValue(mockState); + mockDisplaySetService.getDisplaySetByUID + .mockReturnValueOnce(mockDisplaySetCT) + .mockReturnValueOnce(mockDisplaySetCT) + .mockReturnValueOnce(mockDisplaySetMR); + + mockCornerstoneViewportService.getCornerstoneViewport + .mockReturnValueOnce(mockViewportCT1) + .mockReturnValueOnce(mockViewportCT2) + .mockReturnValueOnce(mockViewportMR1); + + mockSyncGroupService.getSynchronizersForViewport.mockReturnValue([]); + + toggleVOISliceSync({ + ...defaultParameters, + viewports: null, + }); + + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenCalledTimes(3); + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenNthCalledWith( + 1, + 'viewport-ct-1', + 'rendering-engine-ct-1', + { + type: 'voi', + id: 'VOI_SYNC_CT', + source: true, + target: true, + } + ); + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenNthCalledWith( + 2, + 'viewport-ct-2', + 'rendering-engine-ct-2', + { + type: 'voi', + id: 'VOI_SYNC_CT', + source: true, + target: true, + } + ); + expect(mockSyncGroupService.addViewportToSyncGroup).toHaveBeenNthCalledWith( + 3, + 'viewport-mr-1', + 'rendering-engine-mr-1', + { + type: 'voi', + id: 'VOI_SYNC_MR', + source: true, + target: true, + } + ); + }); +}); diff --git a/extensions/cornerstone/src/utils/toggleVOISliceSync.ts b/extensions/cornerstone/src/utils/toggleVOISliceSync.ts index 57e23d116..a571fe8e6 100644 --- a/extensions/cornerstone/src/utils/toggleVOISliceSync.ts +++ b/extensions/cornerstone/src/utils/toggleVOISliceSync.ts @@ -46,7 +46,7 @@ export default function toggleVOISliceSync({ } syncGroupService.addViewportToSyncGroup(viewportId, viewport.getRenderingEngine().id, { type: 'voi', - id: syncIdToUse, + id: syncIdToUse as string, source: true, target: true, }); @@ -76,12 +76,12 @@ function groupViewportsByModality( viewportGridService: ViewportGridService, displaySetService: DisplaySetService ) { - let { viewports } = viewportGridService.getState(); + const { viewports } = viewportGridService.getState(); - viewports = [...viewports.values()]; + const viewportsArray = [...viewports.values()]; // group the viewports by modality - return viewports.reduce((acc, viewport) => { + return viewportsArray.reduce((acc, viewport) => { const { displaySetInstanceUIDs } = viewport; // Todo: add proper fusion support const displaySetInstanceUID = displaySetInstanceUIDs[0]; diff --git a/extensions/cornerstone/src/utils/updateSegmentationStats.test.ts b/extensions/cornerstone/src/utils/updateSegmentationStats.test.ts new file mode 100644 index 000000000..a377ed63d --- /dev/null +++ b/extensions/cornerstone/src/utils/updateSegmentationStats.test.ts @@ -0,0 +1,942 @@ +import { Types as cstTypes, utilities as cstUtilities } from '@cornerstonejs/tools'; +import { + updateSegmentationStats, + updateSegmentBidirectionalStats, +} from './updateSegmentationStats'; + +jest.mock('@cornerstonejs/tools', () => ({ + utilities: { + segmentation: { + getStatistics: jest.fn(), + }, + }, +})); + +describe('updateSegmentationStats', () => { + const mockSegmentation = { + segments: { + '1': { + segmentIndex: 1, + label: 'Segment 1', + }, + '2': { + segmentIndex: 2, + label: 'Segment 2', + }, + }, + }; + + const mockReadableText = { + mean: 'Mean', + stdDev: 'Standard Deviation', + volume: 'Volume', + max: 'Maximum', + }; + + const mockStats = { + '1': { + array: [ + { + name: 'mean', + value: 100.5, + unit: 'HU', + }, + ], + } as cstTypes.NamedStatistics, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return null if no segmentation is provided', async () => { + const result = await updateSegmentationStats({ + segmentation: null, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result).toBe(null); + expect(cstUtilities.segmentation.getStatistics).not.toHaveBeenCalled(); + }); + + it('should return null if segmentation is undefined', async () => { + const result = await updateSegmentationStats({ + segmentation: undefined, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result).toBe(null); + }); + + it('should return null if no segments found (empty segments)', async () => { + const segmentationWithEmptySegments = { + segments: {}, + }; + + const result = await updateSegmentationStats({ + segmentation: segmentationWithEmptySegments, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result).toBe(null); + expect(cstUtilities.segmentation.getStatistics).not.toHaveBeenCalled(); + }); + + it('should return null if no segments found (only segment 0)', async () => { + const segmentationWithOnlyBackground = { + segments: { + '0': { + segmentIndex: 0, + label: 'Background', + }, + }, + }; + + const result = await updateSegmentationStats({ + segmentation: segmentationWithOnlyBackground, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result).toBe(null); + expect(cstUtilities.segmentation.getStatistics).not.toHaveBeenCalled(); + }); + + it('should return null if getStatistics returns null', async () => { + jest.spyOn(cstUtilities.segmentation, 'getStatistics').mockResolvedValue(null); + + const result = await updateSegmentationStats({ + segmentation: mockSegmentation, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(cstUtilities.segmentation.getStatistics).toHaveBeenCalledWith({ + segmentationId: 'test-id', + segmentIndices: [1, 2], + mode: 'individual', + }); + expect(result).toBe(null); + }); + + it('should return null if getStatistics returns undefined', async () => { + jest.spyOn(cstUtilities.segmentation, 'getStatistics').mockResolvedValue(undefined); + + const result = await updateSegmentationStats({ + segmentation: mockSegmentation, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result).toBe(null); + }); + + it('should initialize cachedStats if not present', async () => { + jest.spyOn(cstUtilities.segmentation, 'getStatistics').mockResolvedValue(mockStats); + + const segmentationWithoutCachedStats = { + segments: { + '1': { + segmentIndex: 1, + label: 'Segment 1', + }, + }, + }; + + const result = await updateSegmentationStats({ + segmentation: segmentationWithoutCachedStats, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result.segments['1'].cachedStats).toBeDefined(); + expect(result.segments['1'].cachedStats.namedStats).toBeDefined(); + expect(result.segments['1'].cachedStats.namedStats.mean).toEqual({ + name: mockStats['1'].array[0].name, + label: mockReadableText.mean, + value: mockStats['1'].array[0].value, + unit: mockStats['1'].array[0].unit, + order: 0, + }); + }); + + it('should preserve existing cachedStats and add namedStats', async () => { + jest.spyOn(cstUtilities.segmentation, 'getStatistics').mockResolvedValue(mockStats); + + const segmentationWithExistingCachedStats = { + segments: { + '1': { + segmentIndex: 1, + label: 'Segment 1', + cachedStats: { + existingData: 'test', + }, + }, + }, + }; + + const result = await updateSegmentationStats({ + segmentation: segmentationWithExistingCachedStats, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result.segments['1'].cachedStats.existingData).toBe('test'); + expect(result.segments['1'].cachedStats.namedStats.mean).toEqual({ + name: mockStats['1'].array[0].name, + label: mockReadableText.mean, + value: mockStats['1'].array[0].value, + unit: mockStats['1'].array[0].unit, + order: 0, + }); + }); + + it('should preserve existing namedStats and merge with new stats', async () => { + jest.spyOn(cstUtilities.segmentation, 'getStatistics').mockResolvedValue(mockStats); + + const segmentationWithExistingNamedStats = { + segments: { + '1': { + segmentIndex: 1, + label: 'Segment 1', + cachedStats: { + namedStats: { + existingStat: { + name: 'existingStat', + value: 'existing', + }, + }, + }, + }, + }, + }; + + const result = await updateSegmentationStats({ + segmentation: segmentationWithExistingNamedStats, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result.segments['1'].cachedStats.namedStats.existingStat).toEqual({ + name: 'existingStat', + value: 'existing', + }); + expect(result.segments['1'].cachedStats.namedStats.mean).toEqual({ + name: mockStats['1'].array[0].name, + label: mockReadableText.mean, + value: mockStats['1'].array[0].value, + unit: mockStats['1'].array[0].unit, + order: 0, + }); + }); + + it('should skip stats not in readableText', async () => { + const mockStatsWithUnknownStat = { + '1': { + array: [ + ...mockStats['1'].array, + { + name: 'unknownStat', + value: 50, + unit: 'mm', + }, + ], + } as cstTypes.NamedStatistics, + }; + + jest + .spyOn(cstUtilities.segmentation, 'getStatistics') + .mockResolvedValue(mockStatsWithUnknownStat); + + const result = await updateSegmentationStats({ + segmentation: mockSegmentation, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result.segments['1'].cachedStats.namedStats.mean).toBeDefined(); + expect(result.segments['1'].cachedStats.namedStats.unknownStat).toBeUndefined(); + }); + + it('should skip stats with invalid name', async () => { + const mockStatsWithInvalidName = { + '1': { + array: [ + ...mockStats['1'].array, + { + name: null, + value: 50, + unit: 'mm', + }, + { + value: 25, + unit: 'cm', + }, + ], + } as cstTypes.NamedStatistics, + }; + + jest + .spyOn(cstUtilities.segmentation, 'getStatistics') + .mockResolvedValue(mockStatsWithInvalidName); + + const result = await updateSegmentationStats({ + segmentation: mockSegmentation, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result.segments['1'].cachedStats.namedStats.mean).toBeDefined(); + expect(Object.keys(result.segments['1'].cachedStats.namedStats)).toHaveLength(1); + }); + + it('should add volume stat when volume exists in segmentStats and readableText', async () => { + console.log('mockSegmentation', mockSegmentation); + + const mockStatsWithVolume = { + '1': { + array: [...mockStats['1'].array], + volume: { + value: 250.75, + unit: 'mm³', + }, + } as cstTypes.NamedStatistics, + }; + + jest.spyOn(cstUtilities.segmentation, 'getStatistics').mockResolvedValue(mockStatsWithVolume); + + const result = await updateSegmentationStats({ + segmentation: mockSegmentation, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result.segments['1'].cachedStats.namedStats.volume).toEqual({ + name: 'volume', + label: mockReadableText.volume, + value: mockStatsWithVolume['1'].volume.value, + unit: mockStatsWithVolume['1'].volume.unit, + order: 2, + }); + }); + + it('should not add volume stat when volume not in readableText', async () => { + console.log('mockSegmentation', mockSegmentation); + + const mockStatsWithVolume = { + '1': { + array: [...mockStats['1'].array], + volume: { + value: 250.75, + unit: 'mm³', + }, + } as cstTypes.NamedStatistics, + }; + + jest.spyOn(cstUtilities.segmentation, 'getStatistics').mockResolvedValue(mockStatsWithVolume); + + const readableTextWithoutVolume = { + mean: 'Mean', + stdDev: 'Standard Deviation', + }; + + const result = await updateSegmentationStats({ + segmentation: mockSegmentation, + segmentationId: 'test-id', + readableText: readableTextWithoutVolume, + }); + + expect(result.segments['1'].cachedStats.namedStats.volume).toBeUndefined(); + }); + + it('should not add volume stat when volume already exists in namedStats', async () => { + const mockStatsWithVolume = { + '1': { + array: [ + { + name: 'volume', + value: 200.5, + unit: 'mm³', + }, + ], + volume: { + value: 250.75, + unit: 'mm³', + }, + } as cstTypes.NamedStatistics, + }; + + jest.spyOn(cstUtilities.segmentation, 'getStatistics').mockResolvedValue(mockStatsWithVolume); + + const result = await updateSegmentationStats({ + segmentation: mockSegmentation, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result.segments['1'].cachedStats.namedStats.volume).toEqual({ + name: 'volume', + label: mockReadableText.volume, + value: mockStatsWithVolume['1'].array[0].value, + unit: mockStatsWithVolume['1'].volume.unit, + order: 2, + }); + }); + + it('should not add volume stat when segmentStats.volume is missing', async () => { + jest.spyOn(cstUtilities.segmentation, 'getStatistics').mockResolvedValue(mockStats); + + const result = await updateSegmentationStats({ + segmentation: mockSegmentation, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result.segments['1'].cachedStats.namedStats.volume).toBeUndefined(); + }); + + it('should handle multiple segments with different stats', async () => { + const mockStatsWithMax = { + '1': { + array: [ + ...mockStats['1'].array, + { + name: 'max', + value: 200, + unit: 'HU', + }, + ], + } as cstTypes.NamedStatistics, + '2': { + array: [ + { + name: 'stdDev', + value: 15.2, + unit: 'HU', + }, + ], + volume: { + value: 150.25, + unit: 'mm³', + }, + } as cstTypes.NamedStatistics, + }; + + jest.spyOn(cstUtilities.segmentation, 'getStatistics').mockResolvedValue(mockStatsWithMax); + + const result = await updateSegmentationStats({ + segmentation: mockSegmentation, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result.segments['1'].cachedStats.namedStats.mean).toEqual({ + name: mockStatsWithMax['1'].array[0].name, + label: mockReadableText.mean, + value: mockStatsWithMax['1'].array[0].value, + unit: mockStatsWithMax['1'].array[0].unit, + order: 0, + }); + expect(result.segments['1'].cachedStats.namedStats.max).toEqual({ + name: mockStatsWithMax['1'].array[1].name, + label: mockReadableText.max, + value: mockStatsWithMax['1'].array[1].value, + unit: mockStatsWithMax['1'].array[1].unit, + order: 3, + }); + expect(result.segments['2'].cachedStats.namedStats.stdDev).toEqual({ + name: mockStatsWithMax['2'].array[0].name, + label: mockReadableText.stdDev, + value: mockStatsWithMax['2'].array[0].value, + unit: mockStatsWithMax['2'].array[0].unit, + order: 1, + }); + expect(result.segments['2'].cachedStats.namedStats.volume).toEqual({ + name: 'volume', + label: mockReadableText.volume, + value: mockStatsWithMax['2'].volume.value, + unit: mockStatsWithMax['2'].volume.unit, + order: 2, + }); + }); + + it('should add empty namedStats when no stats are returned(empty array)', async () => { + const mockStatsWithEmptyArray = { + '1': { + array: [], + } as cstTypes.NamedStatistics, + }; + + jest + .spyOn(cstUtilities.segmentation, 'getStatistics') + .mockResolvedValue(mockStatsWithEmptyArray); + + const result = await updateSegmentationStats({ + segmentation: mockSegmentation, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result.segments['1'].cachedStats.namedStats).toEqual({}); + }); + + it('should only initialize namedStats when no matching stats are found', async () => { + const mockStatsWithUnknownStat = { + '1': { + array: [ + { + name: 'unknownStat', + value: 100, + unit: 'unit', + }, + ], + } as cstTypes.NamedStatistics, + }; + + jest + .spyOn(cstUtilities.segmentation, 'getStatistics') + .mockResolvedValue(mockStatsWithUnknownStat); + + const result = await updateSegmentationStats({ + segmentation: mockSegmentation, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result.segments['1'].cachedStats.namedStats).toEqual({}); + }); + + it('should handle segments without array property by initializing cachedStats', async () => { + const mockStatsWithNoArray = { + '1': {} as cstTypes.NamedStatistics, + }; + + jest.spyOn(cstUtilities.segmentation, 'getStatistics').mockResolvedValue(mockStatsWithNoArray); + + const result = await updateSegmentationStats({ + segmentation: mockSegmentation, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result.segments['1'].cachedStats).toEqual({}); + }); + + it('should not update segment if cachedStats is already initialized and no matching stats are found', async () => { + const mockSegmentationWithCachedStats = { + segments: { + '1': { + segmentIndex: 1, + label: 'Segment 1', + cachedStats: { + namedStats: { + existingStat: { + name: 'existing', + value: 'test', + }, + }, + }, + }, + }, + }; + + const mockStatsWithNoArray = { + '1': {} as cstTypes.NamedStatistics, + }; + + jest.spyOn(cstUtilities.segmentation, 'getStatistics').mockResolvedValue(mockStatsWithNoArray); + + const result = await updateSegmentationStats({ + segmentation: mockSegmentationWithCachedStats, + segmentationId: 'test-id', + readableText: mockReadableText, + }); + + expect(result).toBe(null); + }); +}); + +describe('updateSegmentBidirectionalStats', () => { + const mockSegmentationService = { + getSegmentation: jest.fn(), + }; + + const mockAnnotation = { + annotationUID: 'test-annotation-uid', + }; + + const mockBidirectionalData = { + majorAxis: { length: 20.5 }, + minorAxis: { length: 15.3 }, + maxMajor: 20.5, + maxMinor: 15.3, + }; + + const defaultParameters = { + segmentationId: 'test-id', + segmentIndex: 1, + bidirectionalData: mockBidirectionalData, + // @ts-expect-error - only part of the SegmentationService is needed + segmentationService: mockSegmentationService as AppTypes.SegmentationService, + annotation: mockAnnotation, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return null when segmentationId is missing', () => { + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + segmentationId: '', + }); + + expect(result).toBe(null); + expect(mockSegmentationService.getSegmentation).not.toHaveBeenCalled(); + }); + + it('should return null when segmentationId is null', () => { + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + segmentationId: null, + }); + + expect(result).toBe(null); + }); + + it('should return null when segmentIndex is undefined', () => { + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + segmentIndex: undefined, + }); + + expect(result).toBe(null); + }); + + it('should return null when bidirectionalData is missing', () => { + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + bidirectionalData: null, + }); + + expect(result).toBe(null); + }); + + it('should return null when segmentation is not found', () => { + mockSegmentationService.getSegmentation.mockReturnValue(null); + + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + }); + + expect(mockSegmentationService.getSegmentation).toHaveBeenCalledWith('test-id'); + expect(result).toBe(null); + }); + + it('should return null when segment is not found', () => { + const mockSegmentation = { + segments: {}, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + }); + + expect(result).toBe(null); + }); + + it('should return null when majorAxis is missing', () => { + const mockSegmentation = { + segments: { + 1: { + segmentIndex: 1, + label: 'Segment 1', + }, + }, + }; + + const bidirectionalDataWithoutMajorAxis = { + majorAxis: null, + minorAxis: { length: 15.3 }, + maxMajor: 20.5, + maxMinor: 15.3, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + bidirectionalData: bidirectionalDataWithoutMajorAxis, + }); + + expect(result).toBe(null); + }); + + it('should return null when minorAxis is missing', () => { + const mockSegmentation = { + segments: { + 1: { + segmentIndex: 1, + label: 'Segment 1', + }, + }, + }; + + const bidirectionalDataWithoutMinorAxis = { + majorAxis: { length: 20.5 }, + minorAxis: null, + maxMajor: 20.5, + maxMinor: 15.3, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + bidirectionalData: bidirectionalDataWithoutMinorAxis, + }); + + expect(result).toBe(null); + }); + + it('should return null when maxMajor is zero', () => { + const mockSegmentation = { + segments: { + 1: { + segmentIndex: 1, + label: 'Segment 1', + }, + }, + }; + + const bidirectionalDataWithZeroMajor = { + majorAxis: { length: 20.5 }, + minorAxis: { length: 15.3 }, + maxMajor: 0, + maxMinor: 15.3, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + bidirectionalData: bidirectionalDataWithZeroMajor, + }); + + expect(result).toBe(null); + }); + + it('should return null when maxMinor is zero', () => { + const mockSegmentation = { + segments: { + 1: { + segmentIndex: 1, + label: 'Segment 1', + }, + }, + }; + + const bidirectionalDataWithZeroMinor = { + majorAxis: { length: 20.5 }, + minorAxis: { length: 15.3 }, + maxMajor: 20.5, + maxMinor: 0, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + bidirectionalData: bidirectionalDataWithZeroMinor, + }); + + expect(result).toBe(null); + }); + + it('should return null when maxMajor is negative', () => { + const mockSegmentation = { + segments: { + 1: { + segmentIndex: 1, + label: 'Segment 1', + }, + }, + }; + + const bidirectionalDataWithNegativeMajor = { + majorAxis: { length: 20.5 }, + minorAxis: { length: 15.3 }, + maxMajor: -5, + maxMinor: 15.3, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + bidirectionalData: bidirectionalDataWithNegativeMajor, + }); + + expect(result).toBe(null); + }); + + it('should initialize cachedStats when segment has no cachedStats', () => { + const mockSegmentation = { + segments: { + 1: { + segmentIndex: 1, + label: 'Segment 1', + }, + }, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + }); + + expect(result.segments[1].cachedStats).toEqual({ + namedStats: { + bidirectional: { + name: 'bidirectional', + label: 'Bidirectional', + annotationUID: 'test-annotation-uid', + value: { + ...mockBidirectionalData, + }, + unit: 'mm', + }, + }, + }); + }); + + it('should initialize namedStats when segment has cachedStats but no namedStats', () => { + const mockSegmentation = { + segments: { + 1: { + segmentIndex: 1, + label: 'Segment 1', + cachedStats: { + existingData: 'test', + }, + }, + }, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + }); + + expect(result.segments[1].cachedStats.existingData).toBe('test'); + expect(result.segments[1].cachedStats.namedStats).toEqual({ + bidirectional: { + name: 'bidirectional', + label: 'Bidirectional', + annotationUID: 'test-annotation-uid', + value: { + ...mockBidirectionalData, + }, + unit: 'mm', + }, + }); + }); + + it('should add bidirectional stats to existing namedStats', () => { + const mockSegmentation = { + segments: { + 1: { + segmentIndex: 1, + label: 'Segment 1', + cachedStats: { + namedStats: { + existingStat: { + name: 'existing', + value: 'test', + }, + }, + }, + }, + }, + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + }); + + expect( + (result.segments[1].cachedStats.namedStats as { existingStat: cstTypes.NamedStatistics }) + .existingStat + ).toEqual({ + name: 'existing', + value: 'test', + }); + expect( + (result.segments[1].cachedStats.namedStats as { bidirectional: cstTypes.NamedStatistics }) + .bidirectional + ).toEqual({ + name: 'bidirectional', + label: 'Bidirectional', + annotationUID: 'test-annotation-uid', + value: { + ...mockBidirectionalData, + }, + unit: 'mm', + }); + }); + + it('should update bidirectional stats with correct data structure', () => { + const mockSegmentation = { + segments: { + 1: { + segmentIndex: 1, + label: 'Segment 1', + cachedStats: { + namedStats: {}, + }, + }, + }, + }; + + const customBidirectionalData = { + majorAxis: { length: 25.7 }, + minorAxis: { length: 18.9 }, + maxMajor: 25.7, + maxMinor: 18.9, + }; + + const customAnnotation = { + annotationUID: 'custom-annotation-uid', + }; + + mockSegmentationService.getSegmentation.mockReturnValue(mockSegmentation); + + const result = updateSegmentBidirectionalStats({ + ...defaultParameters, + bidirectionalData: customBidirectionalData, + annotation: customAnnotation, + }); + + expect( + (result.segments[1].cachedStats.namedStats as { bidirectional: cstTypes.NamedStatistics }) + .bidirectional + ).toEqual({ + name: 'bidirectional', + label: 'Bidirectional', + annotationUID: 'custom-annotation-uid', + value: { + ...customBidirectionalData, + }, + unit: 'mm', + }); + }); +}); diff --git a/extensions/cornerstone/src/utils/updateSegmentationStats.ts b/extensions/cornerstone/src/utils/updateSegmentationStats.ts index 936c822b4..99ca83f38 100644 --- a/extensions/cornerstone/src/utils/updateSegmentationStats.ts +++ b/extensions/cornerstone/src/utils/updateSegmentationStats.ts @@ -1,4 +1,5 @@ import * as cornerstoneTools from '@cornerstonejs/tools'; +import cloneDeep from 'lodash.clonedeep'; interface BidirectionalAxis { length: number; @@ -51,7 +52,7 @@ export async function updateSegmentationStats({ return null; } - const updatedSegmentation = { ...segmentation }; + const updatedSegmentation = cloneDeep(segmentation); let hasUpdates = false; // Loop through each segment's stats