Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions extensions/default/src/utils/modeSelectorUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
185 changes: 162 additions & 23 deletions extensions/default/src/utils/modeSelectorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@ const { formatPN } = utils;
/** Fields read from `query.studies.search` responses; remainder spread into `study`. */
type StudySearchRow = { modalities?: string } & Record<string, unknown>;

/** 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[]> | StudySearchRow[];
};
series?: {
search?: (studyInstanceUid: string) => Promise<SeriesSearchRow[]> | SeriesSearchRow[];
};
};
};

Expand Down Expand Up @@ -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>): 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<string>();

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<StudyEnvelope | null> {
): Promise<string> {
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<string>();

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<string> {
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;
}
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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<StudyEnvelope | null> {
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.
Expand Down