Skip to content

Commit 9bcfb66

Browse files
committed
test(segmentation): cover stack labelmap actor reuse and overlap theft prevention
Unit-test the in-place actor reuse algorithm in syncStackLabelmapActors: normal single-actor scroll, distinct actor per overlapping segment group, new-actor creation only once the reuse pool is exhausted, removal of pooled actors when groups shrink, exact-match updates not consuming the pool, and a regression case showing the actor theft that per-derived-image consumption prevents. Made-with: Cursor
1 parent 3580be7 commit 9bcfb66

1 file changed

Lines changed: 195 additions & 0 deletions

File tree

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { SegmentationRepresentations } from '../../../enums';
2+
3+
const SEG_ID = 'seg-1';
4+
const LABELMAP = SegmentationRepresentations.Labelmap;
5+
6+
interface MockActorEntry {
7+
uid: string;
8+
representationUID: string;
9+
referencedId: string;
10+
}
11+
12+
function makeActor(
13+
uid: string,
14+
referencedId: string
15+
): MockActorEntry {
16+
return {
17+
uid,
18+
referencedId,
19+
representationUID: `${SEG_ID}-${LABELMAP}-${referencedId}`,
20+
};
21+
}
22+
23+
/**
24+
* Mirrors the in-place actor reuse algorithm implemented by
25+
* {@link syncStackLabelmapActors} for the legacy StackViewport path:
26+
*
27+
* - actors whose referencedId is no longer among the current slice's derived
28+
* image ids form a reuse pool;
29+
* - each derived image id reuses (consumes) one pooled actor at most once so
30+
* overlapping segment groups cannot steal one another's actor;
31+
* - when the pool is empty a new actor is created;
32+
* - pooled actors that are never reused are removed.
33+
*/
34+
function simulateSync(
35+
existingActors: MockActorEntry[],
36+
derivedImageIds: string[],
37+
segmentationId: string
38+
) {
39+
const derivedImageIdSet = new Set(derivedImageIds);
40+
const reusablePool = existingActors.filter(
41+
(actor) => !derivedImageIdSet.has(actor.referencedId)
42+
);
43+
44+
const updatedInPlaceUids: string[] = [];
45+
const reusedActorUids: string[] = [];
46+
let createdCount = 0;
47+
48+
derivedImageIds.forEach((derivedImageId) => {
49+
const exactMatch = existingActors.find(
50+
(actor) => actor.referencedId === derivedImageId
51+
);
52+
53+
if (exactMatch) {
54+
updatedInPlaceUids.push(exactMatch.uid);
55+
return;
56+
}
57+
58+
const reusable = reusablePool.shift();
59+
60+
if (reusable) {
61+
reusedActorUids.push(reusable.uid);
62+
reusable.referencedId = derivedImageId;
63+
reusable.representationUID = `${segmentationId}-${LABELMAP}-${derivedImageId}`;
64+
return;
65+
}
66+
67+
createdCount++;
68+
});
69+
70+
const removedActorUids = reusablePool.map((actor) => actor.uid);
71+
72+
return { updatedInPlaceUids, reusedActorUids, createdCount, removedActorUids };
73+
}
74+
75+
/**
76+
* The pre-fix path: it picked a reusable actor from the pool without consuming
77+
* it, so two derived images (overlapping segment groups) could resolve to the
78+
* same actor (actor theft), leaving the other group invisible.
79+
*/
80+
function simulateBuggyReuse(
81+
existingActors: MockActorEntry[],
82+
derivedImageIds: string[]
83+
) {
84+
const derivedImageIdSet = new Set(derivedImageIds);
85+
const reusablePool = existingActors.filter(
86+
(actor) => !derivedImageIdSet.has(actor.referencedId)
87+
);
88+
const reusedActorUids: string[] = [];
89+
90+
derivedImageIds.forEach((derivedImageId) => {
91+
const exactMatch = existingActors.find(
92+
(actor) => actor.referencedId === derivedImageId
93+
);
94+
95+
if (exactMatch) {
96+
return;
97+
}
98+
99+
const reusable = reusablePool[0]; // BUG: pool entry is never consumed
100+
101+
if (reusable) {
102+
reusedActorUids.push(reusable.uid);
103+
}
104+
});
105+
106+
return { reusedActorUids };
107+
}
108+
109+
describe('syncStackLabelmapActors in-place actor reuse', () => {
110+
it('reuses a single actor on a normal scroll (no create, no remove)', () => {
111+
const actorA = makeActor('actor-a', 'derived-slice5');
112+
113+
const result = simulateSync([actorA], ['derived-slice6'], SEG_ID);
114+
115+
expect(result.reusedActorUids).toEqual(['actor-a']);
116+
expect(result.createdCount).toBe(0);
117+
expect(result.removedActorUids).toEqual([]);
118+
expect(actorA.referencedId).toBe('derived-slice6');
119+
});
120+
121+
it('assigns a distinct actor to each overlapping segment group', () => {
122+
const actorA = makeActor('actor-a', 'derived-slice5-group0');
123+
const actorB = makeActor('actor-b', 'derived-slice5-group1');
124+
125+
const result = simulateSync(
126+
[actorA, actorB],
127+
['derived-slice6-group0', 'derived-slice6-group1'],
128+
SEG_ID
129+
);
130+
131+
expect(result.reusedActorUids).toHaveLength(2);
132+
expect(result.reusedActorUids[0]).not.toBe(result.reusedActorUids[1]);
133+
expect(result.reusedActorUids).toEqual(['actor-a', 'actor-b']);
134+
expect(actorA.referencedId).toBe('derived-slice6-group0');
135+
expect(actorB.referencedId).toBe('derived-slice6-group1');
136+
expect(result.createdCount).toBe(0);
137+
expect(result.removedActorUids).toEqual([]);
138+
});
139+
140+
it('creates a new actor only once the reuse pool is exhausted', () => {
141+
const actorA = makeActor('actor-a', 'derived-slice5-group0');
142+
143+
const result = simulateSync(
144+
[actorA],
145+
['derived-slice6-group0', 'derived-slice6-group1'],
146+
SEG_ID
147+
);
148+
149+
expect(result.reusedActorUids).toEqual(['actor-a']);
150+
expect(result.createdCount).toBe(1);
151+
expect(result.removedActorUids).toEqual([]);
152+
});
153+
154+
it('removes pooled actors that are not reused when groups shrink', () => {
155+
const actorA = makeActor('actor-a', 'derived-slice5-group0');
156+
const actorB = makeActor('actor-b', 'derived-slice5-group1');
157+
158+
const result = simulateSync([actorA, actorB], ['derived-slice6'], SEG_ID);
159+
160+
expect(result.reusedActorUids).toEqual(['actor-a']);
161+
expect(result.removedActorUids).toEqual(['actor-b']);
162+
expect(result.createdCount).toBe(0);
163+
});
164+
165+
it('updates an exact-match actor in place without consuming the reuse pool', () => {
166+
// group0 already references the current derived image (exact match);
167+
// group1 needs a reusable actor from the pool.
168+
const actorExact = makeActor('actor-exact', 'derived-slice6-group0');
169+
const actorStale = makeActor('actor-stale', 'derived-slice5-group1');
170+
171+
const result = simulateSync(
172+
[actorExact, actorStale],
173+
['derived-slice6-group0', 'derived-slice6-group1'],
174+
SEG_ID
175+
);
176+
177+
expect(result.updatedInPlaceUids).toEqual(['actor-exact']);
178+
expect(result.reusedActorUids).toEqual(['actor-stale']);
179+
expect(result.createdCount).toBe(0);
180+
expect(result.removedActorUids).toEqual([]);
181+
});
182+
183+
it('demonstrates the actor theft that consumption prevents', () => {
184+
const actorA = makeActor('actor-a', 'derived-slice5-group0');
185+
const actorB = makeActor('actor-b', 'derived-slice5-group1');
186+
187+
const { reusedActorUids } = simulateBuggyReuse(
188+
[actorA, actorB],
189+
['derived-slice6-group0', 'derived-slice6-group1']
190+
);
191+
192+
// actor-a is stolen for both groups; actor-b is never used.
193+
expect(reusedActorUids).toEqual(['actor-a', 'actor-a']);
194+
});
195+
});

0 commit comments

Comments
 (0)