@cornerstonejs/metadata 5.13.2 → 5.15.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
@@ -1,13 +1,16 @@
1
1
  import { describe, expect, it } from '@jest/globals';
2
2
  import { buildSeriesInfo } from './buildSeriesInfo.js';
3
3
  import { createDisplaySetFromGroup } from './createDisplaySetFromGroup.js';
4
- import { defaultDisplaySetSplitRules } from './defaultDisplaySetSplitRules.js';
5
- import { groupInstancesBySplitRules } from './groupInstancesBySplitRules.js';
4
+ import { compiledDisplaySetRules } from './compiledDisplaySetRules.js';
5
+ import { groupInstancesBySplitRules, orderInstancesForRule, } from './groupInstancesBySplitRules.js';
6
6
  import { ImageStackDisplaySet } from './ImageStackDisplaySet.js';
7
7
  import { isVideoInstance } from './isVideoInstance.js';
8
8
  import { resolveInstances } from './resolveInstances.js';
9
9
  import { splitImageIdsBySplitRules } from './splitImageIdsBySplitRules.js';
10
- import { getPreferredViewportType } from './viewportTypes.js';
10
+ const ruleSet = (...rules) => Object.fromEntries(rules.map(({ id, ...rule }, index) => [
11
+ id,
12
+ { ...rule, priority: index + 1 },
13
+ ]));
11
14
  describe('displayset split utilities', () => {
12
15
  const instances = [
13
16
  {
@@ -34,19 +37,24 @@ describe('displayset split utilities', () => {
34
37
  const resolved = resolveInstances(['wadors:2', 'wadors:missing', 'wadors:1'], getNaturalizedInstance);
35
38
  expect(resolved.map((i) => i.imageId)).toEqual(['wadors:2', 'wadors:1']);
36
39
  });
37
- it('default rules group multi-slice CT as volume (MPR) preferred', () => {
40
+ it('rejects an invalid rule set also when no image id resolves', () => {
41
+ expect(() => splitImageIdsBySplitRules(['wadors:missing'], {
42
+ getNaturalizedInstance,
43
+ splitRules: { bad: { priority: 'high' } },
44
+ })).toThrow(/"bad".*priority/);
45
+ });
46
+ it('default rules group multi-slice CT as one image stack, with no handler', () => {
38
47
  const groups = splitImageIdsBySplitRules(instances.map((i) => i.imageId), {
39
48
  getNaturalizedInstance: (id) => instances.find((i) => i.imageId === id),
40
- splitRules: defaultDisplaySetSplitRules,
49
+ splitRules: compiledDisplaySetRules,
41
50
  });
42
51
  expect(groups).toHaveLength(1);
43
52
  expect(groups[0].matchedRule.id).toBe('volume3d');
44
53
  const displaySet = createDisplaySetFromGroup(groups[0]);
45
- expect(displaySet.viewportTypes[0]).toBe('volume');
46
- expect(displaySet.viewportTypes).toContain('volume3d');
47
- expect(displaySet.preferredViewportType).toBe('volume');
54
+ expect(displaySet).toBeInstanceOf(ImageStackDisplaySet);
55
+ expect(displaySet.handler).toBeUndefined();
48
56
  });
49
- it('video rule uses video viewportTypes', () => {
57
+ it('video rule gives the video handler', () => {
50
58
  const videoInstance = {
51
59
  imageId: 'wadors:video',
52
60
  SOPClassUID: '1.2.840.10008.5.1.4.1.1.77.1.4.1',
@@ -54,15 +62,14 @@ describe('displayset split utilities', () => {
54
62
  };
55
63
  const groups = splitImageIdsBySplitRules(['wadors:video'], {
56
64
  getNaturalizedInstance: () => videoInstance,
57
- splitRules: defaultDisplaySetSplitRules,
65
+ splitRules: compiledDisplaySetRules,
58
66
  });
59
67
  expect(groups[0].matchedRule.id).toBe('video');
60
68
  const displaySet = createDisplaySetFromGroup(groups[0]);
61
- expect(displaySet.viewportTypes).toEqual(['video']);
62
- expect(getPreferredViewportType(displaySet.viewportTypes)).toBe('video');
69
+ expect(displaySet.handler).toBe('video');
63
70
  expect(displaySet.instances[0]?.imageId).toBe('wadors:video');
64
71
  });
65
- it('ecg rule uses ecg viewportTypes', () => {
72
+ it('ecg rule gives the ecg handler', () => {
66
73
  const ecgInstance = {
67
74
  imageId: 'wadors:ecg',
68
75
  SOPClassUID: '1.2.840.10008.5.1.4.1.1.9.1.1',
@@ -70,57 +77,16 @@ describe('displayset split utilities', () => {
70
77
  };
71
78
  const groups = splitImageIdsBySplitRules(['wadors:ecg'], {
72
79
  getNaturalizedInstance: () => ecgInstance,
73
- splitRules: defaultDisplaySetSplitRules,
80
+ splitRules: compiledDisplaySetRules,
74
81
  });
75
82
  expect(groups[0].matchedRule.id).toBe('ecg');
76
- expect(createDisplaySetFromGroup(groups[0]).viewportTypes[0]).toBe('ecg');
77
- });
78
- it('splits MR mixed B-value series', () => {
79
- const mrInstances = [
80
- {
81
- imageId: 'wadors:a',
82
- Modality: 'MR',
83
- SOPClassUID: '1.2.840.10008.5.1.4.1.1.4',
84
- Rows: 256,
85
- SeriesInstanceUID: 'series-mr',
86
- DiffusionBValue: 800,
87
- },
88
- {
89
- imageId: 'wadors:b',
90
- Modality: 'MR',
91
- SOPClassUID: '1.2.840.10008.5.1.4.1.1.4',
92
- Rows: 256,
93
- SeriesInstanceUID: 'series-mr',
94
- },
95
- ];
96
- const groups = splitImageIdsBySplitRules(mrInstances.map((i) => i.imageId), {
97
- getNaturalizedInstance: (id) => mrInstances.find((i) => i.imageId === id),
98
- splitRules: defaultDisplaySetSplitRules,
99
- });
100
- expect(groups).toHaveLength(2);
83
+ expect(createDisplaySetFromGroup(groups[0]).handler).toBe('ecg');
101
84
  });
102
85
  it('ImageStackDisplaySet exposes underlying and frame ids', () => {
103
86
  const displaySet = ImageStackDisplaySet.fromInstances(instances, {
104
87
  displaySetId: 'uid-1',
105
- viewportTypes: ['stack', 'volume', 'volume3d'],
106
88
  });
107
89
  expect(displaySet.underlyingImageIds.length).toBe(2);
108
- expect(displaySet.viewportTypes[0]).toBe('stack');
109
- expect(displaySet.preferredViewportType).toBe('stack');
110
- });
111
- it('groups by default image rule into a single group', () => {
112
- const singleInstance = [instances[0]];
113
- const rules = [
114
- {
115
- id: 'defaultImageRule',
116
- viewportTypes: ['stack'],
117
- matches: (instance) => instance.SOPClassUID === '1.2.840.10008.5.1.4.1.1.2' &&
118
- !!instance.Rows,
119
- },
120
- ];
121
- const groups = groupInstancesBySplitRules(singleInstance, rules);
122
- expect(groups).toHaveLength(1);
123
- expect(groups[0].instances).toHaveLength(1);
124
90
  });
125
91
  it('spreads matched-rule customAttributes flat onto the display set', () => {
126
92
  const multiFrameInstances = [
@@ -129,7 +95,7 @@ describe('displayset split utilities', () => {
129
95
  Modality: 'XA',
130
96
  SOPClassUID: '1.2.840.10008.5.1.4.1.1.12.1',
131
97
  Rows: 512,
132
- NumberOfFrames: 30,
98
+ NumberOfFrames: '30',
133
99
  SliceLocation: 0,
134
100
  SeriesInstanceUID: 'series-mf',
135
101
  InstanceNumber: 1,
@@ -137,36 +103,16 @@ describe('displayset split utilities', () => {
137
103
  ];
138
104
  const groups = splitImageIdsBySplitRules(['wadors:mf'], {
139
105
  getNaturalizedInstance: () => multiFrameInstances[0],
140
- splitRules: defaultDisplaySetSplitRules,
106
+ splitRules: compiledDisplaySetRules,
141
107
  });
142
108
  expect(groups[0].matchedRule.id).toBe('multiFrame');
143
109
  const displaySet = createDisplaySetFromGroup(groups[0], { splitNumber: 2 });
144
110
  expect(displaySet.isClip).toBe(true);
145
111
  expect(displaySet.numImageFrames).toBe(30);
146
112
  expect(displaySet.splitNumber).toBe(2);
147
- expect(displaySet.viewportTypes).toEqual(['stack']);
148
- });
149
- it('coerces a string NumberOfFrames to a numeric numImageFrames', () => {
150
- const multiFrameInstances = [
151
- {
152
- imageId: 'wadors:mf-str',
153
- Modality: 'XA',
154
- SOPClassUID: '1.2.840.10008.5.1.4.1.1.12.1',
155
- Rows: 512,
156
- NumberOfFrames: '30',
157
- SliceLocation: 0,
158
- SeriesInstanceUID: 'series-mf-str',
159
- InstanceNumber: 1,
160
- },
161
- ];
162
- const groups = splitImageIdsBySplitRules(['wadors:mf-str'], {
163
- getNaturalizedInstance: () => multiFrameInstances[0],
164
- splitRules: defaultDisplaySetSplitRules,
165
- });
166
- expect(groups[0].matchedRule.id).toBe('multiFrame');
167
- const displaySet = createDisplaySetFromGroup(groups[0]);
168
- expect(displaySet.numImageFrames).toBe(30);
169
- expect(typeof displaySet.numImageFrames).toBe('number');
113
+ expect(displaySet.sopClassUids).toEqual([
114
+ multiFrameInstances[0].SOPClassUID,
115
+ ]);
170
116
  });
171
117
  it('classifies an MPEG2 transfer syntax instance as video', () => {
172
118
  const mpeg2Instance = {
@@ -181,7 +127,7 @@ describe('displayset split utilities', () => {
181
127
  expect(() => buildSeriesInfo([])).not.toThrow();
182
128
  const seriesInfo = buildSeriesInfo([]);
183
129
  expect(seriesInfo.NumberOfSeriesRelatedInstances).toBe(0);
184
- expect(groupInstancesBySplitRules([], defaultDisplaySetSplitRules)).toEqual([]);
130
+ expect(groupInstancesBySplitRules([], compiledDisplaySetRules)).toEqual([]);
185
131
  });
186
132
  it('does not let customAttributes clobber resolved data fields', () => {
187
133
  const stackInstances = [
@@ -197,7 +143,6 @@ describe('displayset split utilities', () => {
197
143
  instances: stackInstances,
198
144
  matchedRule: {
199
145
  id: 'reserved-clobber',
200
- viewportTypes: ['stack'],
201
146
  customAttributes: () => ({
202
147
  imageIds: ['evil-frame'],
203
148
  underlyingImageIds: ['evil-underlying'],
@@ -216,6 +161,76 @@ describe('displayset split utilities', () => {
216
161
  expect(displaySet.displaySetId).toBe('good-uid');
217
162
  expect(displaySet.customFlag).toBe(true);
218
163
  });
164
+ it('records the split identity, and attributes cannot rename it', () => {
165
+ const group = {
166
+ instances: [
167
+ {
168
+ imageId: 'wadors:identity',
169
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.2',
170
+ Rows: 512,
171
+ SeriesInstanceUID: 'series-identity',
172
+ InstanceNumber: 1,
173
+ },
174
+ ],
175
+ matchedRule: {
176
+ id: 'mgLegacy',
177
+ groupId: 'mammo',
178
+ customAttributes: () => ({
179
+ splitKey: 'other',
180
+ splitRuleId: 'other',
181
+ splitGroupId: 'other',
182
+ }),
183
+ },
184
+ splitKey: '["mgLegacy","series-identity"]',
185
+ };
186
+ const displaySet = createDisplaySetFromGroup(group);
187
+ expect(displaySet.splitKey).toBe('["mgLegacy","series-identity"]');
188
+ expect(displaySet.splitRuleId).toBe('mgLegacy');
189
+ expect(displaySet.splitGroupId).toBe('mammo');
190
+ });
191
+ it('does not let a __proto__ custom attribute replace the prototype', () => {
192
+ const group = {
193
+ instances: [
194
+ {
195
+ imageId: 'wadors:proto',
196
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.2',
197
+ Rows: 512,
198
+ SeriesInstanceUID: 'series-proto',
199
+ InstanceNumber: 1,
200
+ },
201
+ ],
202
+ matchedRule: {
203
+ id: 'proto',
204
+ customAttributes: () => JSON.parse('{"__proto__": {"x": 1}, "y": 2}'),
205
+ },
206
+ };
207
+ const displaySet = createDisplaySetFromGroup(group);
208
+ expect(displaySet).toBeInstanceOf(ImageStackDisplaySet);
209
+ expect(displaySet.x).toBeUndefined();
210
+ expect(displaySet.y).toBe(2);
211
+ });
212
+ it('does not let customAttributes change isDisplayable or the handler', () => {
213
+ const displaySet = createDisplaySetFromGroup({
214
+ instances: [
215
+ {
216
+ imageId: 'wadors:pr',
217
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.11.1',
218
+ SeriesInstanceUID: 'series-pr',
219
+ },
220
+ ],
221
+ matchedRule: {
222
+ id: 'unsupported',
223
+ handler: 'unsupported',
224
+ customAttributes: () => ({ isDisplayable: true, handler: 'image' }),
225
+ },
226
+ });
227
+ expect([displaySet.isDisplayable, displaySet.handler]).toEqual([
228
+ false,
229
+ 'unsupported',
230
+ ]);
231
+ expect(displaySet.imageIds).toEqual([]);
232
+ expect(displaySet.underlyingImageIds).toEqual(['wadors:pr']);
233
+ });
219
234
  it('derives unique displaySetIds for splits of one series', () => {
220
235
  const seriesUID = 'series-split';
221
236
  const makeGroup = (imageId) => ({
@@ -227,7 +242,7 @@ describe('displayset split utilities', () => {
227
242
  SeriesInstanceUID: seriesUID,
228
243
  },
229
244
  ],
230
- matchedRule: { id: 'split', viewportTypes: ['stack'] },
245
+ matchedRule: { id: 'split' },
231
246
  });
232
247
  const ds0 = createDisplaySetFromGroup(makeGroup('wadors:s0'), {
233
248
  splitNumber: 0,
@@ -244,18 +259,15 @@ describe('displayset split utilities', () => {
244
259
  { imageId: 'a', Modality: 'XA' },
245
260
  { imageId: 'b', Modality: 'NM' },
246
261
  ];
247
- const rules = [
248
- {
249
- id: 'ruleA',
250
- matches: (i) => i.Modality === 'XA',
251
- groupBy: [() => 'same'],
252
- },
253
- {
254
- id: 'ruleB',
255
- matches: (i) => i.Modality === 'NM',
256
- groupBy: [() => 'same'],
257
- },
258
- ];
262
+ const rules = ruleSet({
263
+ id: 'ruleA',
264
+ matches: (i) => i.Modality === 'XA',
265
+ groupBy: [() => 'same'],
266
+ }, {
267
+ id: 'ruleB',
268
+ matches: (i) => i.Modality === 'NM',
269
+ groupBy: [() => 'same'],
270
+ });
259
271
  const groups = groupInstancesBySplitRules(insts, rules);
260
272
  expect(groups).toHaveLength(2);
261
273
  expect(new Set(groups.map((g) => g.matchedRule.id))).toEqual(new Set(['ruleA', 'ruleB']));
@@ -280,37 +292,12 @@ describe('displayset split utilities', () => {
280
292
  ];
281
293
  const options = {
282
294
  getNaturalizedInstance: (id) => mr.find((i) => i.imageId === id),
283
- splitRules: defaultDisplaySetSplitRules,
295
+ splitRules: compiledDisplaySetRules,
284
296
  };
285
297
  const forward = splitImageIdsBySplitRules(['a', 'b'], options);
286
298
  const reverse = splitImageIdsBySplitRules(['b', 'a'], options);
287
299
  expect(forward.map((g) => g.splitKey)).toEqual(reverse.map((g) => g.splitKey));
288
300
  });
289
- it('series hook splits a mixed-b-value DWI series into two display sets', () => {
290
- const mixed = [
291
- {
292
- imageId: 'b800',
293
- Modality: 'MR',
294
- SOPClassUID: '1.2.840.10008.5.1.4.1.1.4',
295
- Rows: 256,
296
- SeriesInstanceUID: 'dwi',
297
- DiffusionBValue: 800,
298
- },
299
- {
300
- imageId: 'noB',
301
- Modality: 'MR',
302
- SOPClassUID: '1.2.840.10008.5.1.4.1.1.4',
303
- Rows: 256,
304
- SeriesInstanceUID: 'dwi',
305
- },
306
- ];
307
- const groups = splitImageIdsBySplitRules(['b800', 'noB'], {
308
- getNaturalizedInstance: (id) => mixed.find((i) => i.imageId === id),
309
- splitRules: defaultDisplaySetSplitRules,
310
- });
311
- expect(groups).toHaveLength(2);
312
- expect(groups.every((g) => g.matchedRule.id === 'mixedDimensionalityBValue')).toBe(true);
313
- });
314
301
  it('series hook leaves a non-mixed DWI series as one volume display set', () => {
315
302
  const allBValue = [
316
303
  {
@@ -332,7 +319,7 @@ describe('displayset split utilities', () => {
332
319
  ];
333
320
  const groups = splitImageIdsBySplitRules(['b0', 'b1000'], {
334
321
  getNaturalizedInstance: (id) => allBValue.find((i) => i.imageId === id),
335
- splitRules: defaultDisplaySetSplitRules,
322
+ splitRules: compiledDisplaySetRules,
336
323
  });
337
324
  expect(groups).toHaveLength(1);
338
325
  expect(groups[0].matchedRule.id).toBe('volume3d');
@@ -342,16 +329,321 @@ describe('displayset split utilities', () => {
342
329
  { imageId: 'a', Modality: 'CT' },
343
330
  { imageId: 'b', Modality: 'SR' },
344
331
  ];
345
- const rules = [
346
- {
347
- id: 'ct',
348
- matches: (i) => i.Modality === 'CT',
349
- groupBy: ['imageId'],
350
- },
351
- ];
332
+ const rules = ruleSet({
333
+ id: 'ct',
334
+ matches: (i) => i.Modality === 'CT',
335
+ groupBy: ['imageId'],
336
+ });
352
337
  const unmatched = [];
353
338
  const groups = groupInstancesBySplitRules(insts, rules, (i) => unmatched.push(i.imageId));
354
339
  expect(unmatched).toEqual(['b']);
355
340
  expect(groups).toHaveLength(1);
356
341
  });
357
342
  });
343
+ describe('split key stability', () => {
344
+ const ct = (imageId, InstanceNumber) => ({
345
+ imageId,
346
+ Modality: 'CT',
347
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.2',
348
+ Rows: 512,
349
+ SeriesInstanceUID: 'series-1',
350
+ SOPInstanceUID: `sop-${imageId}`,
351
+ InstanceNumber,
352
+ });
353
+ const ctRule = {
354
+ id: 'ct',
355
+ matches: (i) => i.Modality === 'CT',
356
+ groupBy: ['SeriesInstanceUID', 'InstanceNumber'],
357
+ };
358
+ it('keys off the rule id, so inserting a rule leaves other rules keys unchanged', () => {
359
+ const instances = [ct('a', 1), ct('b', 2)];
360
+ const before = groupInstancesBySplitRules(instances, {
361
+ ct: { ...ctRule, priority: 1 },
362
+ });
363
+ const after = groupInstancesBySplitRules(instances, {
364
+ ct: { ...ctRule, priority: 1 },
365
+ insertedAhead: {
366
+ priority: -1,
367
+ matches: (i) => i.InstanceNumber === 2,
368
+ },
369
+ });
370
+ expect(after.map((g) => g.matchedRule.id)).toEqual(['insertedAhead', 'ct']);
371
+ const ctKey = (groups) => groups.find((g) => g.matchedRule.id === 'ct')?.splitKey;
372
+ expect(ctKey(after)).toBe(ctKey(before));
373
+ });
374
+ it('keys a group by the rule id taken from the rule set key', () => {
375
+ const groups = groupInstancesBySplitRules([ct('a', 1)], {
376
+ byImage: { priority: 1, groupBy: ['imageId'] },
377
+ });
378
+ expect(groups[0].matchedRule.id).toBe('byImage');
379
+ expect(groups[0].splitKey).toBe(JSON.stringify(['byImage', 'a']));
380
+ });
381
+ it('orders groups by rule position, then by key numerically', () => {
382
+ const instances = [ct('j', 10), ct('b', 2), ct('a', 1)];
383
+ const groups = groupInstancesBySplitRules(instances, ruleSet(ctRule));
384
+ expect(groups.map((g) => g.instances[0].imageId)).toEqual(['a', 'b', 'j']);
385
+ });
386
+ it('orders keys differing only in zero padding deterministically', () => {
387
+ const padded = (imageId, AcquisitionNumber) => ({
388
+ imageId,
389
+ Modality: 'CT',
390
+ SeriesInstanceUID: 'series-1',
391
+ SOPInstanceUID: `sop-${imageId}`,
392
+ AcquisitionNumber,
393
+ });
394
+ const rule = {
395
+ id: 'padded',
396
+ matches: () => true,
397
+ groupBy: ['AcquisitionNumber'],
398
+ };
399
+ const forward = groupInstancesBySplitRules([padded('a', '01'), padded('b', '1')], ruleSet(rule));
400
+ const reverse = groupInstancesBySplitRules([padded('b', '1'), padded('a', '01')], ruleSet(rule));
401
+ expect(forward).toHaveLength(2);
402
+ expect(forward.map((g) => g.instances[0].imageId)).toEqual(reverse.map((g) => g.instances[0].imageId));
403
+ expect(forward.map((g) => g.splitKey)).toEqual(reverse.map((g) => g.splitKey));
404
+ });
405
+ });
406
+ describe('compareInstances - rule-declared instance order', () => {
407
+ const slice = (imageId, SliceLocation, InstanceNumber) => ({
408
+ imageId,
409
+ Modality: 'CT',
410
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.2',
411
+ Rows: 512,
412
+ SeriesInstanceUID: 'ct-series',
413
+ SOPInstanceUID: `sop-${imageId}`,
414
+ SliceLocation,
415
+ InstanceNumber,
416
+ });
417
+ const reconstructed = [
418
+ slice('top', 30, 2),
419
+ slice('middle', 20, 3),
420
+ slice('bottom', 10, 1),
421
+ ];
422
+ const spatialRule = {
423
+ id: 'spatial',
424
+ matches: () => true,
425
+ compareInstances: (a, b) => a.SliceLocation - b.SliceLocation,
426
+ };
427
+ it('orders a group by the rule comparator instead of acquisition order', () => {
428
+ const groups = groupInstancesBySplitRules(reconstructed, ruleSet(spatialRule));
429
+ expect(groups[0].instances.map((i) => i.imageId)).toEqual([
430
+ 'bottom',
431
+ 'middle',
432
+ 'top',
433
+ ]);
434
+ });
435
+ it('defaults to acquisition order when the rule declares no comparator', () => {
436
+ const groups = groupInstancesBySplitRules(reconstructed, ruleSet({ id: 'default', matches: () => true }));
437
+ expect(groups[0].instances.map((i) => i.imageId)).toEqual([
438
+ 'bottom',
439
+ 'top',
440
+ 'middle',
441
+ ]);
442
+ });
443
+ it('reads the rule series facts through the comparator context', () => {
444
+ const groups = groupInstancesBySplitRules([...reconstructed].reverse(), ruleSet({
445
+ id: 'directional',
446
+ matches: () => true,
447
+ series: () => ({ descending: true }),
448
+ compareInstances: (a, b, context) => (context.series.descending ? -1 : 1) *
449
+ (a.SliceLocation - b.SliceLocation),
450
+ }));
451
+ expect(groups[0].instances.map((i) => i.imageId)).toEqual([
452
+ 'top',
453
+ 'middle',
454
+ 'bottom',
455
+ ]);
456
+ });
457
+ it('breaks ties from an incomplete comparator by acquisition order', () => {
458
+ const sameLocation = [
459
+ slice('a', 10, 1),
460
+ slice('b', 10, 2),
461
+ slice('c', 10, 3),
462
+ ];
463
+ const forward = groupInstancesBySplitRules(sameLocation, ruleSet(spatialRule));
464
+ const reversed = groupInstancesBySplitRules([...sameLocation].reverse(), ruleSet(spatialRule));
465
+ expect(forward[0].instances.map((i) => i.imageId)).toEqual(['a', 'b', 'c']);
466
+ expect(reversed[0].instances.map((i) => i.imageId)).toEqual([
467
+ 'a',
468
+ 'b',
469
+ 'c',
470
+ ]);
471
+ });
472
+ it('falls back to acquisition order when the comparator returns NaN', () => {
473
+ const partial = [
474
+ slice('second', 20, 2),
475
+ { ...slice('first', 10, 1), SliceLocation: undefined },
476
+ ];
477
+ expect(groupInstancesBySplitRules(partial, ruleSet(spatialRule))[0].instances.map((i) => i.imageId)).toEqual(['first', 'second']);
478
+ expect(groupInstancesBySplitRules([...partial].reverse(), ruleSet(spatialRule))[0].instances.map((i) => i.imageId)).toEqual(['first', 'second']);
479
+ });
480
+ });
481
+ describe('host-supplied instance ordering', () => {
482
+ const slice = (imageId, SliceLocation, InstanceNumber) => ({
483
+ imageId,
484
+ Modality: 'CT',
485
+ SOPClassUID: '1.2.840.10008.5.1.4.1.1.2',
486
+ Rows: 512,
487
+ SeriesInstanceUID: 'ct-series',
488
+ SOPInstanceUID: `sop-${imageId}`,
489
+ SliceLocation,
490
+ InstanceNumber,
491
+ });
492
+ const instances = [slice('a', 10, 3), slice('b', 20, 1), slice('c', 30, 2)];
493
+ const plainRule = { id: 'plain', matches: () => true };
494
+ const byPosition = (list) => [...list].sort((a, b) => a.SliceLocation - b.SliceLocation);
495
+ const byPositionDescending = (list) => [...list].sort((a, b) => b.SliceLocation - a.SliceLocation);
496
+ const ids = (groups) => groups[0].instances.map((i) => i.imageId);
497
+ it('applies a host base sort to every rule', () => {
498
+ const groups = groupInstancesBySplitRules(instances, ruleSet(plainRule), undefined, { sortInstances: byPosition });
499
+ expect(ids(groups)).toEqual(['a', 'b', 'c']);
500
+ });
501
+ it('tells the base sort which rule it is ordering for', () => {
502
+ const seen = [];
503
+ groupInstancesBySplitRules(instances, ruleSet(plainRule), undefined, {
504
+ sortInstances: (list, context) => {
505
+ seen.push(context.matchedRule.id ?? '');
506
+ return list;
507
+ },
508
+ });
509
+ expect(seen).toEqual(['plain']);
510
+ });
511
+ it('lets a rule comparator override the host base sort', () => {
512
+ const groups = groupInstancesBySplitRules(instances, ruleSet({
513
+ id: 'descending',
514
+ matches: () => true,
515
+ compareInstances: (a, b) => b.SliceLocation - a.SliceLocation,
516
+ }), undefined, { sortInstances: byPosition });
517
+ expect(ids(groups)).toEqual(['c', 'b', 'a']);
518
+ });
519
+ it('falls through to the base order when a comparator returns 0', () => {
520
+ const groups = groupInstancesBySplitRules(instances, ruleSet({
521
+ id: 'aFirst',
522
+ matches: () => true,
523
+ compareInstances: (x, y) => {
524
+ if (x.imageId === y.imageId) {
525
+ return 0;
526
+ }
527
+ if (x.imageId === 'a') {
528
+ return -1;
529
+ }
530
+ if (y.imageId === 'a') {
531
+ return 1;
532
+ }
533
+ return 0;
534
+ },
535
+ }), undefined, { sortInstances: byPositionDescending });
536
+ expect(ids(groups)).toEqual(['a', 'c', 'b']);
537
+ });
538
+ it('consults the host comparator after the rule declines', () => {
539
+ const groups = groupInstancesBySplitRules(instances, ruleSet({
540
+ id: 'noOpinion',
541
+ matches: () => true,
542
+ compareInstances: () => 0,
543
+ }), undefined, {
544
+ compareInstances: (a, b) => a.SliceLocation - b.SliceLocation,
545
+ });
546
+ expect(ids(groups)).toEqual(['a', 'b', 'c']);
547
+ });
548
+ it('prefers the rule comparator over the host comparator', () => {
549
+ const groups = groupInstancesBySplitRules(instances, ruleSet({
550
+ id: 'ruleWins',
551
+ matches: () => true,
552
+ compareInstances: (a, b) => b.SliceLocation - a.SliceLocation,
553
+ }), undefined, {
554
+ compareInstances: (a, b) => a.SliceLocation - b.SliceLocation,
555
+ });
556
+ expect(ids(groups)).toEqual(['c', 'b', 'a']);
557
+ });
558
+ it('treats a NaN comparator result as no opinion', () => {
559
+ const groups = groupInstancesBySplitRules(instances, ruleSet({
560
+ id: 'missingTag',
561
+ matches: () => true,
562
+ compareInstances: (a, b) => a.Missing - b.Missing,
563
+ }), undefined, { sortInstances: byPosition });
564
+ expect(ids(groups)).toEqual(['a', 'b', 'c']);
565
+ });
566
+ it('orderInstancesForRule reproduces the engine order outside a split', () => {
567
+ const options = { sortInstances: byPosition };
568
+ const rule = {
569
+ id: 'descending',
570
+ matches: () => true,
571
+ compareInstances: (a, b) => b.SliceLocation - a.SliceLocation,
572
+ };
573
+ const fromEngine = ids(groupInstancesBySplitRules(instances, ruleSet(rule), undefined, options));
574
+ const standalone = orderInstancesForRule(instances, rule, options).map((i) => i.imageId);
575
+ expect(standalone).toEqual(fromEngine);
576
+ });
577
+ });
578
+ describe('InstanceGroup.series - the facts of the whole split', () => {
579
+ const mr = (imageId, InstanceNumber, SliceLocation, DiffusionBValue) => ({
580
+ imageId,
581
+ Modality: 'MR',
582
+ SeriesInstanceUID: 'dwi',
583
+ SOPInstanceUID: `sop-${imageId}`,
584
+ InstanceNumber,
585
+ SliceLocation,
586
+ ...(DiffusionBValue === undefined ? {} : { DiffusionBValue }),
587
+ });
588
+ const dwi = [
589
+ mr('b1', 1, 10, 800),
590
+ mr('b2', 2, 20, 800),
591
+ mr('n1', 3, 10),
592
+ mr('n2', 4, 20),
593
+ ];
594
+ const mixedBValue = ({ instances }) => {
595
+ const withB = instances.filter((i) => i.DiffusionBValue !== undefined);
596
+ return {
597
+ mixed: withB.length > 0 && withB.length < instances.length,
598
+ };
599
+ };
600
+ const dwiRule = {
601
+ id: 'dwi',
602
+ series: mixedBValue,
603
+ groupBy: [
604
+ 'SeriesInstanceUID',
605
+ (instance) => instance.DiffusionBValue === undefined,
606
+ ],
607
+ compareInstances: (a, b, context) => (context.series.mixed ? -1 : 1) *
608
+ (a.SliceLocation - b.SliceLocation),
609
+ };
610
+ const ids = (list) => list.map((i) => i.imageId);
611
+ it('gives every group the series facts of all instances in the split', () => {
612
+ const groups = groupInstancesBySplitRules(dwi, ruleSet(dwiRule));
613
+ expect(groups.map((g) => ids(g.instances))).toEqual([
614
+ ['b2', 'b1'],
615
+ ['n2', 'n1'],
616
+ ]);
617
+ for (const group of groups) {
618
+ expect(mixedBValue({ instances: group.instances })).toEqual({
619
+ mixed: false,
620
+ });
621
+ expect(group.series).toEqual({ mixed: true });
622
+ }
623
+ });
624
+ it('gives a rule with no series hook an empty facts object', () => {
625
+ const groups = groupInstancesBySplitRules(dwi, ruleSet({ id: 'plain', groupBy: ['SeriesInstanceUID'] }));
626
+ expect(groups[0].series).toEqual({});
627
+ });
628
+ it('orderInstancesForRule uses options.series instead of recomputing the facts', () => {
629
+ const [firstHalf] = groupInstancesBySplitRules(dwi, ruleSet(dwiRule));
630
+ expect(ids(orderInstancesForRule(firstHalf.instances, dwiRule, {
631
+ series: firstHalf.series,
632
+ }))).toEqual(['b2', 'b1']);
633
+ expect(ids(orderInstancesForRule(firstHalf.instances, dwiRule))).toEqual([
634
+ 'b1',
635
+ 'b2',
636
+ ]);
637
+ });
638
+ it('orderInstancesForRule passes options.series to the host sort too', () => {
639
+ const seen = [];
640
+ orderInstancesForRule(dwi, dwiRule, {
641
+ series: { mixed: 'from the caller' },
642
+ sortInstances: (list, context) => {
643
+ seen.push(context.series.mixed);
644
+ return list;
645
+ },
646
+ });
647
+ expect(seen).toEqual(['from the caller']);
648
+ });
649
+ });