From 6a2c30f264a83edf9a034db452cca34198a261a1 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 27 Aug 2026 23:44:57 -0400 Subject: [PATCH] fix(dicom): preserve slices across incremental imports Adding chunks to an existing DICOM image re-sorts the list and reallocates the volume buffer. Slices already decoded were zeroed by the reallocation and never rewritten while their status still said Loaded. A decode in flight across that reallocation wrote through the slot it captured before the sort, so its pixels or its error could land on another chunk's slice. Two overlapping additions each awaited a sort of the shared list, and the sort that settled last won, dropping the newer call's chunks. The image cache also starts a load only when it first registers an image, so chunks added on a re-import were allocated for but never asked to load. Redecode every chunk that holds data after a reallocation and report those slices as Loading until the rewrite lands. Resolve a chunk's slot after its decode settles, and discard any result from a previous allocation generation, so completion order cannot overwrite current state. Address failures by chunk rather than by captured index. Serialize additions through a promise queue. Ask an already-registered image to start loading after a re-import adds to it. --- .../__tests__/dicomChunkImage.spec.ts | 361 +++++++++++++++++- src/core/streaming/dicomChunkImage.ts | 98 +++-- .../__tests__/datasets-dicom-reimport.spec.ts | 123 ++++++ src/store/datasets-dicom.ts | 4 +- 4 files changed, 550 insertions(+), 36 deletions(-) create mode 100644 src/store/__tests__/datasets-dicom-reimport.spec.ts diff --git a/src/core/streaming/__tests__/dicomChunkImage.spec.ts b/src/core/streaming/__tests__/dicomChunkImage.spec.ts index 9ddc38851..10c66f619 100644 --- a/src/core/streaming/__tests__/dicomChunkImage.spec.ts +++ b/src/core/streaming/__tests__/dicomChunkImage.spec.ts @@ -84,6 +84,10 @@ function decodeTo(dataFor: (value: number) => ArrayLike) { return read; } +const readDicomImage = decodeTo((value) => + new Uint16Array(PIXELS_PER_SLICE).fill(value) +); + function sliceOf(image: DicomChunkImage, index: number) { const scalars = image.getVtkImageData().getPointData().getScalars(); const data = scalars.getData(); @@ -93,11 +97,11 @@ function sliceOf(image: DicomChunkImage, index: number) { } async function loadRejectingSeries( - readDicomImage: DicomChunkImageInit['readDicomImage'] + read: DicomChunkImageInit['readDicomImage'] ) { const image = new DicomChunkImage({ splitAndSort: splitAndSortByPosition, - readDicomImage, + readDicomImage: read, }); const errors: unknown[] = []; image.addEventListener('chunkError', ({ error }) => { @@ -180,4 +184,357 @@ describe('DicomChunkImage', () => { expect(message).toContain('fractional'); expect(message).toContain('Uint8Array'); }); + + it('serializes concurrent additions so an older sort cannot drop newer chunks', async () => { + const pendingSorts: Array<{ + chunks: Chunk[]; + resolve: (volumes: Record) => void; + }> = []; + const deferredSplitAndSort: DicomChunkImageInit['splitAndSort'] = ( + chunks + ) => + new Promise((resolve) => { + pendingSorts.push({ chunks: [...chunks], resolve }); + }); + const finishSort = (index: number) => { + pendingSorts[index].resolve({ + volume: [...pendingSorts[index].chunks].sort((a, b) => zOf(a) - zOf(b)), + }); + }; + const image = new DicomChunkImage({ + splitAndSort: deferredSplitAndSort, + readDicomImage, + }); + const [first, second] = await Promise.all([ + makeLoadedChunk(1), + makeLoadedChunk(2), + ]); + + const olderAddition = image.addChunks([second]); + await vi.waitFor(() => expect(pendingSorts).toHaveLength(1)); + + const newerAddition = image.addChunks([first]); + await Promise.resolve(); + expect(pendingSorts).toHaveLength(1); + + finishSort(0); + await olderAddition; + await vi.waitFor(() => expect(pendingSorts).toHaveLength(2)); + expect(pendingSorts[1].chunks.map(zOf)).toEqual([2, 1]); + + finishSort(1); + await newerAddition; + await vi.waitFor(() => + expect(image.getChunkStatuses()).toEqual([ + ChunkStatus.Loaded, + ChunkStatus.Loaded, + ]) + ); + + expect(image.getChunks().map(zOf)).toEqual([1, 2]); + expect(sliceOf(image, 0)).toEqual(Array(PIXELS_PER_SLICE).fill(1)); + expect(sliceOf(image, 1)).toEqual(Array(PIXELS_PER_SLICE).fill(2)); + + image.dispose(); + }); + + it('decodes every loaded chunk into its sorted slice position', async () => { + const image = new DicomChunkImage({ + splitAndSort: splitAndSortByPosition, + readDicomImage, + }); + + const loads: Array<{ z: number; zRange: number[] }> = []; + image.addEventListener('chunkLoad', ({ chunk, updatedExtent }) => { + loads.push({ z: zOf(chunk), zRange: updatedExtent.slice(4) }); + }); + + const [first, second, third] = await Promise.all([ + makeLoadedChunk(1), + makeLoadedChunk(2), + makeLoadedChunk(3), + ]); + + await image.addChunks([first]); + await vi.waitFor(() => expect(loads.length).toBe(1)); + + // Arrival order deliberately differs from slice order. + await image.addChunks([third, second]); + await vi.waitFor(() => expect(loads.length).toBe(4)); + + expect(image.getChunks().map(zOf)).toEqual([1, 2, 3]); + // The first slice is redecoded because reallocation cleared its pixels. + expect(loads).toEqual([ + { z: 1, zRange: [0, 0] }, + { z: 1, zRange: [0, 0] }, + { z: 2, zRange: [1, 1] }, + { z: 3, zRange: [2, 2] }, + ]); + expect(sliceOf(image, 0)).toEqual(Array(PIXELS_PER_SLICE).fill(1)); + expect(sliceOf(image, 1)).toEqual(Array(PIXELS_PER_SLICE).fill(2)); + expect(sliceOf(image, 2)).toEqual(Array(PIXELS_PER_SLICE).fill(3)); + + image.dispose(); + }); + + it('keeps a stale in-flight decode from clobbering the re-sorted volume', async () => { + // Hold each decode independently by its pixel value. + const pending = new Map void>>(); + const deferredRead: DicomChunkImageInit['readDicomImage'] = async ( + file + ) => { + const value = Number(await file.text()); + return new Promise((resolve) => { + const resolvers = pending.get(value) ?? []; + resolvers.push(() => + resolve({ + image: { + size: [COLUMNS, ROWS, 1], + data: new Uint16Array(PIXELS_PER_SLICE).fill(value), + imageType: { components: 1 }, + }, + }) + ); + pending.set(value, resolvers); + }); + }; + + const image = new DicomChunkImage({ + splitAndSort: splitAndSortByPosition, + readDicomImage: deferredRead, + }); + + let loads = 0; + image.addEventListener('chunkLoad', () => { + loads += 1; + }); + + const [first, second, third] = await Promise.all([ + makeLoadedChunk(1), + makeLoadedChunk(2), + makeLoadedChunk(3), + ]); + + // Start chunk 3 in slot 0, then move it to slot 2 while decoding. + await image.addChunks([third]); + await vi.waitFor(() => expect(pending.get(3)).toHaveLength(1)); + + await image.addChunks([first, second]); + await vi.waitFor(() => { + expect(pending.get(1)).toHaveLength(1); + expect(pending.get(2)).toHaveLength(1); + expect(pending.get(3)).toHaveLength(2); + }); + + // Complete the current decodes before the stale attempt. + pending.get(1)![0](); + pending.get(2)![0](); + pending.get(3)![1](); + await vi.waitFor(() => expect(loads).toBe(3)); + + pending.get(3)![0](); + await Promise.resolve(); + await Promise.resolve(); + expect(loads).toBe(3); + + expect(sliceOf(image, 0)).toEqual(Array(PIXELS_PER_SLICE).fill(1)); + expect(sliceOf(image, 1)).toEqual(Array(PIXELS_PER_SLICE).fill(2)); + expect(sliceOf(image, 2)).toEqual(Array(PIXELS_PER_SLICE).fill(3)); + + image.dispose(); + }); + + it('does not let a stale success overwrite a replacement failure', async () => { + // Hold each decode independently by its pixel value. + const pending = new Map void>>(); + const deferredRead: DicomChunkImageInit['readDicomImage'] = async ( + file + ) => { + const value = Number(await file.text()); + return new Promise((resolve, reject) => { + const settlers = pending.get(value) ?? []; + settlers.push((err) => { + if (err) reject(err); + else + resolve({ + image: { + size: [COLUMNS, ROWS, 1], + data: new Uint16Array(PIXELS_PER_SLICE).fill(value), + imageType: { components: 1 }, + }, + }); + }); + pending.set(value, settlers); + }); + }; + + const image = new DicomChunkImage({ + splitAndSort: splitAndSortByPosition, + readDicomImage: deferredRead, + }); + + const errors: number[] = []; + image.addEventListener('chunkError', ({ chunk }) => { + errors.push(zOf(chunk)); + }); + + const [first, second, third] = await Promise.all([ + makeLoadedChunk(1), + makeLoadedChunk(2), + makeLoadedChunk(3), + ]); + + // Start chunk 3 in slot 0, then move it to slot 2 while decoding. + await image.addChunks([third]); + await vi.waitFor(() => expect(pending.get(3)).toHaveLength(1)); + + await image.addChunks([first, second]); + await vi.waitFor(() => expect(pending.get(3)).toHaveLength(2)); + + pending.get(1)![0](); + pending.get(2)![0](); + await vi.waitFor(() => + expect(image.getChunkStatuses()[1]).toBe(ChunkStatus.Loaded) + ); + + // Fail the attempt for the current allocation. + pending.get(3)![1](new Error('replacement decode failed')); + await vi.waitFor(() => expect(errors).toEqual([3])); + + expect(image.getChunkStatuses()).toEqual([ + ChunkStatus.Loaded, + ChunkStatus.Loaded, + ChunkStatus.Errored, + ]); + expect(sliceOf(image, 0)).toEqual(Array(PIXELS_PER_SLICE).fill(1)); + + // A late success from the previous allocation must be ignored. + pending.get(3)![0](); + await Promise.resolve(); + await Promise.resolve(); + expect(image.getChunkStatuses()[2]).toBe(ChunkStatus.Errored); + expect(sliceOf(image, 2)).toEqual(Array(PIXELS_PER_SLICE).fill(0)); + + image.dispose(); + }); + + it('does not let a stale failure overwrite a replacement success', async () => { + const pending = new Map void>>(); + const deferredRead: DicomChunkImageInit['readDicomImage'] = async ( + file + ) => { + const value = Number(await file.text()); + return new Promise((resolve, reject) => { + const settlers = pending.get(value) ?? []; + settlers.push((err) => { + if (err) reject(err); + else + resolve({ + image: { + size: [COLUMNS, ROWS, 1], + data: new Uint16Array(PIXELS_PER_SLICE).fill(value), + imageType: { components: 1 }, + }, + }); + }); + pending.set(value, settlers); + }); + }; + + const image = new DicomChunkImage({ + splitAndSort: splitAndSortByPosition, + readDicomImage: deferredRead, + }); + + const errors: number[] = []; + image.addEventListener('chunkError', ({ chunk }) => { + errors.push(zOf(chunk)); + }); + + const [first, second, third] = await Promise.all([ + makeLoadedChunk(1), + makeLoadedChunk(2), + makeLoadedChunk(3), + ]); + + await image.addChunks([third]); + await vi.waitFor(() => expect(pending.get(3)).toHaveLength(1)); + + await image.addChunks([first, second]); + await vi.waitFor(() => expect(pending.get(3)).toHaveLength(2)); + + pending.get(1)![0](); + pending.get(2)![0](); + pending.get(3)![1](); + await vi.waitFor(() => + expect(image.getChunkStatuses()).toEqual([ + ChunkStatus.Loaded, + ChunkStatus.Loaded, + ChunkStatus.Loaded, + ]) + ); + + // A late failure from the previous allocation must be ignored. + pending.get(3)![0](new Error('stale decode failed')); + await Promise.resolve(); + await Promise.resolve(); + expect(errors).toEqual([]); + expect(image.getChunkStatuses()[2]).toBe(ChunkStatus.Loaded); + expect(sliceOf(image, 2)).toEqual(Array(PIXELS_PER_SLICE).fill(3)); + + image.dispose(); + }); + + it('reports a reallocated chunk as loading until its slice is rewritten', async () => { + const pending: Array<() => void> = []; + const deferredRead: DicomChunkImageInit['readDicomImage'] = async ( + file + ) => { + const value = Number(await file.text()); + return new Promise((resolve) => { + pending.push(() => + resolve({ + image: { + size: [COLUMNS, ROWS, 1], + data: new Uint16Array(PIXELS_PER_SLICE).fill(value), + imageType: { components: 1 }, + }, + }) + ); + }); + }; + + const image = new DicomChunkImage({ + splitAndSort: splitAndSortByPosition, + readDicomImage: deferredRead, + }); + + const [first, second] = await Promise.all([ + makeLoadedChunk(1), + makeLoadedChunk(2), + ]); + + await image.addChunks([first]); + await vi.waitFor(() => expect(pending).toHaveLength(1)); + pending[0](); + await vi.waitFor(() => expect(image.status.value).toBe('complete')); + + // Reallocation cleared chunk 1, and neither replacement decode has run. + await image.addChunks([second]); + + expect(image.getChunkStatuses()).toEqual([ + ChunkStatus.Loading, + ChunkStatus.Loading, + ]); + expect(image.status.value).toBe('incomplete'); + expect(sliceOf(image, 0)).toEqual(Array(PIXELS_PER_SLICE).fill(0)); + + await vi.waitFor(() => expect(pending).toHaveLength(3)); + pending.slice(1).forEach((settle) => settle()); + await vi.waitFor(() => expect(image.status.value).toBe('complete')); + expect(sliceOf(image, 0)).toEqual(Array(PIXELS_PER_SLICE).fill(1)); + expect(sliceOf(image, 1)).toEqual(Array(PIXELS_PER_SLICE).fill(2)); + + image.dispose(); + }); }); diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index 3e71a2167..86f8b2fd4 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -107,6 +107,8 @@ export default class DicomChunkImage private thumbnailCache: WeakMap>; private events: Emitter; private chunkStatus: ChunkStatus[]; + private allocationGeneration: number; + private chunkAdditionQueue: Promise; public segBuildInfo: | (JsonCompatible & ReadOverlappingSegmentationMeta) @@ -128,6 +130,8 @@ export default class DicomChunkImage this.chunkStatus = []; this.thumbnailCache = new WeakMap(); this.events = mitt(); + this.allocationGeneration = 0; + this.chunkAdditionQueue = Promise.resolve(); this.segBuildInfo = null; this.addEventListener('loading', (loading) => { @@ -174,6 +178,7 @@ export default class DicomChunkImage } dispose() { + this.allocationGeneration += 1; super.dispose(); this.unregisterChunkListeners(); this.events.all.clear(); @@ -197,7 +202,16 @@ export default class DicomChunkImage this.events.emit('loading', false); } - async addChunks(chunks: Chunk[]) { + addChunks(chunks: Chunk[]) { + const chunksToAdd = chunks.slice(); + const addition = this.chunkAdditionQueue.then(() => + this.addChunksInOrder(chunksToAdd) + ); + this.chunkAdditionQueue = addition.catch(() => {}); + return addition; + } + + private async addChunksInOrder(chunks: Chunk[]) { this.unregisterChunkListeners(); const existingIds = new Set(this.chunks.map((chunk) => getChunkId(chunk))); @@ -217,7 +231,8 @@ export default class DicomChunkImage if (volumes.length !== 1) throw new Error('Did not get just a single volume!'); - // save the newly sorted chunk order + // Invalidate decodes targeting the previous buffer and chunk order. + this.allocationGeneration += 1; this.chunks = volumes[0]; this.chunkStatus = this.chunks.map((chunk) => { @@ -228,8 +243,9 @@ export default class DicomChunkImage return ChunkStatus.NotLoaded; case ChunkState.DataLoading: return ChunkStatus.Loading; + // Loaded pixels belong to the previous allocation. case ChunkState.Loaded: - return ChunkStatus.Loaded; + return ChunkStatus.Loading; default: throw new Error('Chunk is in an invalid state'); } @@ -241,7 +257,7 @@ export default class DicomChunkImage } this.registerChunkListeners(); - this.processNewChunks(newChunks); + this.processLoadedChunks(); // Update data range with already loaded chunks after reallocating image if (this.getModality() !== 'SEG') { @@ -266,27 +282,31 @@ export default class DicomChunkImage return this.thumbnailCache.get(chunk)!; } - private processNewChunks(chunks: Chunk[]) { - chunks.forEach((chunk, idx) => { + // Reallocation clears the buffer, so restore every available slice. + private processLoadedChunks() { + this.chunks.forEach((chunk) => { if (chunk.state !== ChunkState.Loaded) return; + this.decodeChunk(chunk); + }); + } - this.onChunkHasData(idx).catch((err) => { - this.onChunkErrored(idx, err); - }); + private decodeChunk(chunk: Chunk) { + const generation = this.allocationGeneration; + this.onChunkHasData(chunk, generation).catch((err) => { + if (generation !== this.allocationGeneration) return; + this.onChunkErrored(chunk, err); }); } private registerChunkListeners() { this.chunkListeners = [ - ...this.chunks.map((chunk, index) => { + ...this.chunks.map((chunk) => { const stopDoneData = chunk.addEventListener('doneData', () => { - this.onChunkHasData(index).catch((err) => { - this.onChunkErrored(index, err); - }); + this.decodeChunk(chunk); }); const stopError = chunk.addEventListener('error', (err) => { - this.onChunkErrored(index, err); + this.onChunkErrored(chunk, err); }); return () => { @@ -381,24 +401,25 @@ export default class DicomChunkImage return outputRanges; } - private async onChunkHasData(chunkIndex: number) { + private async onChunkHasData(chunk: Chunk, generation: number) { if (this.getModality() === 'SEG') { - await this.onSegChunkHasData(chunkIndex); + await this.onSegChunkHasData(chunk, generation); } else { - await this.onRegularChunkHasData(chunkIndex); + await this.onRegularChunkHasData(chunk, generation); } } - private async onSegChunkHasData(chunkIndex: number) { - if (this.chunks.length !== 1 || chunkIndex !== 0) + private async onSegChunkHasData(chunk: Chunk, generation: number) { + if (this.chunks.length !== 1 || this.chunks[0] !== chunk) throw new Error( - `Cannot handle multiple SEG files. Expected 1 chunk at index 0, got ${this.chunks.length} chunks with current index ${chunkIndex}` + `Cannot handle multiple SEG files. Expected 1 chunk at index 0, got ${this.chunks.length} chunks with current index ${this.chunks.indexOf(chunk)}` ); - const [chunk] = this.chunks; const results = await buildSegmentGroups( new File([chunk.dataBlob!], 'seg.dcm') ); + if (generation !== this.allocationGeneration) return; + const image = vtkITKHelper.convertItkToVtkImage(results.outputImage); this.vtkImageData.value.delete(); this.vtkImageData.value = image; @@ -409,8 +430,8 @@ export default class DicomChunkImage this.onChunksUpdated(); } - private async onRegularChunkHasData(chunkIndex: number) { - const chunk = this.chunks[chunkIndex]; + private async onRegularChunkHasData(chunk: Chunk, generation: number) { + const chunkIndex = this.chunks.indexOf(chunk); if (!chunk.dataBlob) throw new Error(`Chunk ${chunkIndex} does not have data`); @@ -422,11 +443,18 @@ export default class DicomChunkImage if (!result.image.data) throw new Error(`No data read from chunk ${chunkId}`); + // Only the decode started for the current allocation may update it. + if (generation !== this.allocationGeneration) return; + + // Sorting may have changed across the await; resolve the slot now. + const sliceIndex = this.chunks.indexOf(chunk); + if (sliceIndex === -1) return; + if (result.image.size[2] > 1 && this.chunks.length > 1) { // we're trying to load multiple chunks where individual chunks have multiple frames throw new Error( `Loading a single volume from multiple DICOM files where individual files contain multiple frames is not supported. ` + - `File ${chunkId} (chunk ${chunkIndex}) contains ${result.image.size[2]} frames.` + `File ${chunkId} (chunk ${sliceIndex}) contains ${result.image.size[2]} frames.` ); } @@ -455,7 +483,7 @@ export default class DicomChunkImage ? ' Every file in a volume must have the same Rows, Columns, and SamplesPerPixel.' : ''; throw new Error( - `File ${chunkId} (chunk ${chunkIndex}) does not fit the volume it belongs to. ` + + `File ${chunkId} (chunk ${sliceIndex}) does not fit the volume it belongs to. ` + `It decoded to ${chunkWidth}x${chunkHeight}x${chunkFrames} with ${chunkComponents} component(s), ` + `but the volume has room for ${dims[0]}x${dims[1]}x${framesPerChunk} with ${componentCount} component(s).` + advice @@ -482,7 +510,7 @@ export default class DicomChunkImage if (!valuesFitBuffer({ min: chunkMin, max: chunkMax }, pixelData)) { const bufferRange = getBufferValueRange(pixelData)!; throw new Error( - `File ${chunkId} (chunk ${chunkIndex}) has pixel values the volume it belongs to cannot represent. ` + + `File ${chunkId} (chunk ${sliceIndex}) has pixel values the volume it belongs to cannot represent. ` + `Its pixel values run from ${chunkMin} to ${chunkMax}, but the volume's buffer is ` + `${pixelData.constructor.name}, holding values from ${bufferRange.min} to ${bufferRange.max}. ` + `Every file in a volume must decode to values its buffer can hold without conversion.` @@ -490,14 +518,14 @@ export default class DicomChunkImage } if (!samplesAreIntegral(decoded, pixelData)) { throw new Error( - `File ${chunkId} (chunk ${chunkIndex}) has fractional pixel values the volume it belongs to cannot represent. ` + + `File ${chunkId} (chunk ${sliceIndex}) has fractional pixel values the volume it belongs to cannot represent. ` + `Its pixel values run from ${chunkMin} to ${chunkMax}, but the volume's buffer is ` + `${pixelData.constructor.name}, which holds only whole numbers. ` + `Every file in a volume must decode to values its buffer can hold without conversion.` ); } - const offset = dims[0] * dims[1] * componentCount * chunkIndex; + const offset = dims[0] * dims[1] * componentCount * sliceIndex; pixelData.set(result.image.data as TypedArray, offset); const rangeAlreadyInitialized = this.chunkStatus.some( @@ -515,20 +543,24 @@ export default class DicomChunkImage chunk.setUserData(DATA_RANGE_KEY, chunkDataRange); - this.chunkStatus[chunkIndex] = ChunkStatus.Loaded; + this.chunkStatus[sliceIndex] = ChunkStatus.Loaded; this.events.emit('chunkLoad', { chunk, - updatedExtent: [0, dims[0] - 1, 0, dims[1] - 1, chunkIndex, chunkIndex], + updatedExtent: [0, dims[0] - 1, 0, dims[1] - 1, sliceIndex, sliceIndex], }); this.onChunksUpdated(); this.vtkImageData.value.modified(); } - private onChunkErrored(chunkIndex: number, err: unknown) { - this.chunkStatus[chunkIndex] = ChunkStatus.Errored; + private onChunkErrored(chunk: Chunk, err: unknown) { + // Sorting may have changed since the operation started. + const sliceIndex = this.chunks.indexOf(chunk); + if (sliceIndex === -1) return; + + this.chunkStatus[sliceIndex] = ChunkStatus.Errored; this.events.emit('chunkError', { - chunk: this.chunks[chunkIndex], + chunk, error: err, }); this.events.emit('error', ensureError(err)); diff --git a/src/store/__tests__/datasets-dicom-reimport.spec.ts b/src/store/__tests__/datasets-dicom-reimport.spec.ts new file mode 100644 index 000000000..df7fa9ed7 --- /dev/null +++ b/src/store/__tests__/datasets-dicom-reimport.spec.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; + +import type { Chunk } from '@/src/core/streaming/chunk'; +import { Tags } from '@/src/core/dicomTags'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useDICOMStore } from '@/src/store/datasets-dicom'; + +const mocks = vi.hoisted(() => { + const chunkImages: MockDicomChunkImage[] = []; + + class MockDicomChunkImage { + additions: Chunk[][] = []; + startLoadCount = 0; + name = ''; + + constructor() { + chunkImages.push(this); + } + + async addChunks(chunks: Chunk[]) { + this.additions.push(chunks); + } + + getDicomMetadata() { + return this.additions.at(-1)![0].metadata; + } + + getChunks() { + return this.additions.at(-1)!.slice(); + } + + setName(name: string) { + this.name = name; + } + + getStatus() { + return 'incomplete'; + } + + isLoading() { + return false; + } + + addEventListener() {} + + removeEventListener() {} + + startLoad() { + this.startLoadCount += 1; + } + + dispose() {} + } + + return { splitAndSort: vi.fn(), chunkImages, MockDicomChunkImage }; +}); + +// eslint-disable-next-line no-restricted-syntax -- DICOM splitting runs in wasm; unavailable in the node test environment +vi.mock('@/src/io/dicom', () => ({ + splitAndSort: mocks.splitAndSort, +})); + +// eslint-disable-next-line no-restricted-syntax -- needs a streaming chunk source the node environment cannot provide +vi.mock('@/src/core/streaming/dicomChunkImage', () => ({ + default: mocks.MockDicomChunkImage, +})); + +function chunk(sopInstanceUid: string) { + const metadata = [ + [Tags.SOPClassUID, '1.2.840.10008.5.1.4.1.1.2'], + [Tags.NumberOfFrames, '1'], + [Tags.SOPInstanceUID, sopInstanceUid], + [Tags.PatientID, 'patient-1'], + [Tags.PatientName, 'Test Patient'], + [Tags.PatientBirthDate, ''], + [Tags.PatientSex, ''], + [Tags.StudyID, 'study-1'], + [Tags.StudyInstanceUID, 'study-uid'], + [Tags.StudyDate, ''], + [Tags.StudyTime, ''], + [Tags.AccessionNumber, ''], + [Tags.StudyDescription, ''], + [Tags.Modality, 'CT'], + [Tags.SeriesInstanceUID, 'series-uid'], + [Tags.SeriesNumber, '7'], + [Tags.SeriesDescription, 'Incremental series'], + [Tags.WindowLevel, ''], + [Tags.WindowWidth, ''], + ] as [string, string][]; + return { + metadata, + metaBlob: new Blob([new Uint8Array([1])]), + dataBlob: new Blob([new Uint8Array([2])]), + loadData: vi.fn().mockResolvedValue(undefined), + } as unknown as Chunk; +} + +describe('DICOM store incremental import', () => { + beforeEach(() => { + setActivePinia(createPinia()); + mocks.splitAndSort.mockReset(); + mocks.chunkImages.length = 0; + }); + + it('asks an already-registered image to load the chunks a re-import adds', async () => { + const first = chunk('sop-1'); + const second = chunk('sop-2'); + mocks.splitAndSort + .mockResolvedValueOnce({ 'volume-1': [first] }) + .mockResolvedValueOnce({ 'volume-1': [first, second] }); + + const store = useDICOMStore(); + await store.importChunks([first]); + await store.importChunks([second]); + + expect(mocks.chunkImages).toHaveLength(1); + const [image] = mocks.chunkImages; + expect(image.additions).toEqual([[first], [first, second]]); + expect(image.startLoadCount).toBe(2); + expect(useImageCacheStore().imageById['volume-1']).toBe(image); + }); +}); diff --git a/src/store/datasets-dicom.ts b/src/store/datasets-dicom.ts index 56750f197..504a7b529 100644 --- a/src/store/datasets-dicom.ts +++ b/src/store/datasets-dicom.ts @@ -176,7 +176,9 @@ export const useDICOMStore = defineStore('dicom', { const image = cachedImage ?? new DicomChunkImage(); await image.addChunks(sortedChunks); - imageCacheStore.addProgressiveImage(image, { id }); + // Registration starts the first load; a re-import starts its own. + if (cachedImage) image.startLoad(); + else imageCacheStore.addProgressiveImage(image, { id }); // update database const metaPairs = image.getDicomMetadata();