Skip to content

Commit d015314

Browse files
authored
test(dicomImageLoader): add jest infrastructure and unit tests for metadata, parsing, and pixel math (#2791)
1 parent eb6c237 commit d015314

8 files changed

Lines changed: 5214 additions & 1 deletion
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/* eslint-disable */
2+
const base = require('../../jest.config.base.js');
3+
const path = require('path');
4+
5+
module.exports = {
6+
...base,
7+
displayName: 'dicomImageLoader',
8+
testMatch: [...base.testMatch, '<rootDir>/src/**/*.spec.ts'],
9+
moduleNameMapper: {
10+
...base.moduleNameMapper,
11+
'^@cornerstonejs/(.*)$': path.resolve(__dirname, '../$1/src'),
12+
},
13+
};
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { Enums } from '@cornerstonejs/core';
3+
import extractMultipart, {
4+
findBoundary,
5+
findContentType,
6+
uint8ArrayToString,
7+
} from '../imageLoader/wadors/extractMultipart';
8+
9+
const { ImageQualityStatus } = Enums;
10+
11+
function strToBytes(str: string): Uint8Array {
12+
return new TextEncoder().encode(str);
13+
}
14+
15+
function concatBytes(...arrays: Uint8Array[]): Uint8Array {
16+
const total = arrays.reduce((sum, arr) => sum + arr.length, 0);
17+
const result = new Uint8Array(total);
18+
let offset = 0;
19+
for (const arr of arrays) {
20+
result.set(arr, offset);
21+
offset += arr.length;
22+
}
23+
return result;
24+
}
25+
26+
function toArrayBuffer(u8: Uint8Array): ArrayBuffer {
27+
// Ensure we get a plain ArrayBuffer that exactly matches the Uint8Array's
28+
// bytes (mirrors what fetch()/XHR would hand back).
29+
return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
30+
}
31+
32+
describe('extractMultipart', () => {
33+
describe('findBoundary', () => {
34+
it('returns the first header line starting with --', () => {
35+
const headers = [
36+
'Content-Type: multipart/related',
37+
'--theBoundary123',
38+
'Content-Type: application/octet-stream',
39+
];
40+
expect(findBoundary(headers)).toBe('--theBoundary123');
41+
});
42+
43+
it('returns undefined when no boundary line is present', () => {
44+
const headers = ['Content-Type: multipart/related', 'Content-Length: 42'];
45+
expect(findBoundary(headers)).toBeUndefined();
46+
});
47+
48+
it('returns undefined for an empty header array', () => {
49+
expect(findBoundary([])).toBeUndefined();
50+
});
51+
});
52+
53+
describe('findContentType', () => {
54+
it('finds a Content-Type header and trims the value', () => {
55+
const headers = [
56+
'--theBoundary123',
57+
'Content-Type: application/octet-stream ',
58+
];
59+
expect(findContentType(headers)).toBe('application/octet-stream');
60+
});
61+
62+
it('returns undefined when no Content-Type header is present', () => {
63+
const headers = ['--theBoundary123', 'Content-Length: 42'];
64+
expect(findContentType(headers)).toBeUndefined();
65+
});
66+
});
67+
68+
describe('uint8ArrayToString', () => {
69+
it('converts a full Uint8Array to a string', () => {
70+
const bytes = strToBytes('hello world');
71+
expect(uint8ArrayToString(bytes, 0, bytes.length)).toBe('hello world');
72+
});
73+
74+
it('honors offset and length', () => {
75+
const bytes = strToBytes('0123456789');
76+
expect(uint8ArrayToString(bytes, 3, 4)).toBe('3456');
77+
});
78+
79+
it('defaults offset to 0 and length to data.length - offset when omitted', () => {
80+
const bytes = strToBytes('abcdef');
81+
expect(uint8ArrayToString(bytes, undefined, undefined)).toBe('abcdef');
82+
expect(uint8ArrayToString(bytes, 2, undefined)).toBe('cdef');
83+
});
84+
85+
it('handles raw byte values via String.fromCharCode (not UTF-8 decoding)', () => {
86+
const bytes = new Uint8Array([0x41, 0x42, 0xff, 0x00]);
87+
const result = uint8ArrayToString(bytes, 0, bytes.length);
88+
expect(result).toBe(String.fromCharCode(0x41, 0x42, 0xff, 0x00));
89+
});
90+
});
91+
92+
describe('single-part (non-multipart) responses', () => {
93+
it('passes the full buffer through as pixelData with FULL_RESOLUTION when not partial', () => {
94+
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
95+
const buffer = toArrayBuffer(bytes);
96+
97+
const result = extractMultipart('image/jpeg', buffer);
98+
99+
expect(result.contentType).toBe('image/jpeg');
100+
expect(result.imageQualityStatus).toBe(
101+
ImageQualityStatus.FULL_RESOLUTION
102+
);
103+
expect(Array.from(result.pixelData as Uint8Array)).toEqual([
104+
1, 2, 3, 4, 5,
105+
]);
106+
});
107+
108+
it('reports SUBRESOLUTION when options.isPartial is set', () => {
109+
const bytes = new Uint8Array([9, 8, 7]);
110+
const buffer = toArrayBuffer(bytes);
111+
112+
const result = extractMultipart('image/jpeg', buffer, {
113+
isPartial: true,
114+
});
115+
116+
expect(result.imageQualityStatus).toBe(ImageQualityStatus.SUBRESOLUTION);
117+
expect(Array.from(result.pixelData as Uint8Array)).toEqual([9, 8, 7]);
118+
});
119+
120+
it('does not require options to be passed at all', () => {
121+
const bytes = new Uint8Array([42]);
122+
const buffer = toArrayBuffer(bytes);
123+
const result = extractMultipart('text/plain', buffer);
124+
expect(result.imageQualityStatus).toBe(
125+
ImageQualityStatus.FULL_RESOLUTION
126+
);
127+
});
128+
});
129+
130+
describe('full multipart parsing', () => {
131+
function buildMultipart(
132+
bodyBytes: Uint8Array,
133+
boundary = '--theBoundary123'
134+
) {
135+
const header = strToBytes(
136+
`${boundary}\r\nContent-Type: application/octet-stream\r\n\r\n`
137+
);
138+
const trailer = strToBytes(`\r\n${boundary}--`);
139+
const full = concatBytes(header, bodyBytes, trailer);
140+
return {
141+
full,
142+
headerLength: header.length,
143+
bodyLength: bodyBytes.length,
144+
};
145+
}
146+
147+
it('extracts exactly the body bytes between the mime header and the terminating boundary', () => {
148+
const bodyBytes = new Uint8Array([0x00, 0x01, 0x02, 0xff, 0xfe, 0x10]);
149+
const { full, headerLength, bodyLength } = buildMultipart(bodyBytes);
150+
const buffer = toArrayBuffer(full);
151+
152+
const result = extractMultipart('multipart/related', buffer);
153+
154+
expect(result.contentType).toBe('application/octet-stream');
155+
expect(result.multipartContentType).toBe('application/octet-stream');
156+
expect(result.boundary).toBe('--theBoundary123');
157+
expect(result.extractDone).toBe(true);
158+
expect(result.tokenIndex).toBe(headerLength - 4);
159+
160+
const extracted = new Uint8Array(result.pixelData as ArrayBuffer);
161+
expect(Array.from(extracted)).toEqual(Array.from(bodyBytes));
162+
expect(extracted.length).toBe(bodyLength);
163+
164+
// Exact byte-offset assertions against the original buffer.
165+
const original = new Uint8Array(buffer);
166+
const offset = result.tokenIndex + 4;
167+
expect(Array.from(original.slice(offset, offset + bodyLength))).toEqual(
168+
Array.from(bodyBytes)
169+
);
170+
});
171+
172+
it('parses responseHeaders by splitting the mime header on CRLF', () => {
173+
const bodyBytes = strToBytes('BODY');
174+
const { full } = buildMultipart(bodyBytes);
175+
const buffer = toArrayBuffer(full);
176+
177+
const result = extractMultipart('multipart/related', buffer);
178+
179+
expect(result.responseHeaders).toEqual([
180+
'--theBoundary123',
181+
'Content-Type: application/octet-stream',
182+
]);
183+
});
184+
185+
it('handles a boundary containing extra header lines before it', () => {
186+
const boundary = '--myBoundary';
187+
const header = strToBytes(
188+
`Content-Length: 1234\r\n${boundary}\r\nContent-Type: image/dicom+jls\r\n\r\n`
189+
);
190+
const bodyBytes = new Uint8Array([7, 7, 7]);
191+
const trailer = strToBytes(`\r\n${boundary}--`);
192+
const full = concatBytes(header, bodyBytes, trailer);
193+
const buffer = toArrayBuffer(full);
194+
195+
const result = extractMultipart('multipart/related', buffer);
196+
197+
expect(result.boundary).toBe(boundary);
198+
expect(result.multipartContentType).toBe('image/dicom+jls');
199+
expect(
200+
Array.from(new Uint8Array(result.pixelData as ArrayBuffer))
201+
).toEqual([7, 7, 7]);
202+
});
203+
});
204+
205+
describe('partial-content bookkeeping', () => {
206+
it('marks isPartial=true and extractDone=false when the terminating boundary has not arrived yet, and does not throw', () => {
207+
const boundary = '--theBoundary123';
208+
const header = strToBytes(
209+
`${boundary}\r\nContent-Type: application/octet-stream\r\n\r\n`
210+
);
211+
// No terminating boundary yet - simulates a chunk still streaming in.
212+
const partialBody = new Uint8Array([1, 2, 3, 4]);
213+
const full = concatBytes(header, partialBody);
214+
const buffer = toArrayBuffer(full);
215+
216+
const options: Record<string, unknown> = { isPartial: true };
217+
const result = extractMultipart('multipart/related', buffer, options);
218+
219+
expect(result.extractDone).toBe(false);
220+
expect(result.boundary).toBe(boundary);
221+
expect(result.tokenIndex).toBe(header.length - 4);
222+
// isPartial bookkeeping is written onto the options object, not the
223+
// returned result object.
224+
expect(options.isPartial).toBe(true);
225+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
226+
expect((result as any).isPartial).toBeUndefined();
227+
});
228+
229+
it('carries tokenIndex/boundary/responseHeaders forward across subsequent calls via options', () => {
230+
const boundary = '--theBoundary123';
231+
const header = strToBytes(
232+
`${boundary}\r\nContent-Type: application/octet-stream\r\n\r\n`
233+
);
234+
const partialBody = new Uint8Array([1, 2, 3, 4]);
235+
const firstBuffer = toArrayBuffer(concatBytes(header, partialBody));
236+
237+
const options: Record<string, unknown> = { isPartial: true };
238+
const firstResult = extractMultipart(
239+
'multipart/related',
240+
firstBuffer,
241+
options
242+
);
243+
expect(firstResult.extractDone).toBe(false);
244+
245+
// options object is mutated in place with bookkeeping fields.
246+
expect(options.tokenIndex).toBe(header.length - 4);
247+
expect(options.boundary).toBe(boundary);
248+
expect(options.multipartContentType).toBe('application/octet-stream');
249+
expect(options.isPartial).toBe(true);
250+
251+
// Second call: more bytes have arrived, including the terminating boundary.
252+
const moreBody = new Uint8Array([1, 2, 3, 4, 5, 6]);
253+
const trailer = strToBytes(`\r\n${boundary}--`);
254+
const secondBuffer = toArrayBuffer(
255+
concatBytes(header, moreBody, trailer)
256+
);
257+
258+
const secondResult = extractMultipart(
259+
'multipart/related',
260+
secondBuffer,
261+
options
262+
);
263+
264+
expect(secondResult.extractDone).toBe(true);
265+
expect(
266+
Array.from(new Uint8Array(secondResult.pixelData as ArrayBuffer))
267+
).toEqual(Array.from(moreBody));
268+
});
269+
});
270+
271+
describe('malformed inputs', () => {
272+
it('throws when there is no multipart mime header (no \\r\\n\\r\\n at all)', () => {
273+
const bytes = strToBytes('no header separator here at all');
274+
const buffer = toArrayBuffer(bytes);
275+
276+
expect(() => extractMultipart('multipart/related', buffer)).toThrow(
277+
'invalid response - no multipart mime header'
278+
);
279+
});
280+
281+
it('throws when the mime header has no boundary marker line', () => {
282+
const bytes = strToBytes(
283+
'Content-Type: application/octet-stream\r\n\r\nBODYDATA'
284+
);
285+
const buffer = toArrayBuffer(bytes);
286+
287+
expect(() => extractMultipart('multipart/related', buffer)).toThrow(
288+
'invalid response - no boundary marker'
289+
);
290+
});
291+
292+
it('throws when the terminating boundary is not found and the response is not partial', () => {
293+
const boundary = '--theBoundary123';
294+
const bytes = strToBytes(
295+
`${boundary}\r\nContent-Type: application/octet-stream\r\n\r\nBODYDATA-with-no-closing-boundary`
296+
);
297+
const buffer = toArrayBuffer(bytes);
298+
299+
expect(() => extractMultipart('multipart/related', buffer)).toThrow(
300+
'invalid response - terminating boundary not found'
301+
);
302+
});
303+
304+
it('does not throw for a missing terminating boundary when isPartial is set', () => {
305+
const boundary = '--theBoundary123';
306+
const bytes = strToBytes(
307+
`${boundary}\r\nContent-Type: application/octet-stream\r\n\r\nBODYDATA-with-no-closing-boundary`
308+
);
309+
const buffer = toArrayBuffer(bytes);
310+
311+
expect(() =>
312+
extractMultipart('multipart/related', buffer, { isPartial: true })
313+
).not.toThrow();
314+
});
315+
});
316+
});

0 commit comments

Comments
 (0)