@cornerstonejs/metadata 5.13.1 → 5.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/esm/displayset/BaseDisplaySet.d.ts +3 -4
  2. package/dist/esm/displayset/BaseDisplaySet.js +1 -5
  3. package/dist/esm/displayset/IDisplaySet.d.ts +9 -3
  4. package/dist/esm/displayset/ImageStackDisplaySet.d.ts +1 -3
  5. package/dist/esm/displayset/ImageStackDisplaySet.js +0 -1
  6. package/dist/esm/displayset/__fixtures__/scenarioInstances.d.ts +12 -0
  7. package/dist/esm/displayset/__fixtures__/scenarioInstances.js +210 -0
  8. package/dist/esm/displayset/compileSplitRules.d.ts +3 -0
  9. package/dist/esm/displayset/compileSplitRules.js +197 -0
  10. package/dist/esm/displayset/compiledDisplaySetRules.d.ts +3 -0
  11. package/dist/esm/displayset/compiledDisplaySetRules.js +3 -0
  12. package/dist/esm/displayset/createDisplaySetFromGroup.js +54 -19
  13. package/dist/esm/displayset/displaySetContext.d.ts +150 -0
  14. package/dist/esm/displayset/displaySetContext.js +918 -0
  15. package/dist/esm/displayset/displaySetRules.d.ts +5 -0
  16. package/dist/esm/displayset/displaySetRules.js +142 -0
  17. package/dist/esm/displayset/displaySetRules.test.d.ts +1 -0
  18. package/dist/esm/displayset/displaySetRules.test.js +1306 -0
  19. package/dist/esm/displayset/displaySetRulesTypes.d.ts +34 -0
  20. package/dist/esm/displayset/displaySetRulesTypes.js +0 -0
  21. package/dist/esm/displayset/displayset.test.js +422 -130
  22. package/dist/esm/displayset/groupInstancesBySplitRules.d.ts +8 -2
  23. package/dist/esm/displayset/groupInstancesBySplitRules.js +174 -17
  24. package/dist/esm/displayset/index.d.ts +14 -4
  25. package/dist/esm/displayset/index.js +10 -3
  26. package/dist/esm/displayset/isEcgInstance.d.ts +2 -1
  27. package/dist/esm/displayset/isEcgInstance.js +4 -9
  28. package/dist/esm/displayset/isImageInstance.d.ts +2 -1
  29. package/dist/esm/displayset/isImageInstance.js +4 -44
  30. package/dist/esm/displayset/isReconstructableInstances.d.ts +2 -0
  31. package/dist/esm/displayset/isReconstructableInstances.js +91 -0
  32. package/dist/esm/displayset/isVideoInstance.d.ts +2 -1
  33. package/dist/esm/displayset/isVideoInstance.js +4 -30
  34. package/dist/esm/displayset/isWsiInstance.d.ts +2 -1
  35. package/dist/esm/displayset/isWsiInstance.js +4 -6
  36. package/dist/esm/displayset/metadataContext.d.ts +13 -0
  37. package/dist/esm/displayset/metadataContext.js +42 -0
  38. package/dist/esm/displayset/metadataContext.test.d.ts +1 -0
  39. package/dist/esm/displayset/metadataContext.test.js +62 -0
  40. package/dist/esm/displayset/scenarioFixtures.test.d.ts +1 -0
  41. package/dist/esm/displayset/scenarioFixtures.test.js +206 -0
  42. package/dist/esm/displayset/seriesContext.test.d.ts +1 -0
  43. package/dist/esm/displayset/seriesContext.test.js +238 -0
  44. package/dist/esm/displayset/seriesFunctions.d.ts +29 -0
  45. package/dist/esm/displayset/seriesFunctions.js +327 -0
  46. package/dist/esm/displayset/sopClassUidLists.d.ts +14 -0
  47. package/dist/esm/displayset/sopClassUidLists.js +100 -0
  48. package/dist/esm/displayset/sortInstances.d.ts +10 -0
  49. package/dist/esm/displayset/sortInstances.js +33 -0
  50. package/dist/esm/displayset/sortInstances.test.d.ts +1 -0
  51. package/dist/esm/displayset/sortInstances.test.js +56 -0
  52. package/dist/esm/displayset/splitImageIdsBySplitRules.d.ts +3 -3
  53. package/dist/esm/displayset/splitImageIdsBySplitRules.js +5 -5
  54. package/dist/esm/displayset/splitRuleSchema.d.ts +11 -0
  55. package/dist/esm/displayset/splitRuleSchema.js +168 -0
  56. package/dist/esm/displayset/splitRuleSet.d.ts +12 -0
  57. package/dist/esm/displayset/splitRuleSet.js +47 -0
  58. package/dist/esm/displayset/splitRuleSet.test.d.ts +1 -0
  59. package/dist/esm/displayset/splitRuleSet.test.js +119 -0
  60. package/dist/esm/displayset/types.d.ts +37 -4
  61. package/dist/esm/displayset/types.js +15 -0
  62. package/dist/esm/index.d.ts +4 -2
  63. package/dist/esm/index.js +2 -1
  64. package/dist/esm/safeFunctions/compile.d.ts +31 -0
  65. package/dist/esm/safeFunctions/compile.js +142 -0
  66. package/dist/esm/safeFunctions/compile.test.d.ts +1 -0
  67. package/dist/esm/safeFunctions/compile.test.js +82 -0
  68. package/dist/esm/safeFunctions/expression/compiler.d.ts +23 -0
  69. package/dist/esm/safeFunctions/expression/compiler.js +481 -0
  70. package/dist/esm/safeFunctions/expression/expression.test.d.ts +1 -0
  71. package/dist/esm/safeFunctions/expression/expression.test.js +325 -0
  72. package/dist/esm/safeFunctions/expression/index.d.ts +4 -0
  73. package/dist/esm/safeFunctions/expression/index.js +3 -0
  74. package/dist/esm/safeFunctions/expression/parser.d.ts +56 -0
  75. package/dist/esm/safeFunctions/expression/parser.js +248 -0
  76. package/dist/esm/safeFunctions/expression/tokenizer.d.ts +29 -0
  77. package/dist/esm/safeFunctions/expression/tokenizer.js +198 -0
  78. package/dist/esm/safeFunctions/index.d.ts +5 -0
  79. package/dist/esm/safeFunctions/index.js +4 -0
  80. package/dist/esm/safeFunctions/matchTests.d.ts +16 -0
  81. package/dist/esm/safeFunctions/matchTests.js +203 -0
  82. package/dist/esm/safeFunctions/schema.d.ts +72 -0
  83. package/dist/esm/safeFunctions/schema.js +361 -0
  84. package/dist/esm/safeFunctions/types.d.ts +47 -0
  85. package/dist/esm/safeFunctions/types.js +0 -0
  86. package/dist/esm/safeFunctions/values.d.ts +6 -0
  87. package/dist/esm/safeFunctions/values.js +71 -0
  88. package/dist/esm/utilities/Tags.d.ts +1 -0
  89. package/dist/esm/utilities/Tags.js +14 -2
  90. package/dist/esm/utilities/index.d.ts +1 -1
  91. package/dist/esm/utilities/index.js +1 -1
  92. package/dist/esm/utilities/toNumber.d.ts +1 -1
  93. package/dist/esm/utilities/toNumber.js +1 -1
  94. package/dist/esm/version.d.ts +1 -1
  95. package/dist/esm/version.js +1 -1
  96. package/package.json +2 -2
  97. package/dist/esm/displayset/defaultDisplaySetSplitRules.d.ts +0 -2
  98. package/dist/esm/displayset/defaultDisplaySetSplitRules.js +0 -93
  99. package/dist/esm/displayset/viewportTypes.d.ts +0 -4
  100. package/dist/esm/displayset/viewportTypes.js +0 -13
@@ -0,0 +1,1306 @@
1
+ import { createDisplaySetFromGroup } from './createDisplaySetFromGroup.js';
2
+ import { compiledDisplaySetRules } from './compiledDisplaySetRules.js';
3
+ import { groupInstancesBySplitRules } from './groupInstancesBySplitRules.js';
4
+ import { createDisplaySetSplitRules, displaySetRules } from './displaySetRules.js';
5
+ import { RuleCompileError } from '../safeFunctions/index.js';
6
+ import { isVideoInstance } from './isVideoInstance.js';
7
+ import { resolveSplitRuleSet } from './splitRuleSet.js';
8
+ import { BaseDisplaySet } from './BaseDisplaySet.js';
9
+ import { ImageStackDisplaySet } from './ImageStackDisplaySet.js';
10
+ import { DISPLAY_SET_HANDLER_NAMES } from './types.js';
11
+ const CT_SOP_CLASS = '1.2.840.10008.5.1.4.1.1.2';
12
+ const MR_SOP_CLASS = '1.2.840.10008.5.1.4.1.1.4';
13
+ const US_MULTIFRAME_SOP_CLASS = '1.2.840.10008.5.1.4.1.1.3.1';
14
+ function instance(overrides = {}) {
15
+ return {
16
+ SOPClassUID: CT_SOP_CLASS,
17
+ SeriesInstanceUID: 'series-1',
18
+ SOPInstanceUID: `sop-${Math.random()}`,
19
+ Modality: 'CT',
20
+ Rows: 512,
21
+ Columns: 512,
22
+ ...overrides,
23
+ };
24
+ }
25
+ function ruleIdsFor(instances, rules = undefined) {
26
+ return groupInstancesBySplitRules(instances, rules ?? compiledDisplaySetRules).map((group) => group.matchedRule.id);
27
+ }
28
+ describe('displaySetRules - the rules as data', () => {
29
+ it('is pure JSON: survives a stringify/parse round trip', () => {
30
+ const roundTripped = JSON.parse(JSON.stringify(displaySetRules));
31
+ expect(roundTripped).toEqual(displaySetRules);
32
+ });
33
+ it('compiles the same rules after a JSON round trip', () => {
34
+ const instances = [
35
+ instance({ SOPInstanceUID: 'a', InstanceNumber: 1 }),
36
+ instance({ SOPInstanceUID: 'b', InstanceNumber: 2 }),
37
+ ];
38
+ const overTheWire = createDisplaySetSplitRules(JSON.parse(JSON.stringify(displaySetRules)));
39
+ const local = groupInstancesBySplitRules(instances, compiledDisplaySetRules);
40
+ const remote = groupInstancesBySplitRules(instances, overTheWire);
41
+ expect(remote.map((g) => g.matchedRule.id)).toEqual(local.map((g) => g.matchedRule.id));
42
+ expect(remote.map((g) => g.splitKey)).toEqual(local.map((g) => g.splitKey));
43
+ });
44
+ it('gives the defaults the priorities 1..n, and runs the catch-all last', () => {
45
+ const entries = Object.entries(displaySetRules);
46
+ expect(entries.map(([id, rule]) => [id, rule.priority])).toEqual([
47
+ ['video', 1],
48
+ ['ecg', 2],
49
+ ['wholeslide', 3],
50
+ ['singleImageModality', 4],
51
+ ['multiFrame', 5],
52
+ ['mixedDimensionalityBValue', 6],
53
+ ['volume3d', 7],
54
+ ['defaultImageRule', 8],
55
+ ['segmentation', 9],
56
+ ['rtStructureSet', 10],
57
+ ['parametricMap', 11],
58
+ ['structuredReport', 12],
59
+ ['encapsulatedDocument', 13],
60
+ ['bulkAnnotation', 14],
61
+ ['chart', 15],
62
+ ['unsupported', Number.MAX_SAFE_INTEGER],
63
+ ]);
64
+ expect(resolveSplitRuleSet(compiledDisplaySetRules).map((rule) => rule.id)).toEqual(entries.map(([id]) => id));
65
+ });
66
+ });
67
+ describe('createDisplaySetSplitRules - compiled default behaviour', () => {
68
+ it('routes a multi-slice CT series to volume3d', () => {
69
+ expect(ruleIdsFor([
70
+ instance({ SOPInstanceUID: 'a', InstanceNumber: 1 }),
71
+ instance({ SOPInstanceUID: 'b', InstanceNumber: 2 }),
72
+ ])).toEqual(['volume3d']);
73
+ });
74
+ it('routes a single CR image to singleImageModality', () => {
75
+ expect(ruleIdsFor([
76
+ instance({
77
+ Modality: 'CR',
78
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.1',
79
+ SOPInstanceUID: 'cr',
80
+ }),
81
+ ])).toEqual(['singleImageModality']);
82
+ });
83
+ it('gives each instance of a four-view MG series its own display set', () => {
84
+ const groups = groupInstancesBySplitRules(['L CC', 'L MLO', 'R CC', 'R MLO'].map((view, index) => instance({
85
+ Modality: 'MG',
86
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.1.2',
87
+ SOPInstanceUID: view,
88
+ InstanceNumber: index + 1,
89
+ Rows: index % 2 ? 2048 : 512,
90
+ Columns: index % 2 ? 2048 : 512,
91
+ })), compiledDisplaySetRules);
92
+ expect(groups.map((g) => [g.matchedRule.id, g.instances[0].SOPInstanceUID])).toEqual([
93
+ ['singleImageModality', 'L CC'],
94
+ ['singleImageModality', 'L MLO'],
95
+ ['singleImageModality', 'R CC'],
96
+ ['singleImageModality', 'R MLO'],
97
+ ]);
98
+ });
99
+ const MULTI_FRAME = [
100
+ ['a US clip', US_MULTIFRAME_SOP_CLASS, 'US', true],
101
+ ['an enhanced CT', '1.2.840.10008.5.1.4.1.1.2.1', 'CT', false],
102
+ ['an Enhanced US Volume', '1.2.840.10008.5.1.4.1.1.6.2', 'US', false],
103
+ [
104
+ 'a multi-frame secondary capture',
105
+ '1.2.840.10008.5.1.4.1.1.7.4',
106
+ 'OT',
107
+ false,
108
+ ],
109
+ ];
110
+ for (const [name, SOPClassUID, Modality, isClip] of MULTI_FRAME) {
111
+ it(`gives ${name} one display set for each instance`, () => {
112
+ const groups = groupInstancesBySplitRules(['a', 'b'].map((SOPInstanceUID, index) => instance({
113
+ SOPClassUID,
114
+ Modality,
115
+ SOPInstanceUID,
116
+ InstanceNumber: index + 1,
117
+ NumberOfFrames: 30,
118
+ })), compiledDisplaySetRules);
119
+ expect(groups.map((g) => g.matchedRule.id)).toEqual([
120
+ 'multiFrame',
121
+ 'multiFrame',
122
+ ]);
123
+ const displaySet = createDisplaySetFromGroup(groups[0]);
124
+ expect(displaySet.isClip).toBe(isClip);
125
+ expect(displaySet.numImageFrames).toBe(30);
126
+ });
127
+ }
128
+ it('splits a mixed-b-value DWI series in two', () => {
129
+ const instances = [
130
+ instance({
131
+ Modality: 'MR',
132
+ SOPClassUID: MR_SOP_CLASS,
133
+ SOPInstanceUID: 'b0',
134
+ InstanceNumber: 1,
135
+ DiffusionBValue: 0,
136
+ }),
137
+ instance({
138
+ Modality: 'MR',
139
+ SOPClassUID: MR_SOP_CLASS,
140
+ SOPInstanceUID: 'b1000',
141
+ InstanceNumber: 2,
142
+ DiffusionBValue: 1000,
143
+ }),
144
+ instance({
145
+ Modality: 'MR',
146
+ SOPClassUID: MR_SOP_CLASS,
147
+ SOPInstanceUID: 'adc',
148
+ InstanceNumber: 3,
149
+ }),
150
+ ];
151
+ const groups = groupInstancesBySplitRules(instances, compiledDisplaySetRules);
152
+ expect(groups.length).toBe(2);
153
+ expect(groups.every((g) => g.matchedRule.id === 'mixedDimensionalityBValue')).toBe(true);
154
+ });
155
+ it('treats a naturalized empty attribute as absent, not as zero', () => {
156
+ const instances = [
157
+ instance({
158
+ Modality: 'MR',
159
+ SOPClassUID: MR_SOP_CLASS,
160
+ SOPInstanceUID: 'b1000',
161
+ InstanceNumber: 1,
162
+ DiffusionBValue: 1000,
163
+ }),
164
+ instance({
165
+ Modality: 'MR',
166
+ SOPClassUID: MR_SOP_CLASS,
167
+ SOPInstanceUID: 'adc',
168
+ InstanceNumber: 2,
169
+ DiffusionBValue: null,
170
+ }),
171
+ ];
172
+ expect(groupInstancesBySplitRules(instances, compiledDisplaySetRules).length).toBe(2);
173
+ });
174
+ });
175
+ describe('default rules - image classifier coverage', () => {
176
+ const claimedBy = (SOPClassUID, Modality) => ruleIdsFor([
177
+ instance({ SOPClassUID, Modality, SOPInstanceUID: SOPClassUID }),
178
+ ]);
179
+ const shouldRender = [
180
+ ['Ultrasound Image Storage', '1.2.840.10008.5.1.4.1.1.6.1', 'US'],
181
+ ['Ultrasound Multi-frame', '1.2.840.10008.5.1.4.1.1.3.1', 'US'],
182
+ ['Nuclear Medicine Image', '1.2.840.10008.5.1.4.1.1.20', 'NM'],
183
+ ['RT Image Storage', '1.2.840.10008.5.1.4.1.1.481.1', 'RTIMAGE'],
184
+ ['Enhanced PET Image', '1.2.840.10008.5.1.4.1.1.130', 'PT'],
185
+ ['Digital Mammography', '1.2.840.10008.5.1.4.1.1.1.2', 'MG'],
186
+ ['Ophthalmic Tomography', '1.2.840.10008.5.1.4.1.1.77.1.5.4', 'OPT'],
187
+ ['Corneal Topography Map', '1.2.840.10008.5.1.4.1.1.82.1', 'OP'],
188
+ ['RT Dose', '1.2.840.10008.5.1.4.1.1.481.2', 'RTDOSE'],
189
+ ];
190
+ for (const [name, uid, modality] of shouldRender) {
191
+ it(`builds a renderable display set for ${name}`, () => {
192
+ expect(claimedBy(uid, modality)).not.toEqual(['unsupported']);
193
+ expect(claimedBy(uid, modality).length).toBe(1);
194
+ });
195
+ }
196
+ it('does not build an image display set for MR spectroscopy', () => {
197
+ expect(claimedBy('1.2.840.10008.5.1.4.1.1.4.2', 'MR')).toEqual([
198
+ 'unsupported',
199
+ ]);
200
+ });
201
+ });
202
+ describe('createDisplaySetFromGroup - the handler decides the class', () => {
203
+ const CASES = [
204
+ ['no handler', {}, ImageStackDisplaySet, true],
205
+ ['the image handler', { handler: 'image' }, ImageStackDisplaySet, true],
206
+ [
207
+ 'the unsupported handler',
208
+ { handler: 'unsupported' },
209
+ BaseDisplaySet,
210
+ false,
211
+ ],
212
+ ['the video handler', { handler: 'video' }, BaseDisplaySet, true],
213
+ ];
214
+ for (const [name, fields, displaySetClass, isDisplayable] of CASES) {
215
+ it(`builds the display set of ${name}`, () => {
216
+ const displaySet = createDisplaySetFromGroup({
217
+ instances: [instance({ imageId: 'wadors:/x' })],
218
+ matchedRule: { id: 'r', ...fields },
219
+ });
220
+ expect(displaySet.constructor).toBe(displaySetClass);
221
+ expect(displaySet.isDisplayable).toBe(isDisplayable);
222
+ });
223
+ }
224
+ it('copies the viewportTypes of a rule unchanged, and decides the class from handler only', () => {
225
+ const { r: rule } = compileOne({
226
+ handler: 'unsupported',
227
+ viewportTypes: ['stack'],
228
+ });
229
+ const displaySet = createDisplaySetFromGroup({
230
+ instances: [instance({ imageId: 'wadors:/x' })],
231
+ matchedRule: { id: 'r', ...rule },
232
+ });
233
+ expect([
234
+ displaySet.viewportTypes,
235
+ displaySet.constructor,
236
+ displaySet.isDisplayable,
237
+ ]).toEqual([['stack'], BaseDisplaySet, false]);
238
+ expect(Object.values(displaySetRules).filter((r) => 'viewportTypes' in r)).toEqual([]);
239
+ });
240
+ });
241
+ describe('createDisplaySetFromGroup - isReconstructable, as OHIF isDisplaySetReconstructable', () => {
242
+ const AXIAL = [1, 0, 0, 0, 1, 0];
243
+ const SAGITTAL = [0, 1, 0, 0, 0, 1];
244
+ const slice = (id, z, orientation = AXIAL) => instance({
245
+ SOPInstanceUID: id,
246
+ InstanceNumber: Math.round(z * 10) + 1,
247
+ imageId: `wadors:/${id}`,
248
+ SamplesPerPixel: 1,
249
+ ImageOrientationPatient: orientation,
250
+ ImagePositionPatient: [0, 0, z],
251
+ });
252
+ const slices = (...zs) => zs.map((z, i) => slice(`s${i}`, z));
253
+ const multiFrame = (positions) => instance({
254
+ SOPInstanceUID: 'mf',
255
+ Modality: 'MR',
256
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.4.1',
257
+ NumberOfFrames: positions.length,
258
+ PixelSpacing: [1, 1],
259
+ SliceThickness: 5,
260
+ ImageOrientationPatient: AXIAL,
261
+ ImagePositionPatient: positions[0],
262
+ PerFrameFunctionalGroupsSequence: positions.map((position) => ({
263
+ PlanePositionSequence: [{ ImagePositionPatient: position }],
264
+ })),
265
+ });
266
+ const CASES = [
267
+ ['a regular multi-slice series', {}, slices(0, 2.5, 5, 7.5), true],
268
+ ['a single image', {}, slices(0), false],
269
+ ['single frames with through-plane extent', {}, slices(0, 2, 4), true],
270
+ ['single frames all at one location', {}, slices(0, 0, 0), false],
271
+ ['two single frames at one location', {}, slices(0, 0), false],
272
+ [
273
+ 'a multi-frame instance over several locations',
274
+ {},
275
+ [
276
+ multiFrame([
277
+ [0, 0, 0],
278
+ [0, 0, 2],
279
+ [0, 0, 4],
280
+ ]),
281
+ ],
282
+ true,
283
+ ],
284
+ [
285
+ 'a multi-frame cine at one location',
286
+ {},
287
+ [
288
+ multiFrame([
289
+ [0, 0, 0],
290
+ [0, 0, 0],
291
+ [0, 0, 0],
292
+ ]),
293
+ ],
294
+ false,
295
+ ],
296
+ [
297
+ 'a mixed orientation',
298
+ {},
299
+ [slice('a', 0), slice('b', 2), slice('c', 4, SAGITTAL)],
300
+ false,
301
+ ],
302
+ ['a missing slice on a regular lattice', {}, slices(0, 2, 4, 8), true],
303
+ ['an irregular spacing', {}, slices(0, 2, 4, 5), false],
304
+ [
305
+ 'a non-image handler',
306
+ { handler: 'segmentation' },
307
+ slices(0, 2, 4),
308
+ undefined,
309
+ ],
310
+ ];
311
+ for (const [name, fields, instances, isReconstructable] of CASES) {
312
+ it(`gives ${name} isReconstructable ${isReconstructable}`, () => {
313
+ const displaySet = createDisplaySetFromGroup({
314
+ instances,
315
+ matchedRule: { id: 'r', ...fields },
316
+ });
317
+ expect(displaySet.isReconstructable).toBe(isReconstructable);
318
+ });
319
+ }
320
+ });
321
+ describe('default rules - the non-image objects', () => {
322
+ const OBJECTS = [
323
+ [
324
+ 'Segmentation',
325
+ '1.2.840.10008.5.1.4.1.1.66.4',
326
+ 'SEG',
327
+ 'segmentation',
328
+ 'segmentation',
329
+ ],
330
+ [
331
+ 'Labelmap Segmentation',
332
+ '1.2.840.10008.5.1.4.1.1.66.7',
333
+ 'SEG',
334
+ 'segmentation',
335
+ 'segmentation',
336
+ ],
337
+ [
338
+ 'RT Structure Set',
339
+ '1.2.840.10008.5.1.4.1.1.481.3',
340
+ 'RTSTRUCT',
341
+ 'rtStructureSet',
342
+ 'rtStructureSet',
343
+ ],
344
+ [
345
+ 'Parametric Map',
346
+ '1.2.840.10008.5.1.4.1.1.30',
347
+ 'OT',
348
+ 'parametricMap',
349
+ 'parametricMap',
350
+ ],
351
+ [
352
+ 'Comprehensive SR',
353
+ '1.2.840.10008.5.1.4.1.1.88.33',
354
+ 'SR',
355
+ 'structuredReport',
356
+ 'structuredReport',
357
+ ],
358
+ [
359
+ 'Comprehensive 3D SR',
360
+ '1.2.840.10008.5.1.4.1.1.88.34',
361
+ 'SR',
362
+ 'structuredReport',
363
+ 'structuredReport',
364
+ ],
365
+ [
366
+ 'Microscopy Bulk Annotations',
367
+ '1.2.840.10008.5.1.4.1.1.91.1',
368
+ 'ANN',
369
+ 'bulkAnnotation',
370
+ 'bulkAnnotation',
371
+ ],
372
+ [
373
+ 'Whole slide',
374
+ '1.2.840.10008.5.1.4.1.1.77.1.6',
375
+ 'SM',
376
+ 'wholeslide',
377
+ 'wholeSlide',
378
+ ],
379
+ [
380
+ 'RT Plan',
381
+ '1.2.840.10008.5.1.4.1.1.481.5',
382
+ 'RTPLAN',
383
+ 'unsupported',
384
+ 'unsupported',
385
+ ],
386
+ [
387
+ 'Grayscale Presentation State',
388
+ '1.2.840.10008.5.1.4.1.1.11.1',
389
+ 'PR',
390
+ 'unsupported',
391
+ 'unsupported',
392
+ ],
393
+ ];
394
+ for (const [name, SOPClassUID, Modality, ruleId, handler] of OBJECTS) {
395
+ it(`gives ${name} one display set for each series, with its handler name`, () => {
396
+ const groups = groupInstancesBySplitRules(['a', 'b'].map((id, index) => instance({
397
+ SOPClassUID,
398
+ Modality,
399
+ SOPInstanceUID: id,
400
+ InstanceNumber: index + 1,
401
+ imageId: `wadors:/${id}`,
402
+ })), compiledDisplaySetRules);
403
+ expect(groups.map((g) => g.matchedRule.id)).toEqual([ruleId]);
404
+ const displaySet = createDisplaySetFromGroup(groups[0]);
405
+ expect(displaySet.handler).toBe(handler);
406
+ expect(DISPLAY_SET_HANDLER_NAMES).toContain(handler);
407
+ });
408
+ }
409
+ it('copies Manufacturer and ManufacturerModelName into the SR display set', () => {
410
+ const [group] = groupInstancesBySplitRules([
411
+ instance({
412
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.88.34',
413
+ Modality: 'SR',
414
+ Manufacturer: 'CornerstoneJS',
415
+ ManufacturerModelName: '@cornerstonejs/adapters',
416
+ }),
417
+ ], compiledDisplaySetRules);
418
+ const { Manufacturer, ManufacturerModelName } = createDisplaySetFromGroup(group);
419
+ expect([Manufacturer, ManufacturerModelName]).toEqual([
420
+ 'CornerstoneJS',
421
+ '@cornerstonejs/adapters',
422
+ ]);
423
+ });
424
+ it('builds no image stack for a non-image object', () => {
425
+ const [group] = groupInstancesBySplitRules([
426
+ instance({
427
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.66.4',
428
+ Modality: 'SEG',
429
+ NumberOfFrames: 3,
430
+ imageId: 'wadors:/seg',
431
+ }),
432
+ ], compiledDisplaySetRules);
433
+ const displaySet = createDisplaySetFromGroup(group);
434
+ expect(displaySet.isDisplayable).toBe(true);
435
+ expect(displaySet.underlyingImageIds).toEqual(['wadors:/seg']);
436
+ expect(displaySet.imageIds).toEqual(['wadors:/seg']);
437
+ });
438
+ it('gives an image display set no handler', () => {
439
+ const [group] = groupInstancesBySplitRules([instance()], compiledDisplaySetRules);
440
+ expect(createDisplaySetFromGroup(group).handler).toBeUndefined();
441
+ });
442
+ });
443
+ describe('default rules - revision chains of documents', () => {
444
+ const PDF = '1.2.840.10008.5.1.4.1.1.104.1';
445
+ const pdf = (id, InstanceNumber, predecessors = [], SeriesInstanceUID = 'series-1') => instance({
446
+ SOPClassUID: PDF,
447
+ Modality: 'DOC',
448
+ SOPInstanceUID: id,
449
+ SeriesInstanceUID,
450
+ InstanceNumber,
451
+ PredecessorDocumentsSequence: predecessors.length
452
+ ? [
453
+ {
454
+ ReferencedSeriesSequence: [
455
+ {
456
+ SeriesInstanceUID,
457
+ ReferencedSOPSequence: predecessors.map((uid) => ({
458
+ ReferencedSOPInstanceUID: uid,
459
+ })),
460
+ },
461
+ ],
462
+ },
463
+ ]
464
+ : undefined,
465
+ });
466
+ const CASES = [
467
+ [
468
+ 'two PDFs without predecessors',
469
+ [pdf('a', 1), pdf('b', 2)],
470
+ [
471
+ [['a'], 'a'],
472
+ [['b'], 'b'],
473
+ ],
474
+ ],
475
+ [
476
+ 'a chain A <- B <- C',
477
+ [pdf('c', 3, ['b']), pdf('a', 1), pdf('b', 2, ['a'])],
478
+ [[['a', 'b', 'c'], 'c']],
479
+ ],
480
+ [
481
+ 'a predecessor in another series',
482
+ [pdf('b', 2, ['a']), pdf('a', 1, [], 'series-2')],
483
+ [
484
+ [['a'], 'a'],
485
+ [['b'], 'b'],
486
+ ],
487
+ ],
488
+ [
489
+ 'a branch, input [A, B, C]: B is first in the default order and takes A',
490
+ [pdf('a', 1), pdf('b', 2, ['a']), pdf('c', 3, ['a'])],
491
+ [
492
+ [['a', 'b'], 'b'],
493
+ [['c'], 'c'],
494
+ ],
495
+ ],
496
+ [
497
+ 'the same branch, input [C, A, B]: the input order does not decide',
498
+ [pdf('c', 3, ['a']), pdf('a', 1), pdf('b', 2, ['a'])],
499
+ [
500
+ [['a', 'b'], 'b'],
501
+ [['c'], 'c'],
502
+ ],
503
+ ],
504
+ ];
505
+ for (const [name, instances, expected] of CASES) {
506
+ it(`gives ${name}: one display set for each chain, the active instance last`, () => {
507
+ const displaySets = groupInstancesBySplitRules(instances, compiledDisplaySetRules).map((group) => {
508
+ expect(group.matchedRule.id).toBe('encapsulatedDocument');
509
+ const displaySet = createDisplaySetFromGroup(group);
510
+ return [
511
+ group.instances.map((i) => i.SOPInstanceUID),
512
+ displaySet.activeSopInstanceUid,
513
+ ];
514
+ });
515
+ displaySets.sort((x, y) => (x[1] < y[1] ? -1 : 1));
516
+ expect(displaySets).toEqual(expected);
517
+ });
518
+ }
519
+ });
520
+ describe('default rules - video', () => {
521
+ const MPEG4 = '1.2.840.10008.1.2.4.102';
522
+ const EXPLICIT_LE = '1.2.840.10008.1.2.1';
523
+ const SC = '1.2.840.10008.5.1.4.1.1.7';
524
+ const VIDEO_PHOTOGRAPHIC = '1.2.840.10008.5.1.4.1.1.77.1.4.1';
525
+ const VIDEO = [
526
+ [
527
+ 'a video-only SOP class with an image syntax',
528
+ { SOPClassUID: VIDEO_PHOTOGRAPHIC, TransferSyntaxUID: EXPLICIT_LE },
529
+ true,
530
+ ],
531
+ [
532
+ 'a video transfer syntax',
533
+ { SOPClassUID: CT_SOP_CLASS, TransferSyntaxUID: MPEG4 },
534
+ true,
535
+ ],
536
+ [
537
+ 'one video syntax among the available ones',
538
+ {
539
+ SOPClassUID: SC,
540
+ AvailableTransferSyntaxUID: [EXPLICIT_LE, MPEG4],
541
+ },
542
+ true,
543
+ ],
544
+ [
545
+ 'a long secondary capture with no transfer syntax',
546
+ { SOPClassUID: SC, NumberOfFrames: '90' },
547
+ true,
548
+ ],
549
+ [
550
+ 'a long secondary capture with an image syntax',
551
+ { SOPClassUID: SC, NumberOfFrames: 90, TransferSyntaxUID: EXPLICIT_LE },
552
+ false,
553
+ ],
554
+ [
555
+ 'a short secondary capture',
556
+ { SOPClassUID: SC, NumberOfFrames: 89 },
557
+ false,
558
+ ],
559
+ ];
560
+ for (const [name, overrides, expected] of VIDEO) {
561
+ it(`classifies ${name} the same in code and in the rules`, () => {
562
+ const subject = instance(overrides);
563
+ expect(isVideoInstance(subject)).toBe(expected);
564
+ expect(ruleIdsFor([subject])[0] === 'video').toBe(expected);
565
+ });
566
+ }
567
+ });
568
+ describe('default rules - the unsupported catch-all', () => {
569
+ const NON_IMAGE_SOP_CLASSES = [
570
+ ['RT Plan', '1.2.840.10008.5.1.4.1.1.481.5', 'RTPLAN'],
571
+ ['Grayscale Presentation State', '1.2.840.10008.5.1.4.1.1.11.1', 'PR'],
572
+ ['Raw Data Storage', '1.2.840.10008.5.1.4.1.1.66', 'OT'],
573
+ ];
574
+ const displaySetFor = (SOPClassUID, Modality) => {
575
+ const groups = groupInstancesBySplitRules([
576
+ instance({
577
+ SOPClassUID,
578
+ Modality,
579
+ SOPInstanceUID: 'obj-1',
580
+ imageId: 'wadors:/obj-1',
581
+ Rows: undefined,
582
+ Columns: undefined,
583
+ }),
584
+ ], compiledDisplaySetRules);
585
+ return createDisplaySetFromGroup(groups[0]);
586
+ };
587
+ for (const [name, uid, modality] of NON_IMAGE_SOP_CLASSES) {
588
+ it(`claims ${name} instead of dropping it`, () => {
589
+ expect(ruleIdsFor([instance({ SOPClassUID: uid, Modality: modality })])).toEqual(['unsupported']);
590
+ });
591
+ }
592
+ it('marks the display set as not displayable', () => {
593
+ const displaySet = displaySetFor('1.2.840.10008.5.1.4.1.1.11.1', 'PR');
594
+ expect(displaySet.isDisplayable).toBe(false);
595
+ expect(displaySet.handler).toBe('unsupported');
596
+ });
597
+ it('records what it could not render, so a consumer can say which kind', () => {
598
+ const displaySet = displaySetFor('1.2.840.10008.5.1.4.1.1.481.5', 'RTPLAN');
599
+ expect(displaySet.sopClassUids).toEqual(['1.2.840.10008.5.1.4.1.1.481.5']);
600
+ expect(displaySet.instances[0].Modality).toBe('RTPLAN');
601
+ });
602
+ it('advertises no renderable imageIds but stays resolvable by imageId', () => {
603
+ const displaySet = displaySetFor('1.2.840.10008.5.1.4.1.1.481.5', 'RTPLAN');
604
+ expect(displaySet.imageIds).toEqual([]);
605
+ expect(displaySet.underlyingImageIds).toEqual(['wadors:/obj-1']);
606
+ });
607
+ it('produces one display set for each series, with the SOP classes it holds', () => {
608
+ const groups = groupInstancesBySplitRules([
609
+ instance({
610
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.481.5',
611
+ Modality: 'RTPLAN',
612
+ SOPInstanceUID: 'plan-1',
613
+ InstanceNumber: 1,
614
+ Rows: undefined,
615
+ }),
616
+ instance({
617
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.11.1',
618
+ Modality: 'PR',
619
+ SOPInstanceUID: 'pr-1',
620
+ InstanceNumber: 2,
621
+ Rows: undefined,
622
+ }),
623
+ ], compiledDisplaySetRules);
624
+ expect(groups.map((group) => group.matchedRule.id)).toEqual([
625
+ 'unsupported',
626
+ ]);
627
+ expect(createDisplaySetFromGroup(groups[0]).sopClassUids).toEqual([
628
+ '1.2.840.10008.5.1.4.1.1.481.5',
629
+ '1.2.840.10008.5.1.4.1.1.11.1',
630
+ ]);
631
+ });
632
+ it('lets a fallback rule above the priority limit run before the catch-all', () => {
633
+ const rules = createDisplaySetSplitRules({
634
+ ...displaySetRules,
635
+ prFallback: { priority: 20000, matches: { Modality: 'PR' } },
636
+ });
637
+ const pr = instance({
638
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.11.1',
639
+ Modality: 'PR',
640
+ Rows: undefined,
641
+ });
642
+ expect(ruleIdsFor([pr], rules)).toEqual(['prFallback']);
643
+ });
644
+ it('gives an image SOP class whose Rows have not loaded, or are null, to defaultImageRule', () => {
645
+ expect(ruleIdsFor([instance({ SOPInstanceUID: 'ct-no-rows', Rows: undefined })])).toEqual(['defaultImageRule']);
646
+ expect(ruleIdsFor([
647
+ instance({
648
+ SOPInstanceUID: 'null-rows',
649
+ Rows: null,
650
+ }),
651
+ ])).toEqual(['defaultImageRule']);
652
+ });
653
+ it('yields to an application rule with a lower priority', () => {
654
+ const rules = createDisplaySetSplitRules({
655
+ ...displaySetRules,
656
+ seg: {
657
+ priority: -1,
658
+ matches: { Modality: 'SEG' },
659
+ },
660
+ });
661
+ const groups = groupInstancesBySplitRules([
662
+ instance({
663
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.66.4',
664
+ Modality: 'SEG',
665
+ SOPInstanceUID: 'seg-1',
666
+ }),
667
+ ], rules);
668
+ expect(groups[0].matchedRule.id).toBe('seg');
669
+ expect(createDisplaySetFromGroup(groups[0]).isDisplayable).toBe(true);
670
+ });
671
+ it('has the highest priority, so it never shadows a real one', () => {
672
+ const priorities = Object.values(displaySetRules).map((rule) => rule.priority);
673
+ expect(displaySetRules.unsupported.priority).toBe(Math.max(...priorities));
674
+ const catchAlls = Object.entries(displaySetRules).filter(([, rule]) => !rule.matches);
675
+ expect(catchAlls.map(([id]) => id)).toEqual(['unsupported']);
676
+ });
677
+ it('lets a fallback rule run between the last object rule and unsupported', () => {
678
+ const rules = createDisplaySetSplitRules({
679
+ ...displaySetRules,
680
+ prFallback: {
681
+ priority: 16,
682
+ matches: { Modality: 'PR' },
683
+ },
684
+ });
685
+ expect(ruleIdsFor([
686
+ instance({
687
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.11.1',
688
+ Modality: 'PR',
689
+ }),
690
+ instance({
691
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.481.5',
692
+ Modality: 'RTPLAN',
693
+ }),
694
+ ], rules)).toEqual(['prFallback', 'unsupported']);
695
+ });
696
+ it('can be excluded with a null priority', () => {
697
+ const rules = createDisplaySetSplitRules({
698
+ ...displaySetRules,
699
+ unsupported: { ...displaySetRules.unsupported, priority: null },
700
+ });
701
+ const unmatched = [];
702
+ const groups = groupInstancesBySplitRules([
703
+ instance({
704
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.11.1',
705
+ Modality: 'PR',
706
+ }),
707
+ ], rules, (i) => unmatched.push(i));
708
+ expect(groups).toEqual([]);
709
+ expect(unmatched.length).toBe(1);
710
+ });
711
+ });
712
+ const compileOne = (rule, options = {}) => createDisplaySetSplitRules({ r: { priority: 1, ...rule } }, options);
713
+ const errorOf = (rule, options = {}) => {
714
+ try {
715
+ compileOne(rule, options);
716
+ }
717
+ catch (error) {
718
+ expect(error).toBeInstanceOf(RuleCompileError);
719
+ return error;
720
+ }
721
+ throw new Error('expected the rule to fail to compile');
722
+ };
723
+ const expectRejected = (rule, path, name, options = {}) => {
724
+ const error = errorOf(rule, options);
725
+ expect(error.path).toBe(path);
726
+ expect(error.message).toContain(name);
727
+ };
728
+ const claimed = (rule, instances, options = {}) => groupInstancesBySplitRules(instances, compileOne(rule, options)).flatMap((group) => group.instances.map((i) => i.SOPInstanceUID));
729
+ describe('matches - absent and empty values (DICOM meaning)', () => {
730
+ const subjects = [
731
+ ['absent', undefined],
732
+ ['null', null],
733
+ ['text', ''],
734
+ ['array', []],
735
+ ['zero', 0],
736
+ ].map(([id, BodyPartThickness], index) => instance({
737
+ SOPInstanceUID: id,
738
+ InstanceNumber: index + 1,
739
+ BodyPartThickness,
740
+ }));
741
+ const holds = (matches, options = {}) => claimed({ matches }, subjects, options);
742
+ const NO_VALUE = ['absent', 'null', 'text', 'array'];
743
+ it('reads "X": null as "no value", and { notNull: true } as its exact opposite', () => {
744
+ expect(holds({ BodyPartThickness: null })).toEqual(NO_VALUE);
745
+ expect(holds({ BodyPartThickness: { notNull: true } })).toEqual(['zero']);
746
+ });
747
+ it('reads X == null as "no value", and X == undefined as "absent" only', () => {
748
+ const options = {
749
+ context: {
750
+ hasNoThickness: 'BodyPartThickness == null',
751
+ isAbsent: 'BodyPartThickness == undefined',
752
+ },
753
+ };
754
+ expect(holds({ hasNoThickness: true }, options)).toEqual(NO_VALUE);
755
+ expect(holds({ isAbsent: true }, options)).toEqual(['absent']);
756
+ });
757
+ it('gives false for an ordering comparison on no value, and never reads it as 0', () => {
758
+ const options = { context: { isThin: 'BodyPartThickness < 1' } };
759
+ expect(holds({ isThin: true }, options)).toEqual(['zero']);
760
+ expect(holds({ BodyPartThickness: { lessThan: 1 } })).toEqual(['zero']);
761
+ expect(holds({ BodyPartThickness: { range: [-1, 1] } })).toEqual(['zero']);
762
+ });
763
+ it('fails a test on no value, and holds its doesNot form', () => {
764
+ expect(holds({ BodyPartThickness: { contains: '0' } })).toEqual(['zero']);
765
+ expect(holds({ BodyPartThickness: { doesNotContain: '0' } })).toEqual(NO_VALUE);
766
+ expect(holds({ BodyPartThickness: { doesNotEqual: 0 } })).toEqual(NO_VALUE);
767
+ });
768
+ });
769
+ describe('matches - the operator meanings', () => {
770
+ const holds = (value, test) => claimed({ matches: { ImageType: test } }, [
771
+ instance({ SOPInstanceUID: 'x', ImageType: value }),
772
+ ]).length === 1;
773
+ it('reads greaterThan and lessThan as strict, and a numeric string as a number', () => {
774
+ expect(holds(2, { greaterThan: 1 })).toBe(true);
775
+ expect(holds(1, { greaterThan: 1 })).toBe(false);
776
+ expect(holds('2', { greaterThan: 1 })).toBe(true);
777
+ expect(holds(1, { lessThan: 1 })).toBe(false);
778
+ });
779
+ const INCLUSIVE = [
780
+ [1, { greaterThanOrEqual: 1 }, true],
781
+ [0, { greaterThanOrEqual: 1 }, false],
782
+ ['2', { greaterThanOrEqual: 2 }, true],
783
+ [[3, 1], { greaterThanOrEqual: 3 }, true],
784
+ [1, { lessThanOrEqual: 1 }, true],
785
+ [2, { lessThanOrEqual: 1 }, false],
786
+ ['x', { lessThanOrEqual: 1 }, false],
787
+ [undefined, { greaterThanOrEqual: 0 }, false],
788
+ ];
789
+ it('reads greaterThanOrEqual and lessThanOrEqual with the limit included', () => {
790
+ for (const [value, test, expected] of INCLUSIVE) {
791
+ expect([value, test, holds(value, test)]).toEqual([
792
+ value,
793
+ test,
794
+ expected,
795
+ ]);
796
+ }
797
+ });
798
+ it('reads a number shorthand and equals with ==', () => {
799
+ expect(holds('1', 1)).toBe(true);
800
+ expect(holds('1', { equals: 1 })).toBe(true);
801
+ expect(holds(['MR'], { equals: 'MR' })).toBe(true);
802
+ expect(holds('CT', ['MR', 'CT'])).toBe(true);
803
+ });
804
+ const WHOLE_VALUE = [
805
+ [['R', 'L'], { equals: ['R', 'L'] }, true],
806
+ [['L', 'R'], { equals: ['R', 'L'] }, false],
807
+ [['R', 'L', 'F'], { equals: ['R', 'L'] }, false],
808
+ ['R', { equals: ['R', 'L'] }, false],
809
+ [['R', 'L'], { doesNotEqual: ['R', 'L'] }, false],
810
+ [true, { equals: 'true' }, true],
811
+ [true, { equals: 1 }, false],
812
+ [true, { equals: '1' }, false],
813
+ ];
814
+ it('compares the whole value in equals, also with an array operand, and a boolean only with its text', () => {
815
+ for (const [value, test, expected] of WHOLE_VALUE) {
816
+ expect([value, test, holds(value, test)]).toEqual([
817
+ value,
818
+ test,
819
+ expected,
820
+ ]);
821
+ }
822
+ });
823
+ it('reads includes with a scalar operand as "has the element"', () => {
824
+ expect(holds(['ORIGINAL', 'LOCALIZER'], { includes: 'LOCALIZER' })).toBe(true);
825
+ expect(holds('LOCALIZER', { includes: 'LOCALIZER' })).toBe(true);
826
+ expect(holds(['ORIGINAL'], { includes: ['AXIAL', 'ORIGINAL'] })).toBe(true);
827
+ expect(holds(['ORIGINAL'], { doesNotInclude: 'LOCALIZER' })).toBe(true);
828
+ });
829
+ const SHORTHAND = [
830
+ ['LOCALIZER', 'LOCALIZER', true],
831
+ [['ORIGINAL', 'LOCALIZER'], 'LOCALIZER', true],
832
+ [['ORIGINAL', 'PRIMARY'], 'LOCALIZER', false],
833
+ ['ORIGINAL', ['ORIGINAL', 'PRIMARY'], true],
834
+ [['ORIGINAL', 'SECONDARY'], ['ORIGINAL', 'PRIMARY'], true],
835
+ [['DERIVED', 'SECONDARY'], ['ORIGINAL', 'PRIMARY'], false],
836
+ [['ORIGINAL', 'LOCALIZER'], { equals: ['LOCALIZER'] }, false],
837
+ [['ORIGINAL', 'LOCALIZER'], { equals: ['ORIGINAL', 'LOCALIZER'] }, true],
838
+ [['ORIGINAL', 'LOCALIZER'], { equals: 'LOCALIZER' }, false],
839
+ [true, true, true],
840
+ [false, true, false],
841
+ ];
842
+ it('reads a scalar and an array shorthand as includes, and equals as the whole value', () => {
843
+ for (const [value, test, expected] of SHORTHAND) {
844
+ expect([value, test, holds(value, test)]).toEqual([
845
+ value,
846
+ test,
847
+ expected,
848
+ ]);
849
+ }
850
+ });
851
+ it('fails notNull for an empty string', () => {
852
+ expect(holds('', { notNull: true })).toBe(false);
853
+ });
854
+ it('reads false on a named boolean as its negation', () => {
855
+ expect(claimed({ matches: { isMultiFrame: false } }, [
856
+ instance({ SOPInstanceUID: 'single', InstanceNumber: 1 }),
857
+ instance({
858
+ SOPInstanceUID: 'clip',
859
+ InstanceNumber: 2,
860
+ NumberOfFrames: 4,
861
+ }),
862
+ ])).toEqual(['single']);
863
+ });
864
+ it('rejects an operand of the wrong kind, a second operator and an unknown operator', () => {
865
+ const at = "rule 'r'.matches.Rows";
866
+ expectRejected({ matches: { Rows: { greaterThan: '1' } } }, at, 'greaterThan');
867
+ expectRejected({ matches: { Rows: { greaterThan: 1, lessThan: 9 } } }, at, 'lessThan');
868
+ expectRejected({ matches: { Rows: { greater: 1 } } }, at, "'greater'");
869
+ });
870
+ it('rejects the removed forms: all, not, a comparison key, an expression and null', () => {
871
+ for (const name of ['all', 'not', 'NumberOfFrames >']) {
872
+ expectRejected({ matches: { [name]: { Modality: 'CT' } } }, `rule 'r'.matches.${name}`, `'${name}'`);
873
+ }
874
+ expectRejected({ matches: "Modality == 'CT'" }, "rule 'r'.matches", "Modality == 'CT'");
875
+ expectRejected({ matches: null }, "rule 'r'.matches", 'null');
876
+ });
877
+ });
878
+ describe('names - the compile checks every name', () => {
879
+ it('rejects a misspelt keyword in every place, with the rule, the place and the name', () => {
880
+ expectRejected({ matches: { BodyPartExamind: 'CHEST' } }, "rule 'r'.matches.BodyPartExamind", "'BodyPartExamind'");
881
+ expectRejected({ groupBy: ['SeriesInstanceUId'] }, "rule 'r'.groupBy[0]", "'SeriesInstanceUId'");
882
+ expectRejected({ sortBy: ['sweepStrat'] }, "rule 'r'.sortBy[0]", "'sweepStrat'");
883
+ expectRejected({ attributes: { label: { template: '{SeriesDescriptio}' } } }, "rule 'r'.attributes.label.template", "'SeriesDescriptio'");
884
+ expectRejected({ context: { isChest: "BodyPartExamind == 'CHEST'" } }, "rule 'r'.context.isChest", "'BodyPartExamind'");
885
+ expectRejected({}, 'context.isLarge', "'pixelCont'", {
886
+ context: { isLarge: 'pixelCont > 1' },
887
+ });
888
+ });
889
+ it('matches names and operators with the same case, and names the correct one', () => {
890
+ expect(errorOf({ matches: { modality: 'CT' } }).message).toContain("unknown name 'modality'; did you mean 'Modality'?");
891
+ expect(errorOf({ matches: { Rows: { notnull: true } } }).message).toContain("unknown operator 'notnull'; did you mean 'notNull'?");
892
+ expect(() => compileOne({ attributes: { id: 'imageId' } })).not.toThrow();
893
+ });
894
+ it('reads a hex tag only through a declaration', () => {
895
+ expectRejected({ matches: { '00191010': 1 } }, "rule 'r'.matches.00191010", "'00191010'");
896
+ const rule = {
897
+ context: { vendorBValue: { attribute: '00191010', vr: 'FD', vm: '1' } },
898
+ matches: { vendorBValue: { greaterThan: 0 } },
899
+ };
900
+ expect(claimed(rule, [
901
+ instance({ SOPInstanceUID: 'b', '00191010': 800 }),
902
+ instance({ SOPInstanceUID: 'none' }),
903
+ ])).toEqual(['b']);
904
+ });
905
+ it('rejects a context name that is a keyword, or that does not start in lowercase', () => {
906
+ expectRejected({ context: { Modality: "'CT'" } }, "rule 'r'.context.Modality", "'Modality'");
907
+ expectRejected({ context: { IsChest: 'true' } }, "rule 'r'.context.IsChest", "'IsChest'");
908
+ });
909
+ });
910
+ describe('the display set context', () => {
911
+ const sweeps = () => ['090000', '090005', '090100', '090105'].map((AcquisitionTime, index) => instance({
912
+ SOPInstanceUID: `us-${index}`,
913
+ Modality: 'US',
914
+ AcquisitionTime,
915
+ InstanceNumber: index + 1,
916
+ }));
917
+ const sweepRule = {
918
+ matches: { sweepCount: { greaterThan: 1 } },
919
+ groupBy: ['SeriesInstanceUID', 'sweepStart'],
920
+ };
921
+ it('reads a series field and an instance field of a group by their aliases', () => {
922
+ const groups = groupInstancesBySplitRules(sweeps(), compileOne(sweepRule));
923
+ expect(groups.map((g) => g.instances.length)).toEqual([2, 2]);
924
+ });
925
+ it('lets a rule context replace an argument for that rule only, also inside the global entries', () => {
926
+ const rules = createDisplaySetSplitRules({
927
+ wideGap: {
928
+ priority: 1,
929
+ ...sweepRule,
930
+ context: { timeClustersMaxGap: 60 },
931
+ },
932
+ defaultGap: { priority: 2, ...sweepRule },
933
+ });
934
+ expect(groupInstancesBySplitRules(sweeps(), rules).map((g) => g.matchedRule.id)).toEqual(['defaultGap', 'defaultGap']);
935
+ });
936
+ it('lets a rule select a different group, and every alias follows', () => {
937
+ const rule = {
938
+ ...sweepRule,
939
+ context: { timeClusters: 'longTimeClusters' },
940
+ };
941
+ expect(claimed(rule, sweeps())).toEqual([]);
942
+ });
943
+ it('computes an entry one time for each series for all rules that do not replace it', () => {
944
+ const compute = jest.fn(() => ({ count: 2 }));
945
+ const rules = createDisplaySetSplitRules({
946
+ a: { priority: 1, matches: { counted: 3 } },
947
+ b: { priority: 2, matches: { counted: 2 } },
948
+ c: {
949
+ priority: 3,
950
+ context: { counted: 'summary.count + 1' },
951
+ matches: { counted: 3 },
952
+ },
953
+ }, {
954
+ context: {
955
+ summary: { function: 'summarize' },
956
+ counted: 'summary.count',
957
+ },
958
+ seriesFunctions: {
959
+ summarize: { compute, fields: { count: 'series' } },
960
+ },
961
+ });
962
+ const groups = groupInstancesBySplitRules(sweeps(), rules);
963
+ expect([...new Set(groups.map((g) => g.matchedRule.id))]).toEqual(['b']);
964
+ expect(compute).toHaveBeenCalledTimes(1);
965
+ });
966
+ it('gives a rule entry that reads its own name the global value', () => {
967
+ const rule = {
968
+ context: { seriesInstanceCount: 'seriesInstanceCount * 2' },
969
+ matches: { seriesInstanceCount: 8 },
970
+ };
971
+ expect(claimed(rule, sweeps()).length).toBe(4);
972
+ });
973
+ it('rejects a loop of entries, an unknown group field, and a group replaced by a value', () => {
974
+ expectRejected({}, 'context.first', 'first -> second -> first', {
975
+ context: { first: 'second + 1', second: 'first' },
976
+ });
977
+ expectRejected({}, 'context.sweeps', "'cluster'", {
978
+ context: { sweeps: 'timeClusters.cluster' },
979
+ });
980
+ expectRejected({ context: { timeClusters: 5 }, groupBy: ['sweepStart'] }, 'context.sweepStart', "'timeClusters'");
981
+ });
982
+ it('runs min, max, sum and count over the series in a series entry', () => {
983
+ const rule = {
984
+ context: {
985
+ mostFrames: 'series.instances.max(NumberOfFrames)',
986
+ withFrames: 'series.instances.count(NumberOfFrames)',
987
+ },
988
+ matches: { mostFrames: 9, withFrames: 2 },
989
+ };
990
+ expect(claimed(rule, [
991
+ instance({ SOPInstanceUID: 'a', InstanceNumber: 1, NumberOfFrames: 9 }),
992
+ instance({
993
+ SOPInstanceUID: 'b',
994
+ InstanceNumber: 2,
995
+ NumberOfFrames: '3',
996
+ }),
997
+ instance({ SOPInstanceUID: 'c', InstanceNumber: 3 }),
998
+ ])).toEqual(['a', 'b', 'c']);
999
+ });
1000
+ });
1001
+ describe('sortBy and attributes', () => {
1002
+ const series = () => [3, 1, 2].map((SliceLocation) => instance({
1003
+ SOPInstanceUID: `s${SliceLocation}`,
1004
+ SliceLocation,
1005
+ InstanceNumber: 4 - SliceLocation,
1006
+ NumberOfFrames: SliceLocation,
1007
+ SeriesDescription: 'AX',
1008
+ }));
1009
+ const firstGroup = (rule, options = {}) => groupInstancesBySplitRules(series(), compileOne(rule, options))[0];
1010
+ it('sorts by a name in ascending order, and by a named sort function', () => {
1011
+ expect(firstGroup({ sortBy: ['SliceLocation'] }).instances.map((i) => i.SliceLocation)).toEqual([1, 2, 3]);
1012
+ const descending = (a, b) => b.SliceLocation - a.SliceLocation;
1013
+ expect(firstGroup({ sortBy: [{ sortFunction: 'descending' }] }, { sortFunctions: { descending } }).instances.map((i) => i.SliceLocation)).toEqual([3, 2, 1]);
1014
+ expectRejected({ sortBy: [{ sortFunction: 'missing' }] }, "rule 'r'.sortBy[0].sortFunction", '"missing"');
1015
+ });
1016
+ it('reads a keyword in attributes from the first instance of the sorted display set', () => {
1017
+ const group = firstGroup({
1018
+ sortBy: ['SliceLocation'],
1019
+ attributes: {
1020
+ firstSlice: 'SliceLocation',
1021
+ label: { template: '{SeriesDescription} #{splitNumber}' },
1022
+ fixed: { template: 'SCOUT' },
1023
+ flag: true,
1024
+ },
1025
+ });
1026
+ const { firstSlice, label, fixed, flag } = createDisplaySetFromGroup(group, {
1027
+ splitNumber: 1,
1028
+ });
1029
+ expect({ firstSlice, label, fixed, flag }).toEqual({
1030
+ firstSlice: 1,
1031
+ label: 'AX #1',
1032
+ fixed: 'SCOUT',
1033
+ flag: true,
1034
+ });
1035
+ });
1036
+ it('rejects a bare string that is not a name, and names the template form', () => {
1037
+ const error = errorOf({ attributes: { label: 'SCOUT' } });
1038
+ expect(error.path).toBe("rule 'r'.attributes.label");
1039
+ expect(error.message).toMatch(/^Invalid display set rules: rule 'r'/);
1040
+ expect(error.message).toContain('"myValue": { "attribute": "SCOUT" }');
1041
+ expect(error.message).toContain('fixed text is { "template": "SCOUT" }');
1042
+ });
1043
+ it('aggregates over the series or over the display set, as the receiver names', () => {
1044
+ const groups = groupInstancesBySplitRules(series(), compileOne({
1045
+ context: {
1046
+ isThick: 'SliceLocation > 1',
1047
+ seriesMaxFrames: 'series.instances.max(NumberOfFrames)',
1048
+ },
1049
+ groupBy: ['SeriesInstanceUID', 'isThick'],
1050
+ attributes: {
1051
+ seriesMax: 'seriesMaxFrames',
1052
+ displaySetMax: {
1053
+ expression: 'displaySet.instances.max(NumberOfFrames)',
1054
+ },
1055
+ displaySetTotal: {
1056
+ expression: 'displaySet.instances.sum(NumberOfFrames)',
1057
+ },
1058
+ },
1059
+ }));
1060
+ expect(groups.map((group) => {
1061
+ const { seriesMax, displaySetMax, displaySetTotal } = createDisplaySetFromGroup(group);
1062
+ return [seriesMax, displaySetMax, displaySetTotal];
1063
+ })).toEqual([
1064
+ [3, 1, 1],
1065
+ [3, 3, 5],
1066
+ ]);
1067
+ });
1068
+ it('reads a bare name over the series, and displaySet.<name> over the display set', () => {
1069
+ const groups = groupInstancesBySplitRules(series(), compileOne({
1070
+ context: {
1071
+ count: { function: 'countOf' },
1072
+ countN: 'count.n',
1073
+ isThick: 'SliceLocation > 1',
1074
+ },
1075
+ groupBy: ['SeriesInstanceUID', 'isThick'],
1076
+ sortBy: ['displaySet.count.n', 'SliceLocation'],
1077
+ attributes: {
1078
+ seriesCount: 'countN',
1079
+ displaySetCount: 'displaySet.count.n',
1080
+ label: { template: '{countN}/{displaySet.countN}' },
1081
+ both: { expression: 'displaySet.count.n * 10 + countN' },
1082
+ },
1083
+ }, {
1084
+ seriesFunctions: {
1085
+ countOf: {
1086
+ compute: (instances) => ({
1087
+ n: instances.length,
1088
+ }),
1089
+ fields: { n: 'series' },
1090
+ },
1091
+ },
1092
+ }));
1093
+ expect(groups.map((group) => {
1094
+ const { seriesCount, displaySetCount, label, both } = createDisplaySetFromGroup(group);
1095
+ return [seriesCount, displaySetCount, label, both];
1096
+ })).toEqual([
1097
+ [3, 1, '3/1', 13],
1098
+ [3, 2, '3/2', 23],
1099
+ ]);
1100
+ });
1101
+ it('rejects the displaySet. prefix outside sortBy and attributes', () => {
1102
+ expectRejected({ matches: { 'displaySet.sliceSpacing': 1 } }, "rule 'r'.matches.displaySet.sliceSpacing", 'sortBy and attributes');
1103
+ expectRejected({ groupBy: ['displaySet.sliceIndex'] }, "rule 'r'.groupBy[0]", 'sortBy and attributes');
1104
+ expectRejected({ context: { spacing: 'displaySet.sliceSpacing' } }, "rule 'r'.context.spacing", 'sortBy and attributes');
1105
+ expectRejected({ context: { g: { function: 'planeGeometry', over: 'series' } } }, "rule 'r'.context.g", "'over'");
1106
+ });
1107
+ it('rejects a bare aggregate, displaySet.instances outside attributes, and displaySets', () => {
1108
+ expectRejected({ context: { most: 'max(Rows)' } }, "rule 'r'.context.most", 'max(');
1109
+ expectRejected({ context: { most: 'displaySet.instances.max(Rows)' } }, "rule 'r'.context.most", 'displaySet.instances');
1110
+ expectRejected({ attributes: { all: { expression: 'displaySets.instances.count()' } } }, "rule 'r'.attributes.all.expression", 'displaySets');
1111
+ });
1112
+ it('reads splitNumber and sopClassUids in attributes only', () => {
1113
+ expectRejected({ matches: { splitNumber: 1 } }, "rule 'r'.matches.splitNumber", "'splitNumber'");
1114
+ });
1115
+ });
1116
+ describe('createDisplaySetSplitRules - validation', () => {
1117
+ it('rejects rules that are not an object keyed by rule id', () => {
1118
+ expect(createDisplaySetSplitRules({})).toEqual({});
1119
+ expect(() => createDisplaySetSplitRules('rules')).toThrow(/Invalid display set rules: the rules must be an object keyed by rule id/);
1120
+ expect(() => createDisplaySetSplitRules([{ id: 'x', priority: 1 }])).toThrow(/the rules must be an object keyed by rule id/);
1121
+ });
1122
+ it('rejects a __proto__ key instead of losing the entry', () => {
1123
+ expect(() => createDisplaySetSplitRules(JSON.parse('{"__proto__": {"priority": 1}}'))).toThrow(/"__proto__"/);
1124
+ expectRejected({ attributes: JSON.parse('{"__proto__": {"template": "x"}}') }, "rule 'r'.attributes.__proto__", '__proto__');
1125
+ expectRejected({ context: JSON.parse('{"__proto__": 1}') }, "rule 'r'.context.__proto__", '__proto__');
1126
+ });
1127
+ it('rejects an unknown rule field, with the rule id and the field', () => {
1128
+ expectRejected({ matchs: { Modality: 'CT' } }, "rule 'r'", "'matchs'");
1129
+ });
1130
+ });
1131
+ describe('createDisplaySetSplitRules - the code form', () => {
1132
+ it('passes a function through at every function place', () => {
1133
+ const matches = (i) => i.Modality === 'CT';
1134
+ const groupByPart = (i) => i.Rows;
1135
+ const comparator = () => 0;
1136
+ const customAttributes = () => ({ label: 'fn' });
1137
+ const compiled = compileOne({
1138
+ matches,
1139
+ groupBy: ['SeriesInstanceUID', groupByPart],
1140
+ sortBy: [comparator],
1141
+ attributes: customAttributes,
1142
+ }).r;
1143
+ expect(compiled.matches).toBe(matches);
1144
+ expect(compiled.groupBy?.[1]).toBe(groupByPart);
1145
+ expect(compiled.compareInstances).toBe(comparator);
1146
+ expect(compiled.customAttributes).toBe(customAttributes);
1147
+ });
1148
+ it('accepts a code context entry, a function test and a function attribute', () => {
1149
+ const compiled = compileOne({
1150
+ matches: {
1151
+ isWide: true,
1152
+ Rows: (value) => Number(value) > 256,
1153
+ },
1154
+ attributes: {
1155
+ twice: (instances) => instances[0].Rows * 2,
1156
+ },
1157
+ }, {
1158
+ context: {
1159
+ isWide: (_i, values) => values.Columns >= 512,
1160
+ },
1161
+ }).r;
1162
+ const big = instance({ Rows: 512 });
1163
+ expect(compiled.matches?.(big, { series: {} })).toBe(true);
1164
+ expect(compiled.matches?.(instance({ Rows: 128 }), { series: {} })).toBe(false);
1165
+ expect(compiled.customAttributes?.({ instance: big }, { instances: [big] })).toEqual({ twice: 1024 });
1166
+ });
1167
+ it('reads no name from the prototype chain', () => {
1168
+ for (const name of ['toString', 'constructor']) {
1169
+ expectRejected({ matches: { [name]: 1 } }, `rule 'r'.matches.${name}`, `'${name}'`);
1170
+ }
1171
+ });
1172
+ });
1173
+ describe('createDisplaySetSplitRules - groupId', () => {
1174
+ it('does not change how a rule splits: the split key stays per rule id', () => {
1175
+ const rules = createDisplaySetSplitRules({
1176
+ tomo: {
1177
+ priority: 1,
1178
+ groupId: 'mammo',
1179
+ matches: { ImageType: { includes: 'TOMO' } },
1180
+ },
1181
+ legacy: { priority: 2, groupId: 'mammo' },
1182
+ });
1183
+ const groups = groupInstancesBySplitRules([
1184
+ {
1185
+ SeriesInstanceUID: 's',
1186
+ SOPInstanceUID: '1',
1187
+ ImageType: ['DERIVED', 'TOMO'],
1188
+ },
1189
+ {
1190
+ SeriesInstanceUID: 's',
1191
+ SOPInstanceUID: '2',
1192
+ ImageType: ['ORIGINAL'],
1193
+ },
1194
+ ], rules);
1195
+ expect(groups.map((group) => JSON.parse(group.splitKey)[0])).toEqual([
1196
+ 'tomo',
1197
+ 'legacy',
1198
+ ]);
1199
+ });
1200
+ });
1201
+ describe('the default rules - a mixed series', () => {
1202
+ it('tests each instance: a still image after a clip is not a clip', () => {
1203
+ const US_SOP_CLASS = '1.2.840.10008.5.1.4.1.1.6.1';
1204
+ expect(ruleIdsFor([
1205
+ instance({
1206
+ SOPClassUID: US_MULTIFRAME_SOP_CLASS,
1207
+ Modality: 'US',
1208
+ SOPInstanceUID: 'clip',
1209
+ InstanceNumber: 1,
1210
+ NumberOfFrames: 30,
1211
+ SliceLocation: 0,
1212
+ }),
1213
+ instance({
1214
+ SOPClassUID: US_SOP_CLASS,
1215
+ Modality: 'US',
1216
+ SOPInstanceUID: 'still',
1217
+ InstanceNumber: 2,
1218
+ }),
1219
+ ])).toEqual(['multiFrame', 'defaultImageRule']);
1220
+ });
1221
+ });
1222
+ describe('code entries of the context', () => {
1223
+ const ct = [instance({ SOPInstanceUID: 'a', InstanceNumber: 1 })];
1224
+ it('gives a rule code entry that reads its own name the global value', () => {
1225
+ const rule = {
1226
+ context: {
1227
+ isImage: (i, values) => values.isImage === true && i.Modality === 'CT',
1228
+ },
1229
+ matches: { isImage: true },
1230
+ };
1231
+ expect(claimed(rule, ct)).toEqual(['a']);
1232
+ });
1233
+ it('stops a loop through a code entry at the first read, and names each entry', () => {
1234
+ const options = {
1235
+ context: {
1236
+ first: (_i, values) => values.second,
1237
+ second: 'third',
1238
+ third: 'first == true',
1239
+ },
1240
+ };
1241
+ expect(() => claimed({ matches: { first: true } }, ct, options)).toThrow('the context entries form a loop: first -> second -> third -> first');
1242
+ });
1243
+ });
1244
+ describe('the deny list of the host', () => {
1245
+ const options = {
1246
+ deniedNames: ['PatientName'],
1247
+ context: {
1248
+ nameOf: (_i, values) => values.PatientName,
1249
+ },
1250
+ };
1251
+ it('rejects a rule that names a denied name, and gives a code entry no value', () => {
1252
+ expectRejected({ matches: { PatientName: 'X' } }, "rule 'r'.matches.PatientName", "'PatientName'", options);
1253
+ expectRejected({ attributes: { label: { template: '{PatientName}' } } }, "rule 'r'.attributes.label.template", "'PatientName'", options);
1254
+ expect(claimed({ matches: { nameOf: null } }, [instance({ SOPInstanceUID: 'p', PatientName: 'Doe^J' })], options)).toEqual(['p']);
1255
+ });
1256
+ it('rejects a declaration of a denied attribute, by keyword or by hex tag', () => {
1257
+ for (const attribute of ['PatientName', '00100010', 'x00100010']) {
1258
+ expectRejected({
1259
+ context: { pn: { attribute } },
1260
+ attributes: { SeriesDescription: { template: 'leak {pn}' } },
1261
+ }, "rule 'r'.context.pn.attribute", 'deny list', options);
1262
+ }
1263
+ });
1264
+ it('rejects a field of a sequence item that is denied, also a computed field', () => {
1265
+ const denyId = { deniedNames: ['PatientID'] };
1266
+ for (const expression of [
1267
+ "'x' + OtherPatientIDsSequence[0].PatientID",
1268
+ "'x' + OtherPatientIDsSequence[0]['PatientID']",
1269
+ "'x' + OtherPatientIDsSequence[0]['Patient' + 'ID']",
1270
+ ]) {
1271
+ expectRejected({ attributes: { label: { expression } } }, "rule 'r'.attributes.label.expression", 'deny list', denyId);
1272
+ }
1273
+ expect(() => compileOne({ context: { view: 'ViewCodeSequence[0].CodeValue' } }, denyId)).not.toThrow();
1274
+ });
1275
+ });
1276
+ describe('expressions and names: the DICOM rule for no value', () => {
1277
+ it('gives no value for arithmetic on an empty value, so a test on it fails', () => {
1278
+ const rule = {
1279
+ context: { pixelCount: 'Rows * Columns' },
1280
+ matches: { pixelCount: { lessThan: 100 } },
1281
+ };
1282
+ expect(claimed(rule, [
1283
+ instance({ SOPInstanceUID: 'empty', Rows: null }),
1284
+ ])).toEqual([]);
1285
+ });
1286
+ it('reads an array of empty elements as no value', () => {
1287
+ const rule = { matches: { ImageType: { notNull: true } } };
1288
+ expect(claimed(rule, [instance({ SOPInstanceUID: 'x', ImageType: [''] })])).toEqual([]);
1289
+ });
1290
+ it('rejects a field on a keyword, and an index on a group', () => {
1291
+ expectRejected({ attributes: { label: { template: '{Modality.foo}' } } }, "rule 'r'.attributes.label.template", "'foo'");
1292
+ expectRejected({ context: { s: "timeClusters['start']" } }, "rule 'r'.context.s", "'timeClusters'");
1293
+ });
1294
+ it('sorts an instance with no value after the instances with a value', () => {
1295
+ const [group] = groupInstancesBySplitRules([2, undefined, 1].map((SliceLocation, index) => instance({
1296
+ SOPInstanceUID: `s${index}`,
1297
+ InstanceNumber: index,
1298
+ SliceLocation,
1299
+ })), compileOne({ sortBy: ['SliceLocation'] }));
1300
+ expect(group.instances.map((i) => i.SliceLocation)).toEqual([
1301
+ 1,
1302
+ 2,
1303
+ undefined,
1304
+ ]);
1305
+ });
1306
+ });