From 77d341f00ecce8c31a7585c49bb3f236b611c597 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Fri, 22 May 2026 13:56:15 -0300 Subject: [PATCH] Add fallback for mode selector --- .../src/utils/modeSelectorUtils.test.ts | 105 ++++++++++ .../default/src/utils/modeSelectorUtils.ts | 185 +++++++++++++++--- 2 files changed, 267 insertions(+), 23 deletions(-) create mode 100644 extensions/default/src/utils/modeSelectorUtils.test.ts diff --git a/extensions/default/src/utils/modeSelectorUtils.test.ts b/extensions/default/src/utils/modeSelectorUtils.test.ts new file mode 100644 index 00000000000..16fda8ea7a3 --- /dev/null +++ b/extensions/default/src/utils/modeSelectorUtils.test.ts @@ -0,0 +1,105 @@ +import { + hasUsableModalities, + modalitiesStringFromSet, + normalizeModalitiesString, + resolveStudyModalities, +} from './modeSelectorUtils'; + +jest.mock('@ohif/core', () => ({ + DicomMetadataStore: { + getStudy: jest.fn(), + }, + utils: { + formatPN: jest.fn(), + }, +})); + +jest.mock('@ohif/app', () => ({ + preserveQueryParameters: jest.fn(), +})); + +const { DicomMetadataStore } = jest.requireMock('@ohif/core'); + +describe('modeSelectorUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + DicomMetadataStore.getStudy.mockReturnValue(null); + }); + + describe('hasUsableModalities', () => { + it('returns false for empty or whitespace-only modalities', () => { + expect(hasUsableModalities()).toBe(false); + expect(hasUsableModalities('')).toBe(false); + expect(hasUsableModalities(' ')).toBe(false); + }); + + it('returns true when at least one modality is present', () => { + expect(hasUsableModalities('CT/PT')).toBe(true); + expect(hasUsableModalities('CT\\PT')).toBe(true); + }); + }); + + describe('modalitiesStringFromSet', () => { + it('sorts and normalizes modality separators', () => { + expect(modalitiesStringFromSet(new Set(['PT', 'CT', 'SEG']))).toBe('CT\\PT\\SEG'); + }); + }); + + describe('resolveStudyModalities', () => { + it('uses QIDO study modalities when present', async () => { + const result = await resolveStudyModalities('1.2.3', null, 'PT\\CT'); + expect(result).toBe('PT\\CT'); + }); + + it('falls back to series search when study modalities are missing', async () => { + const dataSource = { + query: { + series: { + search: jest.fn().mockResolvedValue([ + { modality: 'PT' }, + { modality: 'CT' }, + { modality: 'SEG' }, + ]), + }, + }, + }; + + const result = await resolveStudyModalities('1.2.3', dataSource, ''); + + expect(result).toBe('CT\\PT\\SEG'); + expect(dataSource.query.series.search).toHaveBeenCalledWith('1.2.3'); + }); + + it('prefers loaded metadata over series search when study modalities are missing', async () => { + DicomMetadataStore.getStudy.mockReturnValue({ + series: [ + { + instances: [{ Modality: 'CT' }], + }, + { + instances: [{ Modality: 'PT' }], + }, + ], + }); + + const dataSource = { + query: { + series: { + search: jest.fn(), + }, + }, + }; + + const result = await resolveStudyModalities('1.2.3', dataSource, ''); + + expect(result).toBe('CT\\PT'); + expect(dataSource.query.series.search).not.toHaveBeenCalled(); + }); + }); + + describe('normalizeModalitiesString', () => { + it('replaces slash separators with backslashes', () => { + expect(normalizeModalitiesString('CT/PT')).toBe('CT\\PT'); + }); + }); +}); diff --git a/extensions/default/src/utils/modeSelectorUtils.ts b/extensions/default/src/utils/modeSelectorUtils.ts index 3bf70b52223..28b7d5ff1ea 100644 --- a/extensions/default/src/utils/modeSelectorUtils.ts +++ b/extensions/default/src/utils/modeSelectorUtils.ts @@ -8,12 +8,17 @@ const { formatPN } = utils; /** Fields read from `query.studies.search` responses; remainder spread into `study`. */ type StudySearchRow = { modalities?: string } & Record; -/** Data source subset used when calling `studies.search` to populate the envelope. */ +type SeriesSearchRow = { modality?: string }; + +/** Data source subset used when calling study/series search to populate the envelope. */ export type DataSourceWithStudySearch = { query?: { studies?: { search?: (params: { studyInstanceUid: string }) => Promise | StudySearchRow[]; }; + series?: { + search?: (studyInstanceUid: string) => Promise | SeriesSearchRow[]; + }; }; }; @@ -55,30 +60,107 @@ export function normalizeModalitiesString(raw?: string): string { } /** - * Loads study metadata for the mode selector: tries the active data source search, then falls back - * to `DicomMetadataStore` and builds modalities plus a study payload for validity checks. + * True when the normalized modalities string contains at least one non-empty modality token. */ -export async function fetchStudyEnvelope( - StudyInstanceUID: string, +export function hasUsableModalities(modalities?: string): boolean { + const normalized = normalizeModalitiesString(modalities); + if (!normalized) { + return false; + } + + return normalized.split('\\').some(token => token.trim() !== ''); +} + +/** + * Builds a sorted, normalized modalities string from a list of modality codes. + */ +export function modalitiesStringFromSet(modalitySet: Set): string { + if (modalitySet.size === 0) { + return ''; + } + + return normalizeModalitiesString([...modalitySet].sort().join('/')); +} + +/** + * Collects modalities from series already loaded in `DicomMetadataStore`. + */ +export function modalitiesFromMetadataStore(studyInstanceUID: string): string { + const meta = DicomMetadataStore.getStudy(studyInstanceUID); + if (!meta?.series?.length) { + return ''; + } + + const modalitySet = new Set(); + + meta.series.forEach(series => { + if (series?.instances?.length) { + const rawModality = series.instances[0].Modality; + if (rawModality != null && `${rawModality}`.trim() !== '') { + modalitySet.add(String(rawModality)); + } + } + }); + + return modalitiesStringFromSet(modalitySet); +} + +/** + * Collects modalities from a QIDO series search when study-level tags are missing. + */ +export async function modalitiesFromSeriesSearch( + studyInstanceUID: string, dataSource: DataSourceWithStudySearch | null | undefined -): Promise { +): Promise { + const seriesSearch = dataSource?.query?.series?.search; + if (!seriesSearch) { + return ''; + } + try { - // Call as a method on query.studies so `this` inside search is bound (do not extract the function). - if (dataSource?.query?.studies?.search) { - const rows = await dataSource.query.studies.search({ studyInstanceUid: StudyInstanceUID }); - const row = rows?.[0]; - if (row) { - return { - modalitiesToCheck: normalizeModalitiesString(row.modalities), - study: { ...row }, - }; + const series = await seriesSearch(studyInstanceUID); + const modalitySet = new Set(); + + series?.forEach(row => { + const modality = row?.modality; + if (modality != null && `${modality}`.trim() !== '') { + modalitySet.add(String(modality)); } - } + }); + + return modalitiesStringFromSet(modalitySet); } catch (_e) { - // Fallback to locally loaded metadata + return ''; + } +} + +/** + * Resolves modalities for mode validity: QIDO study field, then loaded metadata, then series QIDO. + */ +export async function resolveStudyModalities( + studyInstanceUID: string, + dataSource: DataSourceWithStudySearch | null | undefined, + qidoModalities?: string +): Promise { + if (hasUsableModalities(qidoModalities)) { + return normalizeModalitiesString(qidoModalities); + } + + const fromMetadataStore = modalitiesFromMetadataStore(studyInstanceUID); + if (hasUsableModalities(fromMetadataStore)) { + return fromMetadataStore; + } + + const fromSeriesSearch = await modalitiesFromSeriesSearch(studyInstanceUID, dataSource); + if (hasUsableModalities(fromSeriesSearch)) { + return fromSeriesSearch; } - const meta = DicomMetadataStore.getStudy(StudyInstanceUID); + return ''; +} + +function buildStudyEnvelopeFromMetadataStore(studyInstanceUID: string): StudyEnvelope | null { + const meta = DicomMetadataStore.getStudy(studyInstanceUID); if (!meta?.series?.length) { return null; } @@ -96,12 +178,11 @@ export async function fetchStudyEnvelope( } }); - const modalitiesStr = [...modalitySet].sort().join('/'); - const modalitiesNormalized = normalizeModalitiesString(modalitiesStr); + const modalitiesNormalized = modalitiesStringFromSet(modalitySet); const firstSeriesWithInstance = meta.series.find(s => s?.instances?.length); const inst0 = firstSeriesWithInstance?.instances?.[0]; const studyPayload = { - studyInstanceUid: StudyInstanceUID, + studyInstanceUid: studyInstanceUID, modalities: modalitiesNormalized, mrn: inst0?.PatientID, instances: numInstances, @@ -110,8 +191,8 @@ export async function fetchStudyEnvelope( time: inst0?.StudyTime, accession: inst0?.AccessionNumber, patientName: inst0?.PatientName ? formatPN(inst0.PatientName) : '', - studyInstanceUID: StudyInstanceUID, - StudyInstanceUID, + studyInstanceUID: studyInstanceUID, + StudyInstanceUID: studyInstanceUID, }; return { @@ -120,6 +201,64 @@ export async function fetchStudyEnvelope( }; } +/** + * Loads study metadata for the mode selector: tries the active data source search, then falls back + * to `DicomMetadataStore` and series QIDO when study-level modalities are missing. + */ +export async function fetchStudyEnvelope( + StudyInstanceUID: string, + dataSource: DataSourceWithStudySearch | null | undefined +): Promise { + try { + // Call as a method on query.studies so `this` inside search is bound (do not extract the function). + if (dataSource?.query?.studies?.search) { + const rows = await dataSource.query.studies.search({ studyInstanceUid: StudyInstanceUID }); + const row = rows?.[0]; + if (row) { + const modalitiesToCheck = await resolveStudyModalities( + StudyInstanceUID, + dataSource, + row.modalities + ); + + return { + modalitiesToCheck, + study: { ...row, modalities: modalitiesToCheck }, + }; + } + } + } catch (_e) { + // Fallback to locally loaded metadata or series QIDO + } + + const fromMetadataStore = buildStudyEnvelopeFromMetadataStore(StudyInstanceUID); + if (fromMetadataStore?.modalitiesToCheck) { + return fromMetadataStore; + } + + const modalitiesToCheck = await resolveStudyModalities(StudyInstanceUID, dataSource); + if (!modalitiesToCheck) { + return fromMetadataStore; + } + + if (fromMetadataStore) { + return { + modalitiesToCheck, + study: { ...fromMetadataStore.study, modalities: modalitiesToCheck }, + }; + } + + return { + modalitiesToCheck, + study: { + studyInstanceUid: StudyInstanceUID, + studyInstanceUID: StudyInstanceUID, + StudyInstanceUID, + modalities: modalitiesToCheck, + }, + }; +} + /** * Whether a mode should be included when ordering/sorting mode options, using `isValidMode` when present. * Returns true if the mode has no checker or if the checker reports valid.