diff --git a/backend-contract/generated/job-history-detail.schema.json b/backend-contract/generated/job-history-detail.schema.json index 44c80c1c1..f2af413ed 100644 --- a/backend-contract/generated/job-history-detail.schema.json +++ b/backend-contract/generated/job-history-detail.schema.json @@ -19,6 +19,9 @@ "type": "string" }, "additionalProperties": {} + }, + "referenceImageParameter": { + "type": "string" } }, "required": [ diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json index 15816ab98..5865f7143 100644 --- a/backend-contract/generated/openapi.json +++ b/backend-contract/generated/openapi.json @@ -1577,6 +1577,9 @@ "type": "string" }, "additionalProperties": {} + }, + "referenceImageParameter": { + "type": "string" } }, "required": [ diff --git a/backend-contract/processing/wire.ts b/backend-contract/processing/wire.ts index df394d50b..09a1b309f 100644 --- a/backend-contract/processing/wire.ts +++ b/backend-contract/processing/wire.ts @@ -344,6 +344,7 @@ export const jobHistoryDetailSchema = z.object({ jobId: pathSegmentIdSchema, log: z.array(z.string()), parameters: z.record(z.string(), z.unknown()), + referenceImageParameter: z.string().optional(), }); export type JobHistoryDetail = z.infer; diff --git a/src/processing/__tests__/job-adoption.spec.ts b/src/processing/__tests__/job-adoption.spec.ts new file mode 100644 index 000000000..7ea1864c7 --- /dev/null +++ b/src/processing/__tests__/job-adoption.spec.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; + +import { + appApplyDependencies, + autoLoadProcessingResults, +} from '@/src/processing/applyResults'; +import { useProcessingJobsStore } from '@/src/processing/store'; +import type { ProcessingResult } from '@/src/processing/types'; +import { + seatDataSource, + seatVolume, +} from '@/src/store/__tests__/datasetFixtures'; +import { seatImage } from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { serializeAnnotations } from './serializedScene'; + +const jobRef = { providerId: 'analysis', jobId: 'saved-job' }; +const baseUris = ['/ct/2.dcm', '/ct/1.dcm']; +const layerUris = ['/pet/1.dcm', '/pet/2.dcm']; + +const rulerFile = { + schemaVersion: 1, + space: 'LPS', + labels: { + rulers: { Tumor: { color: '#ff0000', strokeWidth: 3 } }, + rectangles: {}, + polygons: {}, + }, + tools: { + rulers: [ + { + name: 'Long axis', + labelName: 'Tumor', + firstPoint: [1, 1, 2], + secondPoint: [4, 1, 2], + frameOfReference: { + planeNormal: [0, 0, 1], + planeOrigin: [0, 0, 2], + }, + }, + ], + rectangles: [], + polygons: [], + }, +}; + +const result: ProcessingResult = { + intent: 'add-annotations', + id: 'rulers', + name: 'measurements.annotations.json', + url: '/measurements.annotations.json', + source: { ...jobRef, outputId: 'outputAnnotations' }, +}; + +const loadVolume = async (id: string, uris: string[]) => { + await seatImage(id, { dimensions: [8, 8, 8] }); + seatVolume(id); + seatDataSource(id, { + type: 'collection', + sources: uris.map((uri) => ({ type: 'uri', uri, name: uri })), + }); +}; + +beforeEach(() => { + setActivePinia(createPinia()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('adopting jobs with base and layer inputs', () => { + it.each([ + { baseLoaded: true, baseSubmitted: true, expectedCount: 1 }, + { baseLoaded: false, baseSubmitted: true, expectedCount: 0 }, + { baseLoaded: true, baseSubmitted: false, expectedCount: 0 }, + ])( + 'restores only the named base with loaded=$baseLoaded, submitted=$baseSubmitted', + async ({ baseLoaded, baseSubmitted, expectedCount }) => { + if (baseLoaded) await loadVolume('base-ct', [...baseUris].reverse()); + await loadVolume('pet-layer', layerUris); + const detail = { + jobId: jobRef.jobId, + log: [], + parameters: { + absentLayer: null, + inputPETVolume: { type: 'image', uris: layerUris }, + inputTools: { type: 'annotations', uris: ['/tools.json'] }, + ...(baseSubmitted + ? { inputVolume: { type: 'image', uris: baseUris } } + : {}), + }, + referenceImageParameter: 'inputVolume', + }; + const history = { + jobs: [ + { + jobId: jobRef.jobId, + taskId: 'measure', + taskTitle: 'Measure segments', + createdBy: { id: 'reader', name: 'Reader' }, + createdAt: '2026-10-21T12:00:00Z', + state: 'success', + resultState: 'ready', + outputSummary: { recorded: 1, missing: 0 }, + }, + ], + nextCursor: null, + }; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const path = new URL(String(input), window.location.origin).pathname; + let payload: unknown; + if (path.endsWith('/detail')) payload = detail; + else if (path.endsWith('/results')) + payload = { resultState: 'ready', intents: [result], missing: 0 }; + else if (path === '/analysis/jobs') payload = history; + else throw new Error(`Unexpected request: ${path}`); + return new Response(JSON.stringify(payload), { + headers: { 'Content-Type': 'application/json' }, + }); + }) + ); + const jobs = useProcessingJobsStore(); + jobs.registerProviderConfig({ + id: jobRef.providerId, + label: 'Analysis', + baseUrl: '/analysis', + jobsBaseUrl: '/analysis', + }); + + await jobs.adoptJobHistory(); + await jobs.loadJobResults(jobRef); + await jobs.applyJobResults(jobRef, (pending, submitted) => + autoLoadProcessingResults(pending, submitted, { + ...appApplyDependencies(), + fetchResult: async () => + new File([JSON.stringify(rulerFile)], result.name), + }) + ); + + const savedRulers = serializeAnnotations().tools?.rulers?.tools ?? []; + if (expectedCount) { + expect(savedRulers).toMatchObject([ + { + imageID: 'base-ct', + name: 'Long axis', + firstPoint: [1, 1, 2], + secondPoint: [4, 1, 2], + }, + ]); + expect(savedRulers).toHaveLength(1); + } else { + expect(savedRulers).toEqual([]); + } + } + ); +}); diff --git a/src/processing/__tests__/store.spec.ts b/src/processing/__tests__/store.spec.ts index fd0e020d0..9028f55b8 100644 --- a/src/processing/__tests__/store.spec.ts +++ b/src/processing/__tests__/store.spec.ts @@ -1514,6 +1514,37 @@ describe('Providers store — re-discovered job history: slim observability adop expect(store.jobResultsApplied.size).toBe(0); }); + it.each([ + [ + 'throws synchronously', + () => { + throw new Error('History unavailable'); + }, + ], + ['rejects', () => Promise.reject(new Error('History unavailable'))], + ] as const)( + 'loads adopted results when job detail %s', + async (_, readDetail) => { + const provider = makeProvider({ + listJobHistory: vi.fn().mockResolvedValue({ + jobs: [handle({ state: 'success' })], + nextCursor: null, + }), + getJobHistoryDetail: vi.fn(readDetail), + getResults: vi.fn().mockResolvedValue(resultsBundle(sampleResults)), + }); + const store = arrange(provider); + + await store.adoptJobHistory(); + await store.loadJobResults(ref('jr')); + + expect(store.jobResults.get(keyFor('jr'))).toEqual(sampleResults); + expect( + store.submittedContexts.get(keyFor('jr'))?.activeDatasetId + ).toBeUndefined(); + } + ); + it('a job that settles between listing and the first poll completes once', async () => { const provider = makeProvider({ listJobHistory: vi.fn().mockResolvedValue({ diff --git a/src/processing/components/JobsModule.vue b/src/processing/components/JobsModule.vue index 27698ad63..9843a798d 100644 --- a/src/processing/components/JobsModule.vue +++ b/src/processing/components/JobsModule.vue @@ -136,7 +136,10 @@ import { type TaskFormModel, type FormValidationIssue, } from '@/src/processing/engine/formModel'; -import { type SourceRefBindingState } from '@/src/processing/engine/mintInput'; +import { + mintInputValue, + type SourceRefBindingState, +} from '@/src/processing/engine/mintInput'; import { bindSourceRefs, type BoundSourceRefType, @@ -510,6 +513,7 @@ function jobDisplayContext(bindings: SourceRefBindings): JobDisplayContext { labelmapNames, types: bindings.types, imageName: activeImageName(), + imageNames: bindings.image.names ?? {}, annotationCount: finishedAnnotationCount.value, }; } @@ -583,6 +587,9 @@ watchDebounced( // Placing the first (or removing the last) tool flips the annotations // binding, so the form must revalidate. annotationCount: finishedAnnotationCount.value, + imageLayers: sourceRefContext().imageLayers?.map(({ dataSource }) => + mintInputValue(dataSource) + ), }; }, () => { diff --git a/src/processing/components/TaskForm.vue b/src/processing/components/TaskForm.vue index aea5c50cc..5888ab7fa 100644 --- a/src/processing/components/TaskForm.vue +++ b/src/processing/components/TaskForm.vue @@ -31,6 +31,7 @@ :binding="sourceRefStates?.[field.id]" :bound-name="sourceRefNames?.[field.id]" :bound-type="sourceRefTypes?.[field.id]" + :image-index="imageInputIndices[field.id]" :warning="sourceRefWarnings?.[field.id]" @update:model-value="(v: ProcessingValue) => update(field.id, v)" /> @@ -96,6 +97,7 @@ import { } from '@/src/processing/engine/formModel'; import type { SourceRefBindingState } from '@/src/processing/engine/mintInput'; import type { BoundSourceRefType } from '@/src/processing/engine/sourceRefs'; +import { TYPE_TAG_IMAGE } from '@/backend-contract'; import BooleanWidget from './widgets/BooleanWidget.vue'; import NumberWidget from './widgets/NumberWidget.vue'; @@ -166,6 +168,20 @@ const inputFields = computed(() => props.model.fields.filter((f) => f.kind === 'sourceRef') ); +const imageInputIndices = computed(() => + Object.fromEntries( + inputFields.value + .filter( + (field) => + props.sourceRefTypes?.[field.id] === TYPE_TAG_IMAGE || + (!props.sourceRefTypes?.[field.id] && + field.accepts.length === 1 && + field.accepts[0] === TYPE_TAG_IMAGE) + ) + .map((field, index) => [field.id, index]) + ) +); + function widgetFor(kind: FormField['kind']) { switch (kind) { case 'bool': diff --git a/src/processing/components/__tests__/JobsModule.spec.ts b/src/processing/components/__tests__/JobsModule.spec.ts index 2d4712237..8cd8a96ca 100644 --- a/src/processing/components/__tests__/JobsModule.spec.ts +++ b/src/processing/components/__tests__/JobsModule.spec.ts @@ -46,10 +46,13 @@ import TaskPicker from '@/src/processing/components/TaskPicker.vue'; import TaskForm from '@/src/processing/components/TaskForm.vue'; import { useProcessingJobsStore } from '@/src/processing/store'; import { useDatasetStore } from '@/src/store/datasets'; +import { useLayersStore } from '@/src/store/datasets-layers'; import { useRulerStore } from '@/src/store/tools/rulers'; import { useSegmentationStore } from '@/src/segmentation/store'; import { + activateAppPinia, seedVoxel, + seatImage, mintSegment, selectSegment, } from '@/src/segmentation/__tests__/segmentMaskFixtures'; @@ -110,6 +113,11 @@ const mountJobsModule = (pinia: ReturnType) => 'v-select': true, TaskForm: false, FileWidget: false, + ReasonedAction: false, + 'v-btn': { + props: ['disabled'], + template: '', + }, 'v-expansion-panels': slotStub, 'v-expansion-panel': slotStub, 'v-expansion-panel-title': slotStub, @@ -530,6 +538,275 @@ describe('JobsModule segmentation staging', () => { return { provider: p, submitSpy }; }; + const orderedImageSpec = (requiredLayer = false): TaskSpecEnvelope => ({ + ...envelope('seg', 'Image report'), + parameters: [ + { + kind: 'sourceRef', + id: 'referenceScan', + accepts: ['image'], + required: true, + }, + { + kind: 'sourceRef', + id: 'auxiliaryScan', + accepts: ['image'], + required: requiredLayer, + }, + { kind: 'sourceRef', id: 'detailScan', accepts: ['image'] }, + ], + }); + + const seatVolume = async (id: string, name: string, slices = 2) => { + await seatImage(id, { name, dimensions: [2, 2, 2] }); + const uris = Array.from( + { length: slices }, + (_, index) => `girder://file/${id}-${index + 1}` + ); + useDatasetStore().addDataSources([ + { + dataID: id, + dataSource: { + type: 'collection', + sources: uris.map((uri, index) => ({ + type: 'uri' as const, + uri, + name: `${index + 1}.dcm`, + })), + }, + }, + ]); + return { type: 'image', uris }; + }; + + const mountImageReport = async (spec = orderedImageSpec()) => { + const p = stagingProvider(spec); + p.getJob = vi.fn(async (jobId: string) => ({ + jobId, + state: 'cancelled' as const, + resultState: 'unavailable' as const, + })); + registerFake(useProcessingJobsStore(), p); + const wrapper = mount(); + await flushPromises(); + return { p, wrapper, form: wrapper.findComponent(TaskForm) }; + }; + + const inputNames = ( + form: Awaited>['form'] + ) => form.findAll('.file-widget .value-text').map((value) => value.text()); + + it('shows declared image roles beside their bound datasets before submission', async () => { + await seatVolume('follow-up', 'Follow-up volume'); + await useLayersStore().addLayer('image-1', 'follow-up'); + const spec = orderedImageSpec(); + spec.parameters = spec.parameters.map((parameter, index) => ({ + ...(parameter as object), + ...(index < 2 + ? { title: index === 0 ? 'Fixed image' : 'Moving image' } + : {}), + })); + const { p, form } = await mountImageReport(spec); + const inputs = form.findAll('.file-widget'); + + expect(inputNames(form)).toEqual([ + 'image.nrrd', + 'Follow-up volume', + 'Not provided', + ]); + expect(inputs[0].text()).toContain('Fixed image'); + expect(inputs[1].text()).toContain('Moving image'); + expect(inputs[2].text()).toContain('detailScan'); + expect(inputs[0].text()).toContain('Active dataset'); + expect(inputs[1].text()).toContain('Image layer 1'); + expect(inputs[1].text()).toContain('(optional)'); + expect(p.runTask).not.toHaveBeenCalled(); + }); + + it('binds each image parameter to one complete volume in base and layer order', async () => { + pinia = activateAppPinia(); + const referenceScan = await seatVolume('image-1', 'CT volume', 3); + useViewStore().setDataForAllViews('image-1'); + const auxiliaryScan = await seatVolume('pet-1', 'PET volume', 2); + const detailScan = await seatVolume('mr-1', 'MR volume', 4); + await seatVolume('unrelated', 'Unattached volume'); + const layers = useLayersStore(); + await layers.addLayer('image-1', 'pet-1'); + await layers.addLayer('image-1', 'mr-1'); + const { p, form } = await mountImageReport(); + + expect(inputNames(form)).toEqual(['CT volume', 'PET volume', 'MR volume']); + expect(form.get('button').attributes('disabled')).toBeUndefined(); + form.vm.$emit( + 'update:values', + Object.fromEntries(Object.entries(form.props('values')).reverse()) + ); + await flushPromises(); + await form.get('button').trigger('click'); + await flushPromises(); + expect(p.runTask).toHaveBeenCalledWith('seg', { + referenceScan, + auxiliaryScan, + detailScan, + }); + }); + + it('submits missing optional layers as null and excludes unrelated loaded volumes', async () => { + await seatVolume('unrelated-pet', 'Unattached PET volume'); + const { p, form } = await mountImageReport(); + + expect(inputNames(form)).toEqual([ + 'image.nrrd', + 'Not provided', + 'Not provided', + ]); + expect(form.get('button').attributes('disabled')).toBeUndefined(); + await form.get('button').trigger('click'); + await flushPromises(); + expect(p.runTask).toHaveBeenCalledWith('seg', { + referenceScan: { + type: 'image', + format: 'nrrd', + uris: ['girder://file/image-1'], + }, + auxiliaryScan: null, + detailScan: null, + }); + }); + + it('blocks submission when a required layer input is missing', async () => { + const { p, form } = await mountImageReport(orderedImageSpec(true)); + + expect(inputNames(form)[1]).not.toBe('Not provided'); + expect(form.get('button').attributes('disabled')).toBeDefined(); + await form.get('button').trigger('click'); + await flushPromises(); + expect(p.runTask).not.toHaveBeenCalled(); + }); + + it('shifts attached layers and clears stale values after removal', async () => { + await seatVolume('pet-1', 'PET volume'); + const remainingLayer = await seatVolume('mr-1', 'MR volume'); + const layers = useLayersStore(); + await layers.addLayer('image-1', 'pet-1'); + await layers.addLayer('image-1', 'mr-1'); + const { p, form } = await mountImageReport(); + expect(inputNames(form)).toEqual(['image.nrrd', 'PET volume', 'MR volume']); + + layers.deleteLayer('image-1', 'pet-1'); + await afterDebounce(); + expect(inputNames(form)).toEqual([ + 'image.nrrd', + 'MR volume', + 'Not provided', + ]); + await form.get('button').trigger('click'); + await flushPromises(); + expect(p.runTask).toHaveBeenLastCalledWith( + 'seg', + expect.objectContaining({ + auxiliaryScan: remainingLayer, + detailScan: null, + }) + ); + + layers.deleteLayer('image-1', 'mr-1'); + await afterDebounce(); + expect(inputNames(form)).toEqual([ + 'image.nrrd', + 'Not provided', + 'Not provided', + ]); + await form.get('button').trigger('click'); + await flushPromises(); + expect(p.runTask).toHaveBeenLastCalledWith( + 'seg', + expect.objectContaining({ auxiliaryScan: null, detailScan: null }) + ); + }); + + it('clears previous layer values when the active base volume changes', async () => { + await seatVolume('pet-1', 'PET volume'); + const referenceScan = await seatVolume('image-2', 'Second CT volume', 3); + await useLayersStore().addLayer('image-1', 'pet-1'); + const { p, form } = await mountImageReport(); + expect(inputNames(form)).toEqual([ + 'image.nrrd', + 'PET volume', + 'Not provided', + ]); + + useViewStore().setDataForAllViews('image-2'); + await afterDebounce(); + expect(inputNames(form)).toEqual([ + 'Second CT volume', + 'Not provided', + 'Not provided', + ]); + await form.get('button').trigger('click'); + await flushPromises(); + expect(p.runTask).toHaveBeenCalledWith('seg', { + referenceScan, + auxiliaryScan: null, + detailScan: null, + }); + }); + + it('stages segmentation and annotations against the base when image inputs also bind layers', async () => { + pinia = activateAppPinia(); + const referenceScan = await seatVolume('image-1', 'CT volume.nrrd', 3); + useViewStore().setDataForAllViews('image-1'); + const auxiliaryScan = await seatVolume('pet-1', 'PET volume'); + const detailScan = await seatVolume('mr-1', 'MR volume'); + const layers = useLayersStore(); + await layers.addLayer('image-1', 'pet-1'); + await layers.addLayer('image-1', 'mr-1'); + seedSegmentation('Tumor'); + finishRuler(); + const spec = orderedImageSpec(); + spec.parameters.splice(1, 0, { + kind: 'sourceRef', + id: 'regions', + accepts: ['labelmap'], + required: true, + }); + spec.parameters.splice(3, 0, { + kind: 'sourceRef', + id: 'measurements', + accepts: ['annotations'], + required: true, + }); + const { p, form } = await mountImageReport(spec); + + expect(form.get('button').attributes('disabled')).toBeUndefined(); + await form.get('button').trigger('click'); + await flushPromises(); + expect( + p.stageInput.mock.calls.map(([request]) => request.descriptor) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'labelmap', + referenceImage: referenceScan, + }), + expect.objectContaining({ + type: 'annotations', + referenceImage: referenceScan, + }), + ]) + ); + expect(p.runTask).toHaveBeenCalledWith('seg', { + referenceScan, + auxiliaryScan, + detailScan, + regions: { type: 'labelmap', uris: ['girder://staged/Tumor.seg.nrrd'] }, + measurements: { + type: 'annotations', + uris: ['girder://staged/CT volume.annotations.json'], + }, + }); + }); + it('stages the image’s segmentation for a multiple param', async () => { seedSegmentation('Tumor'); diff --git a/src/processing/components/widgets/FileWidget.vue b/src/processing/components/widgets/FileWidget.vue index 81fec9af5..de3a42557 100644 --- a/src/processing/components/widgets/FileWidget.vue +++ b/src/processing/components/widgets/FileWidget.vue @@ -2,8 +2,15 @@
{{ kind.icon }} - - {{ kind.caption }}{{ param.required ? '' : ' (optional)' }} + + + {{ fieldLabel(param) }}{{ param.required ? '' : ' (optional)' }} + + {{ kind.caption }}
(); @@ -80,7 +89,15 @@ const kind = computed(() => { ? props.param.accepts[0] : undefined; const type = props.boundType ?? declared; - return (type ? KINDS[type] : undefined) ?? IMAGE_KIND; + const declaredKind = type ? KINDS[type] : undefined; + if (declaredKind) return declaredKind; + return props.imageIndex && props.imageIndex > 0 + ? { + ...IMAGE_KIND, + caption: `Image layer ${props.imageIndex}`, + noun: 'layer' as const, + } + : IMAGE_KIND; }); const OPTIONAL_UNBOUND_STATES = new Set([ @@ -88,6 +105,7 @@ const OPTIONAL_UNBOUND_STATES = new Set([ 'no-segmentation', 'no-annotations', 'no-reference-input', + 'no-layer', ]); // Missing optional inputs are an intentional task state, not an instruction @@ -116,20 +134,29 @@ const boundDisplayName = computed(() =>