@cornerstonejs/metadata 5.13.2 → 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.
- package/dist/esm/displayset/BaseDisplaySet.d.ts +3 -4
- package/dist/esm/displayset/BaseDisplaySet.js +1 -5
- package/dist/esm/displayset/IDisplaySet.d.ts +9 -3
- package/dist/esm/displayset/ImageStackDisplaySet.d.ts +1 -3
- package/dist/esm/displayset/ImageStackDisplaySet.js +0 -1
- package/dist/esm/displayset/__fixtures__/scenarioInstances.d.ts +12 -0
- package/dist/esm/displayset/__fixtures__/scenarioInstances.js +210 -0
- package/dist/esm/displayset/compileSplitRules.d.ts +3 -0
- package/dist/esm/displayset/compileSplitRules.js +197 -0
- package/dist/esm/displayset/compiledDisplaySetRules.d.ts +3 -0
- package/dist/esm/displayset/compiledDisplaySetRules.js +3 -0
- package/dist/esm/displayset/createDisplaySetFromGroup.js +54 -19
- package/dist/esm/displayset/displaySetContext.d.ts +150 -0
- package/dist/esm/displayset/displaySetContext.js +918 -0
- package/dist/esm/displayset/displaySetRules.d.ts +5 -0
- package/dist/esm/displayset/displaySetRules.js +142 -0
- package/dist/esm/displayset/displaySetRules.test.d.ts +1 -0
- package/dist/esm/displayset/displaySetRules.test.js +1306 -0
- package/dist/esm/displayset/displaySetRulesTypes.d.ts +34 -0
- package/dist/esm/displayset/displaySetRulesTypes.js +0 -0
- package/dist/esm/displayset/displayset.test.js +422 -130
- package/dist/esm/displayset/groupInstancesBySplitRules.d.ts +8 -2
- package/dist/esm/displayset/groupInstancesBySplitRules.js +174 -17
- package/dist/esm/displayset/index.d.ts +14 -4
- package/dist/esm/displayset/index.js +10 -3
- package/dist/esm/displayset/isEcgInstance.d.ts +2 -1
- package/dist/esm/displayset/isEcgInstance.js +4 -9
- package/dist/esm/displayset/isImageInstance.d.ts +2 -1
- package/dist/esm/displayset/isImageInstance.js +4 -44
- package/dist/esm/displayset/isReconstructableInstances.d.ts +2 -0
- package/dist/esm/displayset/isReconstructableInstances.js +91 -0
- package/dist/esm/displayset/isVideoInstance.d.ts +2 -1
- package/dist/esm/displayset/isVideoInstance.js +4 -30
- package/dist/esm/displayset/isWsiInstance.d.ts +2 -1
- package/dist/esm/displayset/isWsiInstance.js +4 -6
- package/dist/esm/displayset/metadataContext.d.ts +13 -0
- package/dist/esm/displayset/metadataContext.js +42 -0
- package/dist/esm/displayset/metadataContext.test.d.ts +1 -0
- package/dist/esm/displayset/metadataContext.test.js +62 -0
- package/dist/esm/displayset/scenarioFixtures.test.d.ts +1 -0
- package/dist/esm/displayset/scenarioFixtures.test.js +206 -0
- package/dist/esm/displayset/seriesContext.test.d.ts +1 -0
- package/dist/esm/displayset/seriesContext.test.js +238 -0
- package/dist/esm/displayset/seriesFunctions.d.ts +29 -0
- package/dist/esm/displayset/seriesFunctions.js +327 -0
- package/dist/esm/displayset/sopClassUidLists.d.ts +14 -0
- package/dist/esm/displayset/sopClassUidLists.js +100 -0
- package/dist/esm/displayset/sortInstances.d.ts +10 -0
- package/dist/esm/displayset/sortInstances.js +33 -0
- package/dist/esm/displayset/sortInstances.test.d.ts +1 -0
- package/dist/esm/displayset/sortInstances.test.js +56 -0
- package/dist/esm/displayset/splitImageIdsBySplitRules.d.ts +3 -3
- package/dist/esm/displayset/splitImageIdsBySplitRules.js +5 -5
- package/dist/esm/displayset/splitRuleSchema.d.ts +11 -0
- package/dist/esm/displayset/splitRuleSchema.js +168 -0
- package/dist/esm/displayset/splitRuleSet.d.ts +12 -0
- package/dist/esm/displayset/splitRuleSet.js +47 -0
- package/dist/esm/displayset/splitRuleSet.test.d.ts +1 -0
- package/dist/esm/displayset/splitRuleSet.test.js +119 -0
- package/dist/esm/displayset/types.d.ts +37 -4
- package/dist/esm/displayset/types.js +15 -0
- package/dist/esm/index.d.ts +4 -2
- package/dist/esm/index.js +2 -1
- package/dist/esm/safeFunctions/compile.d.ts +31 -0
- package/dist/esm/safeFunctions/compile.js +142 -0
- package/dist/esm/safeFunctions/compile.test.d.ts +1 -0
- package/dist/esm/safeFunctions/compile.test.js +82 -0
- package/dist/esm/safeFunctions/expression/compiler.d.ts +23 -0
- package/dist/esm/safeFunctions/expression/compiler.js +481 -0
- package/dist/esm/safeFunctions/expression/expression.test.d.ts +1 -0
- package/dist/esm/safeFunctions/expression/expression.test.js +325 -0
- package/dist/esm/safeFunctions/expression/index.d.ts +4 -0
- package/dist/esm/safeFunctions/expression/index.js +3 -0
- package/dist/esm/safeFunctions/expression/parser.d.ts +56 -0
- package/dist/esm/safeFunctions/expression/parser.js +248 -0
- package/dist/esm/safeFunctions/expression/tokenizer.d.ts +29 -0
- package/dist/esm/safeFunctions/expression/tokenizer.js +198 -0
- package/dist/esm/safeFunctions/index.d.ts +5 -0
- package/dist/esm/safeFunctions/index.js +4 -0
- package/dist/esm/safeFunctions/matchTests.d.ts +16 -0
- package/dist/esm/safeFunctions/matchTests.js +203 -0
- package/dist/esm/safeFunctions/schema.d.ts +72 -0
- package/dist/esm/safeFunctions/schema.js +361 -0
- package/dist/esm/safeFunctions/types.d.ts +47 -0
- package/dist/esm/safeFunctions/types.js +0 -0
- package/dist/esm/safeFunctions/values.d.ts +6 -0
- package/dist/esm/safeFunctions/values.js +71 -0
- package/dist/esm/utilities/Tags.d.ts +1 -0
- package/dist/esm/utilities/Tags.js +14 -2
- package/dist/esm/utilities/index.d.ts +1 -1
- package/dist/esm/utilities/index.js +1 -1
- package/dist/esm/utilities/toNumber.d.ts +1 -1
- package/dist/esm/utilities/toNumber.js +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +2 -2
- package/dist/esm/displayset/defaultDisplaySetSplitRules.d.ts +0 -2
- package/dist/esm/displayset/defaultDisplaySetSplitRules.js +0 -93
- package/dist/esm/displayset/viewportTypes.d.ts +0 -4
- 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 {
|
|
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
|
-
|
|
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('
|
|
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:
|
|
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
|
|
46
|
-
expect(displaySet.
|
|
47
|
-
expect(displaySet.preferredViewportType).toBe('volume');
|
|
54
|
+
expect(displaySet).toBeInstanceOf(ImageStackDisplaySet);
|
|
55
|
+
expect(displaySet.handler).toBeUndefined();
|
|
48
56
|
});
|
|
49
|
-
it('video rule
|
|
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:
|
|
65
|
+
splitRules: compiledDisplaySetRules,
|
|
58
66
|
});
|
|
59
67
|
expect(groups[0].matchedRule.id).toBe('video');
|
|
60
68
|
const displaySet = createDisplaySetFromGroup(groups[0]);
|
|
61
|
-
expect(displaySet.
|
|
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
|
|
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:
|
|
80
|
+
splitRules: compiledDisplaySetRules,
|
|
74
81
|
});
|
|
75
82
|
expect(groups[0].matchedRule.id).toBe('ecg');
|
|
76
|
-
expect(createDisplaySetFromGroup(groups[0]).
|
|
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:
|
|
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.
|
|
148
|
-
|
|
149
|
-
|
|
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([],
|
|
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'
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
+
});
|