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();