Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ function ViewportOrientationMarkers({
const p00 = viewport.canvasToWorld([0, 0]);
const p10 = viewport.canvasToWorld([1, 0]);
const p01 = viewport.canvasToWorld([0, 1]);
// WSIViewport returns undefined until the slide metadata loads.
if (!p00 || !p10 || !p01) {
return '';
}
const rowCosines = vec3.sub(vec3.create(), p10, p00);
const columnCosines = vec3.sub(vec3.create(), p01, p00);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -607,12 +607,15 @@ class CornerstoneViewportService
// The broadcast event here ensures that listeners have a valid, up to date
// viewport to access. Doing it too early can result in exceptions or
// invalid data.
displaySetPromise.then(() => {
this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, {
viewportData,
viewportId,
});
});
displaySetPromise.then(
() => {
this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, {
viewportData,
viewportId,
});
},
error => console.error('[CornerstoneViewportService] Failed to set the viewport data', error)
);
}

public getViewportOptions(viewportId: string): ViewportOptions {
Expand Down Expand Up @@ -910,7 +913,17 @@ class CornerstoneViewportService
return;
}

return this.backend.mountEcg(viewport, displaySet, imageId);
try {
await this.backend.mountEcg(viewport, displaySet, imageId);
} catch (error) {
// Tell the user, then reject so that setViewportData does not report a successful mount.
this.servicesManager.services.uiNotificationService?.show({
title: 'ECG',
message: 'The ECG waveform could not be loaded.',
type: 'error',
});
throw error;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Public so the viewport backends (IViewportServiceInternals) can dispatch to it.
Expand Down Expand Up @@ -1528,12 +1541,15 @@ class CornerstoneViewportService
return;
}

displaySetPromise.then(() => {
this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, {
viewportData,
viewportId,
});
});
displaySetPromise.then(
() => {
this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, {
viewportData,
viewportId,
});
},
error => console.error('[CornerstoneViewportService] Failed to update the viewport', error)
);
}

_setDisplaySets(
Expand Down
282 changes: 101 additions & 181 deletions extensions/cornerstone/src/utils/ecgMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -1,217 +1,137 @@
import { decodeInt16Multiplex, base64ToArrayBuffer, buildEcgModule } from './ecgMetadata';

// ---------------------------------------------------------------------------
// decodeInt16Multiplex
// ---------------------------------------------------------------------------
describe('decodeInt16Multiplex', () => {
it('demultiplexes 2 channels correctly', () => {
// Interleaved layout: [ch0_s0, ch1_s0, ch0_s1, ch1_s1, ch0_s2, ch1_s2]
const interleaved = new Int16Array([10, 20, 30, 40, 50, 60]);
const channels = decodeInt16Multiplex(interleaved.buffer, 2, 3);

expect(channels).toHaveLength(2);
expect(Array.from(channels[0])).toEqual([10, 30, 50]);
expect(Array.from(channels[1])).toEqual([20, 40, 60]);
});

it('handles a single channel', () => {
const data = new Int16Array([100, 200, 300]);
const channels = decodeInt16Multiplex(data.buffer, 1, 3);

expect(channels).toHaveLength(1);
expect(Array.from(channels[0])).toEqual([100, 200, 300]);
});

it('handles negative values (signed short)', () => {
const data = new Int16Array([-1000, -2000, -3000]);
const channels = decodeInt16Multiplex(data.buffer, 1, 3);

expect(Array.from(channels[0])).toEqual([-1000, -2000, -3000]);
});

it('returns empty arrays when numberOfChannels or numberOfSamples is 0', () => {
const emptyBuffer = new Int16Array([]).buffer;

expect(decodeInt16Multiplex(emptyBuffer, 0, 0)).toHaveLength(0);
expect(decodeInt16Multiplex(emptyBuffer, 0, 10)).toHaveLength(0);
});

it('produces Int16Array instances for each channel', () => {
const data = new Int16Array([1, 2]);
const channels = decodeInt16Multiplex(data.buffer, 2, 1);

expect(channels[0]).toBeInstanceOf(Int16Array);
expect(channels[1]).toBeInstanceOf(Int16Array);
});
import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata';
import { buildEcgModule } from './ecgMetadata';

jest.mock('@cornerstonejs/metadata', () => {
const actual = jest.requireActual('@cornerstonejs/metadata');
const { buildEcgModuleFromInstance } = actual.utilities;
return {
...actual,
utilities: { ...actual.utilities, buildEcgModuleFromInstance: jest.fn(buildEcgModuleFromInstance) },
};
});

// ---------------------------------------------------------------------------
// base64ToArrayBuffer
// ---------------------------------------------------------------------------
describe('base64ToArrayBuffer', () => {
it('decodes a simple base64 string to the correct bytes', () => {
// "ABC" in ASCII = [65, 66, 67]; base64 of that is 'QUJD'
const buffer = base64ToArrayBuffer('QUJD');
const bytes = new Uint8Array(buffer);

expect(bytes).toHaveLength(3);
expect(Array.from(bytes)).toEqual([65, 66, 67]);
});

it('round-trips an Int16Array through base64', () => {
const original = new Int16Array([500, -300, 0, 32767]);
const base64 = btoa(String.fromCharCode(...new Uint8Array(original.buffer)));
const recovered = new Int16Array(base64ToArrayBuffer(base64));

expect(Array.from(recovered)).toEqual(Array.from(original));
});

it('returns an ArrayBuffer', () => {
const result = base64ToArrayBuffer('AA=='); // single zero byte
expect(result).toBeInstanceOf(ArrayBuffer);
expect(result.byteLength).toBe(1);
});
// Two channels, two samples, interleaved: ch0=[1, -2], ch1=[3, 4].
const SAMPLES = new Int16Array([1, 3, -2, 4]);

const makeInstance = WaveformData => ({
WaveformSequence: [
{
NumberOfWaveformChannels: 2,
NumberOfWaveformSamples: 2,
SamplingFrequency: 500,
WaveformBitsAllocated: 16,
WaveformSampleInterpretation: 'SS',
MultiplexGroupLabel: '12 Lead ECG',
ChannelDefinitionSequence: [
{ ChannelSourceSequence: [{ CodeMeaning: 'Lead I' }] },
{ ChannelSourceSequence: [{ CodeMeaning: 'Lead II' }] },
],
WaveformData,
},
],
});

// ---------------------------------------------------------------------------
// buildEcgModule
// ---------------------------------------------------------------------------
describe('buildEcgModule', () => {
const makeInstance = (overrides = {}) => ({
WaveformSequence: [
{
NumberOfWaveformChannels: 12,
NumberOfWaveformSamples: 5000,
SamplingFrequency: 500,
WaveformBitsAllocated: 16,
WaveformSampleInterpretation: 'SS',
MultiplexGroupLabel: '12 Lead ECG',
ChannelDefinitionSequence: [
{ ChannelSourceSequence: [{ CodeMeaning: 'Lead I' }] },
{ ChannelSourceSequence: [{ CodeMeaning: 'Lead II' }] },
],
WaveformData: { InlineBinary: btoa('\x00\x01') },
},
],
...overrides,
});
const channelsOf = (channels: Int16Array[]) => channels.map(channel => Array.from(channel));

describe('buildEcgModule', () => {
it('returns null when WaveformSequence is absent', () => {
expect(buildEcgModule({})).toBeNull();
expect(buildEcgModule({ WaveformSequence: [] })).toBeNull();
});

it('returns a valid ecgModule for a complete instance', () => {
const module = buildEcgModule(makeInstance());
it('returns the waveform attributes and the lead names', () => {
const module = buildEcgModule(makeInstance({}));

expect(module).not.toBeNull();
expect(module!.numberOfWaveformChannels).toBe(12);
expect(module!.numberOfWaveformSamples).toBe(5000);
expect(module!.numberOfWaveformChannels).toBe(2);
expect(module!.numberOfWaveformSamples).toBe(2);
expect(module!.samplingFrequency).toBe(500);
expect(module!.waveformBitsAllocated).toBe(16);
expect(module!.waveformSampleInterpretation).toBe('SS');
expect(module!.multiplexGroupLabel).toBe('12 Lead ECG');
expect(module!.channelDefinitionSequence).toHaveLength(2);
expect(module!.channelDefinitionSequence[0].channelSourceSequence.codeMeaning).toBe('Lead I');
expect(typeof module!.waveformData.retrieveBulkData).toBe('function');
});

it('applies defaults for missing optional fields', () => {
const instance = {
WaveformSequence: [
{
// No explicit values — all defaults
WaveformData: { InlineBinary: btoa('\x00\x00') },
},
],
};
const module = buildEcgModule(instance);
it('decodes InlineBinary data', async () => {
const base64 = btoa(String.fromCharCode(...new Uint8Array(SAMPLES.buffer)));
const module = buildEcgModule(makeInstance({ InlineBinary: base64 }));

expect(module!.numberOfWaveformChannels).toBe(0);
expect(module!.numberOfWaveformSamples).toBe(0);
expect(module!.samplingFrequency).toBe(1);
expect(module!.waveformBitsAllocated).toBe(16);
expect(module!.waveformSampleInterpretation).toBe('SS');
expect(module!.multiplexGroupLabel).toBe('');
expect(module!.channelDefinitionSequence).toHaveLength(0);
expect(channelsOf(await module!.waveformData.retrieveBulkData())).toEqual([
[1, -2],
[3, 4],
]);
});

it('retrieveBulkData decodes InlineBinary data correctly', async () => {
const samples = new Int16Array([10, 20, 30]);
const base64 = btoa(String.fromCharCode(...new Uint8Array(samples.buffer)));

const instance = {
WaveformSequence: [
{
NumberOfWaveformChannels: 1,
NumberOfWaveformSamples: 3,
WaveformData: { InlineBinary: base64 },
},
],
};

const module = buildEcgModule(instance);
const channels = await module!.waveformData.retrieveBulkData();
it('decodes the bytes that the data source retrieveBulkData returns', async () => {
const retrieveBulkData = jest.fn().mockResolvedValue(SAMPLES.buffer.slice(0));
const module = buildEcgModule(
makeInstance({ BulkDataURI: 'http://example.com/waveform', retrieveBulkData })
);

expect(channels).toHaveLength(1);
expect(Array.from(channels[0])).toEqual([10, 20, 30]);
expect(channelsOf(await module!.waveformData.retrieveBulkData())).toEqual([
[1, -2],
[3, 4],
]);
expect(retrieveBulkData).toHaveBeenCalledTimes(1);
});

it('retrieveBulkData returns [] when WaveformData is absent', async () => {
const instance = {
WaveformSequence: [
{ NumberOfWaveformChannels: 1, NumberOfWaveformSamples: 1 },
],
};
const module = buildEcgModule(instance);
const channels = await module!.waveformData.retrieveBulkData();
it('decodes a typed array from the data source retrieveBulkData', async () => {
const bytes = new Uint8Array(SAMPLES.buffer.slice(0));
const module = buildEcgModule(
makeInstance({ BulkDataURI: 'x', retrieveBulkData: jest.fn().mockResolvedValue(bytes) })
);

expect(channels).toEqual([]);
expect(channelsOf(await module!.waveformData.retrieveBulkData())).toEqual([
[1, -2],
[3, 4],
]);
});

it('retrieveBulkData fetches BulkDataURI with auth header when provided', async () => {
const samples = new Int16Array([1, 2]);
global.fetch = jest.fn().mockResolvedValue({
ok: true,
arrayBuffer: () => Promise.resolve(samples.buffer),
it('uses the cached Value and does not fetch again on a re-mount', async () => {
const retrieveBulkData = jest.fn();
const module = buildEcgModule(
makeInstance({ BulkDataURI: 'x', Value: SAMPLES.buffer.slice(0), retrieveBulkData })
);

await module!.waveformData.retrieveBulkData();
expect(channelsOf(await module!.waveformData.retrieveBulkData())).toEqual([
[1, -2],
[3, 4],
]);
expect(retrieveBulkData).not.toHaveBeenCalled();
});

it('gives the auth headers to the Cornerstone bulkdata fetch', () => {
const builder = csMetadataUtilities.buildEcgModuleFromInstance as jest.Mock;
const authHeader = { Authorization: 'Bearer token' };
buildEcgModule(makeInstance({ BulkDataURI: 'x' }), {
getAuthorizationHeader: () => authHeader,
});

const userAuthenticationService = {
getAuthorizationHeader: () => ({ Authorization: 'Bearer token123' }),
};
const instance = {
WaveformSequence: [
{
NumberOfWaveformChannels: 1,
NumberOfWaveformSamples: 2,
WaveformData: { BulkDataURI: 'http://example.com/waveform' },
},
],
};
const options = builder.mock.lastCall[2] as { getHeaders: () => unknown };
expect(options.getHeaders()).toBe(authHeader);
});

const module = buildEcgModule(instance, userAuthenticationService);
await module!.waveformData.retrieveBulkData();
it('reads lead names from a lowercase codeMeaning', () => {
const instance = makeInstance({});
instance.WaveformSequence[0].ChannelDefinitionSequence[0].ChannelSourceSequence = [
{ codeMeaning: 'Lead I' } as never,
];

expect(global.fetch).toHaveBeenCalledWith('http://example.com/waveform', {
headers: {
Accept: 'application/octet-stream',
Authorization: 'Bearer token123',
},
});
const module = buildEcgModule(instance);

expect(module!.channelDefinitionSequence[0].channelSourceSequence.codeMeaning).toBe('Lead I');
});

it('retrieveBulkData throws when BulkDataURI response is not ok', async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 403 });
it('passes the lead code value and coding scheme', () => {
const instance = makeInstance({});
instance.WaveformSequence[0].ChannelDefinitionSequence[1].ChannelSourceSequence = [
{ CodeValue: '5.6.3-9-2', CodingSchemeDesignator: 'SCPECG', CodeMeaning: 'Lead II' } as never,
];

const instance = {
WaveformSequence: [
{
WaveformData: { BulkDataURI: 'http://example.com/waveform' },
},
],
const source = buildEcgModule(instance)!.channelDefinitionSequence[1].channelSourceSequence as {
codeValue?: string;
codingSchemeDesignator?: string;
};

const module = buildEcgModule(instance);
await expect(module!.waveformData.retrieveBulkData()).rejects.toThrow('403');
expect(source.codeValue).toBe('5.6.3-9-2');
expect(source.codingSchemeDesignator).toBe('SCPECG');
});
});
Loading
Loading