diff --git a/babel.config.js b/babel.config.js index b7871a95a..bcc357079 100644 --- a/babel.config.js +++ b/babel.config.js @@ -35,6 +35,7 @@ module.exports = { '@babel/plugin-transform-typescript', '@babel/plugin-transform-class-static-block', '@babel/plugin-transform-for-of', + ['babel-plugin-transform-import-meta', { module: 'ES6' }], ], }, production: { diff --git a/bun.lock b/bun.lock index dcaf4e08e..733ec5e2d 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,7 @@ "@pmmmwh/react-refresh-webpack-plugin": "0.5.17", "babel-loader": "8.4.1", "babel-plugin-istanbul": "^7.0.0", + "babel-plugin-transform-import-meta": "2.3.3", "css-loader": "6.11.0", "cypress": "14.5.2", "cypress-file-upload": "^5.0.8", @@ -50,7 +51,7 @@ }, "addOns/externals/devDependencies": { "name": "@externals/devDependencies", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "@kitware/vtk.js": "32.12.0", @@ -135,14 +136,14 @@ }, "addOns/externals/dicom-microscopy-viewer": { "name": "@externals/dicom-microscopy-viewer", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "dicom-microscopy-viewer": "^0.48.6", }, }, "extensions/cornerstone": { "name": "@ohif/extension-cornerstone", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/adapters": "4.3.10", @@ -168,8 +169,8 @@ "@cornerstonejs/codec-openjpeg": "^1.2.4", "@cornerstonejs/codec-openjph": "^2.4.5", "@cornerstonejs/dicom-image-loader": "4.3.10", - "@ohif/core": "3.12.0-beta.32", - "@ohif/ui": "3.12.0-beta.32", + "@ohif/core": "3.12.0-beta.35", + "@ohif/ui": "3.12.0-beta.35", "dcmjs": "0.43.1", "dicom-parser": "^1.8.21", "hammerjs": "^2.0.8", @@ -181,7 +182,7 @@ }, "extensions/cornerstone-dicom-pmap": { "name": "@ohif/extension-cornerstone-dicom-pmap", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/adapters": "4.3.10", @@ -204,7 +205,7 @@ }, "extensions/cornerstone-dicom-rt": { "name": "@ohif/extension-cornerstone-dicom-rt", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "react-color": "^2.19.3", @@ -224,7 +225,7 @@ }, "extensions/cornerstone-dicom-seg": { "name": "@ohif/extension-cornerstone-dicom-seg", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/adapters": "4.3.10", @@ -247,7 +248,7 @@ }, "extensions/cornerstone-dicom-sr": { "name": "@ohif/extension-cornerstone-dicom-sr", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/adapters": "4.3.10", @@ -256,10 +257,10 @@ "classnames": "^2.3.2", }, "peerDependencies": { - "@ohif/core": "3.12.0-beta.32", - "@ohif/extension-cornerstone": "3.12.0-beta.32", - "@ohif/extension-measurement-tracking": "3.12.0-beta.32", - "@ohif/ui": "3.12.0-beta.32", + "@ohif/core": "3.12.0-beta.35", + "@ohif/extension-cornerstone": "3.12.0-beta.35", + "@ohif/extension-measurement-tracking": "3.12.0-beta.35", + "@ohif/ui": "3.12.0-beta.35", "dcmjs": "0.43.1", "dicom-parser": "^1.8.9", "hammerjs": "^2.0.8", @@ -269,7 +270,7 @@ }, "extensions/cornerstone-dynamic-volume": { "name": "@ohif/extension-cornerstone-dynamic-volume", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/core": "4.3.10", @@ -291,7 +292,7 @@ }, "extensions/default": { "name": "@ohif/extension-default", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/calculate-suv": "^1.1.0", @@ -299,8 +300,8 @@ "lodash.uniqby": "^4.7.0", }, "peerDependencies": { - "@ohif/core": "3.12.0-beta.32", - "@ohif/i18n": "3.12.0-beta.32", + "@ohif/core": "3.12.0-beta.35", + "@ohif/i18n": "3.12.0-beta.35", "dcmjs": "0.43.1", "dicomweb-client": "^0.10.4", "prop-types": "^15.6.2", @@ -314,7 +315,7 @@ }, "extensions/dicom-microscopy": { "name": "@ohif/extension-dicom-microscopy", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/codec-charls": "^1.2.3", @@ -339,7 +340,7 @@ }, "extensions/dicom-pdf": { "name": "@ohif/extension-dicom-pdf", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", @@ -356,7 +357,7 @@ }, "extensions/dicom-video": { "name": "@ohif/extension-dicom-video", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", @@ -373,20 +374,20 @@ }, "extensions/measurement-tracking": { "name": "@ohif/extension-measurement-tracking", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", - "@ohif/ui": "3.12.0-beta.32", + "@ohif/ui": "3.12.0-beta.35", "@xstate/react": "^3.2.2", "xstate": "^4.10.0", }, "peerDependencies": { "@cornerstonejs/core": "4.3.10", "@cornerstonejs/tools": "4.3.10", - "@ohif/core": "3.12.0-beta.32", - "@ohif/extension-cornerstone-dicom-sr": "3.12.0-beta.32", - "@ohif/extension-default": "3.12.0-beta.32", - "@ohif/ui": "3.12.0-beta.32", + "@ohif/core": "3.12.0-beta.35", + "@ohif/extension-cornerstone-dicom-sr": "3.12.0-beta.35", + "@ohif/extension-default": "3.12.0-beta.35", + "@ohif/ui": "3.12.0-beta.35", "classnames": "^2.3.2", "dcmjs": "0.43.1", "lodash.debounce": "^4.0.8", @@ -399,7 +400,7 @@ }, "extensions/test-extension": { "name": "@ohif/extension-test", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", @@ -416,7 +417,7 @@ }, "extensions/tmtv": { "name": "@ohif/extension-tmtv", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", @@ -433,7 +434,7 @@ }, "extensions/usAnnotation": { "name": "@ohif/extension-ultrasound-pleura-bline", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/core": "4.3.10", @@ -485,7 +486,7 @@ }, "modes/basic-dev-mode": { "name": "@ohif/mode-basic-dev-mode", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -505,7 +506,7 @@ }, "modes/basic-test-mode": { "name": "@ohif/mode-test", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -527,7 +528,7 @@ }, "modes/longitudinal": { "name": "@ohif/mode-longitudinal", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -550,7 +551,7 @@ }, "modes/microscopy": { "name": "@ohif/mode-microscopy", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -562,7 +563,7 @@ }, "modes/preclinical-4d": { "name": "@ohif/mode-preclinical-4d", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", }, @@ -581,7 +582,7 @@ }, "modes/segmentation": { "name": "@ohif/mode-segmentation", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -625,7 +626,7 @@ }, "modes/tmtv": { "name": "@ohif/mode-tmtv", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -646,7 +647,7 @@ }, "modes/usAnnotation": { "name": "@ohif/mode-ultrasound-pleura-bline", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/core": "4.3.10", @@ -685,7 +686,7 @@ }, "platform/app": { "name": "@ohif/app", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/codec-charls": "^1.2.3", @@ -764,7 +765,7 @@ }, "platform/cli": { "name": "@ohif/cli", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "bin": { "ohif-cli": "src/index.js", }, @@ -788,7 +789,7 @@ }, "platform/core": { "name": "@ohif/core", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "dcmjs": "0.43.1", @@ -815,14 +816,14 @@ "@cornerstonejs/codec-openjph": "^2.4.5", "@cornerstonejs/core": "4.3.10", "@cornerstonejs/dicom-image-loader": "4.3.10", - "@ohif/ui": "3.12.0-beta.32", + "@ohif/ui": "3.12.0-beta.35", "cornerstone-math": "0.1.9", "dicom-parser": "^1.8.21", }, }, "platform/i18n": { "name": "@ohif/i18n", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@babel/runtime": "^7.20.13", "i18next-locize-backend": "^2.0.0", @@ -847,7 +848,7 @@ }, "platform/ui": { "name": "@ohif/ui", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@testing-library/react": "^13.1.0", "browser-detect": "^0.2.28", @@ -898,7 +899,7 @@ }, "platform/ui-next": { "name": "@ohif/ui-next", - "version": "3.12.0-beta.32", + "version": "3.12.0-beta.35", "dependencies": { "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-checkbox": "^1.1.1", @@ -2245,6 +2246,8 @@ "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.5", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg=="], + "babel-plugin-transform-import-meta": ["babel-plugin-transform-import-meta@2.3.3", "", { "dependencies": { "@babel/template": "^7.25.9", "tslib": "^2.8.1" }, "peerDependencies": { "@babel/core": "^7.10.0" } }, "sha512-bbh30qz1m6ZU1ybJoNOhA2zaDvmeXMnGNBMVMDOJ1Fni4+wMBoy/j7MTRVmqAUCIcy54/rEnr9VEBsfcgbpm3Q=="], + "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.1.1", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "sha512-23fWKohMTvS5s0wwJKycOe0dBdCwQ6+iiLaNR9zy8P13mtFRFM9qLLX6HJX5DL2pi/FNDf3fCQHM4FIMoHH/7w=="], "babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="], diff --git a/extensions/cornerstone/jest.config.js b/extensions/cornerstone/jest.config.js index f57711b3f..c5eb6dd38 100644 --- a/extensions/cornerstone/jest.config.js +++ b/extensions/cornerstone/jest.config.js @@ -1,9 +1,13 @@ const base = require('../../jest.config.base.js'); -const pkg = require('./package'); module.exports = { ...base, - displayName: pkg.name, + moduleNameMapper: { + ...base.moduleNameMapper, + '@ohif/(.*)': '/../../platform/$1/src', + '^@cornerstonejs/([^/]+)/(.*)$': '/../../node_modules/@cornerstonejs/$1/dist/esm/$2', + '^@cornerstonejs/([^/]+)$': '/../../node_modules/@cornerstonejs/$1/dist/esm', + }, // rootDir: "../.." // testMatch: [ // //`/platform/${pack.name}/**/*.spec.js` diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts new file mode 100644 index 000000000..0b3e6de62 --- /dev/null +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts @@ -0,0 +1,2998 @@ +import { + cache, + Enums as csEnums, + eventTarget, + geometryLoader, + getEnabledElementByViewportId, + imageLoader, + Types as csTypes, + metaData, +} from '@cornerstonejs/core'; +import { ViewportType } from '@cornerstonejs/core/enums'; + +import { + Enums as csToolsEnums, + segmentation as cstSegmentation, + Types as cstTypes, +} from '@cornerstonejs/tools'; + +import { EasingFunctionEnum, EasingFunctionMap } from '../../utils/transitions'; +import * as MapROIContoursToRTStructData from './RTSTRUCT/mapROIContoursToRTStructData'; +import SegmentationServiceClass, { SegmentationRepresentation } from './SegmentationService'; + +jest.mock('@cornerstonejs/core', () => ({ + ...jest.requireActual('@cornerstonejs/core'), + getEnabledElementByViewportId: jest.fn(), + eventTarget: { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, +})); + +jest.mock('@cornerstonejs/tools', () => ({ + ...jest.requireActual('@cornerstonejs/tools'), + segmentation: { + ...jest.requireActual('@cornerstonejs/tools').segmentation, + activeSegmentation: { + getActiveSegmentation: jest.fn(), + setActiveSegmentation: jest.fn(), + }, + addSegmentations: jest.fn(), + config: { + color: { + getSegmentIndexColor: jest.fn(), + setSegmentIndexColor: jest.fn(), + }, + visibility: { + getHiddenSegmentIndices: jest.fn(), + getSegmentIndexVisibility: jest.fn(), + setSegmentIndexVisibility: jest.fn(), + setSegmentationRepresentationVisibility: jest.fn(), + }, + style: { + hasCustomStyle: jest.fn(), + getRenderInactiveSegmentations: jest.fn(), + getStyle: jest.fn(), + resetToGlobalStyle: jest.fn(), + setRenderInactiveSegmentations: jest.fn(), + setStyle: jest.fn(), + }, + }, + getLabelmapImageIds: jest.fn(), + helpers: { convertStackToVolumeLabelmap: jest.fn() }, + removeSegment: jest.fn(), + removeSegmentationRepresentations: jest.fn(), + segmentIndex: { + setActiveSegmentIndex: jest.fn(), + }, + segmentLocking: { + isSegmentIndexLocked: jest.fn(), + setSegmentIndexLocked: jest.fn(), + }, + state: { + addColorLUT: jest.fn(), + getSegmentation: jest.fn(), + getSegmentations: jest.fn(), + getSegmentationRepresentationsBySegmentationId: jest.fn(), + getSegmentationRepresentations: jest.fn(), + getViewportIdsWithSegmentation: jest.fn(), + removeAllSegmentations: jest.fn(), + removeSegmentation: jest.fn(), + updateLabelmapSegmentationImageReferences: jest.fn(), + }, + triggerSegmentationEvents: { triggerSegmentationRepresentationModified: jest.fn() }, + }, +})); + +const serviceManagerMock = { + services: { + cornerstoneViewportService: { + getCornerstoneViewport: jest.fn(), + }, + displaySetService: { + getDisplaySetByUID: jest.fn(), + }, + viewportGridService: { + EVENTS: { + GRID_STATE_CHANGED: 'event::gridStateChanged', + }, + getState: jest.fn(), + setDisplaySetsForViewport: jest.fn(), + subscribe: jest.fn(), + }, + }, +}; + +describe('SegmentationService', () => { + let service: SegmentationServiceClass; + const viewportId = 'viewportId'; + const mockCornerstoneRepresentations = [ + { + segmentationId: 'd7682642-c41d-abe5-3c78-716191336ab1', + type: 'Labelmap', + active: true, + visible: true, + colorLUTIndex: 0, + segments: { + '1': { visible: true }, + }, + config: { + cfun: { nodes: [] }, + ofun: { nodes: [] }, + colorLUTIndex: 0, + colorLUTOrIndex: 0, + }, + }, + { + segmentationId: 'd7682642-c41d-abe5-3c78-716191336ab2', + type: 'Labelmap', + active: true, + visible: true, + colorLUTIndex: 0, + segments: { + '1': { visible: true }, + }, + config: { + cfun: { nodes: [] }, + ofun: { nodes: [] }, + colorLUTIndex: 0, + colorLUTOrIndex: 0, + }, + }, + ]; + const mockCornerstoneSegmentation = { + segmentationId: '1d6ce0c8-aeae-6890-1cc6-b39560866811', + label: 'Segmentation', + cachedStats: {}, + segments: { + '1': { + active: true, + cachedStats: {}, + label: 'Segment 1', + locked: false, + segmentIndex: 1, + }, + }, + representationData: { Labelmap: {} }, + }; + const mockVolumeCornerstoneSegmentation = { + ...mockCornerstoneSegmentation, + representationData: { Labelmap: { volumeId: 'volumeId' } }, + }; + const mockCornerstoneStackViewport = { + element: { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + id: viewportId, + type: ViewportType.STACK, + getFrameOfReferenceUID: jest.fn(), + getViewPresentation: jest.fn(), + getViewReference: jest.fn(), + }; + const mockCornerstoneVolumeViewport = { + id: viewportId, + type: ViewportType.VOLUME_3D, + getFrameOfReferenceUID: jest.fn(), + setViewPresentation: jest.fn(), + setViewReference: jest.fn(), + render: jest.fn(), + }; + const representations = [ + { + ...mockCornerstoneRepresentations[0], + viewportId: 'viewportId', + id: 'test-id', + label: 'Test Segmentation', + styles: {}, + segments: { + 1: { + color: [255, 0, 0, 1], + opacity: 1, + segmentIndex: 1, + visible: true, + }, + }, + }, + ] as SegmentationRepresentation[]; + + beforeEach(() => { + service = new SegmentationServiceClass({ servicesManager: serviceManagerMock }); + + jest.clearAllMocks(); + }); + + it('should instantiate the service properly', () => { + expect(service).toBeDefined(); + expect(service.servicesManager).toBe(serviceManagerMock); + expect(service.EVENTS).toBeDefined(); + }); + + it('should instantiate service through registration', () => { + // @ts-expect-error - mock only has a subset of the properties + const service = SegmentationServiceClass.REGISTRATION.create({ + servicesManager: serviceManagerMock, + }); + + expect(service).toBeDefined(); + }); + + describe('onModeEnter', () => { + it('should add event listeners', () => { + service.onModeEnter(); + + expect(eventTarget.addEventListener).toHaveBeenCalledTimes(7); + + expect(eventTarget.addEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_MODIFIED, + expect.any(Function) + ); + expect(eventTarget.addEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_REMOVED, + expect.any(Function) + ); + expect(eventTarget.addEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_DATA_MODIFIED, + expect.any(Function) + ); + expect(eventTarget.addEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_REPRESENTATION_MODIFIED, + expect.any(Function) + ); + expect(eventTarget.addEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_REPRESENTATION_ADDED, + expect.any(Function) + ); + expect(eventTarget.addEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_REPRESENTATION_REMOVED, + expect.any(Function) + ); + expect(eventTarget.addEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_ADDED, + expect.any(Function) + ); + }); + }); + + describe('onModeExit', () => { + it('should remove event listeners', () => { + jest.spyOn(service, 'reset'); + + service.onModeExit(); + + expect(eventTarget.removeEventListener).toHaveBeenCalledTimes(7); + + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_MODIFIED, + expect.any(Function) + ); + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_REMOVED, + expect.any(Function) + ); + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_DATA_MODIFIED, + expect.any(Function) + ); + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_REPRESENTATION_MODIFIED, + expect.any(Function) + ); + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_REPRESENTATION_ADDED, + expect.any(Function) + ); + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_REPRESENTATION_REMOVED, + expect.any(Function) + ); + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_ADDED, + expect.any(Function) + ); + + expect(service.reset).toHaveBeenCalled(); + }); + }); + + describe('getSegmentation', () => { + it('should call cornerstone to get specific segmentation', () => { + service.getSegmentation('segmentationId'); + + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledWith('segmentationId'); + }); + }); + + describe('getSegmentations', () => { + it('should call cornerstone to get all segmentations', () => { + service.getSegmentations(); + + expect(cstSegmentation.state.getSegmentations).toHaveBeenCalled(); + }); + }); + + describe('getPresentation', () => { + it('should properly retrieve the segmentation presentations', () => { + jest + .spyOn(cstSegmentation.state, 'getSegmentationRepresentations') + .mockReturnValueOnce( + mockCornerstoneRepresentations as cstTypes.SegmentationRepresentation[] + ); + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation); + jest + .spyOn(cstSegmentation.config.color, 'getSegmentIndexColor') + .mockReturnValue([0, 0, 0, 1]); + jest + .spyOn(cstSegmentation.config.visibility, 'getSegmentIndexVisibility') + .mockReturnValue(true); + jest.spyOn(cstSegmentation.config.style, 'getStyle').mockReturnValue({}); + + const returnedPresentations = service.getPresentation(viewportId); + + // config is empty due to _toOHIFSegmentationRepresentation returning empty config + expect(returnedPresentations).toEqual([ + { + config: {}, + hydrated: true, + segmentationId: 'd7682642-c41d-abe5-3c78-716191336ab1', + type: 'Labelmap', + }, + { + config: {}, + hydrated: true, + segmentationId: 'd7682642-c41d-abe5-3c78-716191336ab2', + type: 'Labelmap', + }, + ]); + }); + + it('should ignore when representation is undefined', () => { + jest.spyOn(service, 'getSegmentationRepresentations').mockReturnValueOnce([undefined]); + + const returnedPresentations = service.getPresentation(viewportId); + + expect(returnedPresentations).toEqual([]); + }); + }); + + describe('getRepresentationsForSegmentation', () => { + it('should call cornerstone to get representations for a segmentation', () => { + service.getRepresentationsForSegmentation('segmentationId'); + + expect( + cstSegmentation.state.getSegmentationRepresentationsBySegmentationId + ).toHaveBeenCalledWith('segmentationId'); + }); + }); + + describe('getSegmentationRepresentations', () => { + it('should properly map cornerstone representations to OHIF representations', () => { + jest + .spyOn(cstSegmentation.state, 'getSegmentationRepresentations') + .mockReturnValueOnce( + mockCornerstoneRepresentations as cstTypes.SegmentationRepresentation[] + ); + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation); + jest + .spyOn(cstSegmentation.config.color, 'getSegmentIndexColor') + .mockReturnValue([0, 0, 0, 1]); + jest + .spyOn(cstSegmentation.config.visibility, 'getSegmentIndexVisibility') + .mockReturnValue(true); + jest.spyOn(cstSegmentation.config.style, 'getStyle').mockReturnValue({}); + + const returnedPresentations = service.getSegmentationRepresentations(viewportId); + + expect(returnedPresentations).toEqual([ + { + active: true, + colorLUTIndex: 0, + config: {}, + id: 'd7682642-c41d-abe5-3c78-716191336ab1-Labelmap-viewportId', + label: 'Segmentation', + segmentationId: 'd7682642-c41d-abe5-3c78-716191336ab1', + segments: { + '1': { + color: [0, 0, 0, 1], + opacity: 1, + segmentIndex: 1, + visible: true, + }, + }, + styles: {}, + type: 'Labelmap', + viewportId: 'viewportId', + visible: true, + }, + { + active: true, + colorLUTIndex: 0, + config: {}, + id: 'd7682642-c41d-abe5-3c78-716191336ab2-Labelmap-viewportId', + label: 'Segmentation', + segmentationId: 'd7682642-c41d-abe5-3c78-716191336ab2', + segments: { + '1': { + color: [0, 0, 0, 1], + opacity: 1, + segmentIndex: 1, + visible: true, + }, + }, + styles: {}, + type: 'Labelmap', + viewportId: 'viewportId', + visible: true, + }, + ]); + + expect(cstSegmentation.state.getSegmentationRepresentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.getSegmentationRepresentations).toHaveBeenCalledWith( + viewportId, + {} + ); + + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledTimes(2); + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledWith( + mockCornerstoneRepresentations[0].segmentationId + ); + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledWith( + mockCornerstoneRepresentations[1].segmentationId + ); + + expect(cstSegmentation.config.color.getSegmentIndexColor).toHaveBeenCalledTimes(2); + expect(cstSegmentation.config.color.getSegmentIndexColor).toHaveBeenCalledWith( + viewportId, + mockCornerstoneRepresentations[0].segmentationId, + mockCornerstoneSegmentation.segments['1'].segmentIndex + ); + expect(cstSegmentation.config.color.getSegmentIndexColor).toHaveBeenCalledWith( + viewportId, + mockCornerstoneRepresentations[1].segmentationId, + mockCornerstoneSegmentation.segments['1'].segmentIndex + ); + + expect(cstSegmentation.config.visibility.getSegmentIndexVisibility).toHaveBeenCalledTimes(2); + expect(cstSegmentation.config.visibility.getSegmentIndexVisibility).toHaveBeenCalledWith( + viewportId, + { + segmentationId: mockCornerstoneRepresentations[0].segmentationId, + type: mockCornerstoneRepresentations[0].type, + }, + mockCornerstoneSegmentation.segments['1'].segmentIndex + ); + expect(cstSegmentation.config.visibility.getSegmentIndexVisibility).toHaveBeenCalledWith( + viewportId, + { + segmentationId: mockCornerstoneRepresentations[1].segmentationId, + type: mockCornerstoneRepresentations[1].type, + }, + mockCornerstoneSegmentation.segments['1'].segmentIndex + ); + + expect(cstSegmentation.config.style.getStyle).toHaveBeenCalledTimes(2); + expect(cstSegmentation.config.style.getStyle).toHaveBeenCalledWith({ + viewportId, + segmentationId: mockCornerstoneRepresentations[0].segmentationId, + type: 'Labelmap', + }); + expect(cstSegmentation.config.style.getStyle).toHaveBeenCalledWith({ + viewportId, + segmentationId: mockCornerstoneRepresentations[1].segmentationId, + type: 'Labelmap', + }); + }); + + it('should throw an error if the segmentation is not found', () => { + jest + .spyOn(cstSegmentation.state, 'getSegmentationRepresentations') + .mockReturnValueOnce( + mockCornerstoneRepresentations as cstTypes.SegmentationRepresentation[] + ); + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(null as cstTypes.Segmentation); + + expect(() => service.getSegmentationRepresentations(viewportId)).toThrow( + `Segmentation with ID ${mockCornerstoneRepresentations[0].segmentationId} not found.` + ); + }); + + it('should forward the specifier to the cornerstone getSegmentationRepresentations', () => { + jest + .spyOn(cstSegmentation.state, 'getSegmentationRepresentations') + .mockReturnValueOnce( + mockCornerstoneRepresentations as cstTypes.SegmentationRepresentation[] + ); + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation); + jest + .spyOn(cstSegmentation.config.color, 'getSegmentIndexColor') + .mockReturnValue([0, 0, 0, 1]); + jest + .spyOn(cstSegmentation.config.visibility, 'getSegmentIndexVisibility') + .mockReturnValue(true); + jest.spyOn(cstSegmentation.config.style, 'getStyle').mockReturnValue({}); + + service.getSegmentationRepresentations(viewportId, { + segmentationId: mockCornerstoneRepresentations[0].segmentationId, + type: mockCornerstoneRepresentations[0].type as csToolsEnums.SegmentationRepresentations, + }); + + expect(cstSegmentation.state.getSegmentationRepresentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.getSegmentationRepresentations).toHaveBeenCalledWith( + viewportId, + { + segmentationId: mockCornerstoneRepresentations[0].segmentationId, + type: mockCornerstoneRepresentations[0].type, + } + ); + }); + }); + + describe('destroy', () => { + it('should remove event listeners and reset the service', () => { + jest.spyOn(service, 'reset'); + + service.destroy(); + + expect(eventTarget.removeEventListener).toHaveBeenCalledTimes(7); + + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_MODIFIED, + expect.any(Function) + ); + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_REMOVED, + expect.any(Function) + ); + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_DATA_MODIFIED, + expect.any(Function) + ); + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_REPRESENTATION_MODIFIED, + expect.any(Function) + ); + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_REPRESENTATION_ADDED, + expect.any(Function) + ); + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_REPRESENTATION_REMOVED, + expect.any(Function) + ); + expect(eventTarget.removeEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.SEGMENTATION_ADDED, + expect.any(Function) + ); + + expect(service.reset).toHaveBeenCalled(); + }); + }); + + describe('addSegmentationRepresentation', () => { + describe('stack viewport', () => { + it('should add a non volume segmentation representation to stack viewport through Cornerstone updateLabelmapSegmentationImageReferences', async () => { + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation); + jest + .spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport') + // only needed interfaces for the addSegmentationRepresentation call + .mockReturnValue(mockCornerstoneStackViewport as unknown as csTypes.IStackViewport); + jest + .spyOn(cstSegmentation.state, 'updateLabelmapSegmentationImageReferences') + .mockReturnValue('labelmapImageId'); + jest + .spyOn(cstSegmentation, 'addSegmentationRepresentations') + .mockReturnValueOnce(undefined); + + const callback = jest.fn(); + service.subscribe(service.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, callback); + + await service.addSegmentationRepresentation(viewportId, { + segmentationId: mockCornerstoneSegmentation.segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }); + + expect( + cstSegmentation.state.updateLabelmapSegmentationImageReferences + ).toHaveBeenCalledTimes(1); + expect( + cstSegmentation.state.updateLabelmapSegmentationImageReferences + ).toHaveBeenCalledWith(viewportId, mockCornerstoneSegmentation.segmentationId); + + // this will be called directly because there's no OHIF conversion needed + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledWith(viewportId, [ + { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + segmentationId: mockCornerstoneSegmentation.segmentationId, + config: { colorLUTOrIndex: undefined }, // expected since _segmentationIdToColorLUTIndexMap wasn't previous set + }, + ]); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + segmentationId: mockCornerstoneSegmentation.segmentationId, + }); + }); + + it('should add a non volume segmentation representation to stack viewport through SegmentationService conversion attempt', async () => { + const frameOfReferenceUID = 'frameOfReferenceUID'; + const imageId = 'imageId'; + const viewportGridServiceUnsubscribe = jest.fn(); + + const prevViewPresentation = {}; + const prevViewReference = {}; + + mockCornerstoneStackViewport.getFrameOfReferenceUID.mockReturnValueOnce( + frameOfReferenceUID + ); + mockCornerstoneStackViewport.getViewPresentation.mockReturnValueOnce(prevViewPresentation); + mockCornerstoneStackViewport.getViewReference.mockReturnValueOnce(prevViewReference); + + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation); + jest + .spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport') + // only needed interfaces for the addSegmentationRepresentation call + .mockReturnValue(mockCornerstoneStackViewport as unknown as csTypes.IStackViewport); + jest + .spyOn(cstSegmentation.state, 'updateLabelmapSegmentationImageReferences') + .mockReturnValue(undefined); + // @ts-expect-error - getLabelmapImageIds is wrongly typed at cornerstone3D + jest.spyOn(cstSegmentation, 'getLabelmapImageIds').mockReturnValue([imageId]); + jest + .spyOn(cstSegmentation, 'addSegmentationRepresentations') + .mockReturnValueOnce(undefined); + jest + .spyOn(cache, 'getImage') + .mockReturnValueOnce({ FrameOfReferenceUID: frameOfReferenceUID } as csTypes.IImage); + jest.spyOn(serviceManagerMock.services.viewportGridService, 'getState').mockReturnValue({ + viewports: new Map([ + [ + viewportId, + { displaySetInstanceUIDs: ['displaySetInstanceUID'], viewportOptions: {} }, + ], + ]), + }); + jest + .spyOn(cstSegmentation, 'addSegmentationRepresentations') + .mockReturnValueOnce(undefined); + jest.spyOn(serviceManagerMock.services.viewportGridService, 'subscribe').mockReturnValue({ + unsubscribe: viewportGridServiceUnsubscribe, + }); + + // awaiting here will wait for publishing of viewportGridService.EVENTS.GRID_STATE_CHANGED (deadlock avoidance) + const serviceAddSegmentationRepresentationPromise = service.addSegmentationRepresentation( + viewportId, + { + segmentationId: mockCornerstoneSegmentation.segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + } + ); + + let _addSegmentationRepresentationCallback; + const waitForSubscription = async () => { + let remainingAttempts = 100; + + while (_addSegmentationRepresentationCallback === undefined && remainingAttempts > 0) { + _addSegmentationRepresentationCallback = jest.mocked( + serviceManagerMock.services.viewportGridService.subscribe + ).mock.calls[0]?.[1]; + + await new Promise(resolve => resolve(void 0)); + remainingAttempts--; + } + }; + + await waitForSubscription(); + + // trigger callback being awaited + _addSegmentationRepresentationCallback(); + await serviceAddSegmentationRepresentationPromise; + + expect(cstSegmentation.getLabelmapImageIds).toHaveBeenCalledTimes(1); + expect(cstSegmentation.getLabelmapImageIds).toHaveBeenCalledWith( + mockCornerstoneSegmentation.segmentationId + ); + + expect(mockCornerstoneStackViewport.getFrameOfReferenceUID).toHaveBeenCalledTimes(1); + expect(mockCornerstoneStackViewport.getFrameOfReferenceUID).toHaveBeenCalledWith(); + + expect(cache.getImage).toHaveBeenCalledTimes(1); + expect(cache.getImage).toHaveBeenCalledWith(imageId); + + expect(serviceManagerMock.services.viewportGridService.getState).toHaveBeenCalledTimes(1); + expect(serviceManagerMock.services.viewportGridService.getState).toHaveBeenCalledWith(); + + expect(mockCornerstoneStackViewport.getViewPresentation).toHaveBeenCalledTimes(1); + expect(mockCornerstoneStackViewport.getViewPresentation).toHaveBeenCalledWith(); + + expect(mockCornerstoneStackViewport.getViewReference).toHaveBeenCalledTimes(1); + expect(mockCornerstoneStackViewport.getViewReference).toHaveBeenCalledWith(); + + expect( + serviceManagerMock.services.cornerstoneViewportService.getCornerstoneViewport + ).toHaveBeenCalledTimes(2); + expect( + serviceManagerMock.services.cornerstoneViewportService.getCornerstoneViewport + ).toHaveBeenCalledWith(viewportId); + + expect(mockCornerstoneStackViewport.element.addEventListener).toHaveBeenCalledTimes(1); + expect(mockCornerstoneStackViewport.element.addEventListener).toHaveBeenCalledWith( + csEnums.Events.VOLUME_VIEWPORT_NEW_VOLUME, + expect.any(Function) + ); + + const volumeViewportNewVolumeHandlerCallback = jest.mocked( + mockCornerstoneStackViewport.element.addEventListener + ).mock.calls[0][1]; + + expect( + serviceManagerMock.services.viewportGridService.setDisplaySetsForViewport + ).toHaveBeenCalledTimes(1); + expect( + serviceManagerMock.services.viewportGridService.setDisplaySetsForViewport + ).toHaveBeenCalledWith({ + viewportId, + displaySetInstanceUIDs: ['displaySetInstanceUID'], + viewportOptions: { viewportType: ViewportType.ORTHOGRAPHIC }, + }); + + expect( + cstSegmentation.triggerSegmentationEvents.triggerSegmentationRepresentationModified + ).toHaveBeenCalledTimes(1); + expect( + cstSegmentation.triggerSegmentationEvents.triggerSegmentationRepresentationModified + ).toHaveBeenCalledWith( + viewportId, + mockCornerstoneSegmentation.segmentationId, + csToolsEnums.SegmentationRepresentations.Labelmap + ); + + jest + .spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport') + // only needed interfaces for the addSegmentationRepresentation call + .mockReturnValue(mockCornerstoneVolumeViewport as unknown as csTypes.IVolumeViewport); + + volumeViewportNewVolumeHandlerCallback(); + + expect( + serviceManagerMock.services.cornerstoneViewportService.getCornerstoneViewport + ).toHaveBeenCalledTimes(3); + expect( + serviceManagerMock.services.cornerstoneViewportService.getCornerstoneViewport + ).toHaveBeenCalledWith(viewportId); + + expect(mockCornerstoneVolumeViewport.setViewPresentation).toHaveBeenCalledTimes(1); + expect(mockCornerstoneVolumeViewport.setViewPresentation).toHaveBeenCalledWith( + prevViewPresentation + ); + + expect(mockCornerstoneVolumeViewport.setViewReference).toHaveBeenCalledTimes(1); + expect(mockCornerstoneVolumeViewport.setViewReference).toHaveBeenCalledWith( + prevViewReference + ); + + expect(mockCornerstoneVolumeViewport.render).toHaveBeenCalledTimes(1); + expect(mockCornerstoneVolumeViewport.render).toHaveBeenCalledWith(); + + expect(mockCornerstoneStackViewport.element.removeEventListener).toHaveBeenCalledTimes(1); + expect(mockCornerstoneStackViewport.element.removeEventListener).toHaveBeenCalledWith( + csEnums.Events.VOLUME_VIEWPORT_NEW_VOLUME, + expect.any(Function) + ); + + expect(serviceManagerMock.services.viewportGridService.subscribe).toHaveBeenCalledTimes(1); + expect(serviceManagerMock.services.viewportGridService.subscribe).toHaveBeenCalledWith( + serviceManagerMock.services.viewportGridService.EVENTS.GRID_STATE_CHANGED, + expect.any(Function) + ); + + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledWith(viewportId, [ + { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + segmentationId: mockCornerstoneSegmentation.segmentationId, + config: { colorLUTOrIndex: undefined }, + }, + ]); + + expect(viewportGridServiceUnsubscribe).toHaveBeenCalledTimes(1); + expect(viewportGridServiceUnsubscribe).toHaveBeenCalledWith(); + }); + + it('should add a volume segmentation representation to stack viewport through SegmentationService conversion', async () => { + mockCornerstoneStackViewport.getViewPresentation.mockReturnValueOnce({}); + mockCornerstoneStackViewport.getViewReference.mockReturnValueOnce({}); + + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockVolumeCornerstoneSegmentation as cstTypes.Segmentation); + jest + .spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport') + // only needed interfaces for the addSegmentationRepresentation call + .mockReturnValue(mockCornerstoneStackViewport as unknown as csTypes.IStackViewport); + jest.spyOn(serviceManagerMock.services.viewportGridService, 'getState').mockReturnValue({ + viewports: new Map([ + [ + viewportId, + { displaySetInstanceUIDs: ['displaySetInstanceUID'], viewportOptions: {} }, + ], + ]), + }); + + // awaiting here will wait for publishing of viewportGridService.EVENTS.GRID_STATE_CHANGED (deadlock avoidance) + const serviceAddSegmentationRepresentationPromise = service.addSegmentationRepresentation( + viewportId, + { + segmentationId: mockCornerstoneSegmentation.segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + } + ); + + let _addSegmentationRepresentationCallback; + const waitForSubscription = async () => { + let remainingAttempts = 100; + + while (_addSegmentationRepresentationCallback === undefined && remainingAttempts > 0) { + _addSegmentationRepresentationCallback = jest.mocked( + serviceManagerMock.services.viewportGridService.subscribe + ).mock.calls[0]?.[1]; + + await new Promise(resolve => resolve(void 0)); + remainingAttempts--; + } + }; + + await waitForSubscription(); + + // trigger callback being awaited + _addSegmentationRepresentationCallback(); + await serviceAddSegmentationRepresentationPromise; + + // guarantee the early exit at handleStackViewportCase (isVolumeSegmentation) + expect( + cstSegmentation.state.updateLabelmapSegmentationImageReferences + ).not.toHaveBeenCalled(); + + expect( + serviceManagerMock.services.viewportGridService.setDisplaySetsForViewport + ).toHaveBeenCalledTimes(1); + expect( + serviceManagerMock.services.viewportGridService.setDisplaySetsForViewport + ).toHaveBeenCalledWith({ + viewportId, + displaySetInstanceUIDs: ['displaySetInstanceUID'], + viewportOptions: { viewportType: ViewportType.ORTHOGRAPHIC }, + }); + + const volumeViewportNewVolumeHandlerCallback = jest.mocked( + mockCornerstoneStackViewport.element.addEventListener + ).mock.calls[0][1]; + + jest + .spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport') + // only needed interfaces for the addSegmentationRepresentation call + .mockReturnValue(mockCornerstoneVolumeViewport as unknown as csTypes.IVolumeViewport); + + volumeViewportNewVolumeHandlerCallback(); + + expect( + serviceManagerMock.services.cornerstoneViewportService.getCornerstoneViewport + ).toHaveBeenCalledTimes(3); + expect( + serviceManagerMock.services.cornerstoneViewportService.getCornerstoneViewport + ).toHaveBeenCalledWith(viewportId); + + expect(mockCornerstoneStackViewport.element.removeEventListener).toHaveBeenCalledTimes(1); + expect(mockCornerstoneStackViewport.element.removeEventListener).toHaveBeenCalledWith( + csEnums.Events.VOLUME_VIEWPORT_NEW_VOLUME, + expect.any(Function) + ); + + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledWith(viewportId, [ + { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + segmentationId: mockCornerstoneSegmentation.segmentationId, + config: { colorLUTOrIndex: undefined }, + }, + ]); + }); + }); + + describe('volume viewport', () => { + it('should add a segmentation representation to volume viewport without need for handling', async () => { + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation); + jest + .spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport') + // only needed interfaces for the addSegmentationRepresentation call + .mockReturnValue(mockCornerstoneVolumeViewport as unknown as csTypes.IVolumeViewport); + jest + .spyOn(cstSegmentation, 'addSegmentationRepresentations') + .mockReturnValueOnce(undefined); + + const callback = jest.fn(); + service.subscribe(service.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, callback); + + await service.addSegmentationRepresentation(viewportId, { + segmentationId: mockCornerstoneSegmentation.segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }); + + expect(serviceManagerMock.services.viewportGridService.getState).not.toHaveBeenCalled(); + + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledWith(viewportId, [ + { + type: csToolsEnums.SegmentationRepresentations.Surface, + segmentationId: mockCornerstoneSegmentation.segmentationId, + config: { colorLUTOrIndex: undefined }, + }, + ]); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + segmentationId: mockCornerstoneSegmentation.segmentationId, + }); + }); + + it('should add a volume segmentation representation to volume viewport through SegmentationService handling', async () => { + mockCornerstoneVolumeViewport.type = ViewportType.ORTHOGRAPHIC; + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockVolumeCornerstoneSegmentation as cstTypes.Segmentation); + jest + .spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport') + .mockReturnValue(mockCornerstoneVolumeViewport as unknown as csTypes.IVolumeViewport); + jest + .spyOn(cstSegmentation, 'addSegmentationRepresentations') + .mockReturnValueOnce(undefined); + + const callback = jest.fn(); + service.subscribe(service.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, callback); + + await service.addSegmentationRepresentation(viewportId, { + segmentationId: mockCornerstoneSegmentation.segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }); + + expect(serviceManagerMock.services.viewportGridService.getState).not.toHaveBeenCalled(); + + expect(mockCornerstoneVolumeViewport.getFrameOfReferenceUID).not.toHaveBeenCalled(); + + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledWith(viewportId, [ + { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + segmentationId: mockCornerstoneSegmentation.segmentationId, + config: { colorLUTOrIndex: undefined }, + }, + ]); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + segmentationId: mockCornerstoneSegmentation.segmentationId, + }); + mockCornerstoneVolumeViewport.type = ViewportType.VOLUME_3D; + }); + + it('should add a volume segmentation representation to volume viewport through SegmentationService handling', async () => { + const frameOfReferenceUID = 'frameOfReferenceUID'; + const imageId = 'imageId'; + + mockCornerstoneVolumeViewport.type = ViewportType.ORTHOGRAPHIC; + mockCornerstoneVolumeViewport.getFrameOfReferenceUID.mockReturnValueOnce( + frameOfReferenceUID + ); + + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation); + jest + .spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport') + .mockReturnValue(mockCornerstoneVolumeViewport as unknown as csTypes.IVolumeViewport); + jest + .spyOn(cstSegmentation, 'addSegmentationRepresentations') + .mockReturnValueOnce(undefined); + // @ts-expect-error - getLabelmapImageIds is wrongly typed at cornerstone3D + jest.spyOn(cstSegmentation, 'getLabelmapImageIds').mockReturnValue([imageId]); + jest + .spyOn(cache, 'getImage') + .mockReturnValueOnce({ FrameOfReferenceUID: frameOfReferenceUID } as csTypes.IImage); + jest + .spyOn(cstSegmentation.helpers, 'convertStackToVolumeLabelmap') + .mockReturnValueOnce(undefined); + + const callback = jest.fn(); + service.subscribe(service.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, callback); + + await service.addSegmentationRepresentation(viewportId, { + segmentationId: mockCornerstoneSegmentation.segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }); + + expect(mockCornerstoneVolumeViewport.getFrameOfReferenceUID).toHaveBeenCalledTimes(1); + expect(mockCornerstoneVolumeViewport.getFrameOfReferenceUID).toHaveBeenCalledWith(); + + expect(cstSegmentation.getLabelmapImageIds).toHaveBeenCalledTimes(1); + expect(cstSegmentation.getLabelmapImageIds).toHaveBeenCalledWith( + mockCornerstoneSegmentation.segmentationId + ); + + expect(cache.getImage).toHaveBeenCalledTimes(1); + expect(cache.getImage).toHaveBeenCalledWith(imageId); + + expect(cstSegmentation.helpers.convertStackToVolumeLabelmap).toHaveBeenCalledTimes(1); + expect(cstSegmentation.helpers.convertStackToVolumeLabelmap).toHaveBeenCalledWith( + mockCornerstoneSegmentation + ); + + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledWith(viewportId, [ + { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + segmentationId: mockCornerstoneSegmentation.segmentationId, + config: { colorLUTOrIndex: undefined }, + }, + ]); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + segmentationId: mockCornerstoneSegmentation.segmentationId, + }); + mockCornerstoneVolumeViewport.type = ViewportType.VOLUME_3D; + }); + }); + + it('should early return if the viewport is not found', () => { + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation); + jest + .spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport') + .mockReturnValue(null); + jest.spyOn(console, 'warn').mockReturnValue(undefined); + + const callback = jest.fn(); + service.subscribe(service.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, callback); + + service.addSegmentationRepresentation(viewportId, { + segmentationId: mockCornerstoneSegmentation.segmentationId, + }); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith(`Viewport with id ${viewportId} not found.`); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('createLabelmapForDisplaySet', () => { + it('should create a labelmap for a non dynamic volume display set', async () => { + const displaySet = { + imageIds: ['imageId'], + isDynamicVolume: false, + SeriesNumber: 1, + SeriesDescription: 'Series Description', + } as unknown as AppTypes.DisplaySet; + + jest + .spyOn(imageLoader, 'createAndCacheDerivedLabelmapImages') + .mockReturnValue([{ imageId: 'imageId' }] as csTypes.IImage[]); + jest + .spyOn(cstSegmentation.state, 'getSegmentations') + .mockReturnValue([{ segmentationId: 'segmentationId' }] as cstTypes.Segmentation[]); + jest.spyOn(service, 'addOrUpdateSegmentation').mockReturnValue(undefined); + + const retrievedSegmentationId = await service.createLabelmapForDisplaySet(displaySet); + + expect(imageLoader.createAndCacheDerivedLabelmapImages).toHaveBeenCalledTimes(1); + expect(imageLoader.createAndCacheDerivedLabelmapImages).toHaveBeenCalledWith(['imageId']); + + expect(cstSegmentation.state.getSegmentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.getSegmentations).toHaveBeenCalledWith(); + + expect(service.addOrUpdateSegmentation).toHaveBeenCalledTimes(1); + expect(service.addOrUpdateSegmentation).toHaveBeenCalledWith({ + config: { + cachedStats: { + info: 'S1: Series Description', + }, + label: 'Segmentation 2', + segments: { + '1': { + active: true, + label: 'Segment 1', + }, + }, + }, + representation: { + data: { + imageIds: ['imageId'], + referencedImageIds: ['imageId'], + }, + type: 'Labelmap', + }, + segmentationId: expect.any(String), + }); + + expect(retrievedSegmentationId).toEqual(expect.any(String)); + }); + + it('should create a labelmap for a dynamic volume display set', async () => { + const segmentationId = 'segmentationId'; + const displaySet = { + imageIds: ['imageId'], + isDynamicVolume: true, + SeriesNumber: 1, + SeriesDescription: 'Series Description', + dynamicVolumeInfo: { + timePoints: ['timePoint1', 'timePoint2', 'timePoint3'], + }, + } as unknown as AppTypes.DisplaySet; + + jest + .spyOn(imageLoader, 'createAndCacheDerivedLabelmapImages') + .mockReturnValue([{ imageId: 'imageId' }] as csTypes.IImage[]); + jest + .spyOn(cstSegmentation.state, 'getSegmentations') + .mockReturnValue([{ segmentationId }] as cstTypes.Segmentation[]); + jest.spyOn(service, 'addOrUpdateSegmentation').mockReturnValue(undefined); + + const options = { + segmentationId, + segments: { + 1: { + label: 'Custom Segment 1', + active: true, + }, + }, + FrameOfReferenceUID: 'frameOfReferenceUID', + label: 'Segmentation 2', + }; + + const retrievedSegmentationId = await service.createLabelmapForDisplaySet( + displaySet, + options + ); + + expect(imageLoader.createAndCacheDerivedLabelmapImages).toHaveBeenCalledTimes(1); + expect(imageLoader.createAndCacheDerivedLabelmapImages).toHaveBeenCalledWith('timePoint2'); + + expect(service.addOrUpdateSegmentation).toHaveBeenCalledTimes(1); + expect(service.addOrUpdateSegmentation).toHaveBeenCalledWith({ + config: { + cachedStats: { + info: 'S1: Series Description', + }, + label: 'Segmentation 2', + segments: { + '1': { + active: true, + label: 'Custom Segment 1', + }, + }, + }, + representation: { + data: { + imageIds: ['imageId'], + referencedImageIds: 'timePoint2', + }, + type: 'Labelmap', + }, + segmentationId: segmentationId, + }); + + expect(retrievedSegmentationId).toEqual(segmentationId); + }); + }); + + describe('createSegmentationForSEGDisplaySet', () => { + it('should throw an error if the type is not labelmap', async () => { + await expect( + service.createSegmentationForSEGDisplaySet( + {}, + { + type: csToolsEnums.SegmentationRepresentations.Contour, + } + ) + ).rejects.toThrow('Only labelmap type is supported for SEG display sets right now'); + }); + + it('should throw and error if the labelmap images are not found', async () => { + await expect( + service.createSegmentationForSEGDisplaySet({ + labelMapImages: [], + }) + ).rejects.toThrow('SEG reading failed'); + }); + + it('should throw an error if the referenced display set is not found', async () => { + jest + .spyOn(serviceManagerMock.services.displaySetService, 'getDisplaySetByUID') + .mockReturnValue({ + instances: [], + }); + + await expect( + service.createSegmentationForSEGDisplaySet({ + displaySetInstanceUID: 'display-set-uid', + referencedDisplaySetInstanceUID: 'non-existent-display-set-uid', + labelMapImages: [{}, {}], + }) + ).rejects.toThrow('No instances were provided for the referenced display set of the SEG'); + }); + + it('it should create a segmentation for a SEG display set', async () => { + const segmentationId = 'segmentationId'; + + const voxelManager = { + getScalarData: jest.fn().mockReturnValue([1, 0, 0]), + setScalarData: jest.fn(), + }; + + const segDisplaySet = { + centroids: new Map([ + [0, { image: { x: 0, y: 0, z: 0 }, world: { x: 0, y: 0, z: 0 } }], + [2, { image: { x: 200, y: 200, z: 200 }, world: { x: 200, y: 200, z: 200 } }], + ]), + displaySetInstanceUID: 'display-set-uid', + referencedDisplaySetInstanceUID: 'existent-display-set-uid', + labelMapImages: [ + { imageId: 'imageId1', referencedImageId: 'referencedImageId1', voxelManager }, + { imageId: 'imageId2', referencedImageId: 'referencedImageId2', voxelManager }, + ], + segMetadata: { + data: [ + {}, + { + SegmentedPropertyCategoryCodeSequence: { + CodeMeaning: 'Segmented Property Category Code Sequence', + }, + SegmentNumber: '1', + SegmentLabel: 'Segment 1', + SegmentAlgorithmType: 'MANUAL', + SegmentAlgorithmName: 'OHIF Brush', + SegmentedPropertyTypeCodeSequence: { + CodeMeaning: 'Segmented Property Category Code Sequence', + }, + rgba: [255, 0, 0, 255], + }, + { + SegmentedPropertyCategoryCodeSequence: { + CodeMeaning: 'Segmented Property Category Code Sequence', + }, + SegmentNumber: '2', + SegmentAlgorithmType: 'MANUAL', + SegmentAlgorithmName: 'OHIF Brush', + SegmentedPropertyTypeCodeSequence: { + CodeMeaning: 'Segmented Property Type Code Sequence', + }, + rgba: [0, 255, 0, 255], + }, + ], + }, + SeriesDate: '2025-01-01', + SeriesDescription: 'Series Description', + }; + + const referencedDisplaySet = { + instances: [{ imageId: 'referencedImageId1' }, { imageId: 'referencedImageId2' }], + }; + + jest + .spyOn(serviceManagerMock.services.displaySetService, 'getDisplaySetByUID') + .mockReturnValue(referencedDisplaySet); + // @ts-expect-error - jest can't handle Array.prototype.flat typing + jest.spyOn(Array.prototype, 'flat'); + jest.spyOn(metaData, 'get').mockReturnValue({}); + jest.spyOn(service, 'addOrUpdateSegmentation').mockReturnValue(undefined); + + const callback = jest.fn(); + service.subscribe(service.EVENTS.SEGMENTATION_LOADING_COMPLETE, callback); + + const retrievedSegmentationId = await service.createSegmentationForSEGDisplaySet( + segDisplaySet, + { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + segmentationId, + } + ); + + expect( + serviceManagerMock.services.displaySetService.getDisplaySetByUID + ).toHaveBeenCalledTimes(1); + expect(serviceManagerMock.services.displaySetService.getDisplaySetByUID).toHaveBeenCalledWith( + 'existent-display-set-uid' + ); + + expect(Array.prototype.flat).toHaveBeenCalledTimes(1); + + expect(metaData.get).toHaveBeenCalledTimes(2); + expect(metaData.get).toHaveBeenCalledWith('instance', 'referencedImageId1'); + expect(metaData.get).toHaveBeenCalledWith('instance', 'referencedImageId2'); + + expect(voxelManager.getScalarData).toHaveBeenCalledTimes(2); + expect(voxelManager.getScalarData).toHaveBeenCalledWith(); + expect(voxelManager.setScalarData).toHaveBeenCalledTimes(2); + expect(voxelManager.setScalarData).toHaveBeenCalledWith([1, 0, 0]); + + expect(cstSegmentation.state.addColorLUT).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.addColorLUT).toHaveBeenCalledWith([ + [0, 0, 0, 0], + [255, 0, 0, 255], + [0, 255, 0, 255], + ]); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + segmentationId, + segDisplaySet, + }); + + const expectedSegmentation = { + config: { + label: segDisplaySet.SeriesDescription, + segments: { + '1': { + active: false, + cachedStats: { + algorithmName: 'OHIF Brush', + algorithmType: 'MANUAL', + category: 'Segmented Property Category Code Sequence', + center: { + image: [0, 0, 0], + world: [0, 0, 0], + }, + modifiedTime: '2025-01-01', + type: 'Segmented Property Category Code Sequence', + }, + label: 'Segment 1', + locked: false, + segmentIndex: 1, + }, + '2': { + active: false, + cachedStats: { + algorithmName: 'OHIF Brush', + algorithmType: 'MANUAL', + category: 'Segmented Property Category Code Sequence', + center: { + image: [200, 200, 200], + world: [200, 200, 200], + }, + modifiedTime: '2025-01-01', + type: 'Segmented Property Type Code Sequence', + }, + label: 'Segment 2', + locked: false, + segmentIndex: 2, + }, + }, + }, + representation: { + data: { + imageIds: ['imageId1', 'imageId2'], + referencedImageIds: ['referencedImageId1', 'referencedImageId2'], + }, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }, + segmentationId, + }; + + expect(service.addOrUpdateSegmentation).toHaveBeenCalledTimes(1); + expect(service.addOrUpdateSegmentation).toHaveBeenCalledWith(expectedSegmentation); + + const expectedSegDisplaySet = { + ...segDisplaySet, + firstSegmentedSliceImageId: 'referencedImageId1', + imageIds: ['imageId1', 'imageId2'], + images: [ + { + imageId: 'imageId1', + referencedImageId: 'referencedImageId1', + voxelManager, + }, + { + imageId: 'imageId2', + referencedImageId: 'referencedImageId2', + voxelManager, + }, + ], + isLoaded: true, + labelMapImages: [ + { + imageId: 'imageId1', + referencedImageId: 'referencedImageId1', + voxelManager, + }, + { + imageId: 'imageId2', + referencedImageId: 'referencedImageId2', + voxelManager, + }, + ], + }; + + expect(segDisplaySet).toEqual(expectedSegDisplaySet); + + expect(retrievedSegmentationId).toEqual(segmentationId); + }); + }); + + describe('createSegmentationForRTDisplaySet', () => { + it('should throw an error if the type is not contour', async () => { + await expect( + service.createSegmentationForRTDisplaySet( + { + modality: 'RTSTRUCT', + }, + { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + } + ) + ).rejects.toThrow('Only contour type is supported for RT display sets right now'); + }); + + it('should throw an error if the structureSet is not loaded', async () => { + await expect( + service.createSegmentationForRTDisplaySet( + { + modality: 'RTSTRUCT', + displaySetInstanceUID: 'display-set-uid', + }, + { + type: csToolsEnums.SegmentationRepresentations.Contour, + } + ) + ).rejects.toThrow( + 'To create the contours from RT displaySet, the displaySet should be loaded first. You can perform rtDisplaySet.load() before calling this method.' + ); + }); + + it('should throw and error if structureSet does not contain any ROIContours', async () => { + const rtStructDisplaySet = { + modality: 'RTSTRUCT', + displaySetInstanceUID: 'display-set-uid', + referencedDisplaySetInstanceUID: 'existent-display-set-uid', + structureSet: { + ReferencedSOPInstanceUIDsSet: new Set(['referencedImageId1', 'referencedImageId2']), + ROIContours: [], + }, + }; + + const referencedDisplaySet = { + instances: [{ imageId: 'referencedImageId1' }, { imageId: 'referencedImageId2' }], + }; + + jest + .spyOn(serviceManagerMock.services.displaySetService, 'getDisplaySetByUID') + .mockReturnValue(referencedDisplaySet); + + await expect( + service.createSegmentationForRTDisplaySet(rtStructDisplaySet, { + type: csToolsEnums.SegmentationRepresentations.Contour, + }) + ).rejects.toThrow( + 'The structureSet does not contain any ROIContours. Please ensure the structureSet is loaded first.' + ); + + expect( + serviceManagerMock.services.displaySetService.getDisplaySetByUID + ).toHaveBeenCalledTimes(1); + expect(serviceManagerMock.services.displaySetService.getDisplaySetByUID).toHaveBeenCalledWith( + 'existent-display-set-uid' + ); + }); + + it('should create a segmentation for a RTSTRUCT display set', async () => { + const segmentationId = 'segmentationId'; + const rtStructDisplaySet = { + modality: 'RTSTRUCT', + displaySetInstanceUID: 'display-set-uid', + referencedDisplaySetInstanceUID: 'existent-display-set-uid', + SeriesDate: '2025-01-01', + SeriesDescription: 'Series Description', + structureSet: { + ReferencedSOPInstanceUIDsSet: new Set(['referencedIsmageId1', 'referencedImageId2']), + ROIContours: [{}, {}, {}], + frameOfReferenceUID: 'frameOfReferenceUID', + }, + }; + + const referencedDisplaySet = { + instances: [{ imageId: 'referencedImageId1' }, { imageId: 'referencedImageId2' }], + imageIds: ['referencedImageId1', 'referencedImageId2'], + }; + + const allRTStructData = [ + { + data: {}, + id: 'id3', + color: [255, 0, 0, 255], + group: 'group3', + segmentIndex: 3, + geometryId: 'geometryId3', + }, + { + data: {}, + id: 'id2', + color: [0, 255, 0, 255], + group: 'group2', + segmentIndex: 2, + geometryId: 'geometryId2', + }, + { + data: {}, + id: 'id1', + color: [0, 0, 255, 255], + group: 'group1', + segmentIndex: 1, + geometryId: 'geometryId1', + }, + ]; + + jest + .spyOn(serviceManagerMock.services.displaySetService, 'getDisplaySetByUID') + .mockReturnValue(referencedDisplaySet); + jest + .spyOn(MapROIContoursToRTStructData, 'mapROIContoursToRTStructData') + .mockReturnValue(allRTStructData); + jest.spyOn(service, 'addOrUpdateSegmentation').mockReturnValue(undefined); + jest.spyOn(geometryLoader, 'createAndCacheGeometry').mockReturnValue({ + // @ts-expect-error - only mocking needed properties + data: { centroid: [0, 0, 0] }, + }); + + const segmentLoadingCompleteCallback = jest.fn(); + service.subscribe(service.EVENTS.SEGMENT_LOADING_COMPLETE, segmentLoadingCompleteCallback); + + const segmentationLoadingCompleteCallback = jest.fn(); + service.subscribe( + service.EVENTS.SEGMENTATION_LOADING_COMPLETE, + segmentationLoadingCompleteCallback + ); + + const retrievedSegmentationId = await service.createSegmentationForRTDisplaySet( + rtStructDisplaySet, + { + type: csToolsEnums.SegmentationRepresentations.Contour, + segmentationId, + } + ); + + expect(MapROIContoursToRTStructData.mapROIContoursToRTStructData).toHaveBeenCalledTimes(1); + expect(MapROIContoursToRTStructData.mapROIContoursToRTStructData).toHaveBeenCalledWith( + rtStructDisplaySet.structureSet, + rtStructDisplaySet.displaySetInstanceUID + ); + + expect(geometryLoader.createAndCacheGeometry).toHaveBeenCalledTimes(3); + allRTStructData.forEach(data => { + expect(geometryLoader.createAndCacheGeometry).toHaveBeenCalledWith(data.geometryId, { + geometryData: { + data: data.data, + id: data.id, + color: data.color, + frameOfReferenceUID: rtStructDisplaySet.structureSet.frameOfReferenceUID, + segmentIndex: data.segmentIndex, + }, + type: csEnums.GeometryType.CONTOUR, + }); + }); + + expect(segmentLoadingCompleteCallback).toHaveBeenCalledTimes(3); + expect(segmentLoadingCompleteCallback).toHaveBeenCalledWith({ + percentComplete: expect.any(Number), + numSegments: 3, + }); + + expect(cstSegmentation.state.addColorLUT).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.addColorLUT).toHaveBeenCalledWith([ + [0, 0, 0, 0], + [0, 0, 255, 255], + [0, 255, 0, 255], + [255, 0, 0, 255], + ]); + + expect(segmentationLoadingCompleteCallback).toHaveBeenCalledTimes(1); + expect(segmentationLoadingCompleteCallback).toHaveBeenCalledWith({ + segmentationId, + rtDisplaySet: rtStructDisplaySet, + }); + + const expectedSegmentation = { + config: { + label: rtStructDisplaySet.SeriesDescription, + segments: { + '1': { + active: false, + cachedStats: { + center: { + world: [0, 0, 0], + }, + modifiedTime: rtStructDisplaySet.SeriesDate, + }, + group: 'group1', + label: 'id1', + locked: false, + segmentIndex: 1, + }, + '2': { + active: false, + cachedStats: { + center: { + world: [0, 0, 0], + }, + modifiedTime: rtStructDisplaySet.SeriesDate, + }, + group: 'group2', + label: 'id2', + locked: false, + segmentIndex: 2, + }, + '3': { + active: false, + cachedStats: { + center: { + world: [0, 0, 0], + }, + modifiedTime: rtStructDisplaySet.SeriesDate, + }, + group: 'group3', + label: 'id3', + locked: false, + segmentIndex: 3, + }, + }, + }, + representation: { + data: { + geometryIds: ['geometryId1', 'geometryId2', 'geometryId3'], + }, + type: csToolsEnums.SegmentationRepresentations.Contour, + }, + segmentationId, + }; + expect(service.addOrUpdateSegmentation).toHaveBeenCalledTimes(1); + expect(service.addOrUpdateSegmentation).toHaveBeenCalledWith(expectedSegmentation); + + expect(retrievedSegmentationId).toEqual(segmentationId); + + const expectedRtStructDisplaySet = { + ...rtStructDisplaySet, + isLoaded: true, + }; + + expect(rtStructDisplaySet).toEqual(expectedRtStructDisplaySet); + }); + + it('should ignore when a segment fails to initialize', async () => { + const segmentationId = 'segmentationId'; + const rtStructDisplaySet = { + modality: 'RTSTRUCT', + displaySetInstanceUID: 'display-set-uid', + referencedDisplaySetInstanceUID: 'existent-display-set-uid', + SeriesDate: '2025-01-01', + SeriesDescription: 'Series Description', + structureSet: { + ReferencedSOPInstanceUIDsSet: new Set(['referencedIsmageId1', 'referencedImageId2']), + ROIContours: [{}, {}, {}], + frameOfReferenceUID: 'frameOfReferenceUID', + }, + }; + + const referencedDisplaySet = { + instances: [{ imageId: 'referencedImageId1' }, { imageId: 'referencedImageId2' }], + }; + + const allRTStructData = [ + { + data: {}, + id: 'id3', + color: [255, 0, 0, 255], + group: 'group3', + segmentIndex: 3, + geometryId: 'geometryId3', + }, + ]; + + jest + .spyOn(serviceManagerMock.services.displaySetService, 'getDisplaySetByUID') + .mockReturnValue(referencedDisplaySet); + jest + .spyOn(MapROIContoursToRTStructData, 'mapROIContoursToRTStructData') + .mockReturnValue(allRTStructData); + jest + .spyOn(geometryLoader, 'createAndCacheGeometry') + .mockRejectedValue(new Error('Segment Initialization Error') as never); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(service, 'addOrUpdateSegmentation').mockReturnValue(undefined); + + const segmentLoadingCompleteCallback = jest.fn(); + service.subscribe(service.EVENTS.SEGMENT_LOADING_COMPLETE, segmentLoadingCompleteCallback); + + const segmentationLoadingCompleteCallback = jest.fn(); + service.subscribe( + service.EVENTS.SEGMENTATION_LOADING_COMPLETE, + segmentationLoadingCompleteCallback + ); + + await expect( + service.createSegmentationForRTDisplaySet(rtStructDisplaySet, { + type: csToolsEnums.SegmentationRepresentations.Contour, + segmentationId, + }) + ).resolves.not.toThrow(); + + expect(segmentLoadingCompleteCallback).not.toHaveBeenCalled(); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + `Error initializing contour for segment ${allRTStructData[0].segmentIndex}:`, + expect.any(Error) + ); + + expect(segmentationLoadingCompleteCallback).toHaveBeenCalledTimes(1); + + expect(service.addOrUpdateSegmentation).toHaveBeenCalledTimes(1); + }); + }); + + describe('addOrUpdateSegmentation', () => { + it('should add new segmentation if it does not exist', () => { + const segmentationId = 'segmentationId'; + const segmentationData = { + segmentationId, + config: { + label: 'Segmentation 1', + }, + }; + + jest.spyOn(cstSegmentation.state, 'getSegmentation').mockReturnValue(undefined); + jest.spyOn(cstSegmentation, 'addSegmentations').mockReturnValue(undefined); + + service.addOrUpdateSegmentation(segmentationData); + + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledWith(segmentationId); + + expect(cstSegmentation.addSegmentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.addSegmentations).toHaveBeenCalledWith([segmentationData]); + }); + + it('should update existing segmentation if it exists', () => { + const segmentationId = 'segmentationId'; + const segmentationData = { + segmentationId, + config: { + label: 'Segmentation 1', + }, + }; + + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation); + jest.spyOn(cstSegmentation, 'updateSegmentations').mockReturnValue(undefined); + + service.addOrUpdateSegmentation(segmentationData); + + expect(cstSegmentation.updateSegmentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.updateSegmentations).toHaveBeenCalledWith([ + { segmentationId, payload: segmentationData }, + ]); + }); + }); + + describe('setActiveSegmentation', () => { + it('should set the active segmentation for a viewport', () => { + const viewportId = 'viewportId'; + const segmentationId = 'segmentationId'; + + service.setActiveSegmentation(viewportId, segmentationId); + + expect(cstSegmentation.activeSegmentation.setActiveSegmentation).toHaveBeenCalledTimes(1); + expect(cstSegmentation.activeSegmentation.setActiveSegmentation).toHaveBeenCalledWith( + viewportId, + segmentationId + ); + }); + }); + + describe('getActiveSegmentation', () => { + it('should get the active segmentation for a viewport', () => { + const viewportId = 'viewportId'; + + service.getActiveSegmentation(viewportId); + + expect(cstSegmentation.activeSegmentation.getActiveSegmentation).toHaveBeenCalledTimes(1); + expect(cstSegmentation.activeSegmentation.getActiveSegmentation).toHaveBeenCalledWith( + viewportId + ); + }); + }); + + describe('getActiveSegment', () => { + it('should return undefined if no active segmentation', () => { + const viewportId = 'viewportId'; + jest.spyOn(cstSegmentation.activeSegmentation, 'getActiveSegmentation').mockReturnValue(null); + + const activeSegment = service.getActiveSegment(viewportId); + + expect(cstSegmentation.activeSegmentation.getActiveSegmentation).toHaveBeenCalledTimes(1); + expect(cstSegmentation.activeSegmentation.getActiveSegmentation).toHaveBeenCalledWith( + viewportId + ); + + expect(activeSegment).toBeUndefined(); + }); + + it('should find and return the active segment', () => { + const viewportId = 'viewportId'; + jest + .spyOn(cstSegmentation.activeSegmentation, 'getActiveSegmentation') + .mockReturnValue(mockCornerstoneSegmentation); + + const activeSegment = service.getActiveSegment(viewportId); + + expect(activeSegment).toEqual(mockCornerstoneSegmentation.segments['1']); + }); + }); + + describe('hasCustomStyles', () => { + it('should return true if the segmentation has custom styles', () => { + const viewportId = 'viewportId'; + const segmentationId = 'segmentationId'; + const type = csToolsEnums.SegmentationRepresentations.Contour; + + jest.spyOn(cstSegmentation.config.style, 'hasCustomStyle').mockReturnValue(true); + + const hasCustomStyles = service.hasCustomStyles({ viewportId, segmentationId, type }); + + expect(cstSegmentation.config.style.hasCustomStyle).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.style.hasCustomStyle).toHaveBeenCalledWith({ + viewportId, + segmentationId, + type, + }); + + expect(hasCustomStyles).toBe(true); + }); + }); + + describe('getStyle', () => { + it('should return the style for the segmentation', () => { + const viewportId = 'viewportId'; + const segmentationId = 'segmentationId'; + const type = csToolsEnums.SegmentationRepresentations.Contour; + const segmentIndex = 1; + + jest.spyOn(cstSegmentation.config.style, 'getStyle').mockReturnValue({}); + + const style = service.getStyle({ viewportId, segmentationId, type, segmentIndex }); + + expect(cstSegmentation.config.style.getStyle).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.style.getStyle).toHaveBeenCalledWith({ + viewportId, + segmentationId, + type, + segmentIndex, + }); + + expect(style).toEqual({}); + }); + }); + + describe('setStyle', () => { + it('should set the style for the segmentation', () => { + const viewportId = 'viewportId'; + const segmentationId = 'segmentationId'; + const type = csToolsEnums.SegmentationRepresentations.Contour; + const segmentIndex = 1; + const style = { + fillAlpha: 0.5, + outlineWidth: 2, + renderOutline: true, + renderFill: true, + }; + + jest.spyOn(cstSegmentation.config.style, 'setStyle').mockReturnValue(undefined); + + service.setStyle({ viewportId, segmentationId, type, segmentIndex }, style); + + expect(cstSegmentation.config.style.setStyle).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.style.setStyle).toHaveBeenCalledWith( + { + viewportId, + segmentationId, + type, + segmentIndex, + }, + style + ); + }); + }); + + describe('resetToGlobalStyle', () => { + it('should reset the style for the segmentation', () => { + jest.spyOn(cstSegmentation.config.style, 'resetToGlobalStyle').mockReturnValue(undefined); + + service.resetToGlobalStyle(); + + expect(cstSegmentation.config.style.resetToGlobalStyle).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.style.resetToGlobalStyle).toHaveBeenCalledWith(); + }); + }); + + describe('addSegment', () => { + it('should throw an error if the segment index is 0', () => { + const segmentationId = 'segmentationId'; + const config = { + segmentIndex: 0, + }; + + expect(() => service.addSegment(segmentationId, config)).toThrow( + 'Segment index 0 is reserved for "no label"' + ); + }); + + it('should add a new segment with next available index if not provided', () => { + const segmentationId = 'segmentationId'; + const config = { + label: 'New Segment 2', + visibility: true, + }; + + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation); + jest.spyOn(cstSegmentation, 'updateSegmentations').mockReturnValue(undefined); + jest.spyOn(cstSegmentation.segmentIndex, 'setActiveSegmentIndex').mockReturnValue(undefined); + jest + .spyOn(cstSegmentation.state, 'getViewportIdsWithSegmentation') + .mockReturnValue(['viewportId']); + jest + .spyOn(cstSegmentation.config.visibility, 'setSegmentIndexVisibility') + .mockReturnValue(undefined); + + service.addSegment(segmentationId, config); + + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledWith(segmentationId); + + expect(cstSegmentation.updateSegmentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.updateSegmentations).toHaveBeenCalledWith([ + { + segmentationId, + payload: { + segments: { + ...mockCornerstoneSegmentation.segments, + '2': { + label: 'New Segment 2', + segmentIndex: 2, + cachedStats: {}, + locked: false, + ...config, + }, + }, + }, + }, + ]); + + expect(cstSegmentation.segmentIndex.setActiveSegmentIndex).toHaveBeenCalledTimes(1); + expect(cstSegmentation.segmentIndex.setActiveSegmentIndex).toHaveBeenCalledWith( + segmentationId, + 2 + ); + + expect(cstSegmentation.state.getViewportIdsWithSegmentation).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.getViewportIdsWithSegmentation).toHaveBeenCalledWith( + segmentationId + ); + + expect(cstSegmentation.config.visibility.setSegmentIndexVisibility).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.visibility.setSegmentIndexVisibility).toHaveBeenCalledWith( + 'viewportId', + { segmentationId, type: undefined }, + 2, + config.visibility + ); + }); + + it('should set properties if segment index already exists', () => { + const segmentationId = 'segmentationId'; + const config = { + segmentIndex: 1, + isLocked: false, + active: true, + color: [255, 0, 0, 255] as csTypes.Color, + }; + + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation); + jest.spyOn(cstSegmentation, 'updateSegmentations').mockReturnValue(undefined); + jest.spyOn(cstSegmentation.segmentIndex, 'setActiveSegmentIndex').mockReturnValue(undefined); + jest.spyOn(service, 'getViewportIdsWithSegmentation').mockReturnValue(['viewportId']); + jest + .spyOn(cstSegmentation.segmentLocking, 'setSegmentIndexLocked') + .mockReturnValue(undefined); + jest.spyOn(cstSegmentation.config.color, 'setSegmentIndexColor').mockReturnValue(undefined); + + service.addSegment(segmentationId, config); + + expect(cstSegmentation.updateSegmentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.updateSegmentations).toHaveBeenCalledWith([ + { + segmentationId, + payload: { + segments: { + ...mockCornerstoneSegmentation.segments, + '1': { + label: 'Segment 1', + segmentIndex: 1, + cachedStats: {}, + locked: false, + active: true, + ...config, + }, + }, + }, + }, + ]); + + expect(cstSegmentation.segmentLocking.setSegmentIndexLocked).toHaveBeenCalledTimes(1); + expect(cstSegmentation.segmentLocking.setSegmentIndexLocked).toHaveBeenCalledWith( + segmentationId, + 1, + config.isLocked + ); + + expect(cstSegmentation.config.color.setSegmentIndexColor).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.color.setSegmentIndexColor).toHaveBeenCalledWith( + 'viewportId', + segmentationId, + 1, + config.color + ); + }); + }); + + describe('removeSegment', () => { + it('should remove the segment', () => { + const segmentationId = 'segmentationId'; + const segmentIndex = 1; + jest.spyOn(cstSegmentation, 'removeSegment').mockReturnValue(undefined); + + service.removeSegment(segmentationId, segmentIndex); + + expect(cstSegmentation.removeSegment).toHaveBeenCalledTimes(1); + expect(cstSegmentation.removeSegment).toHaveBeenCalledWith(segmentationId, segmentIndex); + }); + }); + + describe('setSegmentVisibility', () => { + it('should set the visibility of the segment', () => { + const viewportId = 'viewportId'; + const segmentationId = 'segmentationId'; + const segmentIndex = 1; + const isVisible = true; + const type = csToolsEnums.SegmentationRepresentations.Contour; + + jest + .spyOn(cstSegmentation.config.visibility, 'setSegmentIndexVisibility') + .mockReturnValue(undefined); + + service.setSegmentVisibility(viewportId, segmentationId, segmentIndex, isVisible, type); + + expect(cstSegmentation.config.visibility.setSegmentIndexVisibility).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.visibility.setSegmentIndexVisibility).toHaveBeenCalledWith( + viewportId, + { segmentationId, type }, + segmentIndex, + isVisible + ); + }); + }); + + describe('setSegmentLocked', () => { + it('should set the locked status of the segment', () => { + const segmentationId = 'segmentationId'; + const segmentIndex = 1; + const isLocked = true; + + jest + .spyOn(cstSegmentation.segmentLocking, 'setSegmentIndexLocked') + .mockReturnValue(undefined); + + service.setSegmentLocked(segmentationId, segmentIndex, isLocked); + + expect(cstSegmentation.segmentLocking.setSegmentIndexLocked).toHaveBeenCalledTimes(1); + expect(cstSegmentation.segmentLocking.setSegmentIndexLocked).toHaveBeenCalledWith( + segmentationId, + segmentIndex, + isLocked + ); + }); + }); + + describe('toggleSegmentLocked', () => { + it('should toggle the locked status of the segment', () => { + const segmentationId = 'segmentationId'; + const segmentIndex = 1; + const isLocked = true; + + jest.spyOn(cstSegmentation.segmentLocking, 'isSegmentIndexLocked').mockReturnValue(isLocked); + jest + .spyOn(cstSegmentation.segmentLocking, 'setSegmentIndexLocked') + .mockReturnValue(undefined); + + service.toggleSegmentLocked(segmentationId, segmentIndex); + + expect(cstSegmentation.segmentLocking.isSegmentIndexLocked).toHaveBeenCalledTimes(1); + expect(cstSegmentation.segmentLocking.isSegmentIndexLocked).toHaveBeenCalledWith( + segmentationId, + segmentIndex + ); + + expect(cstSegmentation.segmentLocking.setSegmentIndexLocked).toHaveBeenCalledTimes(1); + expect(cstSegmentation.segmentLocking.setSegmentIndexLocked).toHaveBeenCalledWith( + segmentationId, + segmentIndex, + !isLocked + ); + }); + }); + + describe('toggleSegmentVisibility', () => { + it('should toggle the visibility of the segment', () => { + const viewportId = 'viewportId'; + const segmentationId = 'segmentationId'; + const segmentIndex = 1; + const isVisible = true; + const type = csToolsEnums.SegmentationRepresentations.Contour; + + jest + .spyOn(cstSegmentation.config.visibility, 'getSegmentIndexVisibility') + .mockReturnValue(isVisible); + jest + .spyOn(cstSegmentation.config.visibility, 'setSegmentIndexVisibility') + .mockReturnValue(undefined); + + service.toggleSegmentVisibility(viewportId, segmentationId, segmentIndex, type); + + expect(cstSegmentation.config.visibility.getSegmentIndexVisibility).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.visibility.getSegmentIndexVisibility).toHaveBeenCalledWith( + viewportId, + { segmentationId, type }, + segmentIndex + ); + + expect(cstSegmentation.config.visibility.setSegmentIndexVisibility).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.visibility.setSegmentIndexVisibility).toHaveBeenCalledWith( + viewportId, + { segmentationId, type }, + segmentIndex, + !isVisible + ); + }); + }); + + describe('setSegmentColor', () => { + it('should set the color of the segment', () => { + const viewportId = 'viewportId'; + const segmentationId = 'segmentationId'; + const segmentIndex = 1; + const color = [255, 0, 0, 255] as csTypes.Color; + + jest.spyOn(cstSegmentation.config.color, 'setSegmentIndexColor').mockReturnValue(undefined); + + service.setSegmentColor(viewportId, segmentationId, segmentIndex, color); + + expect(cstSegmentation.config.color.setSegmentIndexColor).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.color.setSegmentIndexColor).toHaveBeenCalledWith( + viewportId, + segmentationId, + segmentIndex, + color + ); + }); + }); + + describe('getSegmentColor', () => { + it('should get the color of the segment', () => { + const viewportId = 'viewportId'; + const segmentationId = 'segmentationId'; + const segmentIndex = 1; + const color = [255, 0, 0, 255] as csTypes.Color; + + jest.spyOn(cstSegmentation.config.color, 'getSegmentIndexColor').mockReturnValue(color); + + const returnedColor = service.getSegmentColor(viewportId, segmentationId, segmentIndex); + + expect(cstSegmentation.config.color.getSegmentIndexColor).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.color.getSegmentIndexColor).toHaveBeenCalledWith( + viewportId, + segmentationId, + segmentIndex + ); + + expect(returnedColor).toEqual(color); + }); + }); + + describe('getLabelmapVolume', () => { + it('should get the labelmap volume for the segmentation', () => { + const segmentationId = 'segmentationId'; + const labelmapVolume = { id: 'volumeId' }; + mockCornerstoneSegmentation.representationData.Labelmap = { volumeId: 'volumeId' }; + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation); + // @ts-expect-error - no need to mock every property for this test + jest.spyOn(cache, 'getVolume').mockReturnValue(labelmapVolume); + + const returnedLabelmapVolume = service.getLabelmapVolume(segmentationId); + + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledWith(segmentationId); + + expect(cache.getVolume).toHaveBeenCalledTimes(1); + expect(cache.getVolume).toHaveBeenCalledWith(labelmapVolume.id); + + expect(returnedLabelmapVolume).toEqual(labelmapVolume); + + mockCornerstoneSegmentation.representationData.Labelmap = {}; + }); + + it('should return null if the segmentation does not have a labelmap volume', () => { + const segmentationId = 'segmentationId'; + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation); + + const returnedLabelmapVolume = service.getLabelmapVolume(segmentationId); + + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledTimes(1); + + expect(returnedLabelmapVolume).toEqual(null); + }); + }); + + describe('setSegmentLabel', () => { + it('should set the label of the segment', () => { + const segmentationId = 'segmentationId'; + const segmentIndex = 1; + const label = 'New Segment 1'; + + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation); + jest.spyOn(cstSegmentation, 'updateSegmentations').mockReturnValue(undefined); + + service.setSegmentLabel(segmentationId, segmentIndex, label); + + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledWith(segmentationId); + + expect(cstSegmentation.updateSegmentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.updateSegmentations).toHaveBeenCalledWith([ + { + segmentationId, + payload: { + segments: { + ...mockCornerstoneSegmentation.segments, + [segmentIndex]: { ...mockCornerstoneSegmentation.segments[segmentIndex], label }, + }, + }, + }, + ]); + }); + }); + + describe('setActiveSegment', () => { + it('should set the active segment for the segmentation', () => { + const segmentationId = 'segmentationId'; + const segmentIndex = 1; + + jest.spyOn(cstSegmentation.segmentIndex, 'setActiveSegmentIndex').mockReturnValue(undefined); + + service.setActiveSegment(segmentationId, segmentIndex); + + expect(cstSegmentation.segmentIndex.setActiveSegmentIndex).toHaveBeenCalledTimes(1); + expect(cstSegmentation.segmentIndex.setActiveSegmentIndex).toHaveBeenCalledWith( + segmentationId, + segmentIndex + ); + }); + }); + + describe('setRenderInactiveSegmentations', () => { + it('should set the render inactive segmentations for the viewport', () => { + const viewportId = 'viewportId'; + const renderInactive = true; + + jest + .spyOn(cstSegmentation.config.style, 'setRenderInactiveSegmentations') + .mockReturnValue(undefined); + + service.setRenderInactiveSegmentations(viewportId, renderInactive); + + expect(cstSegmentation.config.style.setRenderInactiveSegmentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.style.setRenderInactiveSegmentations).toHaveBeenCalledWith( + viewportId, + renderInactive + ); + }); + }); + + describe('getRenderInactiveSegmentations', () => { + it('should get the render inactive segmentations for the viewport', () => { + const viewportId = 'viewportId'; + const renderInactive = true; + + jest + .spyOn(cstSegmentation.config.style, 'getRenderInactiveSegmentations') + .mockReturnValue(renderInactive); + + const returnedRenderInactive = service.getRenderInactiveSegmentations(viewportId); + + expect(cstSegmentation.config.style.getRenderInactiveSegmentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.style.getRenderInactiveSegmentations).toHaveBeenCalledWith( + viewportId + ); + + expect(returnedRenderInactive).toEqual(renderInactive); + }); + }); + + describe('setSegmentationGroupStats', () => { + it('should set the segmentation group stats', () => { + const segmentationIds = ['segmentationId1', 'segmentationId2']; + const stats = { key: 'value' }; + + service.setSegmentationGroupStats(segmentationIds, stats); + + const returnedStats = service.getSegmentationGroupStats(segmentationIds); + + expect(returnedStats).toEqual(stats); + }); + }); + + describe('getSegmentationGroupStats', () => { + it('should get the segmentation group stats', () => { + const segmentationIds = ['segmentationId1', 'segmentationId2']; + const stats = { key: 'value' }; + + let returnedStats = service.getSegmentationGroupStats(segmentationIds); + + expect(returnedStats).toEqual(undefined); + + service.setSegmentationGroupStats(segmentationIds, stats); + + returnedStats = service.getSegmentationGroupStats(segmentationIds); + + expect(returnedStats).toEqual(stats); + }); + }); + + describe('toggleSegmentationRepresentationVisibility', () => { + it('should toggle the visibility of the segmentation representation', () => { + jest.spyOn(service, 'getSegmentationRepresentations').mockReturnValue(representations); + jest + .spyOn(cstSegmentation.config.visibility, 'getHiddenSegmentIndices') + .mockReturnValue(new Set()); + jest + .spyOn(cstSegmentation.config.visibility, 'setSegmentationRepresentationVisibility') + .mockReturnValue(undefined); + + service.toggleSegmentationRepresentationVisibility(viewportId, { + segmentationId: representations[0].segmentationId, + type: representations[0].type, + }); + + expect(service.getSegmentationRepresentations).toHaveBeenCalledTimes(2); + expect(service.getSegmentationRepresentations).toHaveBeenCalledWith(viewportId, { + segmentationId: representations[0].segmentationId, + type: representations[0].type, + }); + + expect(cstSegmentation.config.visibility.getHiddenSegmentIndices).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.visibility.getHiddenSegmentIndices).toHaveBeenCalledWith( + viewportId, + { + segmentationId: representations[0].segmentationId, + type: representations[0].type, + } + ); + + expect( + cstSegmentation.config.visibility.setSegmentationRepresentationVisibility + ).toHaveBeenCalledTimes(1); + expect( + cstSegmentation.config.visibility.setSegmentationRepresentationVisibility + ).toHaveBeenCalledWith( + viewportId, + { + segmentationId: representations[0].segmentationId, + type: representations[0].type, + }, + false + ); + }); + + it('should early return if the representation is not found', () => { + jest.spyOn(service, 'getSegmentationRepresentations').mockReturnValueOnce(representations); + jest.spyOn(cstSegmentation.state, 'getSegmentationRepresentations').mockReturnValueOnce([]); + jest + .spyOn(cstSegmentation.config.visibility, 'getHiddenSegmentIndices') + .mockReturnValue(new Set()); + jest.spyOn(console, 'debug').mockReturnValue(undefined); + jest.spyOn(cstSegmentation.config.visibility, 'setSegmentationRepresentationVisibility'); + + service.toggleSegmentationRepresentationVisibility(viewportId, { + segmentationId: representations[0].segmentationId, + type: representations[0].type, + }); + + expect(console.debug).toHaveBeenCalledTimes(1); + expect(console.debug).toHaveBeenCalledWith( + 'No segmentation representation found for the given viewportId and segmentationId' + ); + + expect( + cstSegmentation.config.visibility.setSegmentationRepresentationVisibility + ).not.toHaveBeenCalled(); + }); + }); + + describe('getViewportIdsWithSegmentation', () => { + it('should get the viewport ids with the segmentation', () => { + const segmentationId = 'segmentationId'; + const viewportIds = ['viewportId1', 'viewportId2']; + + jest + .spyOn(cstSegmentation.state, 'getViewportIdsWithSegmentation') + .mockReturnValue(viewportIds); + + const returnedViewportIds = service.getViewportIdsWithSegmentation(segmentationId); + + expect(cstSegmentation.state.getViewportIdsWithSegmentation).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.getViewportIdsWithSegmentation).toHaveBeenCalledWith( + segmentationId + ); + + expect(returnedViewportIds).toEqual(viewportIds); + }); + }); + + describe('clearSegmentationRepresentations', () => { + it('should clear the segmentation representations', () => { + const viewportId = 'viewportId'; + jest.spyOn(service, 'removeSegmentationRepresentations').mockReturnValue(undefined); + + service.clearSegmentationRepresentations(viewportId); + + expect(service.removeSegmentationRepresentations).toHaveBeenCalledTimes(1); + expect(service.removeSegmentationRepresentations).toHaveBeenCalledWith(viewportId); + }); + }); + + describe('remove', () => { + it('should remove the segmentation', () => { + const segmentationId = 'segmentationId'; + + jest.spyOn(cstSegmentation.state, 'removeSegmentation').mockReturnValue(undefined); + + service.remove(segmentationId); + + expect(cstSegmentation.state.removeSegmentation).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.removeSegmentation).toHaveBeenCalledWith(segmentationId); + }); + }); + + describe('removeAllSegmentations', () => { + it('should remove all segmentations', () => { + jest.spyOn(cstSegmentation.state, 'removeAllSegmentations').mockReturnValue(undefined); + + service.removeAllSegmentations(); + + expect(cstSegmentation.state.removeAllSegmentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.removeAllSegmentations).toHaveBeenCalledWith(); + }); + }); + + describe('removeSegmentationRepresentations', () => { + it('should remove the segmentation representations', () => { + const viewportId = 'viewportId'; + const specifier = { + segmentationId: 'segmentationId', + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }; + jest.spyOn(cstSegmentation, 'removeSegmentationRepresentations').mockReturnValue(undefined); + + service.removeSegmentationRepresentations(viewportId, specifier); + + expect(cstSegmentation.removeSegmentationRepresentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.removeSegmentationRepresentations).toHaveBeenCalledWith( + viewportId, + specifier + ); + }); + }); + + describe('jumpToSegmentCenter', () => { + it('should early return if the center is not found', () => { + const segmentationId = 'segmentationId'; + const segmentIndex = 1; + + jest.spyOn(console, 'warn').mockReturnValue(undefined); + jest.spyOn(cstSegmentation.state, 'getSegmentation').mockReturnValue(null); + jest.spyOn(service, 'getViewportIdsWithSegmentation'); + + service.jumpToSegmentCenter(segmentationId, segmentIndex); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + 'No center found for segmentation', + segmentationId, + segmentIndex + ); + + expect(service.getViewportIdsWithSegmentation).not.toHaveBeenCalled(); + }); + + it('should correctly handle scenario where viewportId is provided', () => { + const segmentationId = 'segmentationId'; + const segmentIndex = 1; + const viewportId = 'viewportId'; + const viewport = { jumpToWorld: jest.fn() }; + + const segmentationWithCenter = { + ...mockCornerstoneSegmentation, + segments: { + ...mockCornerstoneSegmentation.segments, + [segmentIndex]: { + ...mockCornerstoneSegmentation.segments[segmentIndex], + cachedStats: { center: { image: [1, 1, 1], world: [10, 10, 10] } }, + }, + }, + }; + + jest.spyOn(cstSegmentation.state, 'getSegmentation').mockReturnValue(segmentationWithCenter); + jest.spyOn(service, 'getViewportIdsWithSegmentation'); + // @ts-expect-error - mock only needed properties + getEnabledElementByViewportId.mockReturnValue({ viewport }); + jest.spyOn(service, 'highlightSegment').mockReturnValue(undefined); + + service.jumpToSegmentCenter(segmentationId, segmentIndex, viewportId); + + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledWith(segmentationId); + + expect(service.getViewportIdsWithSegmentation).not.toHaveBeenCalled(); + + expect(viewport.jumpToWorld).toHaveBeenCalledTimes(1); + expect(viewport.jumpToWorld).toHaveBeenCalledWith([10, 10, 10]); + + expect(service.highlightSegment).toHaveBeenCalledTimes(1); + expect(service.highlightSegment).toHaveBeenCalledWith( + segmentationId, + segmentIndex, + viewportId, + 0.9, + 750, + false, + EasingFunctionEnum.EASE_IN_OUT + ); + }); + + it('should correctly handle custom animation parameters', () => { + const segmentationId = 'segmentationId'; + const segmentIndex = 1; + const viewportId = 'viewportId'; + const viewport = { jumpToWorld: jest.fn() }; + + const segmentationWithNamedStatsCenter = { + ...mockCornerstoneSegmentation, + segments: { + ...mockCornerstoneSegmentation.segments, + [segmentIndex]: { + ...mockCornerstoneSegmentation.segments[segmentIndex], + cachedStats: { + namedStats: { center: { value: [1, 1, 1] } }, + }, + }, + }, + }; + + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(segmentationWithNamedStatsCenter); + jest.spyOn(service, 'getViewportIdsWithSegmentation').mockReturnValue([viewportId]); + // @ts-expect-error - mock only needed properties + getEnabledElementByViewportId.mockReturnValue({ viewport }); + jest.spyOn(service, 'highlightSegment').mockReturnValue(undefined); + + service.jumpToSegmentCenter( + segmentationId, + segmentIndex, + undefined, + 0.8, + true, + 800, + false, + EasingFunctionEnum.LINEAR + ); + + expect(service.getViewportIdsWithSegmentation).toHaveBeenCalledTimes(1); + expect(service.getViewportIdsWithSegmentation).toHaveBeenCalledWith(segmentationId); + + expect(viewport.jumpToWorld).toHaveBeenCalledTimes(1); + expect(viewport.jumpToWorld).toHaveBeenCalledWith([1, 1, 1]); + + expect(service.highlightSegment).toHaveBeenCalledTimes(1); + expect(service.highlightSegment).toHaveBeenCalledWith( + segmentationId, + segmentIndex, + viewportId, + 0.8, + 800, + false, + EasingFunctionEnum.LINEAR + ); + }); + }); + + describe('highlightSegment', () => { + describe('LABELMAP Segmentation', () => { + it('should correctly handle scenario where viewportId is provided', () => { + const segmentIndex = 1; + const viewportId = 'viewportId'; + const initialFillAlpha = 0.3; + const animationDuration = 750; + const animationFunctionConstant = 0.85; + const easingFunction = jest.fn(() => animationFunctionConstant); + + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation); + jest.spyOn(service, 'getViewportIdsWithSegmentation'); + jest.spyOn(service, 'getSegmentationRepresentations').mockReturnValue(representations); + jest.spyOn(cstSegmentation.config.style, 'getStyle').mockReturnValue({ + fillAlpha: initialFillAlpha, + }); + jest.spyOn(window, 'requestAnimationFrame').mockReturnValue(undefined); + jest.spyOn(EasingFunctionMap, 'get').mockReturnValue(easingFunction); + + service.highlightSegment( + representations[0].segmentationId, + segmentIndex, + viewportId, + 0.9, + animationDuration, + false, + EasingFunctionEnum.EASE_IN_OUT + ); + + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledWith( + representations[0].segmentationId + ); + + expect(service.getViewportIdsWithSegmentation).not.toHaveBeenCalled(); + + expect(service.getSegmentationRepresentations).toHaveBeenCalledTimes(1); + expect(service.getSegmentationRepresentations).toHaveBeenCalledWith(viewportId, { + segmentationId: representations[0].segmentationId, + }); + + expect(cstSegmentation.config.style.getStyle).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.style.getStyle).toHaveBeenCalledWith({ + viewportId, + segmentationId: representations[0].segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + segmentIndex, + }); + + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1); + expect(window.requestAnimationFrame).toHaveBeenCalledWith(expect.any(Function)); + + const animationCallback = jest.mocked(window.requestAnimationFrame).mock.calls[0][0]; + + // during animation call + animationCallback(0); + + expect(EasingFunctionMap.get).toHaveBeenCalledTimes(1); + expect(EasingFunctionMap.get).toHaveBeenCalledWith(EasingFunctionEnum.EASE_IN_OUT); + + expect(cstSegmentation.config.style.setStyle).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.style.setStyle).toHaveBeenCalledWith( + { + segmentationId: representations[0].segmentationId, + segmentIndex, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }, + { + fillAlpha: animationFunctionConstant, + } + ); + + expect(easingFunction).toHaveBeenCalledTimes(1); + expect(easingFunction).toHaveBeenCalledWith(0, initialFillAlpha); + + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(2); + expect(window.requestAnimationFrame).toHaveBeenCalledWith(expect.any(Function)); + + // end of animation call + animationCallback(animationDuration); + + expect(cstSegmentation.config.style.setStyle).toHaveBeenCalledTimes(3); + expect(cstSegmentation.config.style.setStyle).toHaveBeenCalledWith( + { + segmentationId: representations[0].segmentationId, + segmentIndex, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }, + {} + ); + + expect(window.requestAnimationFrame).not.toHaveBeenCalledTimes(3); + }); + + it('should throw if hideOthers is true', () => { + const segmentIndex = 1; + + jest.spyOn(window, 'clearInterval').mockReturnValue(undefined); + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation); + jest.spyOn(service, 'getViewportIdsWithSegmentation').mockReturnValue([viewportId]); + jest.spyOn(service, 'getSegmentationRepresentations').mockReturnValue(representations); + + expect(() => + service.highlightSegment(representations[0].segmentationId, segmentIndex) + ).toThrow('hideOthers is not working right now'); + + expect(window.clearInterval).not.toHaveBeenCalled(); + + expect(service.getViewportIdsWithSegmentation).toHaveBeenCalledTimes(1); + expect(service.getViewportIdsWithSegmentation).toHaveBeenCalledWith( + representations[0].segmentationId + ); + + expect(service.getSegmentationRepresentations).toHaveBeenCalledTimes(1); + expect(service.getSegmentationRepresentations).toHaveBeenCalledWith(viewportId, { + segmentationId: representations[0].segmentationId, + }); + }); + }); + + describe('CONTOUR Segmentation', () => { + it('should correctly handle scenario where viewportId is provided', () => { + const segmentIndex = 1; + const viewportId = 'viewportId'; + const initialOutlineWidth = 3; + const animationDuration = 750; + const animationFunctionConstant = 5; + const easingFunction = jest.fn(() => animationFunctionConstant); + + const contourRepresentations = [ + { + ...representations[0], + type: csToolsEnums.SegmentationRepresentations.Contour, + }, + ]; + + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation); + jest.spyOn(service, 'getViewportIdsWithSegmentation'); + jest + .spyOn(service, 'getSegmentationRepresentations') + .mockReturnValue(contourRepresentations); + jest.spyOn(cstSegmentation.config.style, 'getStyle').mockReturnValue({ + outlineWidth: initialOutlineWidth, + }); + jest.spyOn(window, 'requestAnimationFrame').mockReturnValue(undefined); + jest.spyOn(EasingFunctionMap, 'get').mockReturnValue(easingFunction); + jest.spyOn(cstSegmentation.config.style, 'resetToGlobalStyle').mockReturnValue(undefined); + + service.highlightSegment( + contourRepresentations[0].segmentationId, + segmentIndex, + viewportId, + 0.9, + animationDuration, + false, + EasingFunctionEnum.LINEAR + ); + + expect(service.getViewportIdsWithSegmentation).not.toHaveBeenCalled(); + + expect(cstSegmentation.config.style.getStyle).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.style.getStyle).toHaveBeenCalledWith({ + type: csToolsEnums.SegmentationRepresentations.Contour, + }); + + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1); + expect(window.requestAnimationFrame).toHaveBeenCalledWith(expect.any(Function)); + + const animationCallback = jest.mocked(window.requestAnimationFrame).mock.calls[0][0]; + const approximateStartTime = performance.now(); + + // during animation call + animationCallback(approximateStartTime + 200); + + expect(EasingFunctionMap.get).toHaveBeenCalledTimes(1); + expect(EasingFunctionMap.get).toHaveBeenCalledWith(EasingFunctionEnum.LINEAR); + + expect(cstSegmentation.config.style.setStyle).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.style.setStyle).toHaveBeenCalledWith( + { + segmentationId: contourRepresentations[0].segmentationId, + segmentIndex, + type: csToolsEnums.SegmentationRepresentations.Contour, + }, + { + outlineWidth: animationFunctionConstant, + } + ); + + expect(easingFunction).toHaveBeenCalledTimes(1); + expect(easingFunction).toHaveBeenCalledWith(expect.any(Number), initialOutlineWidth, 5); + + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(2); + expect(window.requestAnimationFrame).toHaveBeenCalledWith(expect.any(Function)); + + // end of animation call + animationCallback(approximateStartTime + animationDuration); + + expect(cstSegmentation.config.style.setStyle).toHaveBeenCalledTimes(1); + + expect(cstSegmentation.config.style.resetToGlobalStyle).toHaveBeenCalledTimes(1); + expect(cstSegmentation.config.style.resetToGlobalStyle).toHaveBeenCalledWith(); + + expect(window.requestAnimationFrame).not.toHaveBeenCalledTimes(3); + }); + }); + + it('should clear interval if it exists', () => { + expect(service.highlightIntervalId).toBe(null); + + service.highlightIntervalId = 'intervalId'; + + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation); + jest.spyOn(service, 'getViewportIdsWithSegmentation'); + jest.spyOn(service, 'getSegmentationRepresentations').mockReturnValue(representations); + jest.spyOn(cstSegmentation.config.style, 'getStyle').mockReturnValue({ + fillAlpha: 0.3, + }); + jest.spyOn(window, 'requestAnimationFrame').mockReturnValue(undefined); + jest.spyOn(window, 'clearInterval').mockReturnValue(undefined); + + service.highlightSegment(representations[0].segmentationId, 1, viewportId, 0.9, 750, false); + + expect(window.clearInterval).toHaveBeenCalledTimes(1); + expect(window.clearInterval).toHaveBeenCalledWith('intervalId'); + }); + }); + + describe('_onSegmentationDataModifiedFromSource', () => { + it('should broadcast the event', () => { + jest.spyOn(eventTarget, 'addEventListener').mockReturnValue(undefined); + + const callback = jest.fn(); + service.subscribe(service.EVENTS.SEGMENTATION_DATA_MODIFIED, callback); + + service.onModeEnter(); + + const _onSegmentationDataModifiedFromSource = jest.mocked(eventTarget.addEventListener).mock + .calls[2][1]; + + _onSegmentationDataModifiedFromSource({ + detail: { + segmentationId: 'segmentationId', + }, + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + segmentationId: 'segmentationId', + }); + }); + }); + + describe('_onSegmentationRepresentationModifiedFromSource', () => { + it('should broadcast the event', () => { + jest.spyOn(eventTarget, 'addEventListener').mockReturnValue(undefined); + + const callback = jest.fn(); + service.subscribe(service.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, callback); + + service.onModeEnter(); + + const _onSegmentationRepresentationModifiedFromSource = jest.mocked( + eventTarget.addEventListener + ).mock.calls[3][1]; + + _onSegmentationRepresentationModifiedFromSource({ + detail: { + segmentationId: 'segmentationId', + viewportId: 'viewportId', + }, + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + segmentationId: 'segmentationId', + viewportId: 'viewportId', + }); + }); + }); + + describe('_onSegmentationModifiedFromSource', () => { + it('should broadcast the event', () => { + jest.spyOn(eventTarget, 'addEventListener').mockReturnValue(undefined); + + const callback = jest.fn(); + service.subscribe(service.EVENTS.SEGMENTATION_MODIFIED, callback); + + service.onModeEnter(); + + const _onSegmentationModifiedFromSource = jest.mocked(eventTarget.addEventListener).mock + .calls[0][1]; + + _onSegmentationModifiedFromSource({ + detail: { + segmentationId: 'segmentationId', + }, + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + segmentationId: 'segmentationId', + }); + }); + }); + + describe('_onSegmentationAddedFromSource', () => { + it('should broadcast the event', () => { + jest.spyOn(eventTarget, 'addEventListener').mockReturnValue(undefined); + + const callback = jest.fn(); + service.subscribe(service.EVENTS.SEGMENTATION_ADDED, callback); + + service.onModeEnter(); + + const _onSegmentationAddedFromSource = jest.mocked(eventTarget.addEventListener).mock + .calls[6][1]; + + _onSegmentationAddedFromSource({ + detail: { + segmentationId: 'segmentationId', + }, + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + segmentationId: 'segmentationId', + }); + }); + }); +}); diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index 0bebef5c1..6368ef291 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -9,30 +9,34 @@ import { utilities as csUtils, metaData, } from '@cornerstonejs/core'; +import { ViewportType } from '@cornerstonejs/core/enums'; + import { Enums as csToolsEnums, segmentation as cstSegmentation, Types as cstTypes, } from '@cornerstonejs/tools'; + import { PubSubService, Types as OHIFTypes } from '@ohif/core'; import i18n from '@ohif/i18n'; -import { EasingFunctionEnum, EasingFunctionMap } from '../../utils/transitions'; -import { mapROIContoursToRTStructData } from './RTSTRUCT/mapROIContoursToRTStructData'; -import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; -import { addColorLUT } from '@cornerstonejs/tools/segmentation/addColorLUT'; -import { getNextColorLUTIndex } from '@cornerstonejs/tools/segmentation/getNextColorLUTIndex'; -import { Segment } from '@cornerstonejs/tools/types/SegmentationStateTypes'; -import { ContourStyle, LabelmapStyle, SurfaceStyle } from '@cornerstonejs/tools/types'; -import { ViewportType } from '@cornerstonejs/core/enums'; -import { SegmentationPresentation, SegmentationPresentationItem } from '../../types/Presentation'; -import { updateLabelmapSegmentationImageReferences } from '@cornerstonejs/tools/segmentation/updateLabelmapSegmentationImageReferences'; -import { triggerSegmentationRepresentationModified } from '@cornerstonejs/tools/segmentation/triggerSegmentationEvents'; -import { convertStackToVolumeLabelmap } from '@cornerstonejs/tools/segmentation/helpers/convertStackToVolumeLabelmap'; -import { getLabelmapImageIds } from '@cornerstonejs/tools/segmentation'; -import { VOLUME_LOADER_SCHEME } from '../../constants'; -const LABELMAP = csToolsEnums.SegmentationRepresentations.Labelmap; -const CONTOUR = csToolsEnums.SegmentationRepresentations.Contour; +import { VOLUME_LOADER_SCHEME } from '../../constants'; +import { mapROIContoursToRTStructData } from './RTSTRUCT/mapROIContoursToRTStructData'; +import { SegmentationPresentation, SegmentationPresentationItem } from '../../types/Presentation'; +import { EasingFunctionEnum, EasingFunctionMap } from '../../utils/transitions'; + +const { + Labelmap: LABELMAP, + Contour: CONTOUR, + Surface: SURFACE, +} = csToolsEnums.SegmentationRepresentations; + +const { + getLabelmapImageIds, + helpers: { convertStackToVolumeLabelmap }, + state: { addColorLUT, updateLabelmapSegmentationImageReferences }, + triggerSegmentationEvents: { triggerSegmentationRepresentationModified }, +} = cstSegmentation; export type SegmentRepresentation = { segmentIndex: number; @@ -149,13 +153,11 @@ class SegmentationService extends PubSubService { const representations = this.getSegmentationRepresentations(viewportId); for (const representation of representations) { - const { segmentationId } = representation; - if (!representation) { continue; } - const { type } = representation; + const { segmentationId, type } = representation; segmentationsMap.set(segmentationId, { segmentationId, @@ -212,7 +214,7 @@ class SegmentationService extends PubSubService { viewportId: string, specifier: { segmentationId?: string; - type?: SegmentationRepresentations; + type?: csToolsEnums.SegmentationRepresentations; } = {} ): SegmentationRepresentation[] { // Get all representations for the viewportId @@ -245,9 +247,19 @@ class SegmentationService extends PubSubService { this._onSegmentationDataModifiedFromSource ); + eventTarget.removeEventListener( + csToolsEnums.Events.SEGMENTATION_REPRESENTATION_MODIFIED, + this._onSegmentationRepresentationModifiedFromSource + ); + eventTarget.removeEventListener( csToolsEnums.Events.SEGMENTATION_REPRESENTATION_ADDED, - this._onSegmentationModifiedFromSource + this._onSegmentationRepresentationModifiedFromSource + ); + + eventTarget.removeEventListener( + csToolsEnums.Events.SEGMENTATION_REPRESENTATION_REMOVED, + this._onSegmentationRepresentationModifiedFromSource ); eventTarget.removeEventListener( @@ -283,11 +295,11 @@ class SegmentationService extends PubSubService { const colorLUTIndex = this._segmentationIdToColorLUTIndexMap.get(segmentationId); - const defaultRepresentationType = csToolsEnums.SegmentationRepresentations.Labelmap; + const defaultRepresentationType = LABELMAP; let representationTypeToUse = type || defaultRepresentationType; let isConverted = false; - if (type === csToolsEnums.SegmentationRepresentations.Labelmap) { + if (representationTypeToUse === LABELMAP) { const { isVolumeViewport, isVolumeSegmentation } = this.determineViewportAndSegmentationType( csViewport, segmentation @@ -354,7 +366,7 @@ class SegmentationService extends PubSubService { const derivedImages = await imageLoader.createAndCacheDerivedLabelmapImages(referenceImageIds); const segs = this.getSegmentations(); - const label = options.label || `Segmentation ${segs.length + 1}`; + const label = options?.label || `Segmentation ${segs.length + 1}`; const segImageIds = derivedImages.map(image => image.imageId); @@ -371,7 +383,7 @@ class SegmentationService extends PubSubService { config: { label, segments: - options.segments && Object.keys(options.segments).length > 0 + options?.segments && Object.keys(options.segments).length > 0 ? options.segments : { 1: { @@ -393,7 +405,7 @@ class SegmentationService extends PubSubService { segDisplaySet, options: { segmentationId?: string; - type: SegmentationRepresentations; + type: csToolsEnums.SegmentationRepresentations; } = { type: LABELMAP, } @@ -503,9 +515,7 @@ class SegmentationService extends PubSubService { }; }); - // get next color lut index - const colorLUTIndex = getNextColorLUTIndex(); - addColorLUT(colorLUT, colorLUTIndex); + const colorLUTIndex = addColorLUT(colorLUT); this._segmentationIdToColorLUTIndexMap.set(segmentationId, colorLUTIndex); this._broadcastEvent(EVENTS.SEGMENTATION_LOADING_COMPLETE, { @@ -540,7 +550,7 @@ class SegmentationService extends PubSubService { rtDisplaySet, options: { segmentationId?: string; - type: SegmentationRepresentations; + type: csToolsEnums.SegmentationRepresentations; } = { type: CONTOUR, } @@ -616,7 +626,9 @@ class SegmentationService extends PubSubService { const colorLUT = [[0, 0, 0, 0]]; // First entry is transparent for index 0 // Process each segment similarly to the SEG function - for (const rtStructData of allRTStructData) { + for (let i = 0; i < allRTStructData.length; i++) { + const rtStructData = allRTStructData[i]; + const { data, id, color, segmentIndex, geometryId, group } = rtStructData; // Add the color to the colorLUT array @@ -665,8 +677,7 @@ class SegmentationService extends PubSubService { } // Create and register the colorLUT - const colorLUTIndex = getNextColorLUTIndex(); - addColorLUT(colorLUT, colorLUTIndex); + const colorLUTIndex = addColorLUT(colorLUT); this._segmentationIdToColorLUTIndexMap.set(segmentationId, colorLUTIndex); // Assign processed segments to segmentation config @@ -763,7 +774,7 @@ class SegmentationService extends PubSubService { public hasCustomStyles(specifier: { viewportId: string; segmentationId: string; - type: SegmentationRepresentations; + type: csToolsEnums.SegmentationRepresentations; }): boolean { return cstSegmentation.config.style.hasCustomStyle(specifier); } @@ -771,7 +782,7 @@ class SegmentationService extends PubSubService { public getStyle = (specifier: { viewportId: string; segmentationId: string; - type: SegmentationRepresentations; + type: csToolsEnums.SegmentationRepresentations; segmentIndex?: number; }) => { const style = cstSegmentation.config.style.getStyle(specifier); @@ -781,12 +792,12 @@ class SegmentationService extends PubSubService { public setStyle = ( specifier: { - type: SegmentationRepresentations; + type: csToolsEnums.SegmentationRepresentations; viewportId?: string; segmentationId?: string; segmentIndex?: number; }, - style: LabelmapStyle | ContourStyle | SurfaceStyle + style: cstTypes.LabelmapStyle | cstTypes.ContourStyle | cstTypes.SurfaceStyle ) => { cstSegmentation.config.style.setStyle(specifier, style); }; @@ -904,7 +915,7 @@ class SegmentationService extends PubSubService { segmentationId: string, segmentIndex: number, isVisible: boolean, - type?: SegmentationRepresentations + type?: csToolsEnums.SegmentationRepresentations ): void { this._setSegmentVisibility(viewportId, segmentationId, segmentIndex, isVisible, type); } @@ -941,7 +952,7 @@ class SegmentationService extends PubSubService { viewportId: string, segmentationId: string, segmentIndex: number, - type: SegmentationRepresentations + type: csToolsEnums.SegmentationRepresentations ): void { const isVisible = cstSegmentation.config.visibility.getSegmentIndexVisibility( viewportId, @@ -1013,7 +1024,7 @@ class SegmentationService extends PubSubService { public getLabelmapVolume(segmentationId: string) { const csSegmentation = cstSegmentation.state.getSegmentation(segmentationId); const labelmapData = csSegmentation.representationData[ - SegmentationRepresentations.Labelmap + LABELMAP ] as cstTypes.LabelmapToolOperationDataVolume; if (!labelmapData || !labelmapData.volumeId) { @@ -1115,7 +1126,10 @@ class SegmentationService extends PubSubService { */ public toggleSegmentationRepresentationVisibility = ( viewportId: string, - { segmentationId, type }: { segmentationId: string; type: SegmentationRepresentations } + { + segmentationId, + type, + }: { segmentationId: string; type: csToolsEnums.SegmentationRepresentations } ): void => { this._toggleSegmentationRepresentationVisibility(viewportId, segmentationId, type); }; @@ -1164,7 +1178,7 @@ class SegmentationService extends PubSubService { viewportId: string, specifier: { segmentationId?: string; - type?: SegmentationRepresentations; + type?: csToolsEnums.SegmentationRepresentations; } = {} ): void { cstSegmentation.removeSegmentationRepresentations(viewportId, specifier); @@ -1276,7 +1290,7 @@ class SegmentationService extends PubSubService { private _setSegmentationRepresentationVisibility( viewportId: string, segmentationId: string, - type: SegmentationRepresentations, + type: csToolsEnums.SegmentationRepresentations, isVisible: boolean ): void { const representations = this.getSegmentationRepresentations(viewportId, { @@ -1336,14 +1350,17 @@ class SegmentationService extends PubSubService { private async handleVolumeViewportCase(csViewport, segmentation, isVolumeSegmentation) { if (csViewport.type === ViewportType.VOLUME_3D) { - return { representationTypeToUse: SegmentationRepresentations.Surface, isConverted: false }; + return { + representationTypeToUse: SURFACE, + isConverted: false, + }; } else { await this.handleVolumeViewport( csViewport as csTypes.IVolumeViewport, segmentation, isVolumeSegmentation ); - return { representationTypeToUse: SegmentationRepresentations.Labelmap, isConverted: false }; + return { representationTypeToUse: LABELMAP, isConverted: false }; } } @@ -1353,14 +1370,17 @@ class SegmentationService extends PubSubService { isVolumeSegmentation: boolean, viewportId: string, segmentationId: string - ): Promise<{ representationTypeToUse: SegmentationRepresentations; isConverted: boolean }> { + ): Promise<{ + representationTypeToUse: csToolsEnums.SegmentationRepresentations; + isConverted: boolean; + }> { if (isVolumeSegmentation) { const isConverted = await this.convertStackToVolumeViewport(csViewport); - return { representationTypeToUse: SegmentationRepresentations.Labelmap, isConverted }; + return { representationTypeToUse: LABELMAP, isConverted }; } if (updateLabelmapSegmentationImageReferences(viewportId, segmentationId)) { - return { representationTypeToUse: SegmentationRepresentations.Labelmap, isConverted: false }; + return { representationTypeToUse: LABELMAP, isConverted: false }; } const isConverted = await this.attemptStackToVolumeConversion( @@ -1370,7 +1390,7 @@ class SegmentationService extends PubSubService { segmentationId ); - return { representationTypeToUse: SegmentationRepresentations.Labelmap, isConverted }; + return { representationTypeToUse: LABELMAP, isConverted }; } private async _addSegmentationRepresentation( @@ -1481,11 +1501,7 @@ class SegmentationService extends PubSubService { segImage.FrameOfReferenceUID === frameOfReferenceUID ) { const isConverted = await this.convertStackToVolumeViewport(viewport); - triggerSegmentationRepresentationModified( - viewportId, - segmentationId, - SegmentationRepresentations.Labelmap - ); + triggerSegmentationRepresentationModified(viewportId, segmentationId, LABELMAP); return isConverted; } @@ -1613,7 +1629,7 @@ class SegmentationService extends PubSubService { segmentIndex: number, alpha: number, hideOthers: boolean, - segments: Segment[], + segments: cstTypes.Segment[], viewportId: string, animationLength: number, representation: cstTypes.SegmentationRepresentation, @@ -1685,7 +1701,7 @@ class SegmentationService extends PubSubService { segmentIndex: number, alpha: number, hideOthers: boolean, - segments: Segment[], + segments: cstTypes.Segment[], viewportId: string, animationLength: number, representation: cstTypes.SegmentationRepresentation, @@ -1696,7 +1712,7 @@ class SegmentationService extends PubSubService { const prevStyle = cstSegmentation.config.style.getStyle({ type: CONTOUR, - }) as ContourStyle; + }) as cstTypes.ContourStyle; const prevOutlineWidth = prevStyle.outlineWidth; @@ -1730,7 +1746,7 @@ class SegmentationService extends PubSubService { private _toggleSegmentationRepresentationVisibility = ( viewportId: string, segmentationId: string, - type: SegmentationRepresentations + type: csToolsEnums.SegmentationRepresentations ): void => { const representations = this.getSegmentationRepresentations(viewportId, { segmentationId, @@ -1798,7 +1814,7 @@ class SegmentationService extends PubSubService { segmentationId: string, segmentIndex: number, isVisible: boolean, - type?: SegmentationRepresentations + type?: csToolsEnums.SegmentationRepresentations ) { cstSegmentation.config.visibility.setSegmentIndexVisibility( viewportId, diff --git a/package.json b/package.json index c1b2d0cf9..9e903b7fe 100644 --- a/package.json +++ b/package.json @@ -83,23 +83,24 @@ }, "devDependencies": { "@babel/core": "7.28.0", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.16.7", "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", "@babel/plugin-transform-object-rest-spread": "^7.28.0", "@babel/plugin-transform-private-methods": "^7.27.1", "@babel/plugin-transform-private-property-in-object": "^7.27.1", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-transform-arrow-functions": "^7.16.7", - "@babel/plugin-transform-for-of": "^7.27.1", "@babel/plugin-transform-regenerator": "^7.16.7", "@babel/plugin-transform-runtime": "7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-env": "7.28.0", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", + "@istanbuljs/nyc-config-typescript": "^1.0.2", "@pmmmwh/react-refresh-webpack-plugin": "0.5.17", "babel-loader": "8.4.1", "babel-plugin-istanbul": "^7.0.0", - "@istanbuljs/nyc-config-typescript": "^1.0.2", + "babel-plugin-transform-import-meta": "2.3.3", "css-loader": "6.11.0", "cypress": "14.5.2", "cypress-file-upload": "^5.0.8", diff --git a/yarn.lock b/yarn.lock index f6e230f26..e0db1b35a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1212,7 +1212,7 @@ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6" integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== -"@babel/template@^7.27.1", "@babel/template@^7.27.2", "@babel/template@^7.3.3": +"@babel/template@^7.25.9", "@babel/template@^7.27.1", "@babel/template@^7.27.2", "@babel/template@^7.3.3": version "7.27.2" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== @@ -5354,6 +5354,14 @@ babel-plugin-polyfill-regenerator@^0.6.5: dependencies: "@babel/helper-define-polyfill-provider" "^0.6.5" +babel-plugin-transform-import-meta@2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-import-meta/-/babel-plugin-transform-import-meta-2.3.3.tgz#863de841f7df37e2bf39a057572a24e4f65f3c51" + integrity sha512-bbh30qz1m6ZU1ybJoNOhA2zaDvmeXMnGNBMVMDOJ1Fni4+wMBoy/j7MTRVmqAUCIcy54/rEnr9VEBsfcgbpm3Q== + dependencies: + "@babel/template" "^7.25.9" + tslib "^2.8.1" + babel-preset-current-node-syntax@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz#9a929eafece419612ef4ae4f60b1862ebad8ef30" @@ -18079,7 +18087,7 @@ tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.8.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==