diff --git a/backend-contract/README.md b/backend-contract/README.md index e6fc24fec..8f60df4af 100644 --- a/backend-contract/README.md +++ b/backend-contract/README.md @@ -110,8 +110,25 @@ Two versions on separate clocks: `info.version`, kept in lockstep by `processing/__tests__/openapi.spec.ts`. Versions this package as a published thing. - **Shape versions**: `INTENT_VOCABULARY_VERSION` (`processing/wire.ts`) and - the task-spec `specVersion`. These version the wire vocabulary for additive - compatibility negotiation. + the task-spec `specVersion`. These name the shape of the wire vocabulary in + the generated OpenAPI description and in release notes. Neither travels on + the wire, so neither is negotiated: additive compatibility rests on both + sides failing open on a value they do not know. + +### Result instruction rollout + +Contract artifact 0.3.0 uses intent vocabulary 3 and names segmentation import +`import-segmentation`. A current client still reads the earlier name, +`add-segment-group`, as the same instruction (`LEGACY_RESULT_INTENT_NAMES` in +`processing/wire.ts`), so a producer may move to the new name after the client. +The reverse order does not hold: an older client treats `import-segmentation` +as an ordinary result and will not apply its segmentation automatically. +Update Girder's pinned VolView package before the producer emits the new name. + +This vocabulary change does not change task-spec versions or saved-session +schemas. Girder projects stored job outputs into current instructions when +results are requested; stored output references and mask provenance keep their +identities. ## Regenerating diff --git a/backend-contract/fixtures/negative/wrong-length-color.json b/backend-contract/fixtures/negative/wrong-length-color.json index f5aa4fee1..cc483faa6 100644 --- a/backend-contract/fixtures/negative/wrong-length-color.json +++ b/backend-contract/fixtures/negative/wrong-length-color.json @@ -1,6 +1,6 @@ { "id": "6600000000000000000000e1", - "intent": "add-segment-group", + "intent": "import-segmentation", "url": "/api/v1/file/6600000000000000000000e1/proxiable/otsu.nii.gz", "name": "otsu.nii.gz", "segments": [ diff --git a/backend-contract/fixtures/wire/intent.add-segment-group.embedded.json b/backend-contract/fixtures/wire/intent.import-segmentation.embedded.json similarity index 88% rename from backend-contract/fixtures/wire/intent.add-segment-group.embedded.json rename to backend-contract/fixtures/wire/intent.import-segmentation.embedded.json index 5c6f74ba9..a0a1da4c5 100644 --- a/backend-contract/fixtures/wire/intent.add-segment-group.embedded.json +++ b/backend-contract/fixtures/wire/intent.import-segmentation.embedded.json @@ -1,6 +1,6 @@ { "id": "6600000000000000000000e2", - "intent": "add-segment-group", + "intent": "import-segmentation", "url": "/api/v1/file/6600000000000000000000e2/proxiable/threshold.seg.nrrd", "name": "threshold.seg.nrrd", "source": { diff --git a/backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json b/backend-contract/fixtures/wire/intent.import-segmentation.with-segments.json similarity index 95% rename from backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json rename to backend-contract/fixtures/wire/intent.import-segmentation.with-segments.json index fa0f30949..87cb530d3 100644 --- a/backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json +++ b/backend-contract/fixtures/wire/intent.import-segmentation.with-segments.json @@ -1,6 +1,6 @@ { "id": "6600000000000000000000e1", - "intent": "add-segment-group", + "intent": "import-segmentation", "url": "/api/v1/file/6600000000000000000000e1/proxiable/otsu.nii.gz", "name": "otsu.nii.gz", "segments": [ diff --git a/backend-contract/generated/job-results.schema.json b/backend-contract/generated/job-results.schema.json index 79b8f1c1f..7b651e668 100644 --- a/backend-contract/generated/job-results.schema.json +++ b/backend-contract/generated/job-results.schema.json @@ -114,7 +114,7 @@ "properties": { "intent": { "type": "string", - "const": "add-segment-group" + "const": "import-segmentation" }, "id": { "type": "string", diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json index ff8e661b1..847ae9b41 100644 --- a/backend-contract/generated/openapi.json +++ b/backend-contract/generated/openapi.json @@ -3,8 +3,8 @@ "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", "info": { "title": "VolView neutral backend contract", - "version": "0.2.0", - "description": "DRAFT 0.x — shapes may change until a second backend passes the conformance kit (the pinned 1.0 criterion). The neutral REST surface the VolView client calls to run processing tasks against a backend. A conforming server-side BACKEND implements these endpoints and the referenced wire schemas — no VolView client change is needed to bring a new backend online. Everything here is neutral: no backend routes, ids, status enums, or URL shapes leak. The artifact version is the draft artifact version, distinct from the shape versions: the result-intent vocabulary is at version 2 (INTENT_VOCABULARY_VERSION); the task-spec shape at version 1 (specVersion)." + "version": "0.3.0", + "description": "DRAFT 0.x — shapes may change until a second backend passes the conformance kit (the pinned 1.0 criterion). The neutral REST surface the VolView client calls to run processing tasks against a backend. A conforming server-side BACKEND implements these endpoints and the referenced wire schemas — no VolView client change is needed to bring a new backend online. Everything here is neutral: no backend routes, ids, status enums, or URL shapes leak. The artifact version is the draft artifact version, distinct from the shape versions: the result-intent vocabulary is at version 3 (INTENT_VOCABULARY_VERSION); the task-spec shape at version 1 (specVersion)." }, "servers": [ { @@ -1203,7 +1203,7 @@ "properties": { "intent": { "type": "string", - "const": "add-segment-group" + "const": "import-segmentation" }, "id": { "type": "string", diff --git a/backend-contract/generated/result-intent.schema.json b/backend-contract/generated/result-intent.schema.json index e0b27e58b..9ba4f9ec8 100644 --- a/backend-contract/generated/result-intent.schema.json +++ b/backend-contract/generated/result-intent.schema.json @@ -102,7 +102,7 @@ "properties": { "intent": { "type": "string", - "const": "add-segment-group" + "const": "import-segmentation" }, "id": { "type": "string", diff --git a/backend-contract/package.json b/backend-contract/package.json index 1afe62c13..f1a07f920 100644 --- a/backend-contract/package.json +++ b/backend-contract/package.json @@ -1,5 +1,5 @@ { "name": "@volview/backend-contract", - "version": "0.2.0", + "version": "0.3.0", "private": true } diff --git a/backend-contract/processing/__tests__/wire.spec.ts b/backend-contract/processing/__tests__/wire.spec.ts index 603c1bed6..a472b54ef 100644 --- a/backend-contract/processing/__tests__/wire.spec.ts +++ b/backend-contract/processing/__tests__/wire.spec.ts @@ -194,12 +194,12 @@ describe('neutral job status fixtures', () => { // --------------------------------------------------------------------------- describe('result intent fixtures', () => { - it('exports vocabulary version 2 and the exactly-four state intents', () => { - expect(INTENT_VOCABULARY_VERSION).toBe(2); + it('exports vocabulary version 3 and the exactly-four state intents', () => { + expect(INTENT_VOCABULARY_VERSION).toBe(3); expect([...RESULT_INTENTS]).toEqual([ 'add-base-image', 'add-layer', - 'add-segment-group', + 'import-segmentation', 'add-annotations', ]); expect(wire).not.toHaveProperty('intent.download'); @@ -208,8 +208,8 @@ describe('result intent fixtures', () => { it.each([ 'intent.add-base-image', 'intent.add-layer', - 'intent.add-segment-group.with-segments', - 'intent.add-segment-group.embedded', + 'intent.import-segmentation.with-segments', + 'intent.import-segmentation.embedded', 'intent.add-annotations', 'intent.unknown', ])('validates %s', (name) => { @@ -253,11 +253,11 @@ describe('result intent fixtures', () => { ).toBe(false); }); - it('parses add-segment-group WITH segments and a source provenance tag', () => { + it('parses import-segmentation WITH segments and a source provenance tag', () => { const parsed = resultIntentSchema.parse( - wire['intent.add-segment-group.with-segments'] + wire['intent.import-segmentation.with-segments'] ) as Record; - expect(parsed.intent).toBe('add-segment-group'); + expect(parsed.intent).toBe('import-segmentation'); expect(Array.isArray(parsed.segments)).toBe(true); expect(parsed.source).toEqual({ providerId: 'analysis-provider', @@ -266,18 +266,18 @@ describe('result intent fixtures', () => { }); }); - it('parses add-segment-group WITHOUT segments (embedded metadata) but with source', () => { + it('parses import-segmentation WITHOUT segments (embedded metadata) but with source', () => { const parsed = resultIntentSchema.parse( - wire['intent.add-segment-group.embedded'] + wire['intent.import-segmentation.embedded'] ) as Record; - expect(parsed.intent).toBe('add-segment-group'); + expect(parsed.intent).toBe('import-segmentation'); expect(parsed.segments).toBeUndefined(); expect(parsed.source).toMatchObject({ outputId: 'outputLabelmap' }); }); it('rejects a segment-group source without provider identity', () => { const value = structuredClone( - wire['intent.add-segment-group.with-segments'] + wire['intent.import-segmentation.with-segments'] ) as { source: { providerId?: string } }; delete value.source.providerId; expect(knownResultIntentSchema.safeParse(value).success).toBe(false); @@ -373,7 +373,7 @@ describe('result intent fixtures', () => { expect(knownResultIntentSchema.safeParse(short).success).toBe(false); expect(resultIntentSchema.safeParse(short).success).toBe(true); - const good = wire['intent.add-segment-group.with-segments'] as { + const good = wire['intent.import-segmentation.with-segments'] as { segments: { color: number[] }[]; }; const long = structuredClone(good); diff --git a/backend-contract/processing/openapi.ts b/backend-contract/processing/openapi.ts index 8a62458d6..e9bda206d 100644 --- a/backend-contract/processing/openapi.ts +++ b/backend-contract/processing/openapi.ts @@ -474,7 +474,7 @@ export const buildOpenApiDocument = (): Record => ({ // VERSION / specVersion) below. It is deliberately literal, not derived from // the shape-version constants — the artifact and the shapes version on // separate clocks. - version: '0.2.0', + version: '0.3.0', description: 'DRAFT 0.x — shapes may change until a second backend passes the ' + 'conformance kit (the pinned 1.0 criterion). ' + diff --git a/backend-contract/processing/wire.ts b/backend-contract/processing/wire.ts index 27c2d41ba..cac60a33b 100644 --- a/backend-contract/processing/wire.ts +++ b/backend-contract/processing/wire.ts @@ -18,10 +18,14 @@ import { } from './task-spec'; import { pathSegmentIdSchema } from './ids'; -// Bump when the intent vocabulary's shape changes so producers and the applier -// can negotiate compatibility. Adding an intent is a compatible bump: an older -// client demotes the unknown intent through the fail-open branch above. -export const INTENT_VOCABULARY_VERSION = 2; +// A client-side marker for the shape of the intent vocabulary below, bumped +// when that shape changes. It never travels on the wire: no request header, +// response field, or schema property carries it, so the two sides never see +// each other's value and cannot negotiate on it. It names the vocabulary in +// the generated OpenAPI description and in this package's release notes. +// Adding an intent stays compatible without it: an older client demotes the +// unknown intent through the fail-open branch above. +export const INTENT_VOCABULARY_VERSION = 3; // --------------------------------------------------------------------------- // Input value: what the client sends at submit @@ -158,11 +162,23 @@ export type NeutralJobStatus = z.infer; export const RESULT_INTENTS = [ 'add-base-image', 'add-layer', - 'add-segment-group', + 'import-segmentation', 'add-annotations', ] as const; export type ResultIntentName = (typeof RESULT_INTENTS)[number]; +// Names an earlier vocabulary gave an intent whose shape has not changed since. +// A client reads one as its current name, so a producer still on the old +// vocabulary keeps applying and the two sides need not deploy in lockstep. +export const LEGACY_RESULT_INTENT_NAMES: Readonly< + Record +> = { + 'add-segment-group': 'import-segmentation', +}; + +export const currentResultIntentName = (intent: unknown) => + (typeof intent === 'string' && LEGACY_RESULT_INTENT_NAMES[intent]) || intent; + // Provenance tag on a result: the durable idempotency identity the client // preserves on generated scene state so restored results can be recognized. export const resultSourceSchema = z.object({ @@ -212,13 +228,13 @@ const addLayer = z .object({ intent: z.literal('add-layer'), ...resultListItemSchema.shape }) .passthrough(); -// `add-segment-group` carries OPTIONAL `segments` (the bare-labelmap + +// `import-segmentation` carries OPTIONAL `segments` (the bare-labelmap + // labels-sidecar case; a `seg.nrrd` with embedded metadata carries none — the // client uses `segments` when present, else the file's own metadata) and an // optional `source` provenance tag (the idempotency key). const addSegmentGroup = z .object({ - intent: z.literal('add-segment-group'), + intent: z.literal('import-segmentation'), ...resultListItemSchema.shape, segments: z.array(segmentDescriptorSchema).optional(), source: resultSourceSchema.optional(), diff --git a/src/components/EditableItemList.vue b/src/components/EditableItemList.vue index 49d0185fd..b978ddd0d 100644 --- a/src/components/EditableItemList.vue +++ b/src/components/EditableItemList.vue @@ -191,7 +191,7 @@ const moveBy = async (key: ItemKey, offset: number, event: KeyboardEvent) => { class="reorder-handle" draggable="true" :aria-label="`Reorder ${title}`" - title="Drag to reorder segments and shortcuts. Earlier segments render in front. Alt+Up or Alt+Down also moves this segment." + title="Drag to reorder segments and shortcuts. Earlier segments win picking and flattened export; overlaps blend in the view. Alt+Up or Alt+Down also moves this segment." @click.stop @dragstart.stop="startDrag($event, key)" @dragend="clearDrag" diff --git a/src/components/Settings.vue b/src/components/Settings.vue index 94ca96577..5d81bdf4c 100644 --- a/src/components/Settings.vue +++ b/src/components/Settings.vue @@ -77,7 +77,7 @@ export default defineComponent({ const dark = ref(theme.global.name.value === DarkTheme); watch(dark, (isDark) => { - theme.global.name.value = isDark ? DarkTheme : LightTheme; + theme.change(isDark ? DarkTheme : LightTheme); store.value = theme.global.name.value; }); diff --git a/src/io/state-file/__tests__/boundedMaskRoundTrip.spec.ts b/src/io/state-file/__tests__/boundedMaskRoundTrip.spec.ts index ed65a952a..5b0f2a1a9 100644 --- a/src/io/state-file/__tests__/boundedMaskRoundTrip.spec.ts +++ b/src/io/state-file/__tests__/boundedMaskRoundTrip.spec.ts @@ -31,6 +31,8 @@ import { boundMasks, } from '@/src/segmentation/__tests__/segmentMaskFixtures'; import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; +import { compositeLabelmap } from '@/src/segmentation/io/composition'; +import { labelmapScalars } from '@/src/segmentation/io/labelmap'; // --------------------------------------------------------------------------- // The state file carries N bounded masks. What goes into the archive is each @@ -482,6 +484,16 @@ describe('bounded masks through the state file', () => { extent: [3, 4, 1, 1, 1, 1], reason: 'extent leaves the parent image', }, + { + title: 'an extent with fractional coordinates', + extent: [0.5, 1.5, 1, 1, 1, 1], + reason: 'extent coordinates must be finite integers', + }, + { + title: 'a fractional extent whose axes otherwise look empty', + extent: [0.5, -0.5, 0, -1, 0, -1], + reason: 'extent coordinates must be finite integers', + }, ])('rejects $title', async ({ extent, reason }) => { await buildScene(); @@ -494,6 +506,14 @@ describe('bounded masks through the state file', () => { expect(restoredSegment('Tumor').representations.labelmap).toBeUndefined(); expect(result.skipped).toContainEqual({ name: storage!.name, reason }); + expect(markedVoxels(restoredSegment('Node').id)).toEqual([ + [3, 3, 3, SEGMENT_VALUE], + ]); + expect( + Array.from(labelmapScalars(compositeLabelmap('new-1').labelmap)).flatMap( + (value, index) => (value === 0 ? [] : [[index, value]]) + ) + ).toEqual([[63, 2]]); expect(boundMasks()).toHaveLength(3); }); diff --git a/src/plugins/vuetify.js b/src/plugins/vuetify.js index 3cfa1a0e0..ff3db2278 100644 --- a/src/plugins/vuetify.js +++ b/src/plugins/vuetify.js @@ -48,6 +48,6 @@ const theme = useLocalStorage(ThemeStorageKey, DefaultTheme); if (theme.value !== DarkTheme && theme.value !== LightTheme) { theme.value = DefaultTheme; } -vuetify.theme.global.name.value = theme.value; +vuetify.theme.change(theme.value); export default vuetify; diff --git a/src/processing/__tests__/applyResults.annotations.spec.ts b/src/processing/__tests__/applyResults.annotations.spec.ts index 0b933b9ef..e1d2a2be6 100644 --- a/src/processing/__tests__/applyResults.annotations.spec.ts +++ b/src/processing/__tests__/applyResults.annotations.spec.ts @@ -729,13 +729,17 @@ describe('applyIntent — add-annotations', () => { expect(toolCounts()).toEqual({ rulers: 0, rectangles: 0, polygons: 0 }); }); - it('applies without a source when the producer omitted one', async () => { - const outcome = await apply( - intent({ source: undefined }), - context(IMAGE_ID) - ); - expect(outcome.status).toBe('applied'); - expect(onlyTool(useRulerStore()).source).toBeUndefined(); + it('mints the receipt from the submitted job when the producer omitted a source', async () => { + const unsourced = intent({ source: undefined }); + expect((await apply(unsourced, context(IMAGE_ID))).status).toBe('applied'); + expect(onlyTool(useRulerStore()).source).toEqual({ + providerId: 'provider-1', + jobId: 'job-1', + outputId: 'r1', + }); + + await apply(unsourced, context(IMAGE_ID)); + expect(toolCounts()).toEqual({ rulers: 1, rectangles: 1, polygons: 1 }); }); // A stored `frame` flips a tool into cine semantics (render slice, diff --git a/src/processing/__tests__/applyResults.segments.spec.ts b/src/processing/__tests__/applyResults.segments.spec.ts index 2ff22019d..d5f37e1bd 100644 --- a/src/processing/__tests__/applyResults.segments.spec.ts +++ b/src/processing/__tests__/applyResults.segments.spec.ts @@ -8,6 +8,7 @@ import { appApplyDependencies, } from '@/src/processing/applyResults'; import { buildSegNrrdMetadata } from '@/src/io/segNrrdMetadata'; +import { isEmptyExtent } from '@/src/segmentation/geometry'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useSegmentationStore } from '@/src/segmentation/store'; import { useSegmentStore } from '@/src/segmentation/segments'; @@ -33,7 +34,7 @@ const existingMask = (imageId: string, name: string) => { const importResult = (segments?: SegmentDescriptor[]) => applyIntent( { - intent: 'add-segment-group', + intent: 'import-segmentation', id: 'result', name: 'output.nrrd', url: 'https://example/output.nrrd', @@ -117,7 +118,7 @@ describe('processing segment identity', () => { ]); }); - it('overrides embedded names before binding and preserves undescribed source values', async () => { + it('overrides embedded names, keeps undescribed source values, and keeps a declared empty', async () => { existingMask('parent-A', 'Embedded'); const output = useImageCacheStore().imageById.output; output.headerMetadata = buildSegNrrdMetadata( @@ -128,20 +129,33 @@ describe('processing segment identity', () => { scalars.getData()[42] = 7; scalars.modified(); - await importResult([ - { value: 1, name: 'Explicit', color: blue, visible: false }, - { value: 99, name: 'Absent', color: blue }, - ]); + // Asserted, not discarded: the import pairs the masks it minted with the + // descriptors it decoded by position, so a descriptor list that grows + // after the decode fails the whole apply rather than the row below. + expect( + await importResult([ + { value: 1, name: 'Explicit', color: blue, visible: false }, + { value: 99, name: 'Absent', color: blue }, + ]) + ).toEqual({ status: 'applied' }); + // Value 99 has no voxels and no header block. A segment a result DECLARES + // but leaves EMPTY appears as an empty row, so it is minted after the + // decoded ones. expect(appearanceOnB()).toMatchObject([ { name: 'Explicit', color: blue, visible: false }, { name: 'output 7' }, + { name: 'Absent', color: blue, visible: true }, ]); expect(registry().findSegmentByName('Embedded')?.color).toEqual(red); - expect(registry().findSegmentByName('Absent')).toBeUndefined(); expect( store().imageMasks('parent-B')[1].representations.labelmap?.extent ).toEqual([2, 2, 2, 2, 2, 2]); + expect( + isEmptyExtent( + store().imageMasks('parent-B')[2].representations.labelmap!.extent + ) + ).toBe(true); }); it('applies source-value descriptions independently to every component', async () => { diff --git a/src/processing/__tests__/applyResults.spec.ts b/src/processing/__tests__/applyResults.spec.ts index 8131daf6d..457f485f4 100644 --- a/src/processing/__tests__/applyResults.spec.ts +++ b/src/processing/__tests__/applyResults.spec.ts @@ -70,6 +70,14 @@ const context = (activeDatasetId?: string): SubmittedJobContext => ({ activeDatasetId, }); +// The provenance the client mints for a result whose producer sent none: the +// job it submitted plus the result row it is applying. +const mintedSource = (outputId: string) => ({ + providerId: 'p1', + jobId: 'j1', + outputId, +}); + const result = ( overrides: Partial = {} ): ProcessingResult => ({ @@ -131,21 +139,21 @@ describe('applyIntent', () => { { value: 2, name: 'tumor', color: rgba(0, 255, 0, 255), visible: false }, ]; await apply( - { intent: 'add-segment-group', ...file, segments }, + { intent: 'import-segmentation', ...file, segments }, context('parent') ); expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( 'child-selection', 'parent', - undefined, + mintedSource('r1'), segments ); expect(deps.openVolumeUrls).not.toHaveBeenCalled(); }); - it('add-segment-group removes the temporarily imported child dataset', async () => { + it('import-segmentation removes the temporarily imported child dataset', async () => { const outcome = await apply( - { intent: 'add-segment-group', ...file }, + { intent: 'import-segmentation', ...file }, context('parent') ); expect(outcome.status).toBe('applied'); @@ -155,12 +163,12 @@ describe('applyIntent', () => { ); }); - it('add-segment-group removes the imported child even when conversion fails', async () => { + it('import-segmentation removes the imported child even when conversion fails', async () => { deps.segmentWriter.convertImageToLabelmap.mockRejectedValue( new Error('bounds do not intersect') ); const outcome = await apply( - { intent: 'add-segment-group', ...file }, + { intent: 'import-segmentation', ...file }, context('parent') ); expect(outcome.status).toBe('failed'); @@ -176,12 +184,12 @@ describe('applyIntent', () => { expect(deps.removeDataset).not.toHaveBeenCalled(); }); - it('add-segment-group with no segments still converts (embedded metadata)', async () => { - await apply({ intent: 'add-segment-group', ...file }, context('parent')); + it('import-segmentation with no segments still converts (embedded metadata)', async () => { + await apply({ intent: 'import-segmentation', ...file }, context('parent')); expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( 'child-selection', 'parent', - undefined, + mintedSource('r1'), undefined ); }); @@ -193,7 +201,7 @@ describe('applyIntent', () => { outputId: 'outputLabelmap', }; await apply( - { intent: 'add-segment-group', ...file, source }, + { intent: 'import-segmentation', ...file, source }, context('parent') ); expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( @@ -213,7 +221,7 @@ describe('applyIntent', () => { deps.segmentWriter.resultSourcesInScene.mockReturnValue([source]); const outcome = await apply( - { intent: 'add-segment-group', ...file, source }, + { intent: 'import-segmentation', ...file, source }, context('parent') ); @@ -229,7 +237,7 @@ describe('applyIntent', () => { deps.segmentWriter.resultSourcesInScene.mockReturnValue([inScene]); const outcome = await apply( - { intent: 'add-segment-group', ...file, source }, + { intent: 'import-segmentation', ...file, source }, context('parent') ); @@ -258,7 +266,26 @@ describe('applyIntent', () => { deps.segmentWriter.resultSourcesInScene.mockReturnValue([undefined]); const outcome = await apply( - { intent: 'add-segment-group', ...file }, + { intent: 'import-segmentation', ...file }, + context('parent') + ); + + expect(outcome.status).toBe('applied'); + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledTimes(1); + }); + + // The producer may omit the optional `source`, leaving nothing in the scene + // to say the result was applied. The client mints the key instead, so the + // second Load recognizes it. + it('recognizes the provenance it minted for a source-less result', async () => { + await apply({ intent: 'import-segmentation', ...file }, context('parent')); + + // What a save and restore hand back: the receipt the first Load wrote. + deps.segmentWriter.resultSourcesInScene.mockReturnValue([ + mintedSource('r1'), + ]); + const outcome = await apply( + { intent: 'import-segmentation', ...file }, context('parent') ); @@ -266,8 +293,8 @@ describe('applyIntent', () => { expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledTimes(1); }); - it('add-segment-group with no originating dataset falls back to opening', async () => { - await apply({ intent: 'add-segment-group', ...file }, context(undefined)); + it('import-segmentation with no originating dataset falls back to opening', async () => { + await apply({ intent: 'import-segmentation', ...file }, context(undefined)); expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); expect(deps.openVolumeUrls).toHaveBeenCalledWith({ urls: [file.url], @@ -275,10 +302,10 @@ describe('applyIntent', () => { }); }); - it('add-segment-group reports an explicit failure when the result fails to load (#7)', async () => { + it('import-segmentation reports an explicit failure when the result fails to load (#7)', async () => { deps.importVolume.mockResolvedValue(null); const applied = await apply( - { intent: 'add-segment-group', ...file }, + { intent: 'import-segmentation', ...file }, context('parent') ); expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); @@ -330,7 +357,7 @@ describe('autoLoadProcessingResults', () => { result({ id: 'b', intent: 'add-layer' }), result({ id: 'c', - intent: 'add-segment-group', + intent: 'import-segmentation', source: { providerId: 'p1', jobId: 'j1', outputId: 'seg' }, segments: [{ value: 1, name: 'liver', color: rgba(1, 2, 3, 4) }], }), @@ -358,6 +385,66 @@ describe('autoLoadProcessingResults', () => { expect(deps.openVolumeUrls).not.toHaveBeenCalled(); }); + it('says which result was skipped and why, naming the intent', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await autoLoad( + [result({ name: 'seg.nrrd', intent: 'add-polygon' })], + context('parent') + ); + expect(errorMessages()).toEqual([ + expect.objectContaining({ + title: 'Did not load seg.nrrd', + options: expect.objectContaining({ + details: expect.stringContaining('add-polygon'), + }), + }), + ]); + }); + + it('imports a segmentation a 0.2.0 backend still names add-segment-group', async () => { + await autoLoad( + [result({ name: 'seg.nrrd', intent: 'add-segment-group' })], + context('parent') + ); + expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledTimes(1); + expect(errorMessages()).toEqual([]); + }); + + it('blames the payload, not the client, for a known intent it rejects', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // import-segmentation is routed, but a three-component color fails the + // segment descriptor, so the result carries no directive. + await autoLoad( + [ + result({ + name: 'otsu.nii.gz', + intent: 'import-segmentation', + segments: [ + { value: 1, name: 'Bin 1', color: [255, 0, 0] }, + ] as unknown as ProcessingResult['segments'], + }), + ], + context('parent') + ); + expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); + expect(errorMessages()).toEqual([ + expect.objectContaining({ + title: 'Did not load otsu.nii.gz', + options: expect.objectContaining({ + details: expect.stringContaining('import-segmentation'), + }), + }), + ]); + expect(errorMessages()[0].options.details).not.toContain( + 'This version cannot apply' + ); + }); + + it('stays quiet about a result that declares no intent', async () => { + await autoLoad([result()], context('parent')); + expect(errorMessages()).toEqual([]); + }); + it('opens base images even when there is no originating dataset', async () => { await autoLoad([result({ intent: 'add-base-image' })], context(undefined)); expect(deps.openVolumeUrls).toHaveBeenCalledWith({ @@ -369,7 +456,7 @@ describe('autoLoadProcessingResults', () => { it('opens a parentless segment-group result as an ordinary dataset', async () => { await autoLoad( - [result({ intent: 'add-segment-group' })], + [result({ intent: 'import-segmentation' })], context(undefined) ); expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); @@ -386,8 +473,8 @@ describe('autoLoadProcessingResults', () => { .mockResolvedValueOnce([importedComponent({ 1: 'segment-g2' })]); const application = await autoLoad( [ - result({ id: 'a', intent: 'add-segment-group' }), - result({ id: 'b', intent: 'add-segment-group' }), + result({ id: 'a', intent: 'import-segmentation' }), + result({ id: 'b', intent: 'import-segmentation' }), ], context('parent') ); @@ -422,12 +509,12 @@ describe('autoLoadProcessingResults', () => { [ result({ id: 'restored', - intent: 'add-segment-group', + intent: 'import-segmentation', source: restoredSource, }), result({ id: 'new', - intent: 'add-segment-group', + intent: 'import-segmentation', source: newSource, }), ], @@ -448,7 +535,7 @@ describe('autoLoadProcessingResults', () => { describe('autoLoadProcessingResults — labelmap auto-apply', () => { const segResult = (overrides: Partial = {}) => - result({ id: 'seg', intent: 'add-segment-group', ...overrides }); + result({ id: 'seg', intent: 'import-segmentation', ...overrides }); it('auto-applies an importable labelmap', async () => { deps.segmentWriter.convertImageToLabelmap.mockResolvedValue([ @@ -463,7 +550,7 @@ describe('autoLoadProcessingResults — labelmap auto-apply', () => { expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( 'child-selection', 'parent', - undefined, + mintedSource('seg'), undefined ); }); @@ -483,7 +570,7 @@ describe('autoLoadProcessingResults — born-persistent (no confirm gate)', () = importedComponent({ 1: 'segment-1' }), ]); await autoLoad( - [result({ id: 'seg', intent: 'add-segment-group', source })], + [result({ id: 'seg', intent: 'import-segmentation', source })], context('parent') ); expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( diff --git a/src/processing/__tests__/declaredEmptySegments.spec.ts b/src/processing/__tests__/declaredEmptySegments.spec.ts new file mode 100644 index 000000000..09a7e36d7 --- /dev/null +++ b/src/processing/__tests__/declaredEmptySegments.spec.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { nextTick } from 'vue'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; + +import type { SegmentDescriptor } from '@/backend-contract'; +import { + applyIntent, + appApplyDependencies, +} from '@/src/processing/applyResults'; +import { listMasks } from '@/src/segmentation/model'; +import { isEmptyExtent } from '@/src/segmentation/geometry'; +import { segmentRenderMask } from '@/src/segmentation/rendering/renderMask'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { + inMemoryArtifactIO, + manifestForImages, + markedVoxels, + parentImage, + seatImage, + serializeToStateFiles, + store, + type Index3, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; + +// --------------------------------------------------------------------------- +// A result declares the bins its run looked for, and the labelmap carries the +// voxels it found. A segment a result DECLARES but leaves EMPTY still appears, +// as an empty row. A run that looked for a spleen and found none must not read +// the same as a run that never looked, so the declaration is what mints the +// segment, not the voxels. +// --------------------------------------------------------------------------- + +const GRID = { dimensions: [4, 4, 4] as Index3 }; +const LIVER_INDEX: Index3 = [1, 1, 1]; +const red = [255, 0, 0, 255] as [number, number, number, number]; +const blue = [0, 0, 255, 255] as [number, number, number, number]; + +const DECLARED: SegmentDescriptor[] = [ + { value: 1, name: 'Liver', color: red }, + { value: 2, name: 'Spleen', color: blue }, +]; + +const registry = () => useSegmentStore().segments; + +const importResult = (segments: SegmentDescriptor[]) => + applyIntent( + { + intent: 'import-segmentation', + id: 'result', + name: 'output.nrrd', + url: 'https://example/output.nrrd', + segments, + source: { providerId: 'provider', jobId: 'job', outputId: 'mask' }, + }, + { + jobId: 'job', + taskId: 'task', + providerId: 'provider', + submittedAt: '', + activeDatasetId: 'parent', + }, + { + ...appApplyDependencies(), + importVolume: async () => 'output', + removeDataset: (id) => useImageCacheStore().removeImage(id), + } + ); + +const segmentsOn = (imageId: string) => + listMasks(store().getSegmentationForImage(imageId)!).map((segment) => { + const appearance = registry().appearanceOf(segment.segmentId); + return { + name: appearance.name, + color: [...appearance.color], + visible: appearance.visible, + extent: segment.representations.labelmap + ? [...segment.representations.labelmap.extent] + : undefined, + marks: markedVoxels(segment.id), + }; + }); + +const LIVER_ROW = { + name: 'Liver', + color: red, + visible: true, + extent: [1, 1, 1, 1, 1, 1], + marks: [[...LIVER_INDEX, SEGMENT_VALUE]], +}; +const EMPTY_SPLEEN_ROW = { + name: 'Spleen', + color: blue, + visible: true, + extent: [0, -1, 0, -1, 0, -1], + marks: [], +}; + +const liverOffset = () => + LIVER_INDEX[0] + LIVER_INDEX[1] * 4 + LIVER_INDEX[2] * 16; + +/** Only value 1 is written, so value 2 is declared and never filled. */ +async function seatScene() { + await seatImage('parent', { ...GRID, name: 'CT' }); + const values = new Uint8Array(64); + values[liverOffset()] = 1; + await seatImage('output', { ...GRID, name: 'output.nrrd', values }); +} + +describe('a segment a result declares but leaves empty', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatScene(); + }); + + it('appears as an empty row beside the segment that has voxels', async () => { + expect(await importResult(DECLARED)).toEqual({ status: 'applied' }); + + expect(segmentsOn('parent')).toEqual([LIVER_ROW, EMPTY_SPLEEN_ROW]); + // The empty row is a real mask record, holding no voxels. + const spleen = listMasks(store().getSegmentationForImage('parent')!)[1]; + expect(store().maskVoxels(spleen.id).scalars()).toHaveLength(0); + }); + + it('draws nothing for the empty segment', async () => { + await importResult(DECLARED); + + const spleen = listMasks(store().getSegmentationForImage('parent')!)[1]; + const binding = spleen.representations.labelmap!; + expect(isEmptyExtent(binding.extent)).toBe(true); + expect( + segmentRenderMask(binding.image, parentImage('parent'), binding.extent, { + axis: 2, + index: 1, + }) + ).toBeNull(); + }); + + it.each([0, 1])( + 'mints no empty twin for a value only component %i carries', + async (component) => { + // A value with voxels in one component and none in the other is not a + // declaration left empty: some component found it. Only the value no + // component carries becomes a row of its own. + const values = new Uint8Array(128); + values[liverOffset() * 2 + component] = 1; + useImageCacheStore() + .getVtkImageData('output')! + .getPointData() + .setScalars( + vtkDataArray.newInstance({ numberOfComponents: 2, values }) + ); + + expect(await importResult(DECLARED)).toEqual({ status: 'applied' }); + + // Compared whole: a twin is not the only way this can go wrong, and a + // mis-coloured, hidden or wrongly bounded empty must fail here too. + expect(segmentsOn('parent')).toEqual([LIVER_ROW, EMPTY_SPLEEN_ROW]); + } + ); + + it('keeps both segments across a save and restore', async () => { + await importResult(DECLARED); + const before = segmentsOn('parent'); + // Stated outright, so the comparison below cannot pass on a scene that + // dropped the empty row before it was ever saved. + expect(before).toEqual([LIVER_ROW, EMPTY_SPLEEN_ROW]); + const io = inMemoryArtifactIO(); + + const { parsed, stateFiles } = await serializeToStateFiles( + manifestForImages(['parent']), + io + ); + + setActivePinia(createPinia()); + await seatImage('new-parent', { ...GRID, name: 'CT' }); + const result = await useSegmentationStore().deserialize({ + manifest: parsed, + stateFiles, + dataIDMap: { parent: 'new-parent' }, + segmentIdMap: useSegmentStore().deserialize(parsed), + io, + }); + await nextTick(); + + expect(result.skipped).toEqual([]); + expect(segmentsOn('new-parent')).toEqual(before); + }); +}); diff --git a/src/processing/applyResults.ts b/src/processing/applyResults.ts index cbb2f3b50..6b450e71f 100644 --- a/src/processing/applyResults.ts +++ b/src/processing/applyResults.ts @@ -1,5 +1,7 @@ import { ANNOTATION_TOOL_KINDS, + RESULT_INTENTS, + currentResultIntentName, type AnnotationLabel, type AnnotationToolKind, type KnownResultIntent, @@ -34,14 +36,14 @@ import { useDICOMStore } from '@/src/store/datasets-dicom'; import { useLayersStore } from '@/src/store/datasets-layers'; import { useSegmentationStore } from '@/src/segmentation/store'; import { useImageCacheStore } from '@/src/store/image-cache'; -import { useMessageStore } from '@/src/store/messages'; +import { surfaceWarning, useMessageStore } from '@/src/store/messages'; import { loadVolumeUrls } from '@/src/actions/loadUserFiles'; type ResultFile = { url: string; name: string }; type SegmentationIntent = Extract< KnownResultIntent, - { intent: 'add-segment-group' } + { intent: 'import-segmentation' } >; type AnnotationsIntent = Extract< KnownResultIntent, @@ -60,16 +62,32 @@ const sameResultSource = ( source.outputId === target.outputId; function segmentResultInScene( - intent: SegmentationIntent, + target: ResultSource | undefined, segmentWriter: SegmentWriter ): boolean { - const target = intent.source; if (!target) return false; return segmentWriter .resultSourcesInScene() .some((source) => sameResultSource(source, target)); } +// `source` is optional on the wire, and without one a re-applied result has no +// receipt to recognize: after a reload the re-adopted job's Load button imports +// every mask, or places every annotation, again. The client already knows the same three facts (the +// provider and job it submitted, and the result row it is applying), so it +// mints the key itself. Nothing new travels on the wire; the minted key is +// scene provenance, stored and restored exactly like a producer's own. +const resultSourceOf = ( + intent: SegmentationIntent | AnnotationsIntent, + context: SubmittedJobContext | undefined +): ResultSource | undefined => + intent.source ?? + (context && { + providerId: context.providerId, + jobId: context.jobId, + outputId: intent.id, + }); + async function loadAsImport(file: ResultFile) { const ds = uriToDataSource(file.url, file.name); const importResults = await importVolumeDataSources([ds]); @@ -84,8 +102,7 @@ async function loadAsImport(file: ResultFile) { // Session-restored tools retain their result source, so that durable // provenance doubles as an application receipt: re-Loading a job adds nothing. -function annotationResultInScene(intent: AnnotationsIntent): boolean { - const target = intent.source; +function annotationResultInScene(target: ResultSource | undefined): boolean { if (!target) return false; return ANNOTATION_TOOL_KINDS.some((kind) => Object.values(annotationToolStore(kind).toolByID).some(({ source }) => @@ -286,9 +303,10 @@ const toolPayload = ( async function applyAnnotations( intent: AnnotationsIntent, parentSelection: string | undefined, + source: ResultSource | undefined, fetchResult: FetchProcessingResult ): Promise { - if (annotationResultInScene(intent)) return { status: 'applied' }; + if (annotationResultInScene(source)) return { status: 'applied' }; // Tools are anchored to an image; without one they would be orphans the UI // never shows. Opening the file as a dataset is not a fallback either — it is @@ -353,7 +371,7 @@ async function applyAnnotations( // uniform tool type does not carry the per-kind geometry keys. const payload = { ...geometry, - ...toolPayload(core, segmentIds[kind], intent.source), + ...toolPayload(core, segmentIds[kind], source), }; store.addTool(payload); }); @@ -404,96 +422,131 @@ export const appApplyDependencies = (): ApplyDependencies => ({ }, }); -export async function applyIntent( +const failed = (message: string): ApplyIntentOutcome => ({ + status: 'failed', + error: new Error(message), +}); + +async function openVolumeAsDataset( + file: ResultFile, + dependencies: ApplyDependencies +): Promise { + const datasetIds = await dependencies.openVolumeUrls({ + urls: [file.url], + names: [file.name], + }); + return datasetIds.length === 0 + ? failed('Result did not load') + : { status: 'applied' }; +} + +async function applyLayer( + file: ResultFile, + parentSelection: string, + dependencies: ApplyDependencies +): Promise { + const childSelection = await dependencies.importVolume(file); + if (!childSelection) return failed('Result did not load'); + // addLayer swallows build failures and resolves undefined, so the id is the only failure signal. + const layerId = await dependencies.addLayer(parentSelection, childSelection); + if (layerId) return { status: 'applied' }; + dependencies.removeDataset(childSelection); + return failed('Failed to attach layer'); +} + +async function applySegmentation( + intent: SegmentationIntent, + parentSelection: string, + source: ResultSource | undefined, + dependencies: ApplyDependencies +): Promise { + const childSelection = await dependencies.importVolume(intent); + if (!childSelection) return failed('Result did not load'); + try { + await dependencies.segmentWriter.convertImageToLabelmap( + childSelection, + parentSelection, + source, + intent.segments + ); + return { status: 'applied' }; + } finally { + // The group owns its own labelmap image; the import was only a vehicle. + dependencies.removeDataset(childSelection); + } +} + +async function routeIntent( intent: KnownResultIntent, context: SubmittedJobContext | undefined, - dependencies: ApplyDependencies = appApplyDependencies() + dependencies: ApplyDependencies ): Promise { const parentSelection = context?.activeDatasetId; - const openVolumeAsDatasetOutcome = async ( - file: ResultFile - ): Promise => { - const datasetIds = await dependencies.openVolumeUrls({ - urls: [file.url], - names: [file.name], - }); - if (datasetIds.length === 0) - return { status: 'failed', error: new Error('Result did not load') }; - return { status: 'applied' }; - }; - - try { - switch (intent.intent) { - case 'add-base-image': { - return await openVolumeAsDatasetOutcome(intent); - } - case 'add-layer': { - if (!parentSelection) { - return await openVolumeAsDatasetOutcome(intent); - } - const childSelection = await dependencies.importVolume(intent); - if (!childSelection) - return { status: 'failed', error: new Error('Result did not load') }; - // addLayer swallows build failures and resolves undefined, so the id is the only failure signal. - const layerId = await dependencies.addLayer( - parentSelection, - childSelection - ); - if (!layerId) { - dependencies.removeDataset(childSelection); - return { - status: 'failed', - error: new Error('Failed to attach layer'), - }; - } + switch (intent.intent) { + case 'add-base-image': + return openVolumeAsDataset(intent, dependencies); + case 'add-layer': + return parentSelection + ? applyLayer(intent, parentSelection, dependencies) + : openVolumeAsDataset(intent, dependencies); + case 'import-segmentation': { + // Session-restored groups retain their result source. Treat that + // durable provenance as an application receipt so retrying Load is + // idempotent instead of creating a duplicate group. + const source = resultSourceOf(intent, context); + if (segmentResultInScene(source, dependencies.segmentWriter)) return { status: 'applied' }; - } - case 'add-segment-group': { - // Session-restored groups retain their result source. Treat that - // durable provenance as an application receipt so retrying Load is - // idempotent instead of creating a duplicate group. - if (segmentResultInScene(intent, dependencies.segmentWriter)) - return { status: 'applied' }; - if (!parentSelection) { - return await openVolumeAsDatasetOutcome(intent); - } - const childSelection = await dependencies.importVolume(intent); - if (!childSelection) - return { status: 'failed', error: new Error('Result did not load') }; - try { - await dependencies.segmentWriter.convertImageToLabelmap( - childSelection, - parentSelection, - intent.source, - intent.segments - ); - return { status: 'applied' }; - } finally { - // The group owns its own labelmap image; the import was only a vehicle. - dependencies.removeDataset(childSelection); - } - } - case 'add-annotations': { - return await applyAnnotations( - intent, - parentSelection, - dependencies.fetchResult - ); - } - default: { - const exhaustive: never = intent; - void exhaustive; - return { - status: 'failed', - error: new Error('Unsupported result intent'), - }; - } + return parentSelection + ? applySegmentation(intent, parentSelection, source, dependencies) + : openVolumeAsDataset(intent, dependencies); } + case 'add-annotations': + return applyAnnotations( + intent, + parentSelection, + resultSourceOf(intent, context), + dependencies.fetchResult + ); + default: { + const exhaustive: never = intent; + void exhaustive; + return failed('Unsupported result intent'); + } + } +} + +export async function applyIntent( + intent: KnownResultIntent, + context: SubmittedJobContext | undefined, + dependencies: ApplyDependencies = appApplyDependencies() +): Promise { + try { + return await routeIntent(intent, context, dependencies); } catch (error) { return { status: 'failed', error }; } } +// The applier routes on the declared intent, so a result carrying one it +// cannot read is skipped. Say so: the completion toast has already promised +// the results, and the skip otherwise leaves a plain download and no reason. +// A result is skipped for two different reasons, and they point at different +// culprits: an intent name outside the vocabulary is this client being too old, +// while a name inside it means the payload failed the intent's shape, which is +// the producer's row to fix. +function reportUnroutableIntent(result: ProcessingResult) { + if (!result.intent) return; + const nameIsKnown = (RESULT_INTENTS as readonly unknown[]).includes( + currentResultIntentName(result.intent) + ); + surfaceWarning( + `Did not load ${result.name}`, + nameIsKnown + ? `The result intent "${result.intent}" is supported, but this result does not carry the payload that intent requires, so it was rejected. The result is still available for download in the Jobs panel.` + : `This version cannot apply the result intent "${result.intent}". The result is still available for download in the Jobs panel.` + ); +} + export async function autoLoadProcessingResults( results: ProcessingResult[], context: SubmittedJobContext | undefined, @@ -502,7 +555,10 @@ export async function autoLoadProcessingResults( const failedResultIds: string[] = []; for (const result of results) { const intent = resultToIntent(result); - if (!intent) continue; + if (!intent) { + reportUnroutableIntent(result); + continue; + } const outcome = await applyIntent(intent, context, dependencies); if (outcome.status === 'failed') { failedResultIds.push(result.id); diff --git a/src/processing/components/JobsModule.vue b/src/processing/components/JobsModule.vue index 9b7ee50be..38ce1eabc 100644 --- a/src/processing/components/JobsModule.vue +++ b/src/processing/components/JobsModule.vue @@ -64,18 +64,6 @@ No tasks available. - - Overlapping segments are combined into one file for this job. - Where two overlap, the one listed first wins. - -
Loading task spec…
@@ -102,6 +90,7 @@ :source-ref-states="sourceRefStates" :source-ref-names="sourceRefNames" :source-ref-types="sourceRefTypes" + :source-ref-warnings="inputWarnings" :submitting="submitting" @update:values="onValuesUpdate" @submit="onSubmit" @@ -124,7 +113,11 @@ diff --git a/src/segmentation/components/__tests__/SaveSegmentationDialog.spec.ts b/src/segmentation/components/__tests__/SaveSegmentationDialog.spec.ts new file mode 100644 index 000000000..1b3124400 --- /dev/null +++ b/src/segmentation/components/__tests__/SaveSegmentationDialog.spec.ts @@ -0,0 +1,141 @@ +import * as composition from '@/src/segmentation/io/composition'; +import * as exportFiles from '@/src/segmentation/io/export'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { defineComponent, nextTick } from 'vue'; +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import SaveSegmentationDialog from '@/src/segmentation/components/SaveSegmentationDialog.vue'; +import { + mintSegment, + seatSpecImage, + seedVoxel, + store, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { defer, type Deferred } from '@/src/utils'; + +enableAutoUnmount(afterEach); + +// --------------------------------------------------------------------------- +// Enter is the dialog's submit key. It belongs to this dialog: a keystroke +// aimed at an overlay above it, such as the format menu, is that overlay's, +// not a save. And a write already running is not joined by a second one, which +// would compose the same masks again and download them twice. +// --------------------------------------------------------------------------- + +const CardStub = defineComponent({ + name: 'VCard', + template: '
', +}); + +const globalOptions = { + stubs: { + VCard: CardStub, + VCardTitle: { template: '
' }, + VCardText: { template: '
' }, + VCardActions: { template: '
' }, + VForm: { template: '
' }, + VTextField: { + props: ['modelValue'], + template: '', + }, + VSelect: { props: ['modelValue', 'items'], template: '