From 565c08989963756fcdc3c431d6ac4fcdec687be6 Mon Sep 17 00:00:00 2001 From: Alireza Date: Wed, 8 Jul 2026 21:55:43 -0400 Subject: [PATCH 1/2] test(vitest-browser): add state-based integration suites for GenericViewport and tools Adds 12 state-based (non-screenshot) integration test suites in Vitest Browser Mode, plus a shared harness (deterministic fake image stack, viewport factory, state-snapshot capture, event recorder, tools input synthesis). All assertions target public API state, events, and closed-form expected values on synthetic data. Core viewport suites: render-path state parity, geometric round-trip invariants, event contracts, lifecycle/teardown/races, serialization and session restore. Tools suites (black-box, public API only): annotation tool measurement matrix, tool group state machine + manipulation tools, annotation state management and persistence, labelmap segmentation state, undo/redo history, cross-viewport synchronizers. Runs green in ~90s: 100 passed, 12 expected-fail (each test.fails pins a real engine finding documented in-file), no pixel comparisons. Claude-Session: https://claude.ai/code/session_01NJNAWVWWtguh3aA8w1R9mp --- .gitignore | 4 + .../annotationStateManagement.browser.test.ts | 844 +++++++++++++++++ .../annotationToolsMatrix.browser.test.ts | 723 ++++++++++++++ .../eventContracts.browser.test.ts | 540 +++++++++++ .../harness/captureViewportState.ts | 152 +++ .../harness/createPlanarViewport.ts | 224 +++++ .../vitest-browser/harness/fakeImageStack.ts | 261 +++++ tests/vitest-browser/harness/index.ts | 8 + tests/vitest-browser/harness/recordEvents.ts | 132 +++ tests/vitest-browser/harness/tools.ts | 459 +++++++++ .../lifecycleAndRaces.browser.test.ts | 675 +++++++++++++ .../planarInvariants.browser.test.ts | 518 ++++++++++ .../renderPathParity.browser.test.ts | 298 ++++++ .../segmentationState.browser.test.ts | 892 ++++++++++++++++++ .../synchronizers.browser.test.ts | 708 ++++++++++++++ .../toolGroupAndManipulation.browser.test.ts | 783 +++++++++++++++ .../toolMeasurements.browser.test.ts | 408 ++++++++ .../undoRedoHistory.browser.test.ts | 673 +++++++++++++ .../viewStateSerialization.browser.test.ts | 510 ++++++++++ vitest.browser.config.ts | 1 + 20 files changed, 8813 insertions(+) create mode 100644 tests/vitest-browser/annotationStateManagement.browser.test.ts create mode 100644 tests/vitest-browser/annotationToolsMatrix.browser.test.ts create mode 100644 tests/vitest-browser/eventContracts.browser.test.ts create mode 100644 tests/vitest-browser/harness/captureViewportState.ts create mode 100644 tests/vitest-browser/harness/createPlanarViewport.ts create mode 100644 tests/vitest-browser/harness/fakeImageStack.ts create mode 100644 tests/vitest-browser/harness/index.ts create mode 100644 tests/vitest-browser/harness/recordEvents.ts create mode 100644 tests/vitest-browser/harness/tools.ts create mode 100644 tests/vitest-browser/lifecycleAndRaces.browser.test.ts create mode 100644 tests/vitest-browser/planarInvariants.browser.test.ts create mode 100644 tests/vitest-browser/renderPathParity.browser.test.ts create mode 100644 tests/vitest-browser/segmentationState.browser.test.ts create mode 100644 tests/vitest-browser/synchronizers.browser.test.ts create mode 100644 tests/vitest-browser/toolGroupAndManipulation.browser.test.ts create mode 100644 tests/vitest-browser/toolMeasurements.browser.test.ts create mode 100644 tests/vitest-browser/undoRedoHistory.browser.test.ts create mode 100644 tests/vitest-browser/viewStateSerialization.browser.test.ts diff --git a/.gitignore b/.gitignore index d7b80da462..a0e7c16b42 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,7 @@ CLAUDE.md vtk-js/ .ohif-downstream/ auth.json + +# Vitest browser mode run artifacts +.vitest-attachments/ +tests/vitest-browser/__screenshots__/ diff --git a/tests/vitest-browser/annotationStateManagement.browser.test.ts b/tests/vitest-browser/annotationStateManagement.browser.test.ts new file mode 100644 index 0000000000..4bfeb4b2c1 --- /dev/null +++ b/tests/vitest-browser/annotationStateManagement.browser.test.ts @@ -0,0 +1,844 @@ +// State-based tests pinning the contracts of the `cornerstoneTools.annotation` +// namespace (state, selection, locking, visibility) plus the annotation event +// surface and JSON persistence, driven against a GenericViewport (PLANAR_NEXT) +// harness viewport. Black box: every assertion goes through the public +// `@cornerstonejs/tools` / `@cornerstonejs/core` exports, DOM state, and +// events -- never through packages/tools/src/** deep imports. +// +// Annotations are created realistically (mouse-drawn LengthTool annotations +// via the harness's mouseDrag) except in the test that explicitly exercises +// the programmatic `addAnnotation` round trip. +// +// Event target facts verified against packages/tools/src/stateManagement/annotation/** +// (do not re-derive, see plans/vitest-browser-state-tests/09-annotation-state-management.md): +// - ANNOTATION_ADDED, ANNOTATION_MODIFIED, ANNOTATION_COMPLETED, +// ANNOTATION_REMOVED, ANNOTATION_SELECTION_CHANGE, ANNOTATION_LOCK_CHANGE, +// ANNOTATION_VISIBILITY_CHANGE all dispatch on the core `eventTarget` +// singleton (every trigger* helper in stateManagement/annotation/helpers/ +// state.ts, annotationSelection.ts, annotationLocking.ts and +// annotationVisibility.ts calls `triggerEvent(eventTarget, ...)` +// directly -- never on an element). +// - ANNOTATION_RENDERED is the one exception: AnnotationRenderingEngine +// dispatches it on the viewport `element` (see +// stateManagement/annotation/AnnotationRenderingEngine.ts). +// +// Two non-obvious, verified-empirically behaviors that shape several tests +// below (see the final report for the full writeup): +// - Drawing a NEW annotation auto-selects it: mouseDownActivate.ts calls +// `setAnnotationSelected(annotation.annotationUID)` (preserveSelected +// defaults to false) right after `activeTool.addNewAnnotation(...)` +// returns, so the most-recently-drawn annotation is always the sole +// selected one, and any handle-drag manipulation re-fires the same +// select (see mouseDown.ts `toggleAnnotationSelection`). +// - `addAnnotation`'s target manager has a permanently-installed +// preprocessing hook (packages/tools/src/stateManagement/annotation/ +// resetAnnotationManager.ts, wired at module load via +// `defaultManager.setPreprocessingFn`) that ACTIVELY OVERWRITES a +// just-added annotation's `isLocked`/`isVisible` flags from the current +// `locking`/`visibility` UID stores, regardless of what value the +// incoming object already carried. `isSelected` has no equivalent hook, +// so it is left exactly as the incoming object had it. This asymmetry +// is pinned in test 7. +// +// `cornerstoneTools.annotation` public surface (verified from +// packages/tools/src/stateManagement/annotation/index.ts): `state` (spread of +// annotationState.ts + helpers/state.ts + resetAnnotationManager), +// `selection` (annotationSelection.ts), `locking` (annotationLocking.ts), +// `visibility` (annotationVisibility.ts), `config` (style/font helpers, not +// exercised here). Exact function names used below were read directly off +// those modules' `export {}` blocks, not guessed. +import { afterEach, describe, expect, test } from 'vitest'; +import type { Types } from '@cornerstonejs/core'; +import { eventTarget } from '@cornerstonejs/core'; +import * as cornerstoneTools from '@cornerstonejs/tools'; +import { + setupTools, + mouseDrag, + waitForAnnotationRendered, + waitForToolsEvent, + type ToolsContext, +} from './harness'; + +const { LengthTool, annotation } = cornerstoneTools; +const { Events: ToolsEvents } = cornerstoneTools.Enums; +const { filterAnnotationsForDisplay } = cornerstoneTools.utilities.planar; + +type CanvasPoint = [number, number]; + +function round6(value: number): number { + return Math.round(value * 1e6) / 1e6; +} + +function expectPointCloseTo( + actual: ArrayLike, + expected: ArrayLike, + precision = 3 +): void { + expect(actual.length).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(round6(actual[i])).toBeCloseTo(round6(expected[i]), precision); + } +} + +let active: ToolsContext | null = null; + +afterEach(() => { + active?.cleanup(); + active = null; + + // Selection/locking/visibility are UID-keyed module-level Sets, entirely + // separate from the annotation manager that `cleanup()` above resets via + // `removeAllAnnotations()` -- they are NOT reset by + // `cornerstoneTools.destroy()`/`init()` either. Reset them defensively so + // no test's selection/lock/visibility state can leak into the next one + // (shared-context hard constraint 8: every test independent). + // + // Each call is independently try/caught: `annotationVisibility.ts`'s + // internal `show()`/`hide()` helpers (unlike `lock()`/`unlock()` in + // annotationLocking.ts and `clearSelectionSet()` in + // annotationSelection.ts, which all guard with `if (annotation) {...}`) + // unconditionally dereference `getAnnotation(annotationUID)` and throw + // `TypeError: Cannot set properties of undefined (setting 'isVisible')` + // if that UID's annotation was removed (e.g. by test 8, which hides then + // removes the same annotation) -- a real, narrow defensive-coding + // inconsistency in the source, verified empirically here, not a mistake + // in this cleanup. Guarding keeps this afterEach robust regardless. + try { + annotation.selection.deselectAnnotation(); + } catch { + // See comment above: tolerate a stale/removed UID. + } + try { + annotation.locking.unlockAllAnnotations(); + } catch { + // See comment above: tolerate a stale/removed UID. + } + try { + annotation.visibility.showAllAnnotations(); + } catch { + // See comment above: tolerate a stale/removed UID (the actual trigger + // for this guard, per annotationVisibility.ts's missing null check). + } +}); + +async function setup(): Promise { + const ctx = await setupTools({ + tools: [LengthTool], + activeTool: LengthTool.toolName, + }); + active = ctx; + return ctx; +} + +/** + * Draws a Length annotation via a single mouseDrag from p1 to p2 (canvas + * points, integer-rounded by the harness) and waits for ANNOTATION_RENDERED + * -- the event that reliably gates populated cachedStats (see + * harness/tools.ts `waitForAnnotationRendered`). Returns the created + * annotation, which is a live reference into the annotation manager's + * internal array (new annotations are always appended, see + * FrameOfReferenceSpecificAnnotationManager.addAnnotation), not a copy. + */ +async function drawLength( + ctx: ToolsContext, + p1: CanvasPoint, + p2: CanvasPoint + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise { + const { element } = ctx; + const rendered = waitForAnnotationRendered(element); + mouseDrag(element, p1, p2); + await rendered; + + const anns = annotation.state.getAnnotations(LengthTool.toolName, element); + return anns[anns.length - 1]; +} + +/** Current canvas position of a handle, for driving a follow-up mouseDrag. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function canvasPointOfHandle( + ctx: ToolsContext, + ann: any, + index: number +): CanvasPoint { + const world = ann.data.handles.points[index]; + const canvas = ctx.viewport.worldToCanvas(world); + return [canvas[0], canvas[1]]; +} + +/** Collapses consecutive duplicate entries, per the stabilization rule used + * by the core event-contracts golden-sequence test + * (tests/vitest-browser/eventContracts.browser.test.ts). */ +function collapseConsecutive(sequence: string[]): string[] { + return sequence.filter((type, i) => i === 0 || sequence[i - 1] !== type); +} + +/** Asserts `golden` appears, in order, as a (non-contiguous) subsequence of + * `observed`. Mirrors the ordered-subsequence stabilization rule from + * eventContracts.browser.test.ts's "golden event sequence" test. */ +function expectOrderedSubsequence(observed: string[], golden: string[]): void { + let cursor = 0; + for (const expectedType of golden) { + const foundIndex = observed.indexOf(expectedType, cursor); + expect( + foundIndex, + `expected "${expectedType}" at-or-after position ${cursor} in observed sequence ${JSON.stringify( + observed + )}` + ).toBeGreaterThanOrEqual(0); + cursor = foundIndex + 1; + } +} + +describe('annotationStateManagement', () => { + // ========================================================================== + // 1. State CRUD + // ========================================================================== + test('state CRUD: getAnnotations, getAnnotation, removeAnnotation, removeAllAnnotations', async () => { + const ctx = await setup(); + const { element } = ctx; + + const a = await drawLength(ctx, [60, 60], [160, 60]); + const b = await drawLength(ctx, [60, 240], [160, 240]); + + const all = annotation.state.getAnnotations(LengthTool.toolName, element); + expect(all.length).toBe(2); + + const uids = all.map((x: { annotationUID: string }) => x.annotationUID); + expect(new Set(uids).size).toBe(2); + expect([...uids].sort()).toEqual( + [a.annotationUID, b.annotationUID].sort() + ); + + // getAnnotation returns the SAME object reference, not a copy. + expect(annotation.state.getAnnotation(a.annotationUID)).toBe(a); + expect(annotation.state.getAnnotation(b.annotationUID)).toBe(b); + + const removedPromise = waitForToolsEvent( + eventTarget, + ToolsEvents.ANNOTATION_REMOVED + ); + annotation.state.removeAnnotation(a.annotationUID); + const removedEvent = await removedPromise; + expect(removedEvent.detail.annotation.annotationUID).toBe(a.annotationUID); + + const afterRemove = annotation.state.getAnnotations( + LengthTool.toolName, + element + ); + expect(afterRemove.length).toBe(1); + expect(afterRemove[0].annotationUID).toBe(b.annotationUID); + + annotation.state.removeAllAnnotations(); + expect( + annotation.state.getAnnotations(LengthTool.toolName, element).length + ).toBe(0); + }); + + // ========================================================================== + // 2. Programmatic addAnnotation round trip + // ========================================================================== + test('programmatic addAnnotation restores a structured-cloned mouse-drawn annotation', async () => { + const ctx = await setup(); + const { viewport, element } = ctx; + + const drawn = await drawLength(ctx, [70, 90], [210, 90]); + const uid = drawn.annotationUID; + const originalPoints = drawn.data.handles.points.map( + (p: Types.Point3) => [...p] + ); + + const clone = structuredClone(drawn); + + annotation.state.removeAllAnnotations(); + expect( + annotation.state.getAnnotations(LengthTool.toolName, element).length + ).toBe(0); + + const returnedUid = annotation.state.addAnnotation(clone, element); + expect(returnedUid).toBe(uid); + + const restoredList = annotation.state.getAnnotations( + LengthTool.toolName, + element + ); + expect(restoredList.length).toBe(1); + + const restored = restoredList[0]; + expect(restored.annotationUID).toBe(uid); + restored.data.handles.points.forEach((p: Types.Point3, i: number) => + expectPointCloseTo(p, originalPoints[i], 6) + ); + + // Filterable for display on the slice it was drawn on -- this is the + // persistence primitive: a restored annotation behaves identically to a + // freshly drawn one. + const filtered = filterAnnotationsForDisplay(viewport, restoredList); + expect(filtered.map((a: { annotationUID: string }) => a.annotationUID)).toEqual([ + uid, + ]); + }); + + // ========================================================================== + // 3. Selection contract + // ========================================================================== + test('selection: preserve-flag semantics and ANNOTATION_SELECTION_CHANGE detail', async () => { + const ctx = await setup(); + const { element } = ctx; + + const a = await drawLength(ctx, [60, 60], [140, 60]); + const b = await drawLength(ctx, [60, 220], [140, 220]); + const uid1 = a.annotationUID; + const uid2 = b.annotationUID; + + // Compatibility finding (see file header): drawing a NEW annotation + // auto-selects it and replaces any prior selection, so after these two + // draws only the LAST one (b/uid2) is selected -- not neither, not both. + expect(annotation.selection.getAnnotationsSelected()).toEqual([uid2]); + + // Reset to a known empty baseline before exercising the selection + // contract itself, so the assertions below are unambiguous. + annotation.selection.deselectAnnotation(); + expect(annotation.selection.getAnnotationsSelected()).toEqual([]); + + // Select uid1. + let changed = waitForToolsEvent( + eventTarget, + ToolsEvents.ANNOTATION_SELECTION_CHANGE + ); + annotation.selection.setAnnotationSelected(uid1, true); + let evt = await changed; + expect(annotation.selection.isAnnotationSelected(uid1)).toBe(true); + expect(annotation.selection.getAnnotationsSelected()).toEqual([uid1]); + expect(evt.detail.added).toEqual([uid1]); + expect(evt.detail.removed).toEqual([]); + + // Select uid2 WITHOUT preserve (default false): replaces the selection. + changed = waitForToolsEvent( + eventTarget, + ToolsEvents.ANNOTATION_SELECTION_CHANGE + ); + annotation.selection.setAnnotationSelected(uid2, true); + evt = await changed; + expect(annotation.selection.isAnnotationSelected(uid1)).toBe(false); + expect(annotation.selection.isAnnotationSelected(uid2)).toBe(true); + expect(evt.detail.added).toEqual([uid2]); + expect(evt.detail.removed).toEqual([uid1]); + + // Select uid1 WITH preserveSelected=true: both selected. + changed = waitForToolsEvent( + eventTarget, + ToolsEvents.ANNOTATION_SELECTION_CHANGE + ); + annotation.selection.setAnnotationSelected(uid1, true, true); + evt = await changed; + expect(annotation.selection.isAnnotationSelected(uid1)).toBe(true); + expect(annotation.selection.isAnnotationSelected(uid2)).toBe(true); + expect(new Set(annotation.selection.getAnnotationsSelected())).toEqual( + new Set([uid1, uid2]) + ); + expect(evt.detail.added).toEqual([uid1]); + expect(evt.detail.removed).toEqual([]); + + // Deselect all via the public API (deselectAnnotation with no uid). + changed = waitForToolsEvent( + eventTarget, + ToolsEvents.ANNOTATION_SELECTION_CHANGE + ); + annotation.selection.deselectAnnotation(); + evt = await changed; + expect(annotation.selection.getAnnotationsSelected()).toEqual([]); + expect(annotation.selection.isAnnotationSelected(uid1)).toBe(false); + expect(annotation.selection.isAnnotationSelected(uid2)).toBe(false); + expect([...evt.detail.removed].sort()).toEqual([uid1, uid2].sort()); + }); + + // ========================================================================== + // 4. Locking contract + // ========================================================================== + test('locking: setAnnotationLocked blocks handle manipulation until unlocked', async () => { + const ctx = await setup(); + const { viewport, element } = ctx; + + const ann = await drawLength(ctx, [80, 80], [180, 80]); + const uid = ann.annotationUID; + + const lockChanged = waitForToolsEvent( + eventTarget, + ToolsEvents.ANNOTATION_LOCK_CHANGE + ); + annotation.locking.setAnnotationLocked(uid, true); + const lockEvent = await lockChanged; + expect(annotation.locking.isAnnotationLocked(uid)).toBe(true); + expect(lockEvent.detail.added).toEqual([uid]); + + const preDragPoints = ann.data.handles.points.map((p: Types.Point3) => [ + ...p, + ]); + const handleCanvas = canvasPointOfHandle(ctx, ann, 0); + const elsewhereCanvas: CanvasPoint = [ + handleCanvas[0] + 40, + handleCanvas[1] + 40, + ]; + + const rendered1 = waitForAnnotationRendered(element); + mouseDrag(element, handleCanvas, elsewhereCanvas); + await rendered1; + + // Behavioral consequence of locking, verified against + // packages/tools/src/store/filterToolsWithMoveableHandles.ts and + // filterMoveableAnnotationTools.ts: both skip `annotation.isLocked` + // annotations during mousedown hit-testing, so a drag starting exactly + // at the locked annotation's handle is NOT recognized as a manipulation + // of that annotation at all. With LengthTool still the active/Primary + // tool, mousedown instead falls through to mouseDownActivate and draws a + // BRAND NEW Length annotation from the same two points -- a genuine + // compatibility finding, not a test bug (see final report). Verify the + // ORIGINAL annotation's points are untouched, then remove the stray one + // before continuing so the unlock step below is unambiguous. + const afterLockedDrag = annotation.state.getAnnotations( + LengthTool.toolName, + element + ); + expect(afterLockedDrag.length).toBe(2); + + const stillOriginal = annotation.state.getAnnotation(uid); + expectPointCloseTo( + stillOriginal.data.handles.points[0], + preDragPoints[0], + 6 + ); + expectPointCloseTo( + stillOriginal.data.handles.points[1], + preDragPoints[1], + 6 + ); + + const stray = afterLockedDrag.find( + (candidate: { annotationUID: string }) => + candidate.annotationUID !== uid + ); + annotation.state.removeAnnotation(stray.annotationUID); + expect( + annotation.state.getAnnotations(LengthTool.toolName, element).length + ).toBe(1); + + const unlockChanged = waitForToolsEvent( + eventTarget, + ToolsEvents.ANNOTATION_LOCK_CHANGE + ); + annotation.locking.setAnnotationLocked(uid, false); + const unlockEvent = await unlockChanged; + expect(annotation.locking.isAnnotationLocked(uid)).toBe(false); + expect(unlockEvent.detail.removed).toEqual([uid]); + + // The SAME drag now moves the handle: unlocked annotations are found by + // filterToolsWithMoveableHandles, so mousedown is consumed by + // handleSelectedCallback instead of falling through. + const rendered2 = waitForAnnotationRendered(element); + mouseDrag(element, handleCanvas, elsewhereCanvas); + await rendered2; + + const expectedWorld = viewport.canvasToWorld(elsewhereCanvas); + const afterUnlockDrag = annotation.state.getAnnotation(uid); + expectPointCloseTo( + afterUnlockDrag.data.handles.points[0], + expectedWorld, + 2 + ); + }); + + // ========================================================================== + // 5. Visibility contract + // ========================================================================== + test('visibility: setAnnotationVisibility toggles both the store and annotation.isVisible', async () => { + const ctx = await setup(); + const ann = await drawLength(ctx, [80, 80], [180, 80]); + const uid = ann.annotationUID; + + expect(annotation.visibility.isAnnotationVisible(uid)).toBe(true); + expect(ann.isVisible).not.toBe(false); + + let changed = waitForToolsEvent( + eventTarget, + ToolsEvents.ANNOTATION_VISIBILITY_CHANGE + ); + annotation.visibility.setAnnotationVisibility(uid, false); + let evt = await changed; + expect(annotation.visibility.isAnnotationVisible(uid)).toBe(false); + // Pinned: visibility is BOTH a UID-keyed store (globalHiddenAnnotationUIDsSet + // in annotationVisibility.ts) AND mirrored directly onto the annotation + // object's `isVisible` flag by the same call -- unlike selection/locking, + // which (per test 7) do NOT get repopulated on a naive restore. + expect(ann.isVisible).toBe(false); + expect(evt.detail.lastHidden).toEqual([uid]); + expect(evt.detail.hidden).toEqual([uid]); + + changed = waitForToolsEvent( + eventTarget, + ToolsEvents.ANNOTATION_VISIBILITY_CHANGE + ); + annotation.visibility.setAnnotationVisibility(uid, true); + evt = await changed; + expect(annotation.visibility.isAnnotationVisible(uid)).toBe(true); + expect(ann.isVisible).toBe(true); + expect(evt.detail.lastVisible).toEqual([uid]); + expect(evt.detail.hidden).toEqual([]); + }); + + // ========================================================================== + // 6. Slice binding and display filtering + // ========================================================================== + test('annotations are slice-bound and filterAnnotationsForDisplay reflects the current slice', async () => { + const ctx = await setup(); + const { viewport, element, imageIds } = ctx; + + expect(viewport.getCurrentImageIdIndex()).toBe(0); + const annSlice0 = await drawLength(ctx, [60, 60], [160, 60]); + + await viewport.setImageIdIndex(2); + expect(viewport.getCurrentImageIdIndex()).toBe(2); + const annSlice2 = await drawLength(ctx, [60, 200], [160, 200]); + + const all = annotation.state.getAnnotations(LengthTool.toolName, element); + expect(all.length).toBe(2); + expect( + all.map((a: { annotationUID: string }) => a.annotationUID).sort() + ).toEqual( + [annSlice0.annotationUID, annSlice2.annotationUID].sort() + ); + + expect(annSlice0.metadata.referencedImageId).toBe(imageIds[0]); + expect(annSlice2.metadata.referencedImageId).toBe(imageIds[2]); + + // Still on slice 2: only the slice-2 annotation is displayable here. + let filtered = filterAnnotationsForDisplay(viewport, all); + expect(filtered.map((a: { annotationUID: string }) => a.annotationUID)).toEqual([ + annSlice2.annotationUID, + ]); + + await viewport.setImageIdIndex(0); + expect(viewport.getCurrentImageIdIndex()).toBe(0); + filtered = filterAnnotationsForDisplay( + viewport, + annotation.state.getAnnotations(LengthTool.toolName, element) + ); + expect(filtered.map((a: { annotationUID: string }) => a.annotationUID)).toEqual([ + annSlice0.annotationUID, + ]); + }); + + // ========================================================================== + // 7. Full JSON persistence round trip + // ========================================================================== + test('full JSON persistence round trip: counts, world points, stats, and the selection/locking gap', async () => { + const ctx = await setup(); + const { viewport, element } = ctx; + + const a = await drawLength(ctx, [60, 60], [160, 60]); // slice 0 + const b = await drawLength(ctx, [60, 200], [160, 200]); // slice 0 + + await viewport.setImageIdIndex(2); + const c = await drawLength(ctx, [60, 340], [160, 340]); // slice 2 + await viewport.setImageIdIndex(0); + + annotation.selection.setAnnotationSelected(a.annotationUID, true); + annotation.locking.setAnnotationLocked(b.annotationUID, true); + expect(b.isLocked).toBe(true); + + // Post-render, cachedStats have been computed and `invalidated` has been + // explicitly reset to false (see packages/tools/src/tools/annotation/LengthTool.ts, + // the `annotation.invalidated = false;` right after computing stats). + // This is the mechanism pinned below: a restored annotation whose + // `invalidated` flag survived the JSON round trip as `false` will NOT be + // recomputed on the next render pass because it already has a + // `cachedStats[targetId].unit`. + expect(a.invalidated).toBe(false); + + const preWorldPoints = new Map( + [a, b, c].map((ann) => [ + ann.annotationUID, + ann.data.handles.points.map((p: Types.Point3) => [...p]), + ]) + ); + const preStatsLength = new Map( + [a, b, c].map((ann) => { + const targetId = Object.keys(ann.data.cachedStats)[0]; + return [ann.annotationUID, ann.data.cachedStats[targetId].length]; + }) + ); + + const serialized = JSON.parse( + JSON.stringify(annotation.state.getAllAnnotations()) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any[]; + expect(serialized.length).toBe(3); + serialized.forEach((plain) => expect(plain.invalidated).toBe(false)); + // The serialized clone faithfully captured both flags before anything + // else runs. + expect(serialized.find((p) => p.annotationUID === a.annotationUID).isSelected).toBe( + true + ); + expect(serialized.find((p) => p.annotationUID === b.annotationUID).isLocked).toBe( + true + ); + + annotation.state.removeAllAnnotations(); + expect(annotation.state.getAllAnnotations().length).toBe(0); + + // Selection/locking are UID-keyed Sets separate from annotation state + // (selectedAnnotationUIDs in annotationSelection.ts, + // globalLockedAnnotationUIDsSet in annotationLocking.ts) and are NOT + // cleared by removeAllAnnotations -- reset them explicitly via the + // public API before restoring, exactly as a real persistence layer must. + annotation.selection.deselectAnnotation(); + annotation.locking.unlockAllAnnotations(); + expect(annotation.selection.getAnnotationsSelected()).toEqual([]); + expect(annotation.locking.getAnnotationsLocked()).toEqual([]); + + for (const plain of serialized) { + annotation.state.addAnnotation(plain, element); + } + + const restored = annotation.state.getAnnotations( + LengthTool.toolName, + element + ); + expect(restored.length).toBe(3); + + for (const ann of restored) { + const expectedPoints = preWorldPoints.get(ann.annotationUID)!; + ann.data.handles.points.forEach((p: Types.Point3, i: number) => + expectPointCloseTo(p, expectedPoints[i], 6) + ); + + const targetId = Object.keys(ann.data.cachedStats)[0]; + expect(ann.data.cachedStats[targetId].length).toBeCloseTo( + preStatsLength.get(ann.annotationUID)!, + 6 + ); + } + + const restoredA = restored.find( + (r: { annotationUID: string }) => r.annotationUID === a.annotationUID + ); + const restoredB = restored.find( + (r: { annotationUID: string }) => r.annotationUID === b.annotationUID + ); + + // Pinned persistence-contract finding (report this prominently), and it + // is NOT symmetric between selection and locking: + // + // `isSelected` has no preprocessing hook, so the incoming plain object's + // flag is left exactly as the JSON clone had it -- it survives as an + // object property... + expect(restoredA.isSelected).toBe(true); + // ...but the public accessor still reads the SEPARATE UID-keyed + // `selectedAnnotationUIDs` store (annotationSelection.ts), which the + // restore loop above never repopulated, so it now disagrees with the + // object's own flag: + expect(annotation.selection.isAnnotationSelected(a.annotationUID)).toBe( + false + ); + // + // `isLocked` is DIFFERENT and more dangerous: the default annotation + // manager has a preprocessing hook wired at module load + // (packages/tools/src/stateManagement/annotation/resetAnnotationManager.ts, + // `defaultManager.setPreprocessingFn(preprocessingFn)`) that runs on + // EVERY `addAnnotation()` call and OVERWRITES `annotation.isLocked` with + // `checkAndSetAnnotationLocked(uid)` -- i.e. the CURRENT (here: just + // cleared) `locking` store, discarding whatever the incoming object + // said. So the restored object's `isLocked` does NOT survive even as a + // property, despite the JSON clone itself having carried `true` (see the + // `serialized` assertion above) -- it comes back `false`: + expect(restoredB.isLocked).toBe(false); + expect(annotation.locking.isAnnotationLocked(b.annotationUID)).toBe( + false + ); + // + // The correct restore procedure is therefore NOT "restore the JSON and + // trust the flags" for either store -- it is to separately persist + // `selection.getAnnotationsSelected()` / `locking.getAnnotationsLocked()` + // and replay them through the public setters (in either order relative + // to `addAnnotation`, since `setAnnotationLocked`/`setAnnotationSelected` + // both set the object property AND the store together): + annotation.selection.setAnnotationSelected(a.annotationUID, true); + annotation.locking.setAnnotationLocked(b.annotationUID, true); + expect(annotation.selection.isAnnotationSelected(a.annotationUID)).toBe( + true + ); + expect(annotation.locking.isAnnotationLocked(b.annotationUID)).toBe(true); + expect(restoredA.isSelected).toBe(true); + expect(restoredB.isLocked).toBe(true); + + // Display filtering still works post-restore. + const filteredSlice0 = filterAnnotationsForDisplay(viewport, restored); + expect( + filteredSlice0 + .map((r: { annotationUID: string }) => r.annotationUID) + .sort() + ).toEqual([a.annotationUID, b.annotationUID].sort()); + + await viewport.setImageIdIndex(2); + const filteredSlice2 = filterAnnotationsForDisplay( + viewport, + annotation.state.getAnnotations(LengthTool.toolName, element) + ); + expect( + filteredSlice2.map((r: { annotationUID: string }) => r.annotationUID) + ).toEqual([c.annotationUID]); + }); + + // ========================================================================== + // 8. Annotation event sequence golden + // ========================================================================== + test('golden annotation event sequence: draw -> modify -> select -> lock -> hide -> remove', async () => { + const ctx = await setup(); + const { element } = ctx; + + const TRACKED_EVENTS = [ + ToolsEvents.ANNOTATION_ADDED, + ToolsEvents.ANNOTATION_MODIFIED, + ToolsEvents.ANNOTATION_COMPLETED, + ToolsEvents.ANNOTATION_RENDERED, + ToolsEvents.ANNOTATION_SELECTION_CHANGE, + ToolsEvents.ANNOTATION_LOCK_CHANGE, + ToolsEvents.ANNOTATION_VISIBILITY_CHANGE, + ToolsEvents.ANNOTATION_REMOVED, + ]; + + const recorded: Array<{ type: string; detail: unknown }> = []; + const listener = (evt: Event) => { + recorded.push({ type: evt.type, detail: (evt as CustomEvent).detail }); + }; + + // ANNOTATION_RENDERED fires on `element`; every other tracked event + // fires on the core `eventTarget` singleton (see file header). Both + // listeners push into the SAME array, which preserves true dispatch + // order because both are synchronous CustomEvent dispatches on the main + // thread -- there is no interleaving to reconcile. + TRACKED_EVENTS.forEach((type) => { + eventTarget.addEventListener(type, listener); + element.addEventListener(type, listener); + }); + + try { + const drawn = await drawLength(ctx, [80, 80], [220, 80]); + const uid = drawn.annotationUID; + + const handleCanvas = canvasPointOfHandle(ctx, drawn, 0); + const newCanvas: CanvasPoint = [ + handleCanvas[0] + 30, + handleCanvas[1] - 20, + ]; + const rendered = waitForAnnotationRendered(element); + mouseDrag(element, handleCanvas, newCanvas); + await rendered; + + annotation.selection.setAnnotationSelected(uid, true); + annotation.locking.setAnnotationLocked(uid, true); + // Hiding a SELECTED annotation implicitly deselects it first (see + // annotationVisibility.ts `hide()`: `if (isAnnotationSelected(uid)) + // deselectAnnotation(uid)` runs before the visibility store is + // updated), so this single call fires an EXTRA + // ANNOTATION_SELECTION_CHANGE (removed=[uid]) before + // ANNOTATION_VISIBILITY_CHANGE -- a genuine, non-obvious coupling + // between the two stores, verified below. + annotation.visibility.setAnnotationVisibility(uid, false); + annotation.state.removeAnnotation(uid); + + const observed = recorded.map((r) => r.type); + const collapsed = collapseConsecutive(observed); + + // Golden contract, pinned from an actual run of this file (stable + // across repeated runs; the file's own harness cleanup makes each + // test independent). Per-step reasoning: + // draw: mousedown -> ANNOTATION_ADDED; mouseDownActivate.ts + // auto-selects every newly drawn annotation (see file + // header) -> ANNOTATION_SELECTION_CHANGE; each mousemove of + // the 2-step drag -> ANNOTATION_MODIFIED (collapsed to one); + // mouseup -> ANNOTATION_COMPLETED synchronously, then the + // RAF-driven annotation render recomputes cachedStats and + // fires a further ANNOTATION_MODIFIED (ChangeTypes. + // StatsUpdated, collapsed into the same run), then + // ANNOTATION_RENDERED. + // modify: handle-drag hit-testing ALSO re-selects the annotation + // being dragged (mouseDown.ts `toggleAnnotationSelection` + // runs before `handleSelectedCallback`) -> a SECOND + // ANNOTATION_SELECTION_CHANGE (added=[uid], removed=[uid], + // since it was already selected and preserveSelected + // defaults to false); then the drag's mousemove(s) and the + // post-render stats recompute -> ANNOTATION_MODIFIED + // (collapsed), then ANNOTATION_RENDERED. + // select: MY explicit setAnnotationSelected -> a THIRD + // ANNOTATION_SELECTION_CHANGE (added=[uid], removed=[uid], + // same reason: already selected from the drag above). + // lock: ANNOTATION_LOCK_CHANGE (added=[uid]). + // hide: a FOURTH ANNOTATION_SELECTION_CHANGE (removed=[uid], the + // implicit deselect above) followed by + // ANNOTATION_VISIBILITY_CHANGE (lastHidden=[uid]). + // remove: ANNOTATION_REMOVED. + const golden = [ + ToolsEvents.ANNOTATION_ADDED, + ToolsEvents.ANNOTATION_SELECTION_CHANGE, + ToolsEvents.ANNOTATION_MODIFIED, + ToolsEvents.ANNOTATION_COMPLETED, + ToolsEvents.ANNOTATION_MODIFIED, + ToolsEvents.ANNOTATION_RENDERED, + ToolsEvents.ANNOTATION_SELECTION_CHANGE, + ToolsEvents.ANNOTATION_MODIFIED, + ToolsEvents.ANNOTATION_RENDERED, + ToolsEvents.ANNOTATION_SELECTION_CHANGE, + ToolsEvents.ANNOTATION_LOCK_CHANGE, + ToolsEvents.ANNOTATION_SELECTION_CHANGE, + ToolsEvents.ANNOTATION_VISIBILITY_CHANGE, + ToolsEvents.ANNOTATION_REMOVED, + ]; + + expectOrderedSubsequence(collapsed, golden); + + // Targeted assertions on all four ANNOTATION_SELECTION_CHANGE events + // (auto-select-on-draw, re-select-on-handle-drag, the explicit select, + // then the implicit deselect-on-hide) and the terminal + // ANNOTATION_REMOVED, using the FULL (uncollapsed) detail so the + // added/removed arrays are checked precisely. + const selectionChanges = recorded.filter( + (r) => r.type === ToolsEvents.ANNOTATION_SELECTION_CHANGE + ); + expect(selectionChanges.length).toBe(4); + expect( + (selectionChanges[0].detail as { added: string[]; removed: string[] }) + ).toEqual( + expect.objectContaining({ added: [uid], removed: [] }) + ); + expect( + (selectionChanges[1].detail as { added: string[]; removed: string[] }) + ).toEqual( + expect.objectContaining({ added: [uid], removed: [uid] }) + ); + expect( + (selectionChanges[2].detail as { added: string[]; removed: string[] }) + ).toEqual( + expect.objectContaining({ added: [uid], removed: [uid] }) + ); + expect( + (selectionChanges[3].detail as { added: string[]; removed: string[] }) + ).toEqual( + expect.objectContaining({ added: [], removed: [uid] }) + ); + + const removedEvent = recorded.find( + (r) => r.type === ToolsEvents.ANNOTATION_REMOVED + ); + expect( + (removedEvent?.detail as { annotation: { annotationUID: string } }) + .annotation.annotationUID + ).toBe(uid); + } finally { + TRACKED_EVENTS.forEach((type) => { + eventTarget.removeEventListener(type, listener); + element.removeEventListener(type, listener); + }); + } + }); +}); diff --git a/tests/vitest-browser/annotationToolsMatrix.browser.test.ts b/tests/vitest-browser/annotationToolsMatrix.browser.test.ts new file mode 100644 index 0000000000..8f68a9df19 --- /dev/null +++ b/tests/vitest-browser/annotationToolsMatrix.browser.test.ts @@ -0,0 +1,723 @@ +// State-based coverage of one annotation tool per row of the plan 07 matrix, +// built on top of the tools harness (./harness/tools.ts). Extends, and does +// not duplicate, the proven coverage in toolMeasurements.browser.test.ts +// (Length + RectangleROI basics) -- that file is left untouched. +// +// For every tool: draw via synthetic pointer events, wait for +// ANNOTATION_RENDERED (the event that gates populated cachedStats -- see +// harness/tools.ts waitForAnnotationRendered), then assert annotation STATE +// (handle world coordinates, cachedStats) against closed-form expected +// values derived from the synthetic stack geometry (64x64, 1mm spacing, +// background value 10 + sliceIndex, vertical bar value 255 at world x in +// [20, 25)) via viewport.canvasToWorld/worldToCanvas -- never hard-coded +// world numbers. +// +// Black-box rule: only public @cornerstonejs/tools / @cornerstonejs/core +// exports, DOM, and events are used for assertions. +import { afterEach, describe, expect, test } from 'vitest'; +import { eventTarget } from '@cornerstonejs/core'; +import type { Types } from '@cornerstonejs/core'; +import * as cornerstoneTools from '@cornerstonejs/tools'; +import { + mouseClick, + mouseDrag, + recordEvents, + setupTools, + waitForAnnotationRendered, + worldDistance, + type ToolsContext, +} from './harness'; + +const { + ProbeTool, + AngleTool, + BidirectionalTool, + RectangleROITool, + EllipticalROITool, + CircleROITool, + LengthTool, + annotation, +} = cornerstoneTools; +const { Events: ToolsEvents } = cornerstoneTools.Enums; + +function round2(point: Types.Point2 | number[]): [number, number] { + return [Math.round(point[0]), Math.round(point[1])]; +} + +let active: ToolsContext | null = null; + +afterEach(() => { + if (!active) { + return; + } + + const ctx = active; + active = null; + ctx.cleanup(); +}); + +describe('annotationToolsMatrix', () => { + describe('ProbeTool', () => { + test('single click on the background reads the exact background value', async () => { + const ctx = await setupTools({ + tools: [ProbeTool], + activeTool: ProbeTool.toolName, + viewport: { width: 400, height: 400 }, + }); + active = ctx; + const { viewport, element, imageIds } = ctx; + + // Canvas [200, 200] sits at the default fit-to-viewport zoom's image + // center (~world [32, 32, 0]), well outside the bar (world x in + // [20, 25)). + const canvasPoint: [number, number] = [200, 200]; + + const rendered = waitForAnnotationRendered(element); + mouseClick(element, canvasPoint); + await rendered; + + const annotations = annotation.state.getAnnotations( + ProbeTool.toolName, + element + ); + expect(annotations.length).toBe(1); + + const probeAnnotation = annotations[0]; + expect(probeAnnotation.metadata.toolName).toBe(ProbeTool.toolName); + expect(probeAnnotation.metadata.referencedImageId).toBe(imageIds[0]); + + const expectedWorld = viewport.canvasToWorld(round2(canvasPoint)); + const handlePoints = probeAnnotation.data.handles.points; + expect(handlePoints.length).toBe(1); + expect(handlePoints[0][0]).toBeCloseTo(expectedWorld[0], 2); + expect(handlePoints[0][1]).toBeCloseTo(expectedWorld[1], 2); + expect(handlePoints[0][2]).toBeCloseTo(expectedWorld[2], 2); + + const cachedStats = probeAnnotation.data.cachedStats; + const targetIds = Object.keys(cachedStats); + expect(targetIds.length).toBe(1); + expect(cachedStats[targetIds[0]].value).toBe(10); + }); + + test('single click inside the bar reads the exact bar value', async () => { + const ctx = await setupTools({ + tools: [ProbeTool], + activeTool: ProbeTool.toolName, + viewport: { width: 400, height: 400 }, + }); + active = ctx; + const { viewport, element, imageIds } = ctx; + + const worldTarget: Types.Point3 = [22, 32, 0]; + const canvasPoint = round2(viewport.worldToCanvas(worldTarget)); + + const rendered = waitForAnnotationRendered(element); + mouseClick(element, canvasPoint); + await rendered; + + const annotations = annotation.state.getAnnotations( + ProbeTool.toolName, + element + ); + expect(annotations.length).toBe(1); + + const probeAnnotation = annotations[0]; + expect(probeAnnotation.metadata.toolName).toBe(ProbeTool.toolName); + expect(probeAnnotation.metadata.referencedImageId).toBe(imageIds[0]); + + const expectedWorld = viewport.canvasToWorld(canvasPoint); + const handlePoints = probeAnnotation.data.handles.points; + expect(handlePoints[0][0]).toBeCloseTo(expectedWorld[0], 2); + expect(handlePoints[0][1]).toBeCloseTo(expectedWorld[1], 2); + expect(handlePoints[0][2]).toBeCloseTo(expectedWorld[2], 2); + + const cachedStats = probeAnnotation.data.cachedStats; + const targetIds = Object.keys(cachedStats); + expect(targetIds.length).toBe(1); + expect(cachedStats[targetIds[0]].value).toBe(255); + }); + }); + + describe('AngleTool', () => { + // Interaction sequence learned from packages/tools/src/tools/annotation/AngleTool.ts + // (no dedicated Karma test exists for this tool; CobbAngleTool_test.js + // covers the unrelated 4-independent-point Cobb variant). AngleTool's + // addNewAnnotation runs on the FIRST mousedown, placing 2 co-located + // points (handles[0], handles[0]); the first mousedown+move+up gesture + // drags handles[1] to the drag's endpoint and completes line 1 -- but + // _endCallback special-cases `angleStartedNotYetCompleted && + // points.length === 2` to stay in draw mode (does NOT deactivate, + // does NOT fire ANNOTATION_COMPLETED) instead of finishing. A SECOND + // mousedown+move+up gesture then draws line 2: the second gesture's + // mousedown is itself a no-op (addNewAnnotation early-returns while + // angleStartedNotYetCompleted is true), but _activateDraw bound BOTH + // Events.MOUSE_MOVE and Events.MOUSE_DRAG to the same handler during + // gesture 1, so the second gesture's move+up still sets handles[2] and + // completes the angle. Net effect: two mouseDrag calls in a row draw + // one complete 3-point angle annotation: [handles[0], handles[1], + // handles[2]]. + // + // IMPORTANT: _calculateCachedStats computes + // angleBetweenLines([handles[0], handles[1]], [handles[1], handles[2]]), + // i.e. the shared vertex of the two lines -- the actual angle vertex -- + // is handles[1] (the END of the FIRST drag), not handles[0] (the first + // drag's mousedown/start point). So to build a known angle at a chosen + // vertex V with rays to points A and C, gesture 1 must drag FROM A TO V + // (handles[0]=A, handles[1]=V), then gesture 2 drags to C + // (handles[2]=C). + test('two drags around a shared vertex form a 90 degree angle', async () => { + const ctx = await setupTools({ + tools: [AngleTool], + activeTool: AngleTool.toolName, + viewport: { width: 400, height: 400 }, + }); + active = ctx; + const { viewport, element, imageIds } = ctx; + + const aWorld: Types.Point3 = [10, 30, 0]; + const vertexWorld: Types.Point3 = [30, 30, 0]; + const cWorld: Types.Point3 = [30, 10, 0]; + + const aCanvas = round2(viewport.worldToCanvas(aWorld)); + const vertexCanvas = round2(viewport.worldToCanvas(vertexWorld)); + const cCanvas = round2(viewport.worldToCanvas(cWorld)); + + mouseDrag(element, aCanvas, vertexCanvas); + mouseDrag(element, vertexCanvas, cCanvas); + await waitForAnnotationRendered(element); + + const annotations = annotation.state.getAnnotations( + AngleTool.toolName, + element + ); + expect(annotations.length).toBe(1); + + const angleAnnotation = annotations[0]; + expect(angleAnnotation.metadata.toolName).toBe(AngleTool.toolName); + expect(angleAnnotation.metadata.referencedImageId).toBe(imageIds[0]); + + const handlePoints = angleAnnotation.data.handles.points; + expect(handlePoints.length).toBe(3); + + const expectedA = viewport.canvasToWorld(aCanvas); + const expectedVertex = viewport.canvasToWorld(vertexCanvas); + const expectedC = viewport.canvasToWorld(cCanvas); + const expectedPoints = [expectedA, expectedVertex, expectedC]; + + for (let i = 0; i < 3; i++) { + expect(handlePoints[i][0]).toBeCloseTo(expectedPoints[i][0], 2); + expect(handlePoints[i][1]).toBeCloseTo(expectedPoints[i][1], 2); + expect(handlePoints[i][2]).toBeCloseTo(expectedPoints[i][2], 2); + } + + const cachedStats = angleAnnotation.data.cachedStats; + const targetIds = Object.keys(cachedStats); + expect(targetIds.length).toBe(1); + expect(cachedStats[targetIds[0]].angle).toBeCloseTo(90, 1); + }); + + test('two drags around a shared vertex form a 45 degree angle', async () => { + const ctx = await setupTools({ + tools: [AngleTool], + activeTool: AngleTool.toolName, + viewport: { width: 400, height: 400 }, + }); + active = ctx; + const { viewport, element, imageIds } = ctx; + + // Same vertex/A as the 90 degree case; C moved so the angle between + // (vertex->A) and (vertex->C) is 45 degrees: + // u = A - vertex = (-20, 0), v = C - vertex = (-20, -20), + // cos(theta) = (u.v)/(|u||v|) = 400 / (20 * 20*sqrt(2)) = 1/sqrt(2). + const aWorld: Types.Point3 = [10, 30, 0]; + const vertexWorld: Types.Point3 = [30, 30, 0]; + const cWorld: Types.Point3 = [10, 10, 0]; + + const aCanvas = round2(viewport.worldToCanvas(aWorld)); + const vertexCanvas = round2(viewport.worldToCanvas(vertexWorld)); + const cCanvas = round2(viewport.worldToCanvas(cWorld)); + + mouseDrag(element, aCanvas, vertexCanvas); + mouseDrag(element, vertexCanvas, cCanvas); + await waitForAnnotationRendered(element); + + const annotations = annotation.state.getAnnotations( + AngleTool.toolName, + element + ); + expect(annotations.length).toBe(1); + + const angleAnnotation = annotations[0]; + expect(angleAnnotation.metadata.referencedImageId).toBe(imageIds[0]); + + const cachedStats = angleAnnotation.data.cachedStats; + const targetIds = Object.keys(cachedStats); + expect(targetIds.length).toBe(1); + expect(cachedStats[targetIds[0]].angle).toBeCloseTo(45, 1); + }); + }); + + describe('BidirectionalTool', () => { + test('one drag draws a 20mm major axis with an auto-placed perpendicular minor axis', async () => { + const ctx = await setupTools({ + tools: [BidirectionalTool], + activeTool: BidirectionalTool.toolName, + viewport: { width: 400, height: 400 }, + }); + active = ctx; + const { viewport, element, imageIds } = ctx; + + const p1World: Types.Point3 = [10, 40, 0]; + const p2World: Types.Point3 = [30, 40, 0]; // 20mm major axis + const p1Canvas = round2(viewport.worldToCanvas(p1World)); + const p2Canvas = round2(viewport.worldToCanvas(p2World)); + + const rendered = waitForAnnotationRendered(element); + mouseDrag(element, p1Canvas, p2Canvas); + await rendered; + + const annotations = annotation.state.getAnnotations( + BidirectionalTool.toolName, + element + ); + expect(annotations.length).toBe(1); + + const bidirectionalAnnotation = annotations[0]; + expect(bidirectionalAnnotation.metadata.toolName).toBe( + BidirectionalTool.toolName + ); + expect(bidirectionalAnnotation.metadata.referencedImageId).toBe( + imageIds[0] + ); + + // handles.points = [majorStart, majorEnd, minorStart, minorEnd]. + // majorStart/majorEnd are the literal drag down/up points; the minor + // axis is auto-generated perpendicular to the major axis at its + // midpoint (see BidirectionalTool.ts), so it is checked below only + // for self-consistency (length >= width) rather than against a + // literal canvas input. + const handlePoints = bidirectionalAnnotation.data.handles.points; + expect(handlePoints.length).toBe(4); + + const expectedP1 = viewport.canvasToWorld(p1Canvas); + const expectedP2 = viewport.canvasToWorld(p2Canvas); + expect(handlePoints[0][0]).toBeCloseTo(expectedP1[0], 2); + expect(handlePoints[0][1]).toBeCloseTo(expectedP1[1], 2); + expect(handlePoints[0][2]).toBeCloseTo(expectedP1[2], 2); + expect(handlePoints[1][0]).toBeCloseTo(expectedP2[0], 2); + expect(handlePoints[1][1]).toBeCloseTo(expectedP2[1], 2); + expect(handlePoints[1][2]).toBeCloseTo(expectedP2[2], 2); + + const cachedStats = bidirectionalAnnotation.data.cachedStats; + const targetIds = Object.keys(cachedStats); + expect(targetIds.length).toBe(1); + const stats = cachedStats[targetIds[0]]; + + expect(stats.length).toBeCloseTo(20, 3); + + const selfConsistentWidth = worldDistance( + handlePoints[2], + handlePoints[3] + ); + expect(stats.width).toBeCloseTo(selfConsistentWidth, 3); + expect(stats.length).toBeGreaterThanOrEqual(stats.width); + }); + }); + + describe('RectangleROITool', () => { + test('rectangle entirely in the background reports exact mean/stdDev/area', async () => { + const ctx = await setupTools({ + tools: [RectangleROITool], + activeTool: RectangleROITool.toolName, + viewport: { width: 400, height: 400 }, + }); + active = ctx; + const { viewport, element, imageIds } = ctx; + + // World footprint x in [30, 40], y in [10, 30]: entirely background + // (bar is x in [20, 25)), area = 10 * 20 = 200mm^2. + const p1World: Types.Point3 = [30, 10, 0]; + const p2World: Types.Point3 = [40, 30, 0]; + const p1Canvas = round2(viewport.worldToCanvas(p1World)); + const p2Canvas = round2(viewport.worldToCanvas(p2World)); + + const rendered = waitForAnnotationRendered(element); + mouseDrag(element, p1Canvas, p2Canvas); + await rendered; + + const annotations = annotation.state.getAnnotations( + RectangleROITool.toolName, + element + ); + expect(annotations.length).toBe(1); + + const rectAnnotation = annotations[0]; + expect(rectAnnotation.metadata.toolName).toBe(RectangleROITool.toolName); + expect(rectAnnotation.metadata.referencedImageId).toBe(imageIds[0]); + + // handles.points = [bottomLeft, bottomRight, topLeft, topRight] in the + // tool's own canvas-space naming; only index 0 (drag-down) and index 3 + // (drag-up) are the literal input canvas points (see + // RectangleROITool.ts addNewAnnotation handleIndex: 3 and the + // handleIndex 0/3 branch of _dragCallback) -- 1 and 2 are derived + // corners, verified only indirectly via the area assertion below. + const handlePoints = rectAnnotation.data.handles.points; + expect(handlePoints.length).toBe(4); + + const expectedP1 = viewport.canvasToWorld(p1Canvas); + const expectedP2 = viewport.canvasToWorld(p2Canvas); + expect(handlePoints[0][0]).toBeCloseTo(expectedP1[0], 2); + expect(handlePoints[0][1]).toBeCloseTo(expectedP1[1], 2); + expect(handlePoints[0][2]).toBeCloseTo(expectedP1[2], 2); + expect(handlePoints[3][0]).toBeCloseTo(expectedP2[0], 2); + expect(handlePoints[3][1]).toBeCloseTo(expectedP2[1], 2); + expect(handlePoints[3][2]).toBeCloseTo(expectedP2[2], 2); + + const cachedStats = rectAnnotation.data.cachedStats; + const targetIds = Object.keys(cachedStats); + expect(targetIds.length).toBe(1); + const stats = cachedStats[targetIds[0]]; + + expect(stats.mean).toBe(10); + expect(stats.stdDev).toBe(0); + expect(stats.area).toBeCloseTo(200, 2); + }); + }); + + describe('EllipticalROITool', () => { + test('ellipse bounded by a 20mm square reports exact area/mean', async () => { + const ctx = await setupTools({ + tools: [EllipticalROITool], + activeTool: EllipticalROITool.toolName, + viewport: { width: 400, height: 400 }, + }); + active = ctx; + const { viewport, element, imageIds } = ctx; + + // EllipticalROITool's draw drag is CENTER + offset (not opposite + // bounding-box corners): dX/dY are computed as the absolute + // canvas-space offset between the mousedown point and the current + // drag point, independently per axis (see EllipticalROITool.ts + // _dragDrawCallback). Dragging by equal world offsets in x and y + // (+10, +10) therefore produces equal radii, i.e. a circle -- matching + // the plan's "square-bounded" ellipse and its pi*10*10 expected area. + const centerWorld: Types.Point3 = [40, 32, 0]; + const edgeWorld: Types.Point3 = [50, 42, 0]; + const centerCanvas = round2(viewport.worldToCanvas(centerWorld)); + const edgeCanvas = round2(viewport.worldToCanvas(edgeWorld)); + + const rendered = waitForAnnotationRendered(element); + mouseDrag(element, centerCanvas, edgeCanvas); + await rendered; + + const annotations = annotation.state.getAnnotations( + EllipticalROITool.toolName, + element + ); + expect(annotations.length).toBe(1); + + const ellipseAnnotation = annotations[0]; + expect(ellipseAnnotation.metadata.toolName).toBe( + EllipticalROITool.toolName + ); + expect(ellipseAnnotation.metadata.referencedImageId).toBe(imageIds[0]); + + // handles.points = [bottom, top, left, right] of the ellipse, all + // DERIVED from the drag's center+offset -- none are literally the + // drag's down/up canvas points. At a ~6.25 canvas-px/mm default fit + // zoom (400px viewport over a 64mm stack), independently rounding the + // center and edge canvas points to integers (required for any + // synthetic pointer dispatch, see harness/tools.ts) can shift a + // ~62.5px radius by up to 1px (~1.6% on area) relative to the + // idealized 10mm -- comfortably outside a literal 1% check on the + // idealized value. Per 00-shared-context rule 6 and the reference + // test's own approach, the expectation below is instead derived from + // the SAME rounded canvas points via the tool's own documented + // formula (EllipticalROITool.ts _dragDrawCallback: bottom/top/left/ + // right = centerCanvas +/- (dxCanvas, dyCanvas), each converted with + // canvasToWorld), so rounding cannot introduce a mismatch; a separate, + // looser assertion confirms the resulting radii still land within + // about 2% of the plan's idealized 10mm. + const dxCanvas = Math.abs(edgeCanvas[0] - centerCanvas[0]); + const dyCanvas = Math.abs(edgeCanvas[1] - centerCanvas[1]); + + const expectedBottom = viewport.canvasToWorld([ + centerCanvas[0], + centerCanvas[1] - dyCanvas, + ]); + const expectedTop = viewport.canvasToWorld([ + centerCanvas[0], + centerCanvas[1] + dyCanvas, + ]); + const expectedLeft = viewport.canvasToWorld([ + centerCanvas[0] - dxCanvas, + centerCanvas[1], + ]); + const expectedRight = viewport.canvasToWorld([ + centerCanvas[0] + dxCanvas, + centerCanvas[1], + ]); + const expectedHandles = [ + expectedBottom, + expectedTop, + expectedLeft, + expectedRight, + ]; + + const handlePoints = ellipseAnnotation.data.handles.points; + expect(handlePoints.length).toBe(4); + + for (let i = 0; i < 4; i++) { + expect(handlePoints[i][0]).toBeCloseTo(expectedHandles[i][0], 2); + expect(handlePoints[i][1]).toBeCloseTo(expectedHandles[i][1], 2); + expect(handlePoints[i][2]).toBeCloseTo(expectedHandles[i][2], 2); + } + + const [bottom, top, left, right] = handlePoints; + const xRadius = worldDistance(left, right) / 2; + const yRadius = worldDistance(bottom, top) / 2; + expect(Math.abs(xRadius - 10) / 10).toBeLessThan(0.02); + expect(Math.abs(yRadius - 10) / 10).toBeLessThan(0.02); + + const cachedStats = ellipseAnnotation.data.cachedStats; + const targetIds = Object.keys(cachedStats); + expect(targetIds.length).toBe(1); + const stats = cachedStats[targetIds[0]]; + + expect(stats.mean).toBe(10); + // Self-consistency (per plan: "self-consistency" checks against the + // annotation's own handle world points are sanctioned): the reported + // area must match pi * (the ellipse's own, closed-form-verified, + // handle-derived radii), independent of the idealized 10mm target. + const expectedArea = Math.PI * xRadius * yRadius; + expect(stats.area).toBeCloseTo(expectedArea, 1); + }); + }); + + describe('CircleROITool', () => { + // COMPATIBILITY FINDING (see final report): CircleROITool cannot + // complete its render pass on a direct (non-legacy-adapter) + // GenericViewport/PlanarViewport, so ANNOTATION_RENDERED never fires and + // this hangs until timeout. Root cause: CircleROITool.renderAnnotation + // (packages/tools/src/tools/annotation/CircleROITool.ts:827) calls + // getEllipseWorldCoordinates (packages/tools/src/utilities/ + // getEllipseWorldCoordinates.ts:31), which unconditionally calls + // `viewport.getCamera()`. `getCamera` is NOT part of PlanarViewport's + // API (packages/core/src/RenderingEngine/GenericViewport/Planar/ + // PlanarViewport.ts) -- it exists only on the separate + // PlanarViewportLegacyAdapter subclass + // (.../Planar/PlanarViewportLegacyAdapter.ts:49), which this harness + // deliberately does not use (it opens ViewportType.PLANAR_NEXT + // directly, the architecture under test for this whole campaign). The + // resulting uncaught `TypeError: viewport.getCamera is not a function` + // is thrown from inside the RAF-driven AnnotationRenderingEngine render + // pass (see the "Unhandled Errors" section of a run of this file), + // aborting that render pass before ANNOTATION_RENDERED fires -- observed + // directly via a 5s timeout on `waitForAnnotationRendered` below, + // shortened to 2s here since the outcome is already known. No other + // tool in this matrix hits getEllipseWorldCoordinates (EllipticalROITool + // uses its own, getCamera-free, center+offset formula), so this is + // narrowly a CircleROITool-on-GenericViewport-Next incompatibility, not + // a broader GenericViewport gap. + test.fails( + 'circle drawn to a 10mm radius crashes the render pass via viewport.getCamera', + async () => { + const ctx = await setupTools({ + tools: [CircleROITool], + activeTool: CircleROITool.toolName, + viewport: { width: 400, height: 400 }, + }); + active = ctx; + const { viewport, element } = ctx; + + const centerWorld: Types.Point3 = [45, 32, 0]; + const edgeWorld: Types.Point3 = [55, 32, 0]; + const centerCanvas = round2(viewport.worldToCanvas(centerWorld)); + const edgeCanvas = round2(viewport.worldToCanvas(edgeWorld)); + + const rendered = waitForAnnotationRendered(element, { + timeoutMs: 2000, + }); + mouseDrag(element, centerCanvas, edgeCanvas); + await rendered; + } + ); + }); + + describe('LengthTool handle manipulation (extends toolMeasurements coverage)', () => { + test('dragging an endpoint handle recomputes the length', async () => { + const ctx = await setupTools({ + tools: [LengthTool], + activeTool: LengthTool.toolName, + viewport: { width: 400, height: 400 }, + }); + active = ctx; + const { viewport, element } = ctx; + + const p1World: Types.Point3 = [10, 32, 0]; + const p2World: Types.Point3 = [30, 32, 0]; // 20mm + const p1Canvas = round2(viewport.worldToCanvas(p1World)); + const p2Canvas = round2(viewport.worldToCanvas(p2World)); + + let rendered = waitForAnnotationRendered(element); + mouseDrag(element, p1Canvas, p2Canvas); + await rendered; + + const annotations = annotation.state.getAnnotations( + LengthTool.toolName, + element + ); + expect(annotations.length).toBe(1); + const lengthAnnotation = annotations[0]; + + // Expected length is derived from the SAME rounded canvas points used + // for dispatch (see harness/tools.ts round-trip note), not the + // idealized 20mm world delta: p1World/p2World do not necessarily land + // on an exact integer canvas pixel at this viewport's fit-to-window + // zoom, and canvasToWorld(round(worldToCanvas(x))) can differ from x + // by a fraction of a canvas pixel, so asserting against the idealized + // literal is not epsilon-safe at 1e-3 (see the analogous fix below, + // where it was NOT safe). + const expectedInitialLength = worldDistance( + viewport.canvasToWorld(p1Canvas), + viewport.canvasToWorld(p2Canvas) + ); + const initialStats = lengthAnnotation.data.cachedStats; + const initialTargetIds = Object.keys(initialStats); + expect(initialStats[initialTargetIds[0]].length).toBeCloseTo( + expectedInitialLength, + 3 + ); + // Sanity check (loose: canvas-pixel-rounding budget, see comment + // above) that this is still testing the intended ~20mm scenario, not + // a mistake elsewhere in the setup. + expect(expectedInitialLength).toBeCloseTo(20, 0); + + // Grab the second handle (drawn at p2, world [30, 32, 0]) exactly at + // its current canvas position -- proximity hit-testing on MOUSE_DOWN + // resolves it to handleIndex 1 -- and drag it 10mm further along the + // same direction, to world [40, 32, 0]: new length should be 30mm. + // Interaction sequence learned from + // packages/tools/test/LengthTool_test.js + // "Should successfully create a length tool and modify its handle". + const handleCanvas = round2( + viewport.worldToCanvas(lengthAnnotation.data.handles.points[1]) + ); + const targetWorld: Types.Point3 = [40, 32, 0]; + const targetCanvas = round2(viewport.worldToCanvas(targetWorld)); + + // ANNOTATION_MODIFIED is dispatched on @cornerstonejs/core's + // `eventTarget` singleton, not on the viewport element (see + // packages/tools/src/stateManagement/annotation/helpers/state.ts + // triggerAnnotationModified: `triggerEvent(eventTarget, eventType, + // ...)`) -- unlike ANNOTATION_RENDERED, which the AnnotationRendering + // Engine fires on the element itself. + const events = recordEvents(eventTarget, [ + ToolsEvents.ANNOTATION_MODIFIED, + ]); + rendered = waitForAnnotationRendered(element); + mouseDrag(element, handleCanvas, targetCanvas); + await rendered; + events.stop(); + + expect(events.count(ToolsEvents.ANNOTATION_MODIFIED)).toBeGreaterThanOrEqual( + 1 + ); + + const expectedUpdatedLength = worldDistance( + viewport.canvasToWorld(p1Canvas), + viewport.canvasToWorld(targetCanvas) + ); + const updatedStats = lengthAnnotation.data.cachedStats; + const updatedTargetIds = Object.keys(updatedStats); + expect(updatedTargetIds.length).toBe(1); + expect(updatedStats[updatedTargetIds[0]].length).toBeCloseTo( + expectedUpdatedLength, + 3 + ); + // Sanity check (loose: canvas-pixel-rounding budget) this is still + // testing the intended ~30mm scenario. + expect(expectedUpdatedLength).toBeCloseTo(30, 0); + + const expectedHandle = viewport.canvasToWorld(targetCanvas); + const updatedHandlePoint = lengthAnnotation.data.handles.points[1]; + expect(updatedHandlePoint[0]).toBeCloseTo(expectedHandle[0], 2); + expect(updatedHandlePoint[1]).toBeCloseTo(expectedHandle[1], 2); + expect(updatedHandlePoint[2]).toBeCloseTo(expectedHandle[2], 2); + }); + + test('dragging the line body translates both handles by the same world delta', async () => { + const ctx = await setupTools({ + tools: [LengthTool], + activeTool: LengthTool.toolName, + viewport: { width: 400, height: 400 }, + }); + active = ctx; + const { viewport, element } = ctx; + + const p1World: Types.Point3 = [10, 40, 0]; + const p2World: Types.Point3 = [30, 40, 0]; // 20mm, horizontal + const p1Canvas = round2(viewport.worldToCanvas(p1World)); + const p2Canvas = round2(viewport.worldToCanvas(p2World)); + + let rendered = waitForAnnotationRendered(element); + mouseDrag(element, p1Canvas, p2Canvas); + await rendered; + + const annotations = annotation.state.getAnnotations( + LengthTool.toolName, + element + ); + expect(annotations.length).toBe(1); + const lengthAnnotation = annotations[0]; + + const originalHandles = lengthAnnotation.data.handles.points.map( + (point: Types.Point3) => [...point] as Types.Point3 + ); + const originalLength = worldDistance(originalHandles[0], originalHandles[1]); + + // Midpoint of the line, far from both handles' hit-test proximity, so + // this hits the tool body (toolSelectedCallback -> handleIndex + // undefined in _dragCallback -> whole-annotation translate), not a + // handle. Interaction sequence learned from + // packages/tools/test/LengthTool_test.js + // "Should successfully create a length tool and select AND move it". + const midCanvas: [number, number] = [ + (p1Canvas[0] + p2Canvas[0]) / 2, + (p1Canvas[1] + p2Canvas[1]) / 2, + ]; + const toCanvas: [number, number] = [midCanvas[0] + 40, midCanvas[1] - 25]; + + const fromWorld = viewport.canvasToWorld(round2(midCanvas)); + const toWorld = viewport.canvasToWorld(round2(toCanvas)); + const expectedDelta: Types.Point3 = [ + toWorld[0] - fromWorld[0], + toWorld[1] - fromWorld[1], + toWorld[2] - fromWorld[2], + ]; + + rendered = waitForAnnotationRendered(element); + mouseDrag(element, midCanvas, toCanvas); + await rendered; + + const updatedHandles = lengthAnnotation.data.handles.points; + for (let i = 0; i < originalHandles.length; i++) { + expect(updatedHandles[i][0]).toBeCloseTo( + originalHandles[i][0] + expectedDelta[0], + 2 + ); + expect(updatedHandles[i][1]).toBeCloseTo( + originalHandles[i][1] + expectedDelta[1], + 2 + ); + expect(updatedHandles[i][2]).toBeCloseTo( + originalHandles[i][2] + expectedDelta[2], + 2 + ); + } + + const updatedLength = worldDistance(updatedHandles[0], updatedHandles[1]); + expect(updatedLength).toBeCloseTo(originalLength, 3); + }); + }); +}); diff --git a/tests/vitest-browser/eventContracts.browser.test.ts b/tests/vitest-browser/eventContracts.browser.test.ts new file mode 100644 index 0000000000..5825a8b0e8 --- /dev/null +++ b/tests/vitest-browser/eventContracts.browser.test.ts @@ -0,0 +1,540 @@ +// Plan 3: event contracts. +// +// Pins the observable event behavior of PlanarViewport (GenericViewport / +// PLANAR_NEXT) as a contract: which events fire, how many, in what order, +// with what payload shape, and that nothing fires after teardown. Downstream +// consumers (tools, OHIF) build on this surface; regressions here are +// invisible to screenshot tests. +// +// Event source facts verified against +// packages/core/src/RenderingEngine/GenericViewport/Planar/planarImageEvents.ts, +// GenericViewport.ts, PlanarViewport.ts, BaseRenderingEngine.ts and +// ContextPoolRenderingEngine.ts before writing any assertion: +// - ELEMENT_ENABLED / ELEMENT_DISABLED are triggered on the module-level +// `eventTarget` singleton (BaseRenderingEngine.ts), NOT on the viewport +// element. They do not bubble there because there is nowhere to bubble to. +// - IMAGE_RENDERED is triggered on the viewport element by the rendering +// engine's `_renderFlaggedViewports` RAF callback (ContextPoolRenderingEngine.ts), +// once per flagged viewport per animation frame, regardless of how many +// `render()` calls were coalesced into that frame. +// - CAMERA_MODIFIED is triggered synchronously (not deferred to a frame) by +// `GenericViewport.modified()` every time `setViewState`/the Planar +// zoom/pan/scale legacy shims run, as long as a previous camera snapshot +// was resolvable. +// - STACK_NEW_IMAGE is triggered by `triggerPlanarNewImage` in +// planarImageEvents.ts, on the viewport element, only for the source +// binding, only when the render path actually swaps to a new image alone +// (`resolvePlanarRenderPathCurrentImageIdIndex` dedups against the last +// *requested* index) -- so it fires on scroll/setImageIdIndex but not on a +// pure zoom/pan/rotation call. +// - VOI_MODIFIED is triggered by `PlanarViewport.notifyDataPresentationModified` +// whenever a `setDisplaySetPresentation` call touches `voiRange`/`invert`/ +// `voiLUTFunction` AND resolves a non-undefined range -- this happens on +// every touching call with no old-vs-new diff, including a no-op repeat +// (see test 6, marked `test.fails`). +// +// CustomEvents here are created via `triggerEvent` with no `bubbles: true`, +// so they do not bubble; the recorder is always attached directly to the +// actual dispatch target (the viewport element, or the `eventTarget` +// singleton for lifecycle events). + +import { afterEach, expect, test } from 'vitest'; +import { Enums, eventTarget, utilities } from '@cornerstonejs/core'; +import { + createPlanarViewport, + recordEvents, + renderAndWait, + type PlanarViewportContext, + type RecordEventsHandle, +} from './harness'; + +const { Events, OrientationAxis, ViewportType } = Enums; + +// Safety net: if a test throws before reaching its own cleanup, this ensures +// rendering engines, recorders, and DOM elements do not leak into later +// tests (or other suites -- vitest.browser.config.ts sets +// fileParallelism: false). +let pendingTeardowns: Array<() => void> = []; + +function track(teardown: () => void): void { + pendingTeardowns.push(teardown); +} + +afterEach(() => { + while (pendingTeardowns.length) { + const teardown = pendingTeardowns.pop(); + + try { + teardown?.(); + } catch { + // best-effort safety net only + } + } +}); + +function nextAnimationFrame(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +/** + * Waits until `recorder` has seen no new events across two consecutive + * animation frames, per the plan's quiescence definition. Bounded by + * maxFrames so a genuinely stuck event stream fails the test instead of + * hanging it. + */ +async function waitForQuiescence( + recorder: RecordEventsHandle, + opts: { maxFrames?: number } = {} +): Promise { + const { maxFrames = 60 } = opts; + let stableFrames = 0; + let lastCount = recorder.events.length; + + for (let frame = 0; frame < maxFrames; frame++) { + await nextAnimationFrame(); + + const currentCount = recorder.events.length; + + if (currentCount === lastCount) { + stableFrames += 1; + + if (stableFrames >= 2) { + return; + } + } else { + stableFrames = 0; + lastCount = currentCount; + } + } + + throw new Error( + `waitForQuiescence: events still changing after ${maxFrames} animation frames` + ); +} + +async function waitAnimationFrames(count: number): Promise { + for (let i = 0; i < count; i++) { + await nextAnimationFrame(); + } +} + +// All event types this suite cares about, across every render path (only +// the vtkImage default mode is exercised here per the harness default, but +// the list matches the full set named in the plan plus what +// planarImageEvents.ts can emit for volume-backed data). +const ALL_TRACKED_EVENTS = [ + Events.IMAGE_RENDERED, + Events.CAMERA_MODIFIED, + Events.CAMERA_RESET, + Events.VOI_MODIFIED, + Events.COLORMAP_MODIFIED, + Events.DISPLAY_AREA_MODIFIED, + Events.STACK_NEW_IMAGE, + Events.VOLUME_NEW_IMAGE, +]; + +interface ElementLifecycleDetail { + viewportId?: string; + renderingEngineId?: string; +} + +interface CameraModifiedDetail { + viewportId?: string; + renderingEngineId?: string; +} + +interface StackNewImageDetail { + viewportId?: string; + imageId?: string; + imageIdIndex?: number; +} + +interface VoiModifiedDetail { + viewportId?: string; + range?: { upper: number; lower: number }; + invert?: boolean; +} + +async function setupViewport(): Promise { + const ctx = await createPlanarViewport(); + track(ctx.cleanup); + return ctx; +} + +// ========================================================================== +// 1. Enable/disable lifecycle events +// ========================================================================== + +test('enable fires exactly one ELEMENT_ENABLED on eventTarget with the viewportId; destroy fires one ELEMENT_DISABLED per enabled viewport', async () => { + const recorder = recordEvents(eventTarget, [ + Events.ELEMENT_ENABLED, + Events.ELEMENT_DISABLED, + ]); + track(recorder.stop); + + const ctx = await createPlanarViewport({ skipDisplaySets: true }); + track(ctx.cleanup); + + expect(recorder.count(Events.ELEMENT_ENABLED)).toBe(1); + expect(recorder.count(Events.ELEMENT_DISABLED)).toBe(0); + + const enabledEvent = recorder.events.find( + (event) => event.type === Events.ELEMENT_ENABLED + ); + const enabledDetail = enabledEvent?.detail as + | ElementLifecycleDetail + | undefined; + + expect(enabledDetail?.viewportId).toBe(ctx.viewportId); + expect(enabledDetail?.renderingEngineId).toBe( + ctx.renderingEngine.id + ); + + // Enable a second viewport on the SAME rendering engine to prove + // ELEMENT_DISABLED fires once *per enabled viewport* on destroy(), not + // once per destroy() call. + const secondViewportId = `${ctx.viewportId}-second`; + const secondElement = document.createElement('div'); + secondElement.style.width = '200px'; + secondElement.style.height = '200px'; + document.body.appendChild(secondElement); + + ctx.renderingEngine.enableElement({ + viewportId: secondViewportId, + type: ViewportType.PLANAR_NEXT, + element: secondElement, + defaultOptions: { + orientation: OrientationAxis.AXIAL, + }, + }); + + expect(recorder.count(Events.ELEMENT_ENABLED)).toBe(2); + + recorder.clear(); + ctx.renderingEngine.destroy(); + + expect(recorder.count(Events.ELEMENT_DISABLED)).toBe(2); + expect(recorder.count(Events.ELEMENT_ENABLED)).toBe(0); + + const disabledViewportIds = recorder.events + .filter((event) => event.type === Events.ELEMENT_DISABLED) + .map((event) => (event.detail as ElementLifecycleDetail | undefined)?.viewportId) + .sort(); + + expect(disabledViewportIds).toEqual( + [ctx.viewportId, secondViewportId].sort() + ); + + if (secondElement.parentNode) { + secondElement.parentNode.removeChild(secondElement); + } +}); + +// ========================================================================== +// 2. Exactly-once semantics for a discrete camera operation +// ========================================================================== + +test('setZoom from steady state fires CAMERA_MODIFIED exactly once', async () => { + const ctx = await setupViewport(); + const { viewport, element } = ctx; + + const recorder = recordEvents(element, ALL_TRACKED_EVENTS); + track(recorder.stop); + + // Steady state: wait for two consecutive animation frames with no new + // events before starting the measured operation. + await waitForQuiescence(recorder); + recorder.clear(); + + viewport.setZoom(2); + viewport.render(); + await recorder.waitFor(Events.IMAGE_RENDERED); + await waitForQuiescence(recorder); + + // Observed on a stable run: exactly 1. setZoom -> setScale -> + // applyResolvedViewState -> GenericViewport.modified(previousCamera), + // which fires triggerCameraModifiedEvent synchronously exactly once; the + // render() call in the same modified() only schedules a frame and does + // not itself fire another CAMERA_MODIFIED. + expect(recorder.count(Events.CAMERA_MODIFIED)).toBe(1); + + const cameraEvent = recorder.events.find( + (event) => event.type === Events.CAMERA_MODIFIED + ); + const cameraDetail = cameraEvent?.detail as CameraModifiedDetail | undefined; + + expect(cameraDetail?.viewportId).toBe(ctx.viewportId); + expect(cameraDetail?.renderingEngineId).toBe(ctx.renderingEngine.id); + expect(viewport.getZoom()).toBeCloseTo(2, 5); +}); + +// ========================================================================== +// 3. Render coalescing under a burst +// ========================================================================== + +test('a synchronous burst of setPan calls coalesces IMAGE_RENDERED and applies last-write-wins', async () => { + const ctx = await setupViewport(); + const { viewport, element } = ctx; + + const recorder = recordEvents(element, ALL_TRACKED_EVENTS); + track(recorder.stop); + + await waitForQuiescence(recorder); + recorder.clear(); + + for (let i = 1; i <= 20; i++) { + viewport.setPan([i, i]); + } + + await recorder.waitFor(Events.IMAGE_RENDERED); + await waitForQuiescence(recorder); + + // The rendering engine coalesces every render() request made before the + // next animation frame fires into a single RAF callback + // (BaseRenderingEngine._setViewportsToBeRenderedNextFrame / + // _render), so a synchronous burst of 20 renders must not produce 20 + // IMAGE_RENDERED events. + expect(recorder.count(Events.IMAGE_RENDERED)).toBeGreaterThanOrEqual(1); + expect(recorder.count(Events.IMAGE_RENDERED)).toBeLessThanOrEqual(3); + + const [panX, panY] = viewport.getPan(); + expect(panX).toBeCloseTo(20, 2); + expect(panY).toBeCloseTo(20, 2); +}); + +// ========================================================================== +// 4. Golden event sequence for the canonical flow +// ========================================================================== + +test('golden event sequence for load -> scroll -> zoom', async () => { + // ELEMENT_ENABLED fires synchronously inside createPlanarViewport, before + // this test can attach a listener to `eventTarget` (there is no `await` + // in the harness before that call even in the skipDisplaySets branch), so + // it is verified precisely by test 1 above and intentionally left out of + // this sequence; the harness cannot be edited to expose an earlier hook. + const ctx = await createPlanarViewport({ skipDisplaySets: true }); + track(ctx.cleanup); + + const { viewport, element } = ctx; + const recorder = recordEvents(element, ALL_TRACKED_EVENTS); + track(recorder.stop); + + // -- load: setDisplaySets + first render -- + utilities.genericViewportDisplaySetMetadataProvider.add(ctx.displaySetId, { + imageIds: ctx.imageIds, + kind: 'planar', + initialImageIdIndex: 0, + }); + + await viewport.setDisplaySets({ + displaySetId: ctx.displaySetId, + options: { orientation: OrientationAxis.AXIAL }, + }); + await renderAndWait(element, viewport); + await waitForQuiescence(recorder); + + // -- scroll(1) -- + await viewport.scroll(1); + await waitForQuiescence(recorder); + + // -- setZoom(2) + render -- + viewport.setZoom(2); + viewport.render(); + await waitForQuiescence(recorder); + + const observedSequence = recorder.types(); + + // Golden contract, pinned from an actual run (verified stable across + // repeated runs of this file). No collapsing of consecutive duplicates + // was needed -- none occurred. Ordering, per step: + // load: STACK_NEW_IMAGE fires first (the vtkImage mount fires the + // initial slice synchronously while building the binding), + // then VOI_MODIFIED (addDisplaySetInternal mirrors legacy + // StackViewport by emitting the resolved default VOI right + // after `setDefaultDataPresentation`), then CAMERA_MODIFIED + // (the source-binding's initial camera, emitted directly by + // addDisplaySetInternal), then IMAGE_RENDERED (the RAF + // callback fires after the mount's render() request). + // scroll: CAMERA_MODIFIED fires synchronously inside setViewState's + // modified() before the image-loader microtask resolves; + // STACK_NEW_IMAGE then fires once the new slice's image + // promise resolves and swaps the actor; IMAGE_RENDERED follows + // on the next animation frame. + // zoom: CAMERA_MODIFIED (synchronous) then IMAGE_RENDERED (next + // frame). + const golden = [ + Events.STACK_NEW_IMAGE, + Events.VOI_MODIFIED, + Events.CAMERA_MODIFIED, + Events.IMAGE_RENDERED, + Events.CAMERA_MODIFIED, + Events.STACK_NEW_IMAGE, + Events.IMAGE_RENDERED, + Events.CAMERA_MODIFIED, + Events.IMAGE_RENDERED, + ]; + + // Stabilization rule (b) from the plan: assert the golden array as an + // ordered subsequence of the observed sequence, so extra (but not + // out-of-order or missing) events do not break the contract. + let cursor = 0; + for (const expectedType of golden) { + const foundIndex = observedSequence.indexOf(expectedType, cursor); + + expect( + foundIndex, + `expected "${expectedType}" at-or-after position ${cursor} in observed sequence ${JSON.stringify( + observedSequence + )}` + ).toBeGreaterThanOrEqual(0); + + cursor = foundIndex + 1; + } +}); + +// ========================================================================== +// 5. New-image event on scroll +// ========================================================================== + +test('scroll fires exactly one new-image event whose imageId matches the resolved slice', async () => { + const ctx = await setupViewport(); + const { viewport, element, imageIds } = ctx; + + const recorder = recordEvents(element, ALL_TRACKED_EVENTS); + track(recorder.stop); + + await waitForQuiescence(recorder); + recorder.clear(); + + const resolvedImageId = await viewport.scroll(1); + await recorder.waitFor(Events.STACK_NEW_IMAGE); + await waitForQuiescence(recorder); + + expect(recorder.count(Events.STACK_NEW_IMAGE)).toBe(1); + expect(recorder.count(Events.VOLUME_NEW_IMAGE)).toBe(0); + + const newImageEvent = recorder.events.find( + (event) => event.type === Events.STACK_NEW_IMAGE + ); + const detail = newImageEvent?.detail as StackNewImageDetail | undefined; + + expect(resolvedImageId).toBe(imageIds[1]); + expect(viewport.getCurrentImageId()).toBe(imageIds[1]); + expect(detail?.imageId).toBe(viewport.getCurrentImageId()); + expect(detail?.imageIdIndex).toBe(1); + expect(detail?.viewportId).toBe(ctx.viewportId); +}); + +// ========================================================================== +// 6. VOI event +// ========================================================================== + +test('setDisplaySetPresentation with a new voiRange fires VOI_MODIFIED with the new range', async () => { + const ctx = await setupViewport(); + const { viewport, element, displaySetId } = ctx; + + const recorder = recordEvents(element, ALL_TRACKED_EVENTS); + track(recorder.stop); + + await waitForQuiescence(recorder); + recorder.clear(); + + const nextRange = { lower: 0, upper: 100 }; + viewport.setDisplaySetPresentation(displaySetId, { voiRange: nextRange }); + viewport.render(); + await waitForQuiescence(recorder); + + expect(recorder.count(Events.VOI_MODIFIED)).toBeGreaterThanOrEqual(1); + + const voiEvent = recorder.events.find( + (event) => event.type === Events.VOI_MODIFIED + ); + const detail = voiEvent?.detail as VoiModifiedDetail | undefined; + + expect(detail?.viewportId).toBe(ctx.viewportId); + expect(detail?.range).toEqual(nextRange); + expect(viewport.getDisplaySetPresentation(displaySetId)?.voiRange).toEqual( + nextRange + ); +}); + +// Finding: `GenericViewport.mergeDataPresentation` -> `notifyDataPresentationModified` +// fires VOI_MODIFIED whenever the merged props touch `voiRange` (see +// PlanarViewport.ts around the `notifyDataPresentationModified` override), with +// no comparison against the previously-stored value. Setting the exact same +// VOI range a second time therefore fires VOI_MODIFIED again instead of being +// a no-op. This is marked `test.fails` per shared-context rule 5 rather than +// weakened, so a future fix that makes the no-op case silent will flip this +// test to failing-when-it-should-pass and be caught. +test.fails( + 'setting the same voiRange again is a no-op and must not fire VOI_MODIFIED (known divergence)', + async () => { + const ctx = await setupViewport(); + const { viewport, element, displaySetId } = ctx; + + const recorder = recordEvents(element, ALL_TRACKED_EVENTS); + track(recorder.stop); + + const sameRange = { lower: 0, upper: 100 }; + viewport.setDisplaySetPresentation(displaySetId, { voiRange: sameRange }); + viewport.render(); + await waitForQuiescence(recorder); + + recorder.clear(); + + // Re-apply the exact same range: observed behavior fires VOI_MODIFIED + // again (count becomes 1, not 0), which is the divergence this test + // documents. + viewport.setDisplaySetPresentation(displaySetId, { voiRange: sameRange }); + viewport.render(); + await waitForQuiescence(recorder); + + expect(recorder.count(Events.VOI_MODIFIED)).toBe(0); + } +); + +// ========================================================================== +// 7. Silence after destroy +// ========================================================================== + +test('no tracked events fire after the final ELEMENT_DISABLED', async () => { + const ctx = await setupViewport(); + const { viewport, element, renderingEngine } = ctx; + + const elementRecorder = recordEvents(element, ALL_TRACKED_EVENTS); + const globalRecorder = recordEvents(eventTarget, [ + Events.ELEMENT_ENABLED, + Events.ELEMENT_DISABLED, + ]); + track(elementRecorder.stop); + track(globalRecorder.stop); + + await waitForQuiescence(elementRecorder); + + renderingEngine.destroy(); + + expect(globalRecorder.count(Events.ELEMENT_DISABLED)).toBe(1); + + elementRecorder.clear(); + globalRecorder.clear(); + + window.dispatchEvent(new Event('resize')); + + try { + viewport.render(); + } catch { + // A post-destroy render() is expected to be inert (see + // PlanarViewport.render()'s `isDestroyed` guard); if it throws instead, + // that is tolerated here since this test only asserts no *events* leak, + // not that render() itself is side-effect-free. + } + + // ~200ms via animation frames, per the plan's timing-discipline section, + // rather than an arbitrary setTimeout sleep. + await waitAnimationFrames(12); + + expect(elementRecorder.events).toEqual([]); + expect(globalRecorder.events).toEqual([]); +}); diff --git a/tests/vitest-browser/harness/captureViewportState.ts b/tests/vitest-browser/harness/captureViewportState.ts new file mode 100644 index 0000000000..2548416ca4 --- /dev/null +++ b/tests/vitest-browser/harness/captureViewportState.ts @@ -0,0 +1,152 @@ +// Captures a render-path-agnostic snapshot of PlanarViewport public state. +// +// Design rule: everything in `core` must be identical across render modes +// for the same public-API scenario; anything render-path-dependent belongs +// in `pathSpecific`. + +import type { PlanarViewport, Types } from '@cornerstonejs/core'; + +export interface ViewportStateSnapshot { + core: { + type: string; + currentMode: string; + sliceIndex: number; + currentImageIdIndex: number; + numberOfSlices: number; + zoom: number; + pan: [number, number]; + rotation: number; + frameOfReferenceUID: string; + viewState: unknown; + viewReference: unknown; + presentation: unknown; + worldProbes: Array<{ + canvas: [number, number]; + world: [number, number, number]; + }>; + actorCount: number; + }; + pathSpecific: { + actorClassNames: string[]; + actorUIDs: string[]; + }; +} + +const DEFAULT_PROBE_CANVAS_POINTS: Array<[number, number]> = [ + [200, 200], + [100, 100], + [300, 250], +]; + +export function round6(value: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return value; + } + + // `|| 0` folds -0 to +0 so snapshots are stable under JSON round-tripping + // and under toEqual, which (unlike Object.is) still distinguishes -0/+0. + return Math.round(value * 1e6) / 1e6 || 0; +} + +/** + * Deep-clones and rounds every number recursively, converting typed arrays + * to plain arrays and dropping `undefined`-valued object properties, so the + * result survives JSON.parse(JSON.stringify(...)) unchanged (JSON.stringify + * itself drops undefined object properties, so the snapshot must not rely + * on their presence). + */ +function normalize(value: unknown): unknown { + if (typeof value === 'number') { + return round6(value); + } + + if ( + value instanceof Float32Array || + value instanceof Float64Array || + value instanceof Int32Array || + value instanceof Uint32Array || + value instanceof Int16Array || + value instanceof Uint16Array || + value instanceof Uint8Array + ) { + return Array.from(value, (item) => round6(item)); + } + + if (Array.isArray(value)) { + return value.map((item) => normalize(item)); + } + + if (value && typeof value === 'object') { + const source = value as Record; + const out: Record = {}; + + for (const key of Object.keys(source)) { + const normalized = normalize(source[key]); + + if (normalized !== undefined) { + out[key] = normalized; + } + } + + return out; + } + + return value; +} + +function getActorClassName(actor: unknown): string { + const maybeGetClassName = (actor as { getClassName?: () => string }) + ?.getClassName; + + if (typeof maybeGetClassName === 'function') { + return maybeGetClassName.call(actor); + } + + return (actor as { constructor?: { name?: string } })?.constructor?.name ?? + 'Unknown'; +} + +export function captureViewportState( + viewport: PlanarViewport, + displaySetId: string, + probeCanvasPoints: Array<[number, number]> = DEFAULT_PROBE_CANVAS_POINTS +): ViewportStateSnapshot { + const pan = viewport.getPan(); + const worldProbes = probeCanvasPoints.map(([x, y]) => { + const world = viewport.canvasToWorld([x, y] as Types.Point2); + + return { + canvas: [round6(x), round6(y)] as [number, number], + world: [round6(world[0]), round6(world[1]), round6(world[2])] as [ + number, + number, + number, + ], + }; + }); + + const actors = viewport.getActors(); + + return { + core: { + type: viewport.type, + currentMode: viewport.getCurrentMode(), + sliceIndex: viewport.getSliceIndex(), + currentImageIdIndex: viewport.getCurrentImageIdIndex(), + numberOfSlices: viewport.getNumberOfSlices(), + zoom: round6(viewport.getZoom()), + pan: [round6(pan[0]), round6(pan[1])], + rotation: round6(viewport.getRotation()), + frameOfReferenceUID: viewport.getFrameOfReferenceUID(), + viewState: normalize(viewport.getViewState()), + viewReference: normalize(viewport.getViewReference()), + presentation: normalize(viewport.getDisplaySetPresentation(displaySetId)), + worldProbes, + actorCount: actors.length, + }, + pathSpecific: { + actorClassNames: actors.map((entry) => getActorClassName(entry.actor)), + actorUIDs: actors.map((entry) => entry.uid), + }, + }; +} diff --git a/tests/vitest-browser/harness/createPlanarViewport.ts b/tests/vitest-browser/harness/createPlanarViewport.ts new file mode 100644 index 0000000000..9e8f97b297 --- /dev/null +++ b/tests/vitest-browser/harness/createPlanarViewport.ts @@ -0,0 +1,224 @@ +// Shared GenericViewport (PLANAR_NEXT) test-harness setup, generalizing the +// setup flow proven in tests/vitest-browser/genericStackApi.browser.test.ts +// to all four planar render modes. + +import { + cache, + cornerstoneStreamingImageVolumeLoader, + Enums, + getConfiguration, + getRenderingEngine, + imageLoader as csImageLoader, + init, + RenderingEngine, + utilities, + volumeLoader, + type PlanarViewport, +} from '@cornerstonejs/core'; +import { + registerFakeImageStack, + type FakeStackOptions, +} from './fakeImageStack'; + +const { Events, OrientationAxis, RenderBackend, ViewportType } = Enums; + +export type PlanarRenderMode = + | 'vtkImage' + | 'vtkVolumeSlice' + | 'cpuImage' + | 'cpuVolume'; + +export interface CreatePlanarViewportOptions { + renderMode?: PlanarRenderMode; + orientation?: Enums.OrientationAxis; + width?: number; + height?: number; + stack?: FakeStackOptions; + viewportId?: string; + renderingEngineId?: string; + displaySetId?: string; + /** + * When true, enable the element but do not register/set display sets. For + * lifecycle tests that need an enabled-but-empty viewport. + */ + skipDisplaySets?: boolean; +} + +export interface PlanarViewportContext { + viewport: PlanarViewport; + element: HTMLDivElement; + renderingEngine: RenderingEngine; + imageIds: string[]; + displaySetId: string; + viewportId: string; + /** Idempotent: safe to call more than once. */ + cleanup(): void; +} + +// Volume-backed render modes (vtkVolumeSlice, cpuVolume) build an IImageVolume +// from the stack's imageIds. The built-in cornerstoneStreamingImageVolumeLoader +// already does exactly what is needed here (load each imageId through the +// registered image loader and derive volume props from per-image metadata), +// so it is reused under a dedicated scheme rather than duplicated. +const FAKE_VOLUME_LOADER_SCHEME = 'vitestFakePlanarVolume'; +let fakeVolumeLoaderRegistered = false; + +function ensureFakeVolumeLoaderRegistered(): void { + if (fakeVolumeLoaderRegistered) { + return; + } + + // registerVolumeLoader is a plain scheme->fn assignment; there is no + // unregister API and nothing in this harness ever removes it, so a + // one-time guard is safe (unlike the image-loader scheme, nothing calls an + // equivalent of unregisterAllVolumeLoaders()). + volumeLoader.registerVolumeLoader( + FAKE_VOLUME_LOADER_SCHEME, + cornerstoneStreamingImageVolumeLoader as unknown as Parameters< + typeof volumeLoader.registerVolumeLoader + >[1] + ); + fakeVolumeLoaderRegistered = true; +} + +let harnessInstanceCounter = 0; + +function nextId(prefix: string): string { + harnessInstanceCounter += 1; + return `${prefix}-${harnessInstanceCounter}-${utilities.uuidv4()}`; +} + +/** + * Attaches a once IMAGE_RENDERED listener BEFORE calling render(), resolving + * on the event. Mirrors the reference test's waitForImageRendered helper. + */ +export function renderAndWait( + element: HTMLElement, + viewport: { render(): void } +): Promise { + return new Promise((resolve) => { + element.addEventListener(Events.IMAGE_RENDERED, () => resolve(), { + once: true, + }); + viewport.render(); + }); +} + +export async function createPlanarViewport( + opts: CreatePlanarViewportOptions = {} +): Promise { + const renderMode = opts.renderMode ?? 'vtkImage'; + const orientation = opts.orientation ?? OrientationAxis.AXIAL; + const width = opts.width ?? 400; + const height = opts.height ?? 400; + const viewportId = opts.viewportId ?? nextId('vitest-planar-viewport'); + const renderingEngineId = + opts.renderingEngineId ?? nextId('vitest-planar-engine'); + const displaySetId = opts.displaySetId ?? nextId('vitest-planar-displayset'); + const skipDisplaySets = opts.skipDisplaySets ?? false; + + init(); + + const renderingConfig = getConfiguration().rendering; + const previousUseGenericViewport = renderingConfig.useGenericViewport; + renderingConfig.useGenericViewport = true; + + const isVolumeBacked = + renderMode === 'vtkVolumeSlice' || renderMode === 'cpuVolume'; + const isCpu = renderMode === 'cpuImage' || renderMode === 'cpuVolume'; + + if (isVolumeBacked) { + ensureFakeVolumeLoaderRegistered(); + } + + const fakeStack = registerFakeImageStack(opts.stack); + const { imageIds } = fakeStack; + + const renderingEngine = new RenderingEngine(renderingEngineId); + const element = document.createElement('div'); + element.dataset.testid = 'planar-harness-viewport'; + element.style.width = `${width}px`; + element.style.height = `${height}px`; + document.body.appendChild(element); + + renderingEngine.enableElement({ + viewportId, + type: ViewportType.PLANAR_NEXT, + element, + defaultOptions: { + orientation, + }, + }); + + const viewport = renderingEngine.getViewport(viewportId); + + let cleanedUp = false; + const cleanup = (): void => { + if (cleanedUp) { + return; + } + cleanedUp = true; + + const engine = getRenderingEngine(renderingEngineId); + engine?.destroy(); + cache.purgeCache(); + fakeStack.unregister(); + csImageLoader.unregisterAllImageLoaders(); + utilities.genericViewportDisplaySetMetadataProvider.clear?.(); + + if (element.parentNode) { + element.parentNode.removeChild(element); + } + + if (previousUseGenericViewport !== undefined) { + getConfiguration().rendering.useGenericViewport = + previousUseGenericViewport; + } + }; + + if (skipDisplaySets) { + return { + viewport, + element, + renderingEngine, + imageIds, + displaySetId, + viewportId, + cleanup, + }; + } + + utilities.genericViewportDisplaySetMetadataProvider.add(displaySetId, { + imageIds, + kind: 'planar', + initialImageIdIndex: 0, + ...(isVolumeBacked + ? { volumeId: `${FAKE_VOLUME_LOADER_SCHEME}:${displaySetId}` } + : {}), + }); + + try { + await viewport.setDisplaySets({ + displaySetId, + options: { + orientation, + renderBackend: isCpu ? RenderBackend.CPU : RenderBackend.GPU, + }, + }); + } catch (error) { + cleanup(); + throw error; + } + + await renderAndWait(element, viewport); + + return { + viewport, + element, + renderingEngine, + imageIds, + displaySetId, + viewportId, + cleanup, + }; +} diff --git a/tests/vitest-browser/harness/fakeImageStack.ts b/tests/vitest-browser/harness/fakeImageStack.ts new file mode 100644 index 0000000000..7370e25ac9 --- /dev/null +++ b/tests/vitest-browser/harness/fakeImageStack.ts @@ -0,0 +1,261 @@ +// Shared fake-image-stack loader for vitest-browser GenericViewport state tests. +// +// Generalizes the single-slice fake loader pattern from +// tests/vitest-browser/genericStackApi.browser.test.ts to an N-slice stack. +// All pixel/metadata parameters are encoded directly into the imageId string +// so both the image loader and the metadata provider are pure functions of +// the imageId; this lets multiple fake stacks coexist safely and makes +// registration/unregistration trivial. + +import { + imageLoader as csImageLoader, + metaData, + utilities, + type IImage, + type Types, +} from '@cornerstonejs/core'; + +export const FAKE_IMAGE_LOADER_SCHEME = 'fakeImageLoader'; + +export interface FakeStackOptions { + /** Namespaces imageIds so two stacks can coexist. Default 'stack'. */ + name?: string; + rows?: number; + columns?: number; + barStart?: number; + barWidth?: number; + xSpacing?: number; + ySpacing?: number; + sliceCount?: number; + frameOfReferenceUID?: string; +} + +interface FakeImageIdInfo { + name: string; + rows: number; + columns: number; + barStart: number; + barWidth: number; + xSpacing: number; + ySpacing: number; + sliceIndex: number; + frameOfReferenceUID: string; +} + +const DEFAULT_OPTIONS: Required = { + name: 'stack', + rows: 64, + columns: 64, + barStart: 20, + barWidth: 5, + xSpacing: 1, + ySpacing: 1, + sliceCount: 5, + frameOfReferenceUID: 'VITEST_FAKE_FRAME_OF_REFERENCE', +}; + +function encodeImageIdInfo(info: FakeImageIdInfo): string { + return `${FAKE_IMAGE_LOADER_SCHEME}:${encodeURIComponent(JSON.stringify(info))}`; +} + +function decodeImageIdInfo(imageId: string): FakeImageIdInfo | null { + const colonIndex = imageId.indexOf(':'); + + if (colonIndex < 0) { + return null; + } + + const scheme = imageId.slice(0, colonIndex); + + if (scheme !== FAKE_IMAGE_LOADER_SCHEME) { + return null; + } + + try { + return JSON.parse( + decodeURIComponent(imageId.slice(colonIndex + 1)) + ) as FakeImageIdInfo; + } catch { + return null; + } +} + +function fillVerticalBar( + imageVoxelManager: Types.IVoxelManager, + rows: number, + barStart: number, + barWidth: number +) { + for (let i = 0; i < rows; i++) { + for (let j = barStart; j < barStart + barWidth; j++) { + // Keep the same ijk write pattern as the Karma fake-image tests and the + // reference genericStackApi.browser.test.ts. + imageVoxelManager.setAtIJK(j, i, 0, 255); + } + } +} + +function fakeImageLoader(imageId: string) { + const info = decodeImageIdInfo(imageId); + + if (!info) { + throw new Error(`Unsupported fake imageId: ${imageId}`); + } + + const { rows, columns, barStart, barWidth, xSpacing, ySpacing, sliceIndex } = + info; + + // Deterministic-but-distinct-per-slice background so probe/statistics + // values are exactly computable by other suites. + const backgroundValue = 10 + sliceIndex; + const pixelData = new Uint8Array(rows * columns).fill(backgroundValue); + const imageVoxelManager = utilities.VoxelManager.createImageVoxelManager({ + height: rows, + width: columns, + numberOfComponents: 1, + scalarData: pixelData, + }); + + fillVerticalBar(imageVoxelManager, rows, barStart, barWidth); + + const image: IImage = { + rows, + columns, + width: columns, + height: rows, + imageId, + intercept: 0, + slope: 1, + voxelManager: imageVoxelManager, + invert: false, + windowCenter: 40, + windowWidth: 400, + maxPixelValue: 255, + minPixelValue: 0, + rowPixelSpacing: ySpacing, + columnPixelSpacing: xSpacing, + getPixelData: () => imageVoxelManager.getScalarData(), + sizeInBytes: rows * columns, + FrameOfReferenceUID: info.frameOfReferenceUID, + imageFrame: { + photometricInterpretation: 'MONOCHROME2', + }, + }; + + return { + promise: Promise.resolve(image), + }; +} + +function makeFakeMetaDataProvider() { + return function fakeMetaDataProvider(type: string, imageId: string) { + const info = decodeImageIdInfo(imageId); + + if (!info) { + return; + } + + const { rows, columns, xSpacing, ySpacing, sliceIndex, frameOfReferenceUID, name } = + info; + + if (type === 'imagePixelModule') { + return { + photometricInterpretation: 'MONOCHROME2', + rows, + columns, + samplesPerPixel: 1, + bitsAllocated: 8, + bitsStored: 8, + highBit: 8, + pixelRepresentation: 0, + }; + } + + if (type === 'generalSeriesModule') { + return { + modality: 'MR', + seriesInstanceUID: `VITEST_FAKE_SERIES_${name}`, + }; + } + + if (type === 'imagePlaneModule') { + return { + rows, + columns, + width: rows, + height: columns, + imageOrientationPatient: [1, 0, 0, 0, 1, 0], + rowCosines: [1, 0, 0], + columnCosines: [0, 1, 0], + imagePositionPatient: [0, 0, sliceIndex], + pixelSpacing: [xSpacing, ySpacing], + rowPixelSpacing: ySpacing, + columnPixelSpacing: xSpacing, + frameOfReferenceUID, + }; + } + + if (type === 'voiLutModule') { + return { + windowWidth: undefined, + windowCenter: undefined, + }; + } + + if (type === 'modalityLutModule') { + return { + rescaleSlope: undefined, + rescaleIntercept: undefined, + }; + } + }; +} + +export interface RegisteredFakeImageStack { + imageIds: string[]; + options: Required; + unregister(): void; +} + +export function registerFakeImageStack( + options?: FakeStackOptions +): RegisteredFakeImageStack { + const resolved: Required = { + ...DEFAULT_OPTIONS, + ...options, + }; + + // registerImageLoader is a plain scheme->fn assignment: safe to call + // repeatedly, and this keeps the scheme registered even if a previous + // test's cleanup called imageLoader.unregisterAllImageLoaders(). + csImageLoader.registerImageLoader(FAKE_IMAGE_LOADER_SCHEME, fakeImageLoader); + + const imageIds: string[] = []; + + for (let sliceIndex = 0; sliceIndex < resolved.sliceCount; sliceIndex++) { + imageIds.push( + encodeImageIdInfo({ + name: resolved.name, + rows: resolved.rows, + columns: resolved.columns, + barStart: resolved.barStart, + barWidth: resolved.barWidth, + xSpacing: resolved.xSpacing, + ySpacing: resolved.ySpacing, + sliceIndex, + frameOfReferenceUID: resolved.frameOfReferenceUID, + }) + ); + } + + const provider = makeFakeMetaDataProvider(); + metaData.addProvider(provider, 10000); + + return { + imageIds, + options: resolved, + unregister(): void { + metaData.removeProvider(provider); + }, + }; +} diff --git a/tests/vitest-browser/harness/index.ts b/tests/vitest-browser/harness/index.ts new file mode 100644 index 0000000000..3b67e1e7a2 --- /dev/null +++ b/tests/vitest-browser/harness/index.ts @@ -0,0 +1,8 @@ +// Single entry point for the shared vitest-browser GenericViewport test +// harness. Other suites import from './harness'. + +export * from './fakeImageStack'; +export * from './createPlanarViewport'; +export * from './captureViewportState'; +export * from './recordEvents'; +export * from './tools'; diff --git a/tests/vitest-browser/harness/recordEvents.ts b/tests/vitest-browser/harness/recordEvents.ts new file mode 100644 index 0000000000..bd9f1cccd4 --- /dev/null +++ b/tests/vitest-browser/harness/recordEvents.ts @@ -0,0 +1,132 @@ +// Lightweight event recorder for CustomEvents dispatched on a viewport +// element (or any EventTarget). Keeps only shallow-safe detail fields so +// live object graphs (viewports, images, actors) are never retained. + +export interface RecordedEvent { + type: string; + detail?: unknown; +} + +export interface RecordEventsHandle { + events: RecordedEvent[]; + count(type: string): number; + types(): string[]; + clear(): void; + stop(): void; + waitFor( + type: string, + opts?: { timeoutMs?: number; minCount?: number } + ): Promise; +} + +// Interesting, shallow, primitive-or-small-object detail fields worth +// retaining across the various Cornerstone3D CustomEvent detail shapes. +const INTERESTING_DETAIL_KEYS = [ + 'viewportId', + 'renderingEngineId', + 'imageId', + 'imageIdIndex', + 'newImageIdIndex', + 'previousImageIdIndex', + 'volumeId', + 'displaySetId', + 'dataId', + 'range', + 'VOILUTFunction', + 'invert', + 'colormap', + 'displayArea', + 'rotation', +]; + +function extractDetail(detail: unknown): unknown { + if (!detail || typeof detail !== 'object') { + return detail; + } + + const source = detail as Record; + const out: Record = {}; + + for (const key of INTERESTING_DETAIL_KEYS) { + if (key in source) { + out[key] = source[key]; + } + } + + return out; +} + +export function recordEvents( + target: EventTarget, + eventNames: string[] +): RecordEventsHandle { + const events: RecordedEvent[] = []; + + const listener = (evt: Event) => { + events.push({ + type: evt.type, + detail: extractDetail((evt as CustomEvent).detail), + }); + }; + + eventNames.forEach((name) => target.addEventListener(name, listener)); + + let stopped = false; + + return { + events, + count(type: string): number { + return events.filter((event) => event.type === type).length; + }, + types(): string[] { + return events.map((event) => event.type); + }, + clear(): void { + events.length = 0; + }, + stop(): void { + if (stopped) { + return; + } + + stopped = true; + eventNames.forEach((name) => target.removeEventListener(name, listener)); + }, + waitFor( + type: string, + opts: { timeoutMs?: number; minCount?: number } = {} + ): Promise { + const { timeoutMs = 2000, minCount = 1 } = opts; + const alreadySeen = events.filter((event) => event.type === type).length; + + if (alreadySeen >= minCount) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + let seen = alreadySeen; + + const onEvent = () => { + seen += 1; + + if (seen >= minCount) { + clearTimeout(timer); + target.removeEventListener(type, onEvent); + resolve(); + } + }; + + const timer = setTimeout(() => { + target.removeEventListener(type, onEvent); + reject( + new Error( + `recordEvents.waitFor: timed out after ${timeoutMs}ms waiting for "${type}" (minCount=${minCount}, seen=${seen})` + ) + ); + }, timeoutMs); + + target.addEventListener(type, onEvent); + }); + }, + }; +} diff --git a/tests/vitest-browser/harness/tools.ts b/tests/vitest-browser/harness/tools.ts new file mode 100644 index 0000000000..6b88409512 --- /dev/null +++ b/tests/vitest-browser/harness/tools.ts @@ -0,0 +1,459 @@ +// Shared @cornerstonejs/tools test harness for vitest-browser state tests. +// +// Factors the proven patterns from tests/vitest-browser/toolMeasurements.browser.test.ts +// (do not modify that file) into reusable primitives: tool-group bootstrap on +// top of the GenericViewport harness (./createPlanarViewport), and synthetic +// pointer-event dispatch that mirrors the real DOM event flow @cornerstonejs/tools +// depends on (see packages/tools/src/eventListeners/mouse/mouseDownListener.ts). +// +// FROZEN CONTRACT: this module's exported signatures are relied on by five +// other vitest-browser suites (plans 08-12). Do not change existing exported +// signatures; additive exports are fine but must be documented in the +// consuming plan's final report. +// +// Black-box rule: this file itself only imports the PUBLIC @cornerstonejs/tools +// and @cornerstonejs/core entry points (never packages/tools/src/**), same as +// any spec file built on top of it. + +import * as cornerstoneTools from '@cornerstonejs/tools'; +import { utilities } from '@cornerstonejs/core'; +import { + createPlanarViewport, + type CreatePlanarViewportOptions, + type PlanarViewportContext, +} from './createPlanarViewport'; + +const { ToolGroupManager, annotation, cancelActiveManipulations } = + cornerstoneTools; +const { MouseBindings } = cornerstoneTools.Enums; + +// Minimal shape needed from a tool class: `addTool`/`toolGroup.addTool` only +// ever need the static `toolName`. Kept separate from the frozen `unknown[]` +// parameter type below so internals stay type-safe without changing the +// public signature. +interface ToolClassLike { + toolName: string; +} + +export interface SetupToolsOptions { + /** default: generated unique id, namespaced by the harness viewportId once known */ + toolGroupId?: string; + /** Tool classes; each gets addTool(t) (idempotent-safe) + toolGroup.addTool(t.toolName). */ + tools?: unknown[]; + /** toolName to setToolActive with a Primary mouse binding. */ + activeTool?: string; + /** Forwarded to createPlanarViewport. */ + viewport?: CreatePlanarViewportOptions; +} + +export interface ToolsContext extends PlanarViewportContext { + /** The IToolGroup returned by ToolGroupManager.createToolGroup. */ + toolGroup: unknown; + toolGroupId: string; + /** Idempotent: safe to call more than once. */ + cleanup(): void; +} + +let toolsHarnessInstanceCounter = 0; + +function nextToolGroupId(namespace: string): string { + toolsHarnessInstanceCounter += 1; + return `${namespace}-${toolsHarnessInstanceCounter}-${utilities.uuidv4()}`; +} + +/** + * Boots @cornerstonejs/tools against a fresh harness viewport: init(), + * addTool for every requested tool class, a dedicated tool group, the + * viewport attached to it, and (if requested) the tool set active with a + * Primary mouse binding. + * + * CRITICAL ordering: cornerstoneTools.init() + addTool() run BEFORE + * createPlanarViewport() -- tools wires its element listeners from the + * synchronous ELEMENT_ENABLED event fired inside enableElement(). Calling + * init() afterwards misses that event and leaves the element with no tools + * listeners at all (mousedown/mousemove dispatch silently does nothing). + * Mirrors packages/tools/src/init.ts + store/addEnabledElement.ts and the + * reference test's setupToolTest. + */ +export async function setupTools( + opts: SetupToolsOptions = {} +): Promise { + const tools = opts.tools ?? []; + + cornerstoneTools.init(); + tools.forEach((toolClass) => { + cornerstoneTools.addTool(toolClass); + }); + + const viewportCtx = await createPlanarViewport(opts.viewport); + + const toolGroupId = + opts.toolGroupId ?? nextToolGroupId(`vitest-tools:${viewportCtx.viewportId}`); + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + + if (!toolGroup) { + viewportCtx.cleanup(); + cornerstoneTools.destroy(); + throw new Error(`Failed to create tool group ${toolGroupId}`); + } + + tools.forEach((toolClass) => { + toolGroup.addTool((toolClass as ToolClassLike).toolName); + }); + + toolGroup.addViewport(viewportCtx.viewportId, viewportCtx.renderingEngine.id); + + if (opts.activeTool) { + toolGroup.setToolActive(opts.activeTool, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + } + + let cleanedUp = false; + const cleanup = (): void => { + if (cleanedUp) { + return; + } + cleanedUp = true; + + // Teardown order (see plans/vitest-browser-state-tests/00b-tools-shared-context.md): + // cancel any live manipulation, destroy the tool group + global tool + // registry + annotation state, THEN tear down the viewport/rendering + // engine, and finally reset @cornerstonejs/tools entirely so the next + // test's init() starts from a clean slate. Each step is independently + // guarded so a failure in one (e.g. nothing to cancel) never skips the + // rest. + try { + cancelActiveManipulations(viewportCtx.element); + } catch { + // Nothing active to cancel -- not an error. + } + + try { + ToolGroupManager.destroyToolGroup(toolGroupId); + } catch { + // Already destroyed -- not an error. + } + + try { + annotation.state.removeAllAnnotations(); + } catch { + // No annotation manager state to clear -- not an error. + } + + viewportCtx.cleanup(); + + try { + cornerstoneTools.destroy(); + } catch { + // Already destroyed -- not an error. + } + }; + + return { + ...viewportCtx, + toolGroup, + toolGroupId, + cleanup, + }; +} + +// --------------------------------------------------------------------------- +// Input synthesis +// +// Mirrors packages/tools/test/LengthTool_test.js and +// packages/tools/src/eventListeners/mouse/mouseDownListener.ts: a native +// `mousedown` dispatched on the viewport element, followed by `mousemove`(s) +// and `mouseup` dispatched on `document` (mouseDownListener adds its +// move/up listeners to `document`, not the element, for the duration of a +// held button). Canvas points are rounded to integers before dispatch +// (client/page coordinates round-trip cleanly only at integer canvas +// pixels -- see getMouseEventPoints.ts), so callers must derive their +// expected values from the SAME rounded canvas point via +// `viewport.canvasToWorld`/`worldToCanvas`. +// --------------------------------------------------------------------------- + +function roundCanvasPoint(point: [number, number]): [number, number] { + return [Math.round(point[0]), Math.round(point[1])]; +} + +function clientPointFromCanvasPoint( + element: HTMLElement, + canvasPoint: [number, number] +): [number, number] { + const rect = element.getBoundingClientRect(); + return [canvasPoint[0] + rect.left, canvasPoint[1] + rect.top]; +} + +function dispatchMouseDownOnElement( + element: HTMLElement, + canvasPoint: [number, number], + buttons: number +): void { + const [clientX, clientY] = clientPointFromCanvasPoint(element, canvasPoint); + element.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + view: window, + buttons, + clientX, + clientY, + }) + ); +} + +function dispatchMouseMoveOnDocument( + element: HTMLElement, + canvasPoint: [number, number], + buttons: number +): void { + const [clientX, clientY] = clientPointFromCanvasPoint(element, canvasPoint); + document.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + view: window, + buttons, + clientX, + clientY, + }) + ); +} + +function dispatchMouseUpOnDocument( + element: HTMLElement, + canvasPoint: [number, number] +): void { + const [clientX, clientY] = clientPointFromCanvasPoint(element, canvasPoint); + document.dispatchEvent( + new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + view: window, + buttons: 0, + clientX, + clientY, + }) + ); +} + +/** + * Single click: mousedown on the element followed by mouseup on document, at + * the same canvas point. `opts.button` is the `MouseEvent.buttons` bitmask + * (cornerstoneTools reads `evt.buttons` exclusively, never `evt.button`); + * default is `MouseBindings.Primary` (1), i.e. the left button. + */ +export function mouseClick( + element: HTMLElement, + canvasPoint: [number, number], + opts: { button?: number } = {} +): void { + const point = roundCanvasPoint(canvasPoint); + const buttons = opts.button ?? MouseBindings.Primary; + + dispatchMouseDownOnElement(element, point, buttons); + dispatchMouseUpOnDocument(element, point); +} + +/** + * Drag gesture: mousedown at `fromCanvas`, one or more mousemoves ending + * exactly at `toCanvas`, then mouseup at `toCanvas`. `opts.steps` (default 2, + * clamped to a minimum of 2) is the number of mousemove dispatches; they are + * linearly interpolated from `fromCanvas` to `toCanvas` so the first move + * already covers a meaningful fraction of the total distance. + * + * The first dispatched move must exceed the 3px double-click-drag tolerance + * in mouseDownListener.ts (DOUBLE_CLICK_DRAG_TOLERANCE) so the gesture is + * recognized as a drag synchronously instead of waiting on the 400ms + * double-click timer -- true automatically for any `steps` at the default + * when the total drag distance is more than a few pixels, which holds for + * every annotation gesture in this campaign's synthetic geometry. If the + * total distance is at or under that 3px tolerance there is no way to + * satisfy the guarantee while still ending at `toCanvas`, so this instead + * collapses to a single move straight to `toCanvas`. + */ +export function mouseDrag( + element: HTMLElement, + fromCanvas: [number, number], + toCanvas: [number, number], + opts: { button?: number; steps?: number } = {} +): void { + const buttons = opts.button ?? MouseBindings.Primary; + const steps = Math.max(2, opts.steps ?? 2); + const from = roundCanvasPoint(fromCanvas); + const to = roundCanvasPoint(toCanvas); + + dispatchMouseDownOnElement(element, from, buttons); + + const totalDx = to[0] - from[0]; + const totalDy = to[1] - from[1]; + + if (Math.abs(totalDx) + Math.abs(totalDy) <= 3) { + dispatchMouseMoveOnDocument(element, to, buttons); + } else { + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const point: [number, number] = [ + Math.round(from[0] + totalDx * t), + Math.round(from[1] + totalDy * t), + ]; + dispatchMouseMoveOnDocument(element, point, buttons); + } + } + + dispatchMouseUpOnDocument(element, to); +} + +/** + * Hover-only move: a single `mousemove` dispatched directly on `element` + * with no button held. This is the listener that stays bound to the element + * itself outside of a mousedown/mouseup cycle (see mouseMoveListener.ts), so + * it is what drives multi-gesture tools' "preview" phases between two + * separate mousedown/mouseup cycles (e.g. AngleTool's second arm -- its + * `_activateDraw` binds `Events.MOUSE_MOVE`, not only `Events.MOUSE_DRAG`, to + * the same drag handler). + */ +export function mouseMove( + element: HTMLElement, + canvasPoint: [number, number] +): void { + const point = roundCanvasPoint(canvasPoint); + const [clientX, clientY] = clientPointFromCanvasPoint(element, point); + + element.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + view: window, + buttons: 0, + clientX, + clientY, + }) + ); +} + +/** + * Mouse wheel event dispatched directly on `element` (wheelListener.ts reads + * `evt.currentTarget`, so the element must be the dispatch target, not + * document). `deltaY` must be outside (-1, 1) or the listener ignores it + * (see wheelListener.ts, guarding against spurious zero-delta wheel events). + */ +export function mouseWheel( + element: HTMLElement, + deltaY: number, + canvasPoint: [number, number] = [0, 0] +): void { + const point = roundCanvasPoint(canvasPoint); + const [clientX, clientY] = clientPointFromCanvasPoint(element, point); + + element.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + view: window, + deltaY, + clientX, + clientY, + }) + ); +} + +/** + * Native `dblclick` dispatched directly on `element`. cornerstoneTools does + * NOT synthesize its double-click event from replayed mousedown/mouseup + * pairs (mouseDownListener.ts's internal double-click state machine + * swallows both raw click cycles of a real double click without emitting + * any cornerstone event for either -- it defers entirely to the browser's + * native `dblclick`); a headless-dispatched `dblclick` is what + * mouseDoubleClickListener.ts listens for, so that is what this synthesizes. + */ +export function mouseDoubleClick( + element: HTMLElement, + canvasPoint: [number, number] +): void { + const point = roundCanvasPoint(canvasPoint); + const [clientX, clientY] = clientPointFromCanvasPoint(element, point); + + element.dispatchEvent( + new MouseEvent('dblclick', { + bubbles: true, + cancelable: true, + view: window, + buttons: 0, + clientX, + clientY, + }) + ); +} + +// --------------------------------------------------------------------------- +// Waiting +// --------------------------------------------------------------------------- + +/** + * Resolves (or rejects on timeout) the next occurrence of `type` on `target`. + * Generic version of the reference test's per-file ANNOTATION_RENDERED wait. + */ +export function waitForToolsEvent( + target: EventTarget, + type: string, + opts: { timeoutMs?: number } = {} +): Promise { + const { timeoutMs = 5000 } = opts; + + return new Promise((resolve, reject) => { + const onEvent = (evt: Event) => { + clearTimeout(timer); + target.removeEventListener(type, onEvent); + resolve(evt as CustomEvent); + }; + + const timer = setTimeout(() => { + target.removeEventListener(type, onEvent); + reject( + new Error( + `waitForToolsEvent: timed out after ${timeoutMs}ms waiting for "${type}"` + ) + ); + }, timeoutMs); + + target.addEventListener(type, onEvent, { once: true }); + }); +} + +/** + * Waits for `Enums.Events.ANNOTATION_RENDERED` on `element`. This is the + * event that reliably gates a just-drawn/just-modified annotation's + * `cachedStats` being populated: stats are computed inside each tool's + * `renderAnnotation` (requestAnimationFrame-driven, via + * AnnotationRenderingEngine), which fires strictly after + * ANNOTATION_COMPLETED/ANNOTATION_MODIFIED (both dispatched synchronously in + * the mouse-up handler, before that RAF). + */ +export function waitForAnnotationRendered( + element: HTMLElement, + opts: { timeoutMs?: number } = {} +): Promise { + const { Events: ToolsEvents } = cornerstoneTools.Enums; + + return waitForToolsEvent(element, ToolsEvents.ANNOTATION_RENDERED, opts).then( + () => undefined + ); +} + +// --------------------------------------------------------------------------- +// Closed-form math convenience +// --------------------------------------------------------------------------- + +/** Euclidean distance between two points of equal (2 or 3) dimension. */ +export function worldDistance(a: number[], b: number[]): number { + let sumOfSquares = 0; + + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const delta = (a[i] ?? 0) - (b[i] ?? 0); + sumOfSquares += delta * delta; + } + + return Math.sqrt(sumOfSquares); +} diff --git a/tests/vitest-browser/lifecycleAndRaces.browser.test.ts b/tests/vitest-browser/lifecycleAndRaces.browser.test.ts new file mode 100644 index 0000000000..4b91ae1dac --- /dev/null +++ b/tests/vitest-browser/lifecycleAndRaces.browser.test.ts @@ -0,0 +1,675 @@ +// Plan 4: lifecycle, teardown, and race-condition coverage for the +// GenericViewport (PLANAR_NEXT) architecture. State-based only -- no +// screenshots. See plans/vitest-browser-state-tests/04-lifecycle-teardown-races.md. + +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { + cache, + Enums, + getConfiguration, + getRenderingEngine, + imageLoader, + init, + RenderingEngine, + utilities, + type PlanarViewport, +} from '@cornerstonejs/core'; +import { + createPlanarViewport, + recordEvents, + registerFakeImageStack, + renderAndWait, + round6, + type PlanarViewportContext, +} from './harness'; + +const { Events, OrientationAxis, RenderBackend, ViewportStatus, ViewportType } = + Enums; + +// ---------------------------------------------------------------------------- +// Global error trap (shared-context / plan-04 requirement): any async error +// escaping the engine during these scenarios is itself a failure. Installed +// once at module scope; beforeEach clears the buffer, afterEach asserts it is +// empty (before running cleanup) unless a test-local catch already consumed +// the rejection/error. +// ---------------------------------------------------------------------------- + +interface CapturedGlobalError { + kind: 'error' | 'unhandledrejection'; + message: string; +} + +const capturedGlobalErrors: CapturedGlobalError[] = []; + +function onWindowError(event: ErrorEvent): void { + capturedGlobalErrors.push({ + kind: 'error', + message: event.message || String(event.error ?? 'unknown window error'), + }); +} + +function onUnhandledRejection(event: PromiseRejectionEvent): void { + capturedGlobalErrors.push({ + kind: 'unhandledrejection', + message: String(event.reason ?? 'unknown unhandled rejection'), + }); +} + +window.addEventListener('error', onWindowError); +window.addEventListener('unhandledrejection', onUnhandledRejection); + +// ---------------------------------------------------------------------------- +// Per-test cleanup tracking (mirrors the safety-net pattern already +// established in renderPathParity.browser.test.ts): tests push a harness +// context's cleanup() (or a local cleanup function) here; afterEach runs and +// drains the list even if a test throws before reaching its own teardown. +// ---------------------------------------------------------------------------- + +let activeCleanups: Array<() => void> = []; + +function track(ctx: T): T { + activeCleanups.push(ctx.cleanup); + return ctx; +} + +function runActiveCleanups(): void { + while (activeCleanups.length) { + const cleanup = activeCleanups.pop(); + + try { + cleanup?.(); + } catch { + // best-effort safety net only + } + } +} + +beforeEach(() => { + capturedGlobalErrors.length = 0; +}); + +afterEach(() => { + try { + expect( + capturedGlobalErrors, + `unexpected global error(s)/unhandled rejection(s): ${JSON.stringify( + capturedGlobalErrors + )}` + ).toEqual([]); + } finally { + runActiveCleanups(); + } +}); + +// ---------------------------------------------------------------------------- +// 1. Enable -> disable -> re-enable the same element +// ---------------------------------------------------------------------------- + +test('enable -> disable -> re-enable the same element yields a live, distinct viewport with no leaked canvases', async () => { + const ctx = track(await createPlanarViewport()); + const { renderingEngine, element, viewportId, displaySetId } = ctx; + const firstViewport = ctx.viewport; + + // Pinned: a live PlanarViewport legitimately owns two canvas elements, not + // one -- the vtk.js on-screen canvas created by getOrCreateCanvas() (inside + // a shared div.viewport-element wrapper) plus a dedicated per-instance + // `cpuCanvas` overlay that the PlanarViewport constructor always appends + // directly to `element` (used for CPU-path compositing; hidden via + // display:none in GPU modes -- see PlanarViewport constructor around the + // `cpuCanvas` field). So the correct "no leftover canvases" check is that + // the count returns to this same per-instance baseline after a disable/ + // re-enable cycle, not that it equals 1. + const canvasCountBeforeDisable = element.querySelectorAll('canvas').length; + expect(canvasCountBeforeDisable).toBe(2); + + renderingEngine.disableElement(viewportId); + + renderingEngine.enableElement({ + viewportId, + type: ViewportType.PLANAR_NEXT, + element, + defaultOptions: { + orientation: OrientationAxis.AXIAL, + }, + }); + + const secondViewport = renderingEngine.getViewport(viewportId); + + expect(secondViewport).toBeTruthy(); + expect(secondViewport).not.toBe(firstViewport); + + await secondViewport.setDisplaySets({ + displaySetId, + options: { + orientation: OrientationAxis.AXIAL, + renderBackend: RenderBackend.GPU, + }, + }); + + const recorder = recordEvents(element, [Events.IMAGE_RENDERED]); + await renderAndWait(element, secondViewport); + expect(recorder.count(Events.IMAGE_RENDERED)).toBeGreaterThanOrEqual(1); + recorder.stop(); + + expect(renderingEngine.getViewport(viewportId)).toBe(secondViewport); + // Pinned: disableElement() removes the old instance's cpuCanvas + // (PlanarViewport.onDestroy() calls this.cpuCanvas?.remove()) while the + // vtk.js on-screen canvas is left in place and reused by the new instance's + // getOrCreateCanvas() call; the new instance's constructor then appends its + // own fresh cpuCanvas. Net effect: the count returns to the original + // baseline (2) rather than growing (would be 3+ if the old vtk canvas were + // duplicated, or if the old cpuCanvas leaked). + expect(element.querySelectorAll('canvas').length).toBe( + canvasCountBeforeDisable + ); +}); + +// ---------------------------------------------------------------------------- +// 2. Destroy semantics +// ---------------------------------------------------------------------------- + +test('renderingEngine.destroy() invalidates the engine and makes the viewport a safe no-op, but does not remove DOM children', async () => { + const ctx = track(await createPlanarViewport()); + const { renderingEngine, renderingEngineId, viewportId, element, viewport } = + ctx; + + expect(element.querySelector('canvas')).not.toBeNull(); + expect(element.getAttribute('data-viewport-uid')).toBe(viewportId); + + renderingEngine.destroy(); + + // Pinned: getRenderingEngine(id) no longer resolves the destroyed engine + // (BaseRenderingEngine.destroy() calls renderingEngineCache.delete(this.id)). + expect(getRenderingEngine(renderingEngineId)).toBeUndefined(); + + // Pinned: getViewport() on the (still-referenced) destroyed engine instance + // returns undefined rather than throwing. BaseRenderingEngine._reset() + // replaces the internal viewport map with a fresh empty Map, and + // getViewport() never calls _throwIfDestroyed(). + expect(renderingEngine.getViewport(viewportId)).toBeUndefined(); + + // Pinned: viewport.render() after the owning engine has been destroyed is a + // documented no-op, not a throw. BaseRenderingEngine._resetViewport() (run + // from destroy() -> _reset()) calls viewport.destroy?.() on every mounted + // viewport, which sets PlanarViewport.isDestroyed = true; render() checks + // that flag first and returns immediately. + let renderThrew = false; + let renderError: unknown; + + try { + viewport.render(); + } catch (error) { + renderThrew = true; + renderError = error; + } + + expect(renderThrew, `viewport.render() threw: ${String(renderError)}`).toBe( + false + ); + + // Pinned: destroy() removes the viewport-authored data attributes from the + // element (PlanarViewport.destroy() calls element.removeAttribute for both + // data-viewport-uid and data-rendering-engine-uid)... + expect(element.getAttribute('data-viewport-uid')).toBeNull(); + + // ...but does NOT remove the canvas / internal wrapper div it created. + // Neither BaseRenderingEngine._resetViewport() nor PlanarViewport.destroy() + // /onDestroy() ever calls removeChild on the wrapper + // (div.viewport-element) or the canvas (canvas.cornerstone-canvas) -- + // _resetViewport() only clears the 2D context and removes attributes. This + // is a leak-by-design consequence of getOrCreateCanvas() reusing an + // existing canvas/wrapper found on the element rather than recreating one + // (see test 1: this is exactly what makes re-enable-on-the-same-element + // produce a single reused canvas instead of a second one). + expect(element.querySelector('canvas')).not.toBeNull(); + expect(element.querySelector('div.viewport-element')).not.toBeNull(); +}); + +// ---------------------------------------------------------------------------- +// 3. Double cleanup is safe +// ---------------------------------------------------------------------------- + +test('harness cleanup() is idempotent: calling it a second time does not throw', async () => { + const ctx = await createPlanarViewport(); + + ctx.cleanup(); + + expect(() => ctx.cleanup()).not.toThrow(); +}); + +// ---------------------------------------------------------------------------- +// 4. Cache accounting +// ---------------------------------------------------------------------------- + +test('cache accounts for every loaded stack image and returns to baseline after destroy + purgeCache', async () => { + const before = cache.getCacheSize(); + + const ctx = await createPlanarViewport({ + stack: { name: 'cache-accounting', sliceCount: 5, rows: 64, columns: 64 }, + }); + const { imageIds } = ctx; + + expect(imageIds.length).toBe(5); + + // Pinned: a stack-backed planar display set lazy-loads one slice at a time, + // not the whole stack up front. DefaultPlanarDataProvider.loadPlanarData + // only calls loadAndCacheImage() for the requested initialImageIdIndex, and + // VtkImageMapperRenderPath's navigation path only loads the *target* slice + // of a setImageIdIndex/scroll call. So immediately after the first render, + // only imageIds[0] is cached -- the other 4 registered imageIds are known + // to the display set (metadata-registered) but not yet loaded. + expect(cache.isLoaded(imageIds[0])).toBe(true); + for (let i = 1; i < imageIds.length; i++) { + expect(cache.isLoaded(imageIds[i]), imageIds[i]).toBe(false); + } + + // Force every remaining slice to load via the same public cache API an + // application would use to prefetch a stack, so the "every imageId is + // present" accounting below reflects a real, deterministic cache state + // rather than depending on render-timing of the lazy per-slice navigation + // load (which resolves off-band from setImageIdIndex/scroll's own promise). + for (let i = 1; i < imageIds.length; i++) { + await imageLoader.loadAndCacheImage(imageIds[i]); + } + + for (const imageId of imageIds) { + expect(cache.isLoaded(imageId), imageId).toBe(true); + expect(cache.getImageLoadObject(imageId), imageId).toBeTruthy(); + } + + const afterLoad = cache.getCacheSize(); + expect(afterLoad - before).toBeGreaterThanOrEqual(5 * 64 * 64); + + // Ordering constraint: purge only after the engine/viewport are torn down. + // Calling cache.purgeCache() while the viewport is still bound would pull + // pixel data out from under a live render path; ctx.cleanup() destroys the + // rendering engine BEFORE calling cache.purgeCache() (see + // harness/createPlanarViewport.ts), and this test calls cleanup() directly + // (rather than deferring to afterEach) precisely so that ordering is + // exercised and its effect on cache size can be observed inline. + ctx.cleanup(); + + expect(cache.getCacheSize()).toBe(0); +}); + +// ---------------------------------------------------------------------------- +// 5. setDisplaySets race: last call wins +// ---------------------------------------------------------------------------- + +interface RaceStacks { + idA: string; + idB: string; + imageIdsA: string[]; + imageIdsB: string[]; + unregister(): void; +} + +function registerRaceStacks(prefix: string): RaceStacks { + const stackA = registerFakeImageStack({ name: `${prefix}-A`, sliceCount: 5 }); + const stackB = registerFakeImageStack({ name: `${prefix}-B`, sliceCount: 3 }); + const idA = `${prefix}-displayset-A`; + const idB = `${prefix}-displayset-B`; + + utilities.genericViewportDisplaySetMetadataProvider.add(idA, { + imageIds: stackA.imageIds, + kind: 'planar', + initialImageIdIndex: 0, + }); + utilities.genericViewportDisplaySetMetadataProvider.add(idB, { + imageIds: stackB.imageIds, + kind: 'planar', + initialImageIdIndex: 0, + }); + + return { + idA, + idB, + imageIdsA: stackA.imageIds, + imageIdsB: stackB.imageIds, + unregister(): void { + stackA.unregister(); + stackB.unregister(); + }, + }; +} + +async function createRaceViewport(): Promise { + return track( + await createPlanarViewport({ + skipDisplaySets: true, + stack: { name: 'race-harness-unused', sliceCount: 1 }, + }) + ); +} + +describe('setDisplaySets race: last call wins', () => { + test('control: sequential awaits settle on B (5-slice A, then 3-slice B)', async () => { + const ctx = await createRaceViewport(); + const race = registerRaceStacks('vitest-race-control'); + + try { + const { viewport, element } = ctx; + + await viewport.setDisplaySets({ + displaySetId: race.idA, + options: { renderBackend: RenderBackend.GPU }, + }); + await viewport.setDisplaySets({ + displaySetId: race.idB, + options: { renderBackend: RenderBackend.GPU }, + }); + await renderAndWait(element, viewport); + + expect( + viewport.getDisplaySets().map((entry) => entry.displaySetId) + ).toEqual([race.idB]); + expect(viewport.getNumberOfSlices()).toBe(3); + expect(viewport.getImageIds()).toEqual(race.imageIdsB); + } finally { + race.unregister(); + } + }); + + test('race: concurrent calls (A started first, B started second) settle on B without interleaved state', async () => { + const ctx = await createRaceViewport(); + const race = registerRaceStacks('vitest-race-concurrent'); + + try { + const { viewport, element } = ctx; + + const pA = viewport.setDisplaySets({ + displaySetId: race.idA, + options: { renderBackend: RenderBackend.GPU }, + }); + const pB = viewport.setDisplaySets({ + displaySetId: race.idB, + options: { renderBackend: RenderBackend.GPU }, + }); + + await Promise.allSettled([pA, pB]); + await renderAndWait(element, viewport); + + // If this fails, it indicates a zombie mount: A's async completion + // clobbered B's after B was already the last call. Per shared-context + // rule 5, if that happens this test must be converted to test.fails + // with the observed values recorded in a comment -- see the run report. + expect( + viewport.getDisplaySets().map((entry) => entry.displaySetId) + ).toEqual([race.idB]); + expect(viewport.getNumberOfSlices()).toBe(3); + expect(viewport.getImageIds()).toEqual(race.imageIdsB); + } finally { + race.unregister(); + } + }); + + test('reverse-order race: concurrent calls (B started first, A started second) settle on A', async () => { + const ctx = await createRaceViewport(); + const race = registerRaceStacks('vitest-race-reverse'); + + try { + const { viewport, element } = ctx; + + const pB = viewport.setDisplaySets({ + displaySetId: race.idB, + options: { renderBackend: RenderBackend.GPU }, + }); + const pA = viewport.setDisplaySets({ + displaySetId: race.idA, + options: { renderBackend: RenderBackend.GPU }, + }); + + await Promise.allSettled([pB, pA]); + await renderAndWait(element, viewport); + + expect( + viewport.getDisplaySets().map((entry) => entry.displaySetId) + ).toEqual([race.idA]); + expect(viewport.getNumberOfSlices()).toBe(5); + expect(viewport.getImageIds()).toEqual(race.imageIdsA); + } finally { + race.unregister(); + } + }); +}); + +// ---------------------------------------------------------------------------- +// 6. Scroll during load +// ---------------------------------------------------------------------------- + +test('scroll calls fired during an in-flight setDisplaySets reject cleanly and leave the slice index in bounds', async () => { + const ctx = track( + await createPlanarViewport({ + skipDisplaySets: true, + stack: { name: 'scroll-during-load', sliceCount: 5 }, + }) + ); + const { viewport, element, imageIds, displaySetId } = ctx; + + utilities.genericViewportDisplaySetMetadataProvider.add(displaySetId, { + imageIds, + kind: 'planar', + initialImageIdIndex: 0, + }); + + const setDisplaySetsPromise = viewport.setDisplaySets({ + displaySetId, + options: { renderBackend: RenderBackend.GPU }, + }); + + // Fired without awaiting, immediately after setDisplaySets and before its + // internal await settles: getImageIds() is still empty at this instant (no + // binding mounted yet), so PlanarViewport.setImageIdIndex() is documented + // to reject with "Cannot set image index on empty stack". Each rejection is + // caught locally here -- this is the "specific scenario has a known, + // legitimately expected error" case the global error trap rule allows for. + const scrollResults = [1, 1, 1].map((delta) => + viewport.scroll(delta).catch((error) => ({ caught: error })) + ); + + await setDisplaySetsPromise; + await Promise.all(scrollResults); + await renderAndWait(element, viewport); + + const sliceIndex = viewport.getSliceIndex(); + const numberOfSlices = viewport.getNumberOfSlices(); + + expect(sliceIndex).toBeGreaterThanOrEqual(0); + expect(sliceIndex).toBeLessThan(numberOfSlices); +}); + +// ---------------------------------------------------------------------------- +// 7. removeData +// ---------------------------------------------------------------------------- + +test('removeData clears bindings and actors, and viewportStatus returns to noData', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, displaySetId } = ctx; + + viewport.removeData(displaySetId); + + expect(viewport.getDisplaySets()).toEqual([]); + expect(viewport.getActors()).toEqual([]); + + let renderThrew = false; + + try { + viewport.render(); + } catch { + renderThrew = true; + } + + expect(renderThrew).toBe(false); + + // Pinned: PlanarViewport.render() sets viewportStatus to NO_DATA ('noData') + // synchronously as soon as bindings.size is 0 (this already happened as a + // side effect of removeData()'s own internal render() call via + // GenericViewport.removeData -> this.render()); the explicit render() call + // above is a no-op re-confirmation of that same state. + expect(viewport.viewportStatus).toBe(ViewportStatus.NO_DATA); +}); + +// ---------------------------------------------------------------------------- +// 8. Resize keeps the view anchored +// ---------------------------------------------------------------------------- + +test('resize keeps the fit-mode anchor world point centered after the canvas dimensions change', async () => { + const ctx = track(await createPlanarViewport({ width: 400, height: 400 })); + const { viewport, element, renderingEngine } = ctx; + + const canvasBefore = viewport.getCanvas(); + const widthBefore = canvasBefore.width; + const heightBefore = canvasBefore.height; + const centerWorldBefore = viewport.canvasToWorld([ + widthBefore / 2, + heightBefore / 2, + ]); + + element.style.width = '600px'; + element.style.height = '300px'; + + renderingEngine.resize(); + await renderAndWait(element, viewport); + + const canvasAfter = viewport.getCanvas(); + const widthAfter = canvasAfter.width; + const heightAfter = canvasAfter.height; + + expect(widthAfter).not.toBe(widthBefore); + expect(heightAfter).not.toBe(heightBefore); + // deviceScaleFactor is pinned to 1 in vitest.browser.config.ts, so the + // on-screen canvas backing size should match the new CSS size exactly. + expect(widthAfter).toBe(600); + expect(heightAfter).toBe(300); + + const centerWorldAfter = viewport.canvasToWorld([ + widthAfter / 2, + heightAfter / 2, + ]); + + // Pinned "fit mode recenter" semantics: PlanarViewport's default view state + // (createDefaultPlanarViewState, Planar/planarViewState.ts) uses + // scaleMode 'fit' with anchorCanvas [0.5, 0.5] (fractional canvas center). + // ContextPoolRenderingEngine._resizeVTKViewports special-cases Generic + // viewports (isGenericViewport(vp)) to call only vp.resize() and skip the + // legacy getCamera/setCamera snapshot-restore dance; PlanarViewport.resize() + // -> resizeBindingsWithActiveFirst() recomputes the fit scale for the new + // canvas size while keeping the same fractional anchor point pinned to the + // same world point. So the world point under the canvas center should be + // preserved (within 1e-1 world units) even though the pixel dimensions, + // aspect ratio, and zoom level all change. + const worldEpsilon = 1e-1; + + for (let axis = 0; axis < 3; axis++) { + const delta = Math.abs(centerWorldAfter[axis] - centerWorldBefore[axis]); + expect( + delta, + `axis ${axis}: before=${centerWorldBefore[axis]} after=${centerWorldAfter[axis]}` + ).toBeLessThanOrEqual(worldEpsilon); + } +}); + +// ---------------------------------------------------------------------------- +// 9. Many viewports independence +// ---------------------------------------------------------------------------- + +test('a 2x2 grid of viewports on one engine renders independently; disabling one leaves the others untouched', async () => { + init(); + + const renderingConfig = getConfiguration().rendering; + const previousUseGenericViewport = renderingConfig.useGenericViewport; + renderingConfig.useGenericViewport = true; + + const stack = registerFakeImageStack({ + name: 'grid-independence', + sliceCount: 5, + }); + const gridRenderingEngineId = `vitest-lifecycle-grid-engine-${utilities.uuidv4()}`; + const gridDisplaySetId = `vitest-lifecycle-grid-displayset-${utilities.uuidv4()}`; + const renderingEngine = new RenderingEngine(gridRenderingEngineId); + const viewportIds = [0, 1, 2, 3].map( + (index) => `vitest-lifecycle-grid-viewport-${index}` + ); + const elements: HTMLDivElement[] = []; + + utilities.genericViewportDisplaySetMetadataProvider.add(gridDisplaySetId, { + imageIds: stack.imageIds, + kind: 'planar', + initialImageIdIndex: 0, + }); + + const cleanupGrid = (): void => { + getRenderingEngine(gridRenderingEngineId)?.destroy(); + cache.purgeCache(); + stack.unregister(); + imageLoader.unregisterAllImageLoaders(); + utilities.genericViewportDisplaySetMetadataProvider.clear?.(); + + for (const element of elements) { + element.parentNode?.removeChild(element); + } + + if (previousUseGenericViewport !== undefined) { + getConfiguration().rendering.useGenericViewport = + previousUseGenericViewport; + } + }; + + activeCleanups.push(cleanupGrid); + + for (const viewportId of viewportIds) { + const element = document.createElement('div'); + element.style.width = '200px'; + element.style.height = '200px'; + document.body.appendChild(element); + elements.push(element); + + renderingEngine.enableElement({ + viewportId, + type: ViewportType.PLANAR_NEXT, + element, + defaultOptions: { + orientation: OrientationAxis.AXIAL, + }, + }); + } + + const viewports = viewportIds.map((viewportId) => + renderingEngine.getViewport(viewportId) + ); + + for (const [index, viewport] of viewports.entries()) { + await viewport.setDisplaySets({ + displaySetId: gridDisplaySetId, + options: { renderBackend: RenderBackend.GPU }, + }); + await renderAndWait(elements[index], viewport); + } + + expect(new Set(viewports.map((vp) => vp.id)).size).toBe(4); + + for (const viewport of viewports) { + expect(viewport.viewportStatus).toBe(ViewportStatus.RENDERED); + } + + const stateBefore = viewports.slice(1).map((viewport) => ({ + sliceIndex: viewport.getSliceIndex(), + zoom: round6(viewport.getZoom()), + })); + + renderingEngine.disableElement(viewportIds[0]); + + expect(renderingEngine.getViewport(viewportIds[0])).toBeUndefined(); + + for (const [index, viewport] of viewports.slice(1).entries()) { + await renderAndWait(elements[index + 1], viewport); + expect(viewport.viewportStatus).toBe(ViewportStatus.RENDERED); + } + + const stateAfter = viewports.slice(1).map((viewport) => ({ + sliceIndex: viewport.getSliceIndex(), + zoom: round6(viewport.getZoom()), + })); + + expect(stateAfter).toEqual(stateBefore); +}); diff --git a/tests/vitest-browser/planarInvariants.browser.test.ts b/tests/vitest-browser/planarInvariants.browser.test.ts new file mode 100644 index 0000000000..5fa4e35e74 --- /dev/null +++ b/tests/vitest-browser/planarInvariants.browser.test.ts @@ -0,0 +1,518 @@ +// Round-trip and fixed-point invariant tests for PlanarViewport (GenericViewport +// architecture). These assert geometric/API contracts that must hold +// regardless of which render path is active. Default renderMode is +// 'vtkImage' with the harness's default 5-slice fake stack, per the plan. +// +// See plans/vitest-browser-state-tests/02-roundtrip-invariants.md. + +import { afterEach, describe, expect, test } from 'vitest'; +import { + Enums, + RenderingEngine, + utilities, + type PlanarViewport, + type Types, +} from '@cornerstonejs/core'; +import { + createPlanarViewport, + renderAndWait, + round6, + type PlanarViewportContext, +} from './harness'; + +const { InterpolationType, OrientationAxis, RenderBackend, ViewportType } = Enums; + +// Per shared-context rule 6: canvas-px comparisons use ~1e-2 epsilon (we use +// 0.5px, matching the plan's explicit "within 0.5 canvas px" wording for the +// round-trip test); world-space (mm) comparisons use ~1e-3. +const CANVAS_PX_EPS = 0.5; +const WORLD_MM_EPS = 1e-3; + +const GRID_COORDS = [50, 200, 350]; +const GRID_POINTS: Array<[number, number]> = GRID_COORDS.flatMap((x) => + GRID_COORDS.map((y): [number, number] => [x, y]) +); + +let cleanups: Array<() => void> = []; + +function track(ctx: PlanarViewportContext): PlanarViewportContext { + cleanups.push(() => ctx.cleanup()); + return ctx; +} + +afterEach(() => { + while (cleanups.length) { + const cleanup = cleanups.pop(); + + try { + cleanup?.(); + } catch { + // Best-effort cleanup; a failure here must not mask the test's own + // pass/fail result. + } + } + + document.body.innerHTML = ''; +}); + +function toCanvasPoint(point: [number, number]): Types.Point2 { + return point as unknown as Types.Point2; +} + +function expectCanvasPointClose( + actual: Types.Point2, + expected: [number, number], + eps: number = CANVAS_PX_EPS +) { + expect(Math.abs(actual[0] - expected[0])).toBeLessThanOrEqual(eps); + expect(Math.abs(actual[1] - expected[1])).toBeLessThanOrEqual(eps); +} + +function expectWorldPointClose( + actual: Types.Point3, + expected: Types.Point3, + eps: number = WORLD_MM_EPS +) { + for (let i = 0; i < 3; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(eps); + } +} + +function assertCanvasWorldRoundTrip( + viewport: PlanarViewport, + points: Array<[number, number]> +) { + for (const point of points) { + const world = viewport.canvasToWorld(toCanvasPoint(point)); + const roundTripped = viewport.worldToCanvas(world); + expectCanvasPointClose(roundTripped, point); + } +} + +/** + * Local re-implementation of the harness's internal snapshot normalizer + * (round every number, convert typed arrays, drop undefined-valued keys) so + * getViewState() objects can be compared for the idempotency check in test 9 + * without editing harness files. + */ +function deepRound(value: unknown): unknown { + if (typeof value === 'number') { + return round6(value); + } + + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + return Array.from(value as unknown as ArrayLike, (item) => + round6(item) + ); + } + + if (Array.isArray(value)) { + return value.map((item) => deepRound(item)); + } + + if (value && typeof value === 'object') { + const source = value as Record; + const out: Record = {}; + + for (const key of Object.keys(source)) { + const normalized = deepRound(source[key]); + + if (normalized !== undefined) { + out[key] = normalized; + } + } + + return out; + } + + return value; +} + +describe('planarInvariants: canvasToWorld / worldToCanvas round trip', () => { + test('holds across default fit, zoom, pan, rotation and flip+zoom configurations', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element } = ctx; + + // Configuration 1: default fit. + assertCanvasWorldRoundTrip(viewport, GRID_POINTS); + + // Configuration 2: after setZoom(2). + viewport.setZoom(2); + await renderAndWait(element, viewport); + assertCanvasWorldRoundTrip(viewport, GRID_POINTS); + + // Configuration 3: after setPan([30, -20]). + viewport.setPan([30, -20]); + await renderAndWait(element, viewport); + assertCanvasWorldRoundTrip(viewport, GRID_POINTS); + + // Configuration 4: after setViewState({ rotation: 90 }). + viewport.setViewState({ rotation: 90 }); + await renderAndWait(element, viewport); + assertCanvasWorldRoundTrip(viewport, GRID_POINTS); + + // Configuration 5: after setViewState({ flipHorizontal: true }) combined + // with zoom 1.5. + viewport.setViewState({ flipHorizontal: true }); + viewport.setZoom(1.5); + await renderAndWait(element, viewport); + assertCanvasWorldRoundTrip(viewport, GRID_POINTS); + }); +}); + +describe('planarInvariants: fixed point under zoom at a canvas point', () => { + test('setScaleAtCanvasPoint keeps the world point under the canvas point fixed', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element } = ctx; + const canvasPoint = toCanvasPoint([100, 150]); + + const worldBefore = viewport.canvasToWorld(canvasPoint); + + viewport.setScaleAtCanvasPoint(2, canvasPoint); + await renderAndWait(element, viewport); + + const worldAfter = viewport.canvasToWorld(canvasPoint); + expectWorldPointClose(worldAfter, worldBefore, 1e-2); + }); + + test('setZoom(zoom, canvasPoint) keeps the world point under the canvas point fixed (fresh fit state)', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element } = ctx; + const canvasPoint = toCanvasPoint([100, 150]); + + // Fresh fit state (this is the initial state right after mount, but + // resetViewState() is called explicitly to document that the assertion + // is meant to start from a known-fit baseline). + viewport.resetViewState(); + await renderAndWait(element, viewport); + + const worldBefore = viewport.canvasToWorld(canvasPoint); + + // setZoom's optional canvasPoint argument: read around + // Planar/PlanarViewport.ts:1067 (setZoom delegates to setScale) and + // :1122 (setScaleAtCanvasPoint). Both ultimately call + // PlanarResolvedView.withScale(nextScale, canvasPoint), which anchors the + // world point under canvasPoint before rescaling -- i.e. setZoom's + // canvasPoint semantics are identical to setScaleAtCanvasPoint's when a + // resolved view is present (true here, since the viewport has already + // rendered). No divergence was found between the two methods. + viewport.setZoom(3, canvasPoint); + await renderAndWait(element, viewport); + + const worldAfter = viewport.canvasToWorld(canvasPoint); + expectWorldPointClose(worldAfter, worldBefore, 1e-2); + }); +}); + +describe('planarInvariants: rotation preserves the anchor', () => { + test('rotating about the canvas center leaves the center world point unchanged', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element } = ctx; + const canvasCenter = toCanvasPoint([200, 200]); + + const worldBefore = viewport.canvasToWorld(canvasCenter); + + viewport.setViewState({ rotation: 90 }); + await renderAndWait(element, viewport); + + const worldAfter = viewport.canvasToWorld(canvasCenter); + expectWorldPointClose(worldAfter, worldBefore, 1e-2); + expect(round6(viewport.getRotation())).toBe(90); + }); +}); + +describe('planarInvariants: setter/getter symmetry', () => { + test('setZoom / getZoom', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element } = ctx; + + viewport.setZoom(2); + await renderAndWait(element, viewport); + + expect(round6(viewport.getZoom())).toBe(2); + }); + + test('setPan / getPan', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element } = ctx; + + viewport.setPan([25, -10]); + await renderAndWait(element, viewport); + + const pan = viewport.getPan(); + expect(round6(pan[0])).toBeCloseTo(25, 2); + expect(round6(pan[1])).toBeCloseTo(-10, 2); + }); + + test('setImageIdIndex / getCurrentImageIdIndex / getSliceIndex', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element } = ctx; + + await viewport.setImageIdIndex(3); + await renderAndWait(element, viewport); + + expect(viewport.getCurrentImageIdIndex()).toBe(3); + expect(viewport.getSliceIndex()).toBe(3); + }); + + test('setViewState({ rotation }) / getRotation', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element } = ctx; + + viewport.setViewState({ rotation: 45 }); + await renderAndWait(element, viewport); + + expect(round6(viewport.getRotation())).toBe(45); + }); + + test('setViewState({ flipHorizontal }) / getViewState().flipHorizontal', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element } = ctx; + + viewport.setViewState({ flipHorizontal: true }); + await renderAndWait(element, viewport); + + expect(viewport.getViewState().flipHorizontal).toBe(true); + }); +}); + +describe('planarInvariants: view reference round trip (same viewport)', () => { + test('setViewReference restores the navigated slice after scrolling away', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element } = ctx; + + await viewport.setImageIdIndex(3); + await renderAndWait(element, viewport); + + const ref = viewport.getViewReference(); + expect(viewport.isReferenceViewable(ref)).toBeTruthy(); + + await viewport.setImageIdIndex(0); + await renderAndWait(element, viewport); + expect(viewport.getSliceIndex()).toBe(0); + + viewport.setViewReference(ref); + await renderAndWait(element, viewport); + + expect(viewport.getSliceIndex()).toBe(3); + }); + + test('getViewReferenceId is stable within a state and changes across slices', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element } = ctx; + + await viewport.setImageIdIndex(3); + await renderAndWait(element, viewport); + + // Read getPlanarViewReferenceId (Planar/planarViewReference.ts): for the + // default vtkImage (image-path) render mode it returns + // `imageId:${referencedImageId}`, which is a pure function of the current + // slice's imageId, so it both encodes the slice and is stable when the + // state does not change. + const idAtSlice3First = viewport.getViewReferenceId(); + const idAtSlice3Second = viewport.getViewReferenceId(); + expect(idAtSlice3Second).toBe(idAtSlice3First); + + await viewport.setImageIdIndex(1); + await renderAndWait(element, viewport); + + const idAtSlice1 = viewport.getViewReferenceId(); + expect(idAtSlice1).not.toBe(idAtSlice3First); + }); +}); + +describe('planarInvariants: cross-viewport reference (same frame of reference)', () => { + test('a view reference captured on viewport A is viewable and restorable on viewport B', async () => { + const a = track(await createPlanarViewport()); + + // Manually create a second viewport following the harness's own + // construction pattern (see createPlanarViewport.ts), reusing viewport + // A's already-registered fake stack and displaySetId so both viewports + // resolve the same frameOfReferenceUID and imageIds without registering + // a second fake stack. + const renderingEngineIdB = `vitest-planar-invariants-cross-b-engine-${utilities.uuidv4()}`; + const viewportIdB = `vitest-planar-invariants-cross-b-viewport-${utilities.uuidv4()}`; + const renderingEngineB = new RenderingEngine(renderingEngineIdB); + const elementB = document.createElement('div'); + elementB.dataset.testid = 'planar-invariants-cross-b'; + elementB.style.width = '400px'; + elementB.style.height = '400px'; + document.body.appendChild(elementB); + + cleanups.push(() => { + renderingEngineB.destroy(); + + if (elementB.parentNode) { + elementB.parentNode.removeChild(elementB); + } + }); + + renderingEngineB.enableElement({ + viewportId: viewportIdB, + type: ViewportType.PLANAR_NEXT, + element: elementB, + defaultOptions: { + orientation: OrientationAxis.AXIAL, + }, + }); + + const viewportB = renderingEngineB.getViewport(viewportIdB); + + await viewportB.setDisplaySets({ + displaySetId: a.displaySetId, + options: { + orientation: OrientationAxis.AXIAL, + renderBackend: RenderBackend.GPU, + }, + }); + await renderAndWait(elementB, viewportB); + + await a.viewport.setImageIdIndex(4); + await renderAndWait(a.element, a.viewport); + + const refA = a.viewport.getViewReference(); + + // Read isPlanarReferenceViewable / isPlanarPlaneViewable + // (Planar/planarViewReference.ts): a resolved planar view reference always + // carries a `planeRestriction` populated from the CURRENT camera focal + // point, so without `withNavigation: true` the check answers "is this + // reference what I am currently displaying" (false here, since B is still + // at its initial slice) rather than "could I navigate to display it". + // `withNavigation: true` is the documented flag for the latter question, + // which is what this test actually wants to assert before calling + // setViewReference. This matches the legacy StackViewport.isReferenceViewable + // contract, which is equally strict by default (see StackViewport.ts:3380). + expect( + viewportB.isReferenceViewable(refA, { withNavigation: true }) + ).toBeTruthy(); + + viewportB.setViewReference(refA); + await renderAndWait(elementB, viewportB); + + expect(viewportB.getSliceIndex()).toBe(4); + }); +}); + +describe('planarInvariants: scroll clamping at stack bounds', () => { + test('scroll clamps at the first and last slice instead of wrapping', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport } = ctx; + const lastSliceIndex = viewport.getNumberOfSlices() - 1; + + expect(viewport.getSliceIndex()).toBe(0); + + // PlanarViewport.scroll (Planar/PlanarViewport.ts:1394) delegates to + // setImageIdIndex, which clamps the requested index into + // [0, getMaxImageIdIndex()] via Math.min/Math.max -- i.e. scroll clamps + // at the stack bounds; it does not wrap. + await viewport.scroll(-3); + expect(viewport.getSliceIndex()).toBe(0); + + await viewport.setImageIdIndex(lastSliceIndex); + expect(viewport.getSliceIndex()).toBe(lastSliceIndex); + + await viewport.scroll(5); + expect(viewport.getSliceIndex()).toBe(lastSliceIndex); + }); +}); + +describe('planarInvariants: presentation round trip', () => { + test('setDisplaySetPresentation round-trips interpolationType and voiRange', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element, displaySetId } = ctx; + + const defaultsBeforeMutation = viewport.getDisplaySetPresentation( + displaySetId + ); + + // Defaults captured right after mount: only the load-time `visible: true` + // default is stored; interpolationType/voiRange overrides are unset + // until explicitly requested (see GenericViewport.setDefaultDataPresentation + // call in PlanarViewport.ts around line 441). + expect(defaultsBeforeMutation?.interpolationType).toBeUndefined(); + expect(defaultsBeforeMutation?.voiRange).toBeUndefined(); + + const nextVoiRange = { upper: 300, lower: -100 }; + viewport.setDisplaySetPresentation(displaySetId, { + interpolationType: InterpolationType.NEAREST, + voiRange: nextVoiRange, + }); + await renderAndWait(element, viewport); + + const mutated = viewport.getDisplaySetPresentation(displaySetId); + expect(mutated?.interpolationType).toBe(InterpolationType.NEAREST); + expect(mutated?.voiRange).toEqual(nextVoiRange); + + viewport.resetDisplaySetPresentation(displaySetId); + await renderAndWait(element, viewport); + + const afterReset = viewport.getDisplaySetPresentation(displaySetId); + expect(afterReset?.interpolationType).toBe( + defaultsBeforeMutation?.interpolationType + ); + expect(afterReset?.voiRange).toEqual(defaultsBeforeMutation?.voiRange); + }); + + // Divergence found while implementing the above test: resetDisplaySetPresentation + // (Planar/PlanarViewport.ts:1511) replaces the ENTIRE stored presentation + // object with `{}` rather than restoring the pre-mutation snapshot. The + // interpolationType/voiRange fields do return to their (undefined) default, + // which is what the test above verifies, but any other field captured in + // the pre-mutation snapshot -- notably `visible: true`, set once at mount + // time via setDefaultDataPresentation -- is dropped too, so + // getDisplaySetPresentation(displaySetId) does NOT deep-equal the + // pre-mutation snapshot as a whole. Observed: defaultsBeforeMutation is + // `{ visible: true }`; afterReset is `{}`. The method's own doc comment + // frames this as intentional ("the next viewport intentionally has no + // get/set Properties"), but it is still a real behavioral divergence from + // a literal "reset restores defaults" contract, so it is kept here as a + // documented test.fails rather than silently dropped or narrowed away. + test.fails( + 'resetDisplaySetPresentation does not restore the full pre-mutation presentation snapshot (visible flag is lost)', + async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element, displaySetId } = ctx; + + const defaultsBeforeMutation = viewport.getDisplaySetPresentation( + displaySetId + ); + + viewport.setDisplaySetPresentation(displaySetId, { + interpolationType: InterpolationType.NEAREST, + voiRange: { upper: 300, lower: -100 }, + }); + await renderAndWait(element, viewport); + + viewport.resetDisplaySetPresentation(displaySetId); + await renderAndWait(element, viewport); + + const afterReset = viewport.getDisplaySetPresentation(displaySetId); + expect(afterReset).toEqual(defaultsBeforeMutation); + } + ); +}); + +describe('planarInvariants: view state round trip', () => { + test('setViewState(getViewState()) is idempotent', async () => { + const ctx = track(await createPlanarViewport()); + const { viewport, element } = ctx; + + // Exercise a non-default state first so the round trip is meaningful + // (a purely default state could trivially round-trip even if setViewState + // silently dropped fields). + viewport.setZoom(1.75); + viewport.setPan([12, -7]); + viewport.setViewState({ rotation: 30, flipVertical: true }); + await renderAndWait(element, viewport); + + const vs = viewport.getViewState(); + const vsBefore = deepRound(vs); + + viewport.setViewState(vs); + await renderAndWait(element, viewport); + + const vsAfter = deepRound(viewport.getViewState()); + + expect(vsAfter).toEqual(vsBefore); + }); +}); diff --git a/tests/vitest-browser/renderPathParity.browser.test.ts b/tests/vitest-browser/renderPathParity.browser.test.ts new file mode 100644 index 0000000000..48fa36d91b --- /dev/null +++ b/tests/vitest-browser/renderPathParity.browser.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { + captureViewportState, + createPlanarViewport, + renderAndWait, + type PlanarRenderMode, + type PlanarViewportContext, + type ViewportStateSnapshot, +} from './harness'; + +const ALL_MODES: PlanarRenderMode[] = [ + 'vtkImage', + 'vtkVolumeSlice', + 'cpuImage', + 'cpuVolume', +]; +const BASELINE_MODE: PlanarRenderMode = 'vtkImage'; + +// vtkVolumeSlice and cpuVolume are volume-backed and genuinely diverge from +// the vtkImage/cpuImage (stack-backed) baseline; see the confirmed findings +// documented above the parity tests below. cpuImage is stack-backed exactly +// like the baseline (only the render backend differs) and matches exactly. +const CLEAN_NON_BASELINE_MODES: PlanarRenderMode[] = ['cpuImage']; +const VOLUME_BACKED_NON_BASELINE_MODES: PlanarRenderMode[] = [ + 'vtkVolumeSlice', + 'cpuVolume', +]; + +// Safety net: if a test throws before reaching its own cleanup() call, this +// ensures the rendering engine, caches, and providers are still torn down so +// later tests (and other suites, since fileParallelism is false) do not see +// leaked global state. +let activeCleanups: Array<() => void> = []; + +afterEach(() => { + while (activeCleanups.length) { + const cleanup = activeCleanups.pop(); + + try { + cleanup?.(); + } catch { + // best-effort safety net only + } + } +}); + +async function setupMode( + mode: PlanarRenderMode, + displaySetId?: string +): Promise { + const ctx = await createPlanarViewport({ renderMode: mode, displaySetId }); + activeCleanups.push(ctx.cleanup); + return ctx; +} + +function teardown(ctx: PlanarViewportContext): void { + ctx.cleanup(); + activeCleanups = activeCleanups.filter((cleanup) => cleanup !== ctx.cleanup); +} + +/** + * Canonical 6-step scenario from plan 01. Captures a snapshot after each + * step (render + wait for IMAGE_RENDERED where the step does not itself + * render synchronously before its promise resolves). + */ +async function runCanonicalScenario( + ctx: PlanarViewportContext +): Promise { + const { viewport, element, displaySetId } = ctx; + const snapshots: ViewportStateSnapshot[] = []; + + // Step 1: initial render (already done by createPlanarViewport). + snapshots.push(captureViewportState(viewport, displaySetId)); + + // Step 2: scroll(2). setImageIdIndex settles the view state and renders + // synchronously within the returned promise, so no extra render/wait step + // is needed here. + await viewport.scroll(2); + snapshots.push(captureViewportState(viewport, displaySetId)); + + // Step 3: setZoom(2) then render. + viewport.setZoom(2); + await renderAndWait(element, viewport); + snapshots.push(captureViewportState(viewport, displaySetId)); + + // Step 4: setPan([20, 10]) then render. + viewport.setPan([20, 10]); + await renderAndWait(element, viewport); + snapshots.push(captureViewportState(viewport, displaySetId)); + + // Step 5: setViewState({ rotation: 90 }) then render. + viewport.setViewState({ rotation: 90 }); + await renderAndWait(element, viewport); + snapshots.push(captureViewportState(viewport, displaySetId)); + + // Step 6: set a VOI via setDisplaySetPresentation then render. + viewport.setDisplaySetPresentation(displaySetId, { + voiRange: { lower: 0, upper: 200 }, + }); + await renderAndWait(element, viewport); + snapshots.push(captureViewportState(viewport, displaySetId)); + + return snapshots; +} + +/** + * Runs the baseline (vtkImage) and a target mode through the canonical + * scenario under a shared displaySetId (so viewReference.dataId and the + * presentation lookup key line up across the two runs and do not pollute + * the diff with an incidental id mismatch), tearing each context down before + * the next is created. + */ +async function collectBaselineAndTargetSnapshots( + mode: PlanarRenderMode +): Promise<{ + baseline: ViewportStateSnapshot[]; + target: ViewportStateSnapshot[]; +}> { + const displaySetId = `render-path-parity:${mode}`; + + const baselineCtx = await setupMode(BASELINE_MODE, displaySetId); + const baseline = await runCanonicalScenario(baselineCtx); + teardown(baselineCtx); + + const targetCtx = await setupMode(mode, displaySetId); + const target = await runCanonicalScenario(targetCtx); + teardown(targetCtx); + + return { baseline, target }; +} + +describe('renderPathParity', () => { + test.each(ALL_MODES)( + 'sanity: %s reaches rendered state with a matching actor', + async (mode) => { + const ctx = await setupMode(mode); + + try { + const { viewport } = ctx; + const actors = viewport.getActors(); + + expect(actors.length).toBeGreaterThanOrEqual(1); + expect(viewport.getNumberOfSlices()).toBe(5); + expect(['stack', 'volume', 'unknown', 'empty']).toContain( + viewport.getCurrentMode() + ); + + const snapshot = captureViewportState(viewport, ctx.displaySetId); + + expect( + snapshot.pathSpecific.actorClassNames.length + ).toBeGreaterThanOrEqual(1); + expect(snapshot.pathSpecific.actorUIDs.length).toBe( + snapshot.pathSpecific.actorClassNames.length + ); + + // The actor's render path must match the requested backend: CPU + // modes (cpuImage, cpuVolume) mount a CanvasActor; GPU modes + // (vtkImage, vtkVolumeSlice) mount a vtk.js actor. Note: vtkImage and + // vtkVolumeSlice both mount an actor whose getClassName() is + // "vtkImageSlice" (ImageActor) -- vtk.js reuses the same actor class + // for the plain image mapper and the reslice mapper, distinguishing + // them only by mapper class (vtkImageMapper vs vtkImageResliceMapper, + // see packages/core/src/types/IActor.ts ActorMapperProxy). The frozen + // pathSpecific contract (actorClassNames/actorUIDs only) cannot + // surface that distinction, so this check is intentionally scoped to + // CPU-vs-GPU rather than to the specific GPU sub-mode. + const isCpuMode = mode === 'cpuImage' || mode === 'cpuVolume'; + const actorClassNames = snapshot.pathSpecific.actorClassNames; + + if (isCpuMode) { + expect(actorClassNames.some((name) => name === 'CanvasActor')).toBe( + true + ); + } else { + expect(actorClassNames.some((name) => name === 'vtkImageSlice')).toBe( + true + ); + } + } finally { + teardown(ctx); + } + } + ); + + test('vtkImage baseline snapshot survives a JSON round-trip', async () => { + const ctx = await setupMode(BASELINE_MODE); + + try { + const snapshots = await runCanonicalScenario(ctx); + + for (const [step, snapshot] of snapshots.entries()) { + const roundTripped = JSON.parse(JSON.stringify(snapshot)); + expect(roundTripped, `step ${step}`).toEqual(snapshot); + } + } finally { + teardown(ctx); + } + }); + + describe.each(CLEAN_NON_BASELINE_MODES)( + '%s vs vtkImage baseline core-state parity', + (mode) => { + test('core state matches the vtkImage baseline across the canonical scenario', async () => { + const { baseline, target } = await collectBaselineAndTargetSnapshots( + mode + ); + + expect(target.length).toBe(baseline.length); + + for (let step = 0; step < baseline.length; step++) { + expect(target[step].core, `mode=${mode} step=${step}`).toEqual( + baseline[step].core + ); + } + }); + } + ); + + // ------------------------------------------------------------------------ + // Confirmed cross-render-path divergences (shared-context rule 5). + // + // vtkVolumeSlice and cpuVolume are volume-backed: the harness registers + // their planar display set with an explicit `volumeId` (see + // createPlanarViewport.ts) so PlanarRenderPathDecisionService selects the + // volume render path (packages/core/src/RenderingEngine/GenericViewport/ + // Planar/PlanarRenderPathDecisionService.ts, isVolumeBackedDataSet). Two + // distinct divergences from the vtkImage/cpuImage (stack-backed) baseline + // were confirmed by inspecting every one of the 6 canonical-scenario steps + // (see the per-step diffs captured during development of this suite): + // + // 1. Present at every step, by design: `core.currentMode` is "volume" for + // the volume-backed modes vs "stack" for the baseline (see + // PlanarViewport.getCurrentMode, which classifies content type from the + // mounted render mode); and `core.viewReference.volumeId` is present + // only for the volume-backed modes (a volume-backed reference carries a + // volumeId; a stack-backed one does not). Both are direct, documented + // consequences of the architecture's stack/volume content-mode split, + // not an accidental render-path leak -- but per rule 5 they are kept as + // a real, asserted divergence rather than moved to pathSpecific or + // silently excluded, since the plan explicitly calls out viewReference + // as NOT legitimately path-specific. + // + // 2. A genuine, isolated bug found ONLY at step 0 (the initial render, + // before any explicit slice navigation): with the planar display set + // registered with `initialImageIdIndex: 0`, the volume-backed render + // paths resolve the initial slice to world Z = 4 (the LAST of the 5 + // fake-stack slices, imageId sliceIndex 4), while the baseline resolves + // it to world Z = 0 (sliceIndex 0) as requested. Observed step-0 values + // (5-slice stack, 1 mm z-spacing, slices at z=0..4): + // - core.viewState.slice: baseline + // { kind: 'stackIndex', imageIdIndex: 0 } + // vs vtkVolumeSlice/cpuVolume + // { kind: 'volumePoint', sliceWorldPoint: [31.5, 31.5, 4] } + // - core.viewReference.cameraFocalPoint[2] / planeRestriction.point[2]: + // 0 (baseline) vs 4 (volume-backed) + // - core.viewReference.referencedImageId / referencedImageURI: encode + // sliceIndex 0 (baseline) vs sliceIndex 4 (volume-backed) + // - core.worldProbes[*].world[2]: 0 (baseline) vs 4 (volume-backed), + // for all 3 probe canvas points + // getCurrentImageIdIndex()/getSliceIndex() mask this: both report "0" + // for every mode at step 0, even though the volume-backed modes are + // actually showing the opposite end of the stack. Root cause traced to + // PlanarViewport.createInitialVolumeSliceState (packages/core/src/ + // RenderingEngine/GenericViewport/Planar/PlanarViewport.ts), which + // builds the initial volumePoint slice basis independently of the + // stack-backed imageIdIndex path. + // + // Critically, this is a step-0-only divergence: after ANY explicit + // slice navigation (step 2 here, `scroll(2)`), the volume-backed modes + // realign with the baseline and stay aligned for the remaining steps + // (confirmed: baseline imageIdIndex=2 after scroll(2) matches + // sliceWorldPoint z=2 for vtkVolumeSlice/cpuVolume at steps 2 through 6, + // with zoom/pan/rotation/VOI changes in between having no further + // effect on slice alignment). + // ------------------------------------------------------------------------ + describe.each(VOLUME_BACKED_NON_BASELINE_MODES)( + '%s vs vtkImage baseline core-state parity', + (mode) => { + test.fails( + 'core state matches the vtkImage baseline across the canonical scenario', + async () => { + const { baseline, target } = await collectBaselineAndTargetSnapshots( + mode + ); + + expect(target.length).toBe(baseline.length); + + for (let step = 0; step < baseline.length; step++) { + expect(target[step].core, `mode=${mode} step=${step}`).toEqual( + baseline[step].core + ); + } + } + ); + } + ); +}); diff --git a/tests/vitest-browser/segmentationState.browser.test.ts b/tests/vitest-browser/segmentationState.browser.test.ts new file mode 100644 index 0000000000..9a3703fcff --- /dev/null +++ b/tests/vitest-browser/segmentationState.browser.test.ts @@ -0,0 +1,892 @@ +// State-based tests for the labelmap segmentation lifecycle on a +// GenericViewport stack (voxel values, segment indices, active-segmentation +// bookkeeping, visibility, events, statistics). No pixels are asserted +// anywhere in this file. +// +// Setup recipe (mirrors tests/genericViewport/genericStackLabelmapSegmentation.spec.ts +// and packages/tools/examples/genericStackLabelmapSegmentation/index.ts, using +// only public @cornerstonejs/core and @cornerstonejs/tools exports): +// 1. tools harness viewport: setupTools({ tools: [BrushTool], viewport: {...} }) +// (vtkImage render mode, 5-slice default synthetic stack -- 64x64, 1mm +// spacing, background value 10 + sliceIndex, vertical bar value 255 at +// world x in [20, 25), see harness/fakeImageStack.ts). +// 2. imageLoader.createAndCacheDerivedLabelmapImages(ctx.imageIds) -- one +// derived Uint8Array labelmap image per stack imageId, index-aligned +// with ctx.imageIds (slice k's labelmap is labelmapImageIds[k]). This is +// a synchronous public core API (no promise despite the example +// `await`-ing it). +// 3. segmentation.addSegmentations([{ segmentationId, representation: { +// type: Enums.SegmentationRepresentations.Labelmap, +// data: { imageIds: labelmapImageIds } } }]) +// 4. await segmentation.addSegmentationRepresentations(viewportId, [{ +// segmentationId, type: Enums.SegmentationRepresentations.Labelmap, +// }]) -- synchronous in the current implementation, `await` is a no-op. +// 5. toolGroup.addToolInstance('CircularBrush', BrushTool.toolName, { +// activeStrategy: 'FILL_INSIDE_CIRCLE', preview: { enabled: false }, +// }) (+ a 'CircularEraser' instance with 'ERASE_INSIDE_CIRCLE'), then +// toolGroup.setToolActive(instanceName, { bindings: [{ mouseButton: +// MouseBindings.Primary }] }). setBrushSizeForToolGroup(toolGroupId, +// radiusMm) pins the brush radius (world mm, verified against +// packages/tools/src/tools/segmentation/strategies/compositions/circularCursor.ts). +// +// Engine-source findings that shaped this file (see final report for the +// full writeup): +// - All segmentation events (Events.ts CORNERSTONE_TOOLS_SEGMENTATION_*) +// are triggered on the @cornerstonejs/core `eventTarget` singleton, never +// on the viewport element -- confirmed by reading every +// stateManagement/segmentation/events/trigger*.ts source file. +// - Events.SEGMENTATION_REPRESENTATION_ADDED is declared in the Events enum +// and listened for internally (packages/tools/src/init.ts), but no +// production code path ever triggers it -- addSegmentationRepresentations +// only triggers SEGMENTATION_REPRESENTATION_MODIFIED and +// SEGMENTATION_MODIFIED. Documented as a dedicated test.fails below. +// - A synthetic mousedown+mouseup at the same point (mouseClick) is held by +// mouseDownListener.ts's double-click disambiguation timer for up to 400ms +// (real setTimeout, real wall-clock time in this real-browser test) before +// MOUSE_CLICK actually dispatches. Every wait in this file uses a timeout +// comfortably above that. +// - getUniqueSegmentIndices reads the segmentation's `segments` bookkeeping +// map (populated by setActiveSegmentIndex), not a live voxel scan -- an +// index shows up the moment it becomes active, whether or not any voxel +// has been painted with it yet. +// - The first SEGMENTATION_RENDERED after adding a representation is +// RAF-gated (SegmentationRenderingEngine schedules it via +// window.requestAnimationFrame) and its latency is highly variable in +// this environment -- headless Chromium under vitest browser mode logs +// frequent "WebGL context lost" warnings as each test's RenderingEngine +// is created and destroyed in quick succession, and GPU/rAF scheduling +// visibly slows down under that context churn. Observed wait times for +// the same code path ranged from under 100ms to several seconds across +// runs. waitForFirstSegmentationRender below uses a generous timeout and, +// if it elapses, falls back to an explicit viewport.render() retry +// (observed to unstick it) before giving up for real. +import { afterEach, describe, expect, test } from 'vitest'; +import { cache, eventTarget, imageLoader, Enums as CoreEnums } from '@cornerstonejs/core'; +import type { Types as CoreTypes } from '@cornerstonejs/core'; +import * as cornerstoneTools from '@cornerstonejs/tools'; +import type { Types as ToolsTypes } from '@cornerstonejs/tools'; +import { + mouseClick, + setupTools, + waitForToolsEvent, + type ToolsContext, +} from './harness'; + +// @cornerstonejs/core does not re-export IImage from its top-level entry +// point (only via the Types namespace), unlike most other core types used +// across this suite. +type IImage = CoreTypes.IImage; + +const { BrushTool, segmentation } = cornerstoneTools; +const { Events: ToolsEvents, MouseBindings } = cornerstoneTools.Enums; +const { Labelmap: LABELMAP } = cornerstoneTools.Enums.SegmentationRepresentations; +const segmentationUtils = cornerstoneTools.utilities.segmentation; + +const BRUSH_INSTANCE = 'VitestCircularBrush'; +const ERASER_INSTANCE = 'VitestCircularEraser'; + +// Canvas point mandated by the plan for the primary paint stroke. +const PRIMARY_PAINT_CANVAS: [number, number] = [200, 200]; +const PRIMARY_PAINT_RADIUS_MM = 5; + +let active: ToolsContext | null = null; + +afterEach(() => { + if (!active) { + return; + } + + const ctx = active; + active = null; + ctx.cleanup(); +}); + +// --------------------------------------------------------------------------- +// Setup helpers +// --------------------------------------------------------------------------- + +async function setupSegmentationHarness(): Promise { + const ctx = await setupTools({ + tools: [BrushTool], + viewport: { width: 400, height: 400 }, + }); + + const toolGroup = ctx.toolGroup as ToolsTypes.IToolGroup; + + toolGroup.addToolInstance(BRUSH_INSTANCE, BrushTool.toolName, { + activeStrategy: 'FILL_INSIDE_CIRCLE', + preview: { enabled: false }, + }); + toolGroup.addToolInstance(ERASER_INSTANCE, BrushTool.toolName, { + activeStrategy: 'ERASE_INSIDE_CIRCLE', + preview: { enabled: false }, + }); + + return ctx; +} + +/** + * Activates exactly one of the brush/eraser instances on the Primary mouse + * binding. setToolActive does NOT automatically deactivate a previously + * active tool sharing the same binding (verified by reading ToolGroup.ts -- + * the example app's toolbar handler explicitly calls setToolDisabled on the + * previously active tool before activating a new one), so the previous + * instance is disabled first to avoid both instances responding to the same + * click. + */ +function activateBrushInstance( + ctx: ToolsContext, + instanceName: typeof BRUSH_INSTANCE | typeof ERASER_INSTANCE +): void { + const toolGroup = ctx.toolGroup as ToolsTypes.IToolGroup; + const other = instanceName === BRUSH_INSTANCE ? ERASER_INSTANCE : BRUSH_INSTANCE; + + toolGroup.setToolDisabled(other); + toolGroup.setToolActive(instanceName, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); +} + +function setBrushRadius(ctx: ToolsContext, radiusMm: number): void { + segmentationUtils.setBrushSizeForToolGroup(ctx.toolGroupId, radiusMm); +} + +interface SegmentationSetup { + segmentationId: string; + labelmapImageIds: string[]; +} + +/** + * Waits for the first post-add SEGMENTATION_RENDERED for `segmentationId`, + * tolerating this environment's highly variable RAF-scheduling latency (see + * file header). If the primary wait elapses, falls back to an explicit + * `viewport.render()` + IMAGE_RENDERED wait, which was observed to reliably + * unstick a delayed render pass without depending on + * SegmentationRenderingEngine's internal RAF queue at all. + */ +async function waitForFirstSegmentationRender( + ctx: ToolsContext, + segmentationId: string +): Promise { + const primaryWait = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_RENDERED, + { timeoutMs: 8000 } + ); + + try { + await primaryWait; + return; + } catch { + // Fall through to the manual-render fallback below. + } + + const fallbackWait = waitForToolsEvent( + ctx.element, + CoreEnums.Events.IMAGE_RENDERED, + { timeoutMs: 5000 } + ); + ctx.viewport.render(); + + try { + await fallbackWait; + } catch (fallbackError) { + throw new Error( + `SEGMENTATION_RENDERED never fired for ${segmentationId} within 8000ms, ` + + `and the viewport.render() fallback also did not fire IMAGE_RENDERED ` + + `within 5000ms: ${(fallbackError as Error).message}` + ); + } +} + +/** + * Creates one derived-labelmap segmentation (one labelmap image per stack + * imageId, index-aligned with ctx.imageIds) and adds a Labelmap + * representation of it to the harness viewport. + */ +async function createLabelmapSegmentation( + ctx: ToolsContext, + segmentationId: string +): Promise { + const derivedImages = imageLoader.createAndCacheDerivedLabelmapImages( + ctx.imageIds + ); + const labelmapImageIds = derivedImages.map((image) => image.imageId); + + segmentation.addSegmentations([ + { + segmentationId, + representation: { + type: LABELMAP, + data: { imageIds: labelmapImageIds }, + }, + }, + ]); + + const renderedWait = waitForFirstSegmentationRender(ctx, segmentationId); + + await segmentation.addSegmentationRepresentations(ctx.viewportId, [ + { segmentationId, type: LABELMAP }, + ]); + + // Wait for the first labelmap render pass to complete before returning. + // internalAddSegmentationRepresentation only schedules a render (via + // triggerSegmentationModified -> SegmentationRenderingEngine's RAF-based + // queue); painting before that first render pass runs leaves the + // labelmap-to-viewport viewability bookkeeping + // (LabelmapImageReferenceResolver) unresolved, and the brush strategy + // silently no-ops. + await renderedWait; + + return { segmentationId, labelmapImageIds }; +} + +// --------------------------------------------------------------------------- +// Voxel-reading helpers (public: cache.getImage + IImage.voxelManager.getAtIJK) +// --------------------------------------------------------------------------- + +function getLabelmapImage(imageId: string): IImage { + const image = cache.getImage(imageId); + + if (!image) { + throw new Error(`Labelmap image not found in cache: ${imageId}`); + } + + return image; +} + +function countVoxelsMatching( + image: IImage, + predicate: (value: number) => boolean +): number { + const { rows, columns, voxelManager } = image; + let count = 0; + + for (let j = 0; j < rows; j++) { + for (let i = 0; i < columns; i++) { + if (predicate(voxelManager.getAtIJK(i, j, 0) as number)) { + count++; + } + } + } + + return count; +} + +interface CentroidResult { + count: number; + /** [I, J] centroid in voxel-index space; null when count === 0. */ + centroidIJ: [number, number] | null; +} + +function centroidOfVoxelsMatching( + image: IImage, + predicate: (value: number) => boolean +): CentroidResult { + const { rows, columns, voxelManager } = image; + let count = 0; + let sumI = 0; + let sumJ = 0; + + for (let j = 0; j < rows; j++) { + for (let i = 0; i < columns; i++) { + if (predicate(voxelManager.getAtIJK(i, j, 0) as number)) { + count++; + sumI += i; + sumJ += j; + } + } + } + + return { + count, + centroidIJ: count > 0 ? [sumI / count, sumJ / count] : null, + }; +} + +// The synthetic stack's imagePlaneModule (identity orientation, 1mm spacing, +// imagePositionPatient = [0, 0, sliceIndex]) is copied verbatim onto every +// derived labelmap image by core's createAndCacheDerivedImage, so voxel +// (column I, row J) on slice k sits at world [I, J, k] -- the same closed-form +// relationship already relied on by toolMeasurements.browser.test.ts for the +// same harness stack. +function voxelIJToWorld(i: number, j: number, sliceIndex: number): CoreTypes.Point3 { + return [i, j, sliceIndex]; +} + +// --------------------------------------------------------------------------- +// Event waiting (local, not the shared recordEvents harness helper: that +// helper only retains a fixed allowlist of detail keys for memory safety, +// which does not include `segmentationId` -- see harness/recordEvents.ts +// INTERESTING_DETAIL_KEYS. The events-contract test needs the full detail, so +// it listens directly on eventTarget here instead.) +// --------------------------------------------------------------------------- + +function waitForSegmentationDataModified( + segmentationId: string, + timeoutMs = 5000 +): Promise { + return new Promise((resolve, reject) => { + const onEvent = (evt: Event) => { + const detail = (evt as CustomEvent).detail as + | { segmentationId?: string } + | undefined; + + if (detail?.segmentationId === segmentationId) { + clearTimeout(timer); + eventTarget.removeEventListener( + ToolsEvents.SEGMENTATION_DATA_MODIFIED, + onEvent + ); + resolve(evt as CustomEvent); + } + }; + + const timer = setTimeout(() => { + eventTarget.removeEventListener( + ToolsEvents.SEGMENTATION_DATA_MODIFIED, + onEvent + ); + reject( + new Error( + `Timed out after ${timeoutMs}ms waiting for SEGMENTATION_DATA_MODIFIED (segmentationId=${segmentationId})` + ) + ); + }, timeoutMs); + + eventTarget.addEventListener(ToolsEvents.SEGMENTATION_DATA_MODIFIED, onEvent); + }); +} + +/** + * Clicks at `canvasPoint` and waits for the SEGMENTATION_DATA_MODIFIED event + * carrying `segmentationId`. The click itself may sit behind mouseDownListener's + * up-to-400ms double-click disambiguation timer (real wall-clock time in this + * real-browser test) before the underlying MOUSE_CLICK (and therefore the + * brush fill) actually fires -- waitForSegmentationDataModified's default + * 5000ms timeout comfortably covers that. + */ +async function paintAndWaitForDataModified( + ctx: ToolsContext, + canvasPoint: [number, number], + segmentationId: string +): Promise { + const dataModified = waitForSegmentationDataModified(segmentationId); + mouseClick(ctx.element, canvasPoint); + return dataModified; +} + +describe('segmentationState', () => { + test('paint produces exactly-typed voxels at the right place', async () => { + const ctx = await setupSegmentationHarness(); + active = ctx; + + const { segmentationId, labelmapImageIds } = await createLabelmapSegmentation( + ctx, + 'vitest-seg-paint' + ); + + activateBrushInstance(ctx, BRUSH_INSTANCE); + setBrushRadius(ctx, PRIMARY_PAINT_RADIUS_MM); + + await paintAndWaitForDataModified(ctx, PRIMARY_PAINT_CANVAS, segmentationId); + + const activeSegmentIndex = segmentation.segmentIndex.getActiveSegmentIndex( + segmentationId + ); + expect(activeSegmentIndex).toBe(1); + + const slice0Image = getLabelmapImage(labelmapImageIds[0]); + const nonZero = centroidOfVoxelsMatching(slice0Image, (v) => v !== 0); + + expect(nonZero.count).toBeGreaterThan(0); + + // Every nonzero voxel must carry the default active segment index (1). + const nonDefaultValue = countVoxelsMatching( + slice0Image, + (v) => v !== 0 && v !== activeSegmentIndex + ); + expect(nonDefaultValue).toBe(0); + + // Centroid of the painted voxels must equal canvasToWorld(the click + // point) within one voxel spacing (1mm). + const expectedWorld = ctx.viewport.canvasToWorld(PRIMARY_PAINT_CANVAS); + const [centroidI, centroidJ] = nonZero.centroidIJ as [number, number]; + const centroidWorld = voxelIJToWorld(centroidI, centroidJ, 0); + + expect(centroidWorld[0]).toBeCloseTo(expectedWorld[0], 0); + expect(centroidWorld[1]).toBeCloseTo(expectedWorld[1], 0); + + // Loose disc-area sanity bound: [0.6, 1.4] x (pi * r^2) for the + // configured brush radius (1mm voxel spacing => 1 voxel per mm^2). + const expectedArea = Math.PI * PRIMARY_PAINT_RADIUS_MM * PRIMARY_PAINT_RADIUS_MM; + expect(nonZero.count).toBeGreaterThanOrEqual(expectedArea * 0.6); + expect(nonZero.count).toBeLessThanOrEqual(expectedArea * 1.4); + + // Regression sentinel: exact observed voxel count for a 5mm-radius + // circular fill centered at canvasToWorld([200, 200]) on the harness's + // default 64x64/1mm synthetic stack, pinned from repeated actual runs + // (consistently 80, not recomputed from the area formula -- pi*5^2 is + // ~78.5, and rasterization is not obliged to match that exactly). If + // this changes, the fill rasterization algorithm changed -- update the + // pin after confirming the new count is still within the loose bound + // above. + expect(nonZero.count).toBe(80); + }); + + test('slice isolation: a circular brush on a stack only affects the current slice', async () => { + const ctx = await setupSegmentationHarness(); + active = ctx; + + const { segmentationId, labelmapImageIds } = await createLabelmapSegmentation( + ctx, + 'vitest-seg-slice-isolation' + ); + + activateBrushInstance(ctx, BRUSH_INSTANCE); + setBrushRadius(ctx, PRIMARY_PAINT_RADIUS_MM); + + expect(ctx.viewport.getCurrentImageIdIndex()).toBe(0); + + await paintAndWaitForDataModified(ctx, PRIMARY_PAINT_CANVAS, segmentationId); + + const slice0Image = getLabelmapImage(labelmapImageIds[0]); + const slice0NonZero = countVoxelsMatching(slice0Image, (v) => v !== 0); + expect(slice0NonZero).toBeGreaterThan(0); + + const slice1Image = getLabelmapImage(labelmapImageIds[1]); + const slice1NonZero = countVoxelsMatching(slice1Image, (v) => v !== 0); + expect(slice1NonZero).toBe(0); + }); + + test('segment index switching: new voxels take the new index, old voxels keep theirs', async () => { + const ctx = await setupSegmentationHarness(); + active = ctx; + + const { segmentationId, labelmapImageIds } = await createLabelmapSegmentation( + ctx, + 'vitest-seg-index-switch' + ); + + activateBrushInstance(ctx, BRUSH_INSTANCE); + setBrushRadius(ctx, PRIMARY_PAINT_RADIUS_MM); + + await paintAndWaitForDataModified(ctx, PRIMARY_PAINT_CANVAS, segmentationId); + + const slice0Image = getLabelmapImage(labelmapImageIds[0]); + const segment1CountBefore = countVoxelsMatching(slice0Image, (v) => v === 1); + expect(segment1CountBefore).toBeGreaterThan(0); + + segmentation.segmentIndex.setActiveSegmentIndex(segmentationId, 2); + expect( + segmentation.segmentIndex.getActiveSegmentIndex(segmentationId) + ).toBe(2); + + // A different location, well separated from the first paint (world [50, + // 15, 0] vs. the first paint's ~world [32, 32, 0]) so the two discs + // (radius 5mm each) cannot overlap. + const secondWorldTarget: CoreTypes.Point3 = [50, 15, 0]; + const secondCanvasPoint = ctx.viewport.worldToCanvas(secondWorldTarget); + + await paintAndWaitForDataModified( + ctx, + [Math.round(secondCanvasPoint[0]), Math.round(secondCanvasPoint[1])], + segmentationId + ); + + const segment1CountAfter = countVoxelsMatching(slice0Image, (v) => v === 1); + const segment2Count = countVoxelsMatching(slice0Image, (v) => v === 2); + + expect(segment1CountAfter).toBe(segment1CountBefore); + expect(segment2Count).toBeGreaterThan(0); + + const uniqueIndices = segmentationUtils.getUniqueSegmentIndices( + segmentationId + ); + expect(uniqueIndices).toEqual([1, 2]); + }); + + test('eraser clears only the erased region, leaving other segments untouched', async () => { + const ctx = await setupSegmentationHarness(); + active = ctx; + + const { segmentationId, labelmapImageIds } = await createLabelmapSegmentation( + ctx, + 'vitest-seg-eraser' + ); + + activateBrushInstance(ctx, BRUSH_INSTANCE); + setBrushRadius(ctx, PRIMARY_PAINT_RADIUS_MM); + + // Segment 1 at the primary paint point. + await paintAndWaitForDataModified(ctx, PRIMARY_PAINT_CANVAS, segmentationId); + + segmentation.segmentIndex.setActiveSegmentIndex(segmentationId, 2); + + const segment2WorldTarget: CoreTypes.Point3 = [50, 15, 0]; + const segment2Canvas = ctx.viewport.worldToCanvas(segment2WorldTarget); + const segment2CanvasPoint: [number, number] = [ + Math.round(segment2Canvas[0]), + Math.round(segment2Canvas[1]), + ]; + + await paintAndWaitForDataModified(ctx, segment2CanvasPoint, segmentationId); + + const slice0Image = getLabelmapImage(labelmapImageIds[0]); + const segment1CountBeforeErase = countVoxelsMatching( + slice0Image, + (v) => v === 1 + ); + const segment2CountBeforeErase = countVoxelsMatching( + slice0Image, + (v) => v === 2 + ); + expect(segment1CountBeforeErase).toBeGreaterThan(0); + expect(segment2CountBeforeErase).toBeGreaterThan(0); + + // Erase exactly where segment 1 was painted, with a radius >= the paint + // radius (equal, via the same setBrushSizeForToolGroup call, which sets + // every brush-based instance in the tool group uniformly). + activateBrushInstance(ctx, ERASER_INSTANCE); + setBrushRadius(ctx, PRIMARY_PAINT_RADIUS_MM); + + await paintAndWaitForDataModified(ctx, PRIMARY_PAINT_CANVAS, segmentationId); + + const segment1CountAfterErase = countVoxelsMatching( + slice0Image, + (v) => v === 1 + ); + const segment2CountAfterErase = countVoxelsMatching( + slice0Image, + (v) => v === 2 + ); + + expect(segment1CountAfterErase).toBe(0); + expect(segment2CountAfterErase).toBe(segment2CountBeforeErase); + }); + + test('active segmentation bookkeeping: getActiveSegmentation/setActiveSegmentation route paints', async () => { + const ctx = await setupSegmentationHarness(); + active = ctx; + + const seg1 = await createLabelmapSegmentation(ctx, 'vitest-seg-active-1'); + const seg2 = await createLabelmapSegmentation(ctx, 'vitest-seg-active-2'); + + // Finding: adding a second segmentation representation to a viewport + // makes IT the active one -- SegmentationStateManager.addDefaultSegmentationRepresentation + // pushes { active: true } and then calls _setActiveSegmentation for the + // newly added segmentation unconditionally, with no code path that + // demotes a previously active representation before that push. Pinning + // the observed behavior here since the plan only asks to "pin which is + // active after adding two". + const activeAfterAdd = segmentation.activeSegmentation.getActiveSegmentation( + ctx.viewportId + ); + expect(activeAfterAdd?.segmentationId).toBe(seg2.segmentationId); + + activateBrushInstance(ctx, BRUSH_INSTANCE); + setBrushRadius(ctx, PRIMARY_PAINT_RADIUS_MM); + + // Flip active to segmentation 1 and paint: must land in seg1's labelmap. + segmentation.activeSegmentation.setActiveSegmentation( + ctx.viewportId, + seg1.segmentationId + ); + expect( + segmentation.activeSegmentation.getActiveSegmentation(ctx.viewportId) + ?.segmentationId + ).toBe(seg1.segmentationId); + + await paintAndWaitForDataModified( + ctx, + PRIMARY_PAINT_CANVAS, + seg1.segmentationId + ); + + const seg1Slice0 = getLabelmapImage(seg1.labelmapImageIds[0]); + const seg2Slice0 = getLabelmapImage(seg2.labelmapImageIds[0]); + + expect(countVoxelsMatching(seg1Slice0, (v) => v !== 0)).toBeGreaterThan(0); + expect(countVoxelsMatching(seg2Slice0, (v) => v !== 0)).toBe(0); + + // Flip active back to segmentation 2 and paint elsewhere: must land in + // seg2's labelmap, leaving seg1 untouched. + segmentation.activeSegmentation.setActiveSegmentation( + ctx.viewportId, + seg2.segmentationId + ); + expect( + segmentation.activeSegmentation.getActiveSegmentation(ctx.viewportId) + ?.segmentationId + ).toBe(seg2.segmentationId); + + const seg2WorldTarget: CoreTypes.Point3 = [50, 15, 0]; + const seg2Canvas = ctx.viewport.worldToCanvas(seg2WorldTarget); + const seg2CanvasPoint: [number, number] = [ + Math.round(seg2Canvas[0]), + Math.round(seg2Canvas[1]), + ]; + + const seg1CountBeforeSecondPaint = countVoxelsMatching( + seg1Slice0, + (v) => v !== 0 + ); + + await paintAndWaitForDataModified( + ctx, + seg2CanvasPoint, + seg2.segmentationId + ); + + expect(countVoxelsMatching(seg2Slice0, (v) => v !== 0)).toBeGreaterThan(0); + expect(countVoxelsMatching(seg1Slice0, (v) => v !== 0)).toBe( + seg1CountBeforeSecondPaint + ); + }); + + test('visibility round trip: setSegmentationRepresentationVisibility / getSegmentationRepresentationVisibility', async () => { + const ctx = await setupSegmentationHarness(); + active = ctx; + + const { segmentationId } = await createLabelmapSegmentation( + ctx, + 'vitest-seg-visibility' + ); + + const specifier = { segmentationId, type: LABELMAP }; + + // Default visibility must be true (a freshly added representation is + // visible by default -- see SegmentationStateManager.addDefaultSegmentationRepresentation). + expect( + segmentation.config.visibility.getSegmentationRepresentationVisibility( + ctx.viewportId, + specifier + ) + ).toBe(true); + + segmentation.config.visibility.setSegmentationRepresentationVisibility( + ctx.viewportId, + specifier, + false + ); + expect( + segmentation.config.visibility.getSegmentationRepresentationVisibility( + ctx.viewportId, + specifier + ) + ).toBe(false); + + segmentation.config.visibility.setSegmentationRepresentationVisibility( + ctx.viewportId, + specifier, + true + ); + expect( + segmentation.config.visibility.getSegmentationRepresentationVisibility( + ctx.viewportId, + specifier + ) + ).toBe(true); + }); + + test('events contract: ADDED/MODIFIED ordering, DATA_MODIFIED detail, REMOVED isolation', async () => { + const ctx = await setupSegmentationHarness(); + active = ctx; + + const seg1Id = 'vitest-seg-events-1'; + const seg2Id = 'vitest-seg-events-2'; + + // Local ordering recorder: records event TYPES only (order is all that + // matters here), attached directly to eventTarget -- the confirmed + // dispatch target for every segmentation event (see file header and + // stateManagement/segmentation/events/trigger*.ts). + const order: string[] = []; + const trackedTypes = [ + ToolsEvents.SEGMENTATION_ADDED, + ToolsEvents.SEGMENTATION_MODIFIED, + ToolsEvents.SEGMENTATION_REMOVED, + ]; + const onTracked = (evt: Event) => order.push(evt.type); + trackedTypes.forEach((type) => eventTarget.addEventListener(type, onTracked)); + + try { + const seg1 = await createLabelmapSegmentation(ctx, seg1Id); + const seg2 = await createLabelmapSegmentation(ctx, seg2Id); + + // addSegmentations triggers SEGMENTATION_ADDED (from + // SegmentationStateManager.addSegmentation) followed synchronously by + // SEGMENTATION_MODIFIED (from addSegmentations.ts itself) for each + // segmentation -- assert ADDED precedes MODIFIED for segmentation 1. + const addedIndex = order.indexOf(ToolsEvents.SEGMENTATION_ADDED); + const modifiedIndex = order.indexOf(ToolsEvents.SEGMENTATION_MODIFIED); + expect(addedIndex).toBeGreaterThanOrEqual(0); + expect(modifiedIndex).toBeGreaterThan(addedIndex); + + expect(order.filter((t) => t === ToolsEvents.SEGMENTATION_ADDED).length).toBe( + 2 + ); + + // One paint stroke fires at least one DATA_MODIFIED whose detail + // carries the segmentationId. Adding segmentation 2 after segmentation + // 1 made segmentation 2 the active one (see the active-segmentation + // bookkeeping test's finding), so segmentation 1 is explicitly + // reactivated here -- otherwise the brush would paint into + // segmentation 2 and this wait would time out. + segmentation.activeSegmentation.setActiveSegmentation( + ctx.viewportId, + seg1.segmentationId + ); + activateBrushInstance(ctx, BRUSH_INSTANCE); + setBrushRadius(ctx, PRIMARY_PAINT_RADIUS_MM); + + const dataModifiedEvent = await paintAndWaitForDataModified( + ctx, + PRIMARY_PAINT_CANVAS, + seg1.segmentationId + ); + expect(dataModifiedEvent.detail.segmentationId).toBe(seg1.segmentationId); + + // removeSegmentation fires REMOVED and getSegmentation returns + // undefined afterwards; segmentation 2 and its representation survive. + const removed = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_REMOVED + ); + segmentation.removeSegmentation(seg1.segmentationId); + await removed; + + expect(order).toContain(ToolsEvents.SEGMENTATION_REMOVED); + expect(segmentation.state.getSegmentation(seg1.segmentationId)).toBeUndefined(); + expect(segmentation.state.getSegmentation(seg2.segmentationId)).toBeDefined(); + expect( + segmentation.state.getSegmentationRepresentations(ctx.viewportId, { + segmentationId: seg2.segmentationId, + }).length + ).toBe(1); + } finally { + trackedTypes.forEach((type) => + eventTarget.removeEventListener(type, onTracked) + ); + } + }); + + // Genuine engine finding, not a test mistake: Events.SEGMENTATION_REPRESENTATION_ADDED + // is declared in packages/tools/src/enums/Events.ts and is subscribed to + // internally in packages/tools/src/init.ts (lines ~168, ~230), but no + // production code path anywhere in packages/tools/src ever triggers it -- + // there is no `triggerEvent(..., Events.SEGMENTATION_REPRESENTATION_ADDED, + // ...)` call in the entire source tree. Adding a segmentation representation + // to a viewport instead triggers SEGMENTATION_REPRESENTATION_MODIFIED (from + // SegmentationStateManager.addSegmentationRepresentation) and + // SEGMENTATION_MODIFIED (from internalAddSegmentationRepresentation). + // Observed: REPRESENTATION_ADDED never recorded. Expected (per the plan and + // the enum's own name): it should fire once when a representation is first + // added to a viewport. + test.fails( + 'events contract: SEGMENTATION_REPRESENTATION_ADDED never fires (engine bug)', + async () => { + const ctx = await setupSegmentationHarness(); + active = ctx; + + // Settle immediately (via .then's two handlers, attached in the same + // microtask the promise is created in) rather than awaiting the raw + // promise directly. createLabelmapSegmentation's own render wait can + // take several seconds in this environment (see file header), which is + // longer than this wait's 2000ms timeout -- if the timeout's rejection + // fires while control is still inside that other await, Vitest's + // browser runtime flags it as an unhandled rejection (a real error, + // separate from -- and reported even though -- this test's expected + // failure) before this function ever reaches a bare `await + // representationAdded`. Attaching both handlers up front avoids that. + const representationAddedSettled: Promise = + waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_REPRESENTATION_ADDED, + { timeoutMs: 2000 } + ).then( + () => undefined, + (err: Error) => err + ); + + await createLabelmapSegmentation(ctx, 'vitest-seg-representation-added'); + + const settledError = await representationAddedSettled; + if (settledError) { + throw settledError; + } + } + ); + + test('exact statistics on synthetic data: mean/min/max/voxelCount inside the bar', async () => { + const ctx = await setupSegmentationHarness(); + active = ctx; + + const { segmentationId, labelmapImageIds } = await createLabelmapSegmentation( + ctx, + 'vitest-seg-statistics' + ); + + activateBrushInstance(ctx, BRUSH_INSTANCE); + // Small brush, fully inside the bar (world x in [20, 25); center 22.5, + // radius 1.5 => x in [21, 24], matching the interior already proven safe + // by toolMeasurements.browser.test.ts's RectangleROI statistics test). + const STATS_RADIUS_MM = 1.5; + setBrushRadius(ctx, STATS_RADIUS_MM); + + const statsWorldTarget: CoreTypes.Point3 = [22.5, 30, 0]; + const statsCanvas = ctx.viewport.worldToCanvas(statsWorldTarget); + const statsCanvasPoint: [number, number] = [ + Math.round(statsCanvas[0]), + Math.round(statsCanvas[1]), + ]; + + await paintAndWaitForDataModified(ctx, statsCanvasPoint, segmentationId); + + const slice0Image = getLabelmapImage(labelmapImageIds[0]); + const activeSegmentIndex = segmentation.segmentIndex.getActiveSegmentIndex( + segmentationId + ); + const expectedVoxelCount = countVoxelsMatching( + slice0Image, + (v) => v === activeSegmentIndex + ); + expect(expectedVoxelCount).toBeGreaterThan(0); + + const TIMEOUT_MS = 8000; + // getStatistics's return type is a union with the per-segment-index-keyed + // shape (only possible when `segmentIndices` is an array in 'individual' + // mode); passing a single number with the default 'collective' mode + // always yields the flat NamedStatistics branch actually asserted below. + let stats: ToolsTypes.NamedStatistics; + + try { + stats = (await Promise.race([ + segmentationUtils.getStatistics({ + segmentationId, + segmentIndices: activeSegmentIndex, + }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('getStatistics timed out (worker did not resolve)')), + TIMEOUT_MS + ) + ), + ])) as ToolsTypes.NamedStatistics; + } catch (error) { + // Per the plan: if the worker-based statistics path cannot resolve + // under vitest browser mode, this single test is timeboxed and its + // failure mode is documented here rather than hanging the suite. See + // the final report for whether this branch was actually taken. + throw new Error( + `utilities.segmentation.getStatistics did not resolve within ${TIMEOUT_MS}ms: ${ + (error as Error).message + }` + ); + } + + expect(stats.mean.value).toBe(255); + expect(stats.min.value).toBe(255); + expect(stats.max.value).toBe(255); + expect(stats.count.value).toBe(expectedVoxelCount); + }); +}); diff --git a/tests/vitest-browser/synchronizers.browser.test.ts b/tests/vitest-browser/synchronizers.browser.test.ts new file mode 100644 index 0000000000..c37697e272 --- /dev/null +++ b/tests/vitest-browser/synchronizers.browser.test.ts @@ -0,0 +1,708 @@ +// State-based tests pinning the `@cornerstonejs/tools` synchronizer contracts +// (`createZoomPanSynchronizer`, `createVOISynchronizer`, +// `createImageSliceSynchronizer`, `createCameraPositionSynchronizer`, plus +// the `SynchronizerManager`/`Synchronizer` API surface) as pure cross-viewport +// state equality on GenericViewports (`PlanarViewport`, `ViewportType.PLANAR_NEXT`). +// +// Black-box rule: assertions only ever touch public `@cornerstonejs/tools` and +// `@cornerstonejs/core` exports, DOM state, and events -- never +// `packages/tools/src/**` / `packages/core/src/**` deep imports. Reading +// source (done ahead of writing this file) informed HOW the callbacks behave +// so genuine engine bugs could be pinned precisely; it is not used to assert +// against private state here. +// +// Two (or three) independent PlanarViewport contexts are used per test: `a` +// comes from the frozen harness's `createPlanarViewport()` (owns the fake +// image loader + metadata provider + `useGenericViewport` config flag and +// their teardown); `b`/`c` are attached via the local `attachSharedViewport` +// helper below, which mounts the SAME `displaySetId` (and therefore the same +// imageIds / FrameOfReferenceUID) that `a` already registered, mirroring the +// proven "cross-viewport reference" pattern in +// planarInvariants.browser.test.ts. This -- rather than each viewport +// registering its own independent fake stack -- is what the plan calls "SAME +// fake stack registration so they share frameOfReferenceUID", and it matters +// beyond just the FrameOfReferenceUID match: the CameraPosition synchronizer +// copies a `ViewReference` whose `dataId` must resolve to a mounted display +// set on the TARGET viewport too, which only holds when the id is literally +// shared. +// +// `attachSharedViewport` ALSO deliberately reuses `a`'s own RenderingEngine +// instance rather than creating a second one (multiple viewports on one +// engine, the common single-engine multi-viewport app topology -- e.g. a +// layout grid). This turned out to be load-bearing, not just convenient: the +// tools-side callbacks for both `createZoomPanSynchronizer` +// (packages/tools/src/synchronizers/callbacks/zoomPanSyncCallback.ts) and +// `createImageSliceSynchronizer` +// (packages/tools/src/synchronizers/callbacks/imageSliceSyncCallback.ts) +// resolve the SOURCE viewport via +// `getRenderingEngine(targetViewport.renderingEngineId).getViewport(sourceViewport.viewportId)` +// -- i.e. they look up the source viewport's id inside the TARGET's +// rendering engine, silently returning `undefined` (and then throwing on the +// next property access) whenever source and target live on different +// engines. This is a genuine, separate engine-topology requirement/bug +// documented in the tests below; using one shared engine here isolates the +// synchronizer contract tests from it so the other, GenericViewport-specific +// findings (documented per-test) are not masked by it. +import { afterEach, describe, expect, test } from 'vitest'; +import { + Enums, + utilities, + type PlanarViewport, + type Types, +} from '@cornerstonejs/core'; +import * as cornerstoneTools from '@cornerstonejs/tools'; +import { + captureViewportState, + createPlanarViewport, + recordEvents, + renderAndWait, + round6, + type PlanarViewportContext, +} from './harness'; + +const { Events, OrientationAxis, RenderBackend, ViewportType } = Enums; +const { SynchronizerManager, synchronizers } = cornerstoneTools; +const { + createZoomPanSynchronizer, + createVOISynchronizer, + createImageSliceSynchronizer, + createCameraPositionSynchronizer, +} = synchronizers; + +// --------------------------------------------------------------------------- +// Local setup helpers (this file's own -- the frozen harness is untouched) +// --------------------------------------------------------------------------- + +let idCounter = 0; +function uniqueId(prefix: string): string { + idCounter += 1; + return `${prefix}-${idCounter}-${utilities.uuidv4()}`; +} + +function toViewportId(ctx: PlanarViewportContext): Types.IViewportId { + return { + renderingEngineId: ctx.renderingEngine.id, + viewportId: ctx.viewportId, + }; +} + +/** + * Attaches a second (or third) PlanarViewport to the SAME displaySetId that + * `base` already registered via the harness's `createPlanarViewport()` -- + * AND to `base`'s own RenderingEngine instance (see the file-header comment + * for why sharing the engine matters, not just the display set) -- so all + * viewports resolve identical imageIds / FrameOfReferenceUID / dataId / + * renderingEngineId without registering a second fake stack or a second + * engine. Mirrors the construction inlined in planarInvariants.browser.test.ts's + * "cross-viewport reference" test, generalized to reuse the engine too. + * Deliberately does NOT touch the fake image loader, metadata provider, or + * `useGenericViewport` config flag -- `base`'s own harness cleanup owns those. + */ +async function attachSharedViewport( + base: PlanarViewportContext, + label: string +): Promise { + const viewportId = uniqueId(`vitest-sync-${label}-viewport`); + const { renderingEngine } = base; + const element = document.createElement('div'); + element.dataset.testid = `sync-harness-${label}`; + element.style.width = '400px'; + element.style.height = '400px'; + document.body.appendChild(element); + + renderingEngine.enableElement({ + viewportId, + type: ViewportType.PLANAR_NEXT, + element, + defaultOptions: { + orientation: OrientationAxis.AXIAL, + }, + }); + + const viewport = renderingEngine.getViewport(viewportId); + + await viewport.setDisplaySets({ + displaySetId: base.displaySetId, + options: { + orientation: OrientationAxis.AXIAL, + renderBackend: RenderBackend.GPU, + }, + }); + + await renderAndWait(element, viewport); + + let cleanedUp = false; + const cleanup = (): void => { + if (cleanedUp) { + return; + } + cleanedUp = true; + + // Only disable THIS viewport, never destroy the engine -- it is shared + // with `base` (and any sibling attached viewports), whose own cleanup + // owns the engine's lifetime. + try { + renderingEngine.disableElement(viewportId); + } catch { + // Engine may already be destroyed by `base`'s cleanup if teardown + // order was inverted -- not an error for this viewport's own cleanup. + } + + if (element.parentNode) { + element.parentNode.removeChild(element); + } + }; + + return { + viewport, + element, + renderingEngine, + imageIds: base.imageIds, + displaySetId: base.displaySetId, + viewportId, + cleanup, + }; +} + +let cleanups: Array<() => void> = []; +function track(ctx: PlanarViewportContext): PlanarViewportContext { + cleanups.push(() => ctx.cleanup()); + return ctx; +} + +const synchronizerIds: string[] = []; +function trackSynchronizer(id: string): string { + synchronizerIds.push(id); + return id; +} + +afterEach(() => { + while (synchronizerIds.length) { + const id = synchronizerIds.pop(); + try { + SynchronizerManager.destroySynchronizer(id as string); + } catch { + // Already destroyed by the test itself -- not an error. + } + } + + // Belt-and-suspenders: guarantees no synchronizer from a failed test can + // leak listeners into the next one, per the "run twice in a row" no-leakage + // requirement. + try { + SynchronizerManager.destroy(); + } catch { + // Nothing to destroy -- not an error. + } + + while (cleanups.length) { + const cleanup = cleanups.pop(); + + try { + cleanup?.(); + } catch { + // Best-effort cleanup; a failure here must not mask the test's own + // pass/fail result. + } + } + + document.body.innerHTML = ''; +}); + +async function setupPair( + label = 'pair' +): Promise<{ a: PlanarViewportContext; b: PlanarViewportContext }> { + const a = track(await createPlanarViewport()); + const b = track(await attachSharedViewport(a, `${label}-b`)); + return { a, b }; +} + +async function setupTriple(): Promise<{ + a: PlanarViewportContext; + b: PlanarViewportContext; + c: PlanarViewportContext; +}> { + const a = track(await createPlanarViewport()); + const b = track(await attachSharedViewport(a, 'triple-b')); + const c = track(await attachSharedViewport(a, 'triple-c')); + return { a, b, c }; +} + +function waitForEventOn( + target: EventTarget, + type: string, + timeoutMs = 5000 +): Promise { + return new Promise((resolve, reject) => { + const onEvent = (evt: Event) => { + clearTimeout(timer); + resolve(evt as CustomEvent); + }; + + const timer = setTimeout(() => { + target.removeEventListener(type, onEvent); + reject(new Error(`waitForEventOn: timed out waiting for "${type}"`)); + }, timeoutMs); + + target.addEventListener(type, onEvent, { once: true }); + }); +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function doubleRAF(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); +} + +// A generous-but-bounded window used ONLY for negative-result assertions +// ("this must NOT have propagated") where there is no positive event to wait +// on. Per shared-context rule 6/plan wording (test 7's own "500ms of +// quiescence-waiting"), a fixed wait is the accepted pattern for proving an +// absence of change; every use below is commented at the call site. +const NO_PROPAGATION_WAIT_MS = 400; + +describe('synchronizers', () => { + // ------------------------------------------------------------------------- + // 1. ZoomPan synchronizer + // ------------------------------------------------------------------------- + describe('ZoomPan synchronizer', () => { + test('syncs zoom and pan bidirectionally; a non-member viewport is untouched', async () => { + const { a, b, c } = await setupTriple(); + const syncId = trackSynchronizer(uniqueId('zoom-pan-sync')); + const sync = createZoomPanSynchronizer(syncId); + sync.add(toViewportId(a)); + sync.add(toViewportId(b)); + + const cZoomBefore = round6(c.viewport.getZoom()); + const cPanBefore = c.viewport.getPan().map(round6); + + const bZoomRendered = waitForEventOn(b.element, Events.IMAGE_RENDERED); + a.viewport.setZoom(2); + await bZoomRendered; + + expect(round6(b.viewport.getZoom())).toBe(round6(2)); + // C was never added to the synchronizer: it must be completely unaffected. + expect(round6(c.viewport.getZoom())).toBe(cZoomBefore); + expect(c.viewport.getPan().map(round6)).toEqual(cPanBefore); + + const bPanRendered = waitForEventOn(b.element, Events.IMAGE_RENDERED); + a.viewport.setPan([25, -10]); + await bPanRendered; + + // Compared against A's OWN post-set getPan() (not the raw [25, -10] + // input literal) with a 1e-3 epsilon per shared-context rule 6: A's own + // setPan -> getPan round-trips through the projection's canvas/world + // math and already carries ~1e-6-level floating point drift off the + // literal input (observed: getPan()[1] reads -10.000002, not exactly + // -10) independent of the synchronizer; the synchronizer's OWN + // contribution to that drift being negligible next to it is exactly + // what "equals A's exactly" should mean here. + const aPan = a.viewport.getPan(); + const bPan = b.viewport.getPan(); + expect(bPan[0]).toBeCloseTo(aPan[0], 3); + expect(bPan[1]).toBeCloseTo(aPan[1], 3); + // Still untouched after the pan change too. + expect(round6(c.viewport.getZoom())).toBe(cZoomBefore); + expect(c.viewport.getPan().map(round6)).toEqual(cPanBefore); + + // Reverse direction: `.add()` wires each viewport as BOTH source and + // target (it calls addTarget + addSource -- see + // packages/tools/src/store/SynchronizerManager/Synchronizer.ts), so the + // factory produces a bidirectional pair by construction; mutating B + // must propagate back to A. + const aRendered = waitForEventOn(a.element, Events.IMAGE_RENDERED); + b.viewport.setZoom(3); + await aRendered; + + expect(round6(a.viewport.getZoom())).toBe(round6(3)); + }); + }); + + // ------------------------------------------------------------------------- + // 2. VOI synchronizer + // ------------------------------------------------------------------------- + describe('VOI synchronizer', () => { + test('syncs voiRange from A to B exactly', async () => { + const { a, b } = await setupPair('voi'); + const syncId = trackSynchronizer(uniqueId('voi-sync')); + const sync = createVOISynchronizer(syncId, { + syncInvertState: true, + syncColormap: true, + }); + sync.add(toViewportId(a)); + sync.add(toViewportId(b)); + + const beforeRange = a.viewport.getDisplaySetPresentation( + a.displaySetId + )?.voiRange; + const targetRange = { lower: 5, upper: 220 }; + // Sanity check that the mutation below is an actual change, not a no-op. + expect(targetRange).not.toEqual(beforeRange); + + const voiOnB = waitForEventOn(b.element, Events.VOI_MODIFIED); + a.viewport.setDisplaySetPresentation(a.displaySetId, { + voiRange: targetRange, + }); + await voiOnB; + + const aRange = a.viewport.getDisplaySetPresentation(a.displaySetId) + ?.voiRange; + const bRange = b.viewport.getDisplaySetPresentation(b.displaySetId) + ?.voiRange; + + expect(bRange).toEqual(targetRange); + expect(bRange).toEqual(aRange); + }); + + // BUG (observed / root-cause traced in source): voiSyncCallback + // (packages/tools/src/synchronizers/callbacks/voiSyncCallback.ts) only + // copies `invert` onto the target when the VOI_MODIFIED event's + // `invertStateChanged` field is truthy. PlanarViewport's + // `notifyDataPresentationModified` + // (packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts) + // never includes `invertStateChanged` in the VOI_MODIFIED detail it + // triggers (only `range`, `volumeId`, `VOILUTFunction`, `invert`, + // `colormap`) -- so this branch is permanently dead for GenericViewports: + // B's invert flag never updates, regardless of the (default-true) + // `syncInvertState` option. + test.fails( + 'does NOT sync the invert flag even though syncInvertState defaults to true', + async () => { + const { a, b } = await setupPair('voi-invert'); + const syncId = trackSynchronizer(uniqueId('voi-invert-sync')); + const sync = createVOISynchronizer(syncId, { + syncInvertState: true, + syncColormap: true, + }); + sync.add(toViewportId(a)); + sync.add(toViewportId(b)); + + const beforeInvertB = b.viewport.getDisplaySetPresentation( + b.displaySetId + )?.invert; + expect(beforeInvertB).toBeFalsy(); + + const voiOnB = waitForEventOn(b.element, Events.VOI_MODIFIED); + a.viewport.setDisplaySetPresentation(a.displaySetId, { + voiRange: { lower: 1, upper: 2 }, + invert: true, + }); + await voiOnB; + + expect( + b.viewport.getDisplaySetPresentation(b.displaySetId)?.invert + ).toBe(true); + } + ); + }); + + // ------------------------------------------------------------------------- + // 3. ImageSlice synchronizer + // ------------------------------------------------------------------------- + describe('ImageSlice synchronizer', () => { + // BUG (observed / root-cause traced in source): imageSliceSyncCallback + // (packages/tools/src/synchronizers/callbacks/imageSliceSyncCallback.ts) + // calls `areViewportsCoplanar(sViewport, tViewport)` + // (packages/tools/src/synchronizers/callbacks/areViewportsCoplanar.ts), + // which calls `viewport.getCamera()` on both viewports. + // GenericViewport/PlanarViewport + // (packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts) + // has no public `getCamera()` method (only a protected + // `getCameraForEvent()`), so this throws + // "TypeError: sViewport.getCamera is not a function" inside the callback. + // This is the SAME root-cause class as the CircleROITool / + // getEllipseWorldCoordinates bug documented in + // annotationToolsMatrix.browser.test.ts, but the failure mode here is + // silent rather than an uncaught error: imageSliceSyncCallback is an + // `async function`, so the synchronous throw becomes a REJECTED Promise + // instead of a thrown exception; `Synchronizer.fireEvent` + // (packages/tools/src/store/SynchronizerManager/Synchronizer.ts) collects + // that promise and awaits it via `Promise.allSettled`, which fully + // swallows the rejection. STACK_NEW_IMAGE itself DOES fire correctly on + // the GenericViewport element (planarImageEvents.ts) -- the event wiring + // this synchronizer depends on is fine; only its own callback is broken + // for this viewport family. + test.fails( + 'does NOT follow the source slice change (silently swallowed callback error)', + async () => { + const { a, b } = await setupPair('slice'); + const syncId = trackSynchronizer(uniqueId('image-slice-sync')); + const sync = createImageSliceSynchronizer(syncId); + sync.add(toViewportId(a)); + sync.add(toViewportId(b)); + + expect(a.viewport.getSliceIndex()).toBe(0); + expect(b.viewport.getSliceIndex()).toBe(0); + + const stackNewImageOnA = waitForEventOn(a.element, Events.STACK_NEW_IMAGE); + await a.viewport.setImageIdIndex(3); + await stackNewImageOnA; + + // No event to wait on for a definitive negative result (the failure + // is a swallowed promise rejection, not an observable event) -- give + // the async callback a bounded window to (not) run. + await wait(NO_PROPAGATION_WAIT_MS); + + expect(b.viewport.getSliceIndex()).toBe(3); + } + ); + }); + + // ------------------------------------------------------------------------- + // 4. CameraPosition synchronizer + // ------------------------------------------------------------------------- + describe('CameraPosition synchronizer', () => { + test('syncs zoom, pan, and world probes via the copied view reference + presentation', async () => { + const { a, b } = await setupPair('camera'); + const syncId = trackSynchronizer(uniqueId('camera-position-sync')); + const sync = createCameraPositionSynchronizer(syncId); + sync.add(toViewportId(a)); + sync.add(toViewportId(b)); + + const rendered = waitForEventOn(b.element, Events.IMAGE_RENDERED); + a.viewport.setZoom(2); + a.viewport.setPan([12, -6]); + await rendered; + + const snapA = captureViewportState(a.viewport, a.displaySetId); + const snapB = captureViewportState(b.viewport, b.displaySetId); + + expect(snapB.core.zoom).toBe(snapA.core.zoom); + expect(snapB.core.pan).toEqual(snapA.core.pan); + expect(snapB.core.worldProbes).toEqual(snapA.core.worldProbes); + }); + + // GAP (observed / traced in source): cameraSyncCallback's GenericViewport + // branch (packages/tools/src/synchronizers/callbacks/cameraSyncCallback.ts) + // copies the source's ViewReference (spatial reference only -- carries no + // rotation; rotation lives on ViewPresentation, see + // packages/core/src/types/IViewport.ts) plus a zoom/pan-only + // ViewPresentationSelector (`ZOOM_PAN_SELECTOR = { pan: true, zoom: true }`). + // Rotation is therefore never transported to the target on + // GenericViewports, unlike the legacy `setCamera(camera)` path this + // synchronizer was built to replace (which carried the full camera, + // including viewUp/rotation, for non-Generic viewports). + test.fails( + 'does NOT sync rotation (ViewPresentation selector used by cameraSyncCallback excludes it)', + async () => { + const { a, b } = await setupPair('camera-rotation'); + const syncId = trackSynchronizer( + uniqueId('camera-position-rotation-sync') + ); + const sync = createCameraPositionSynchronizer(syncId); + sync.add(toViewportId(a)); + sync.add(toViewportId(b)); + + const rendered = waitForEventOn(b.element, Events.IMAGE_RENDERED); + a.viewport.setViewState({ rotation: 45 }); + await rendered; + + expect(round6(b.viewport.getRotation())).toBe(round6(45)); + } + ); + }); + + // ------------------------------------------------------------------------- + // 5. Synchronizer API surface + // ------------------------------------------------------------------------- + describe('Synchronizer API surface', () => { + test('getSynchronizer / getAllSynchronizers / destroySynchronizer', async () => { + const { a, b } = await setupPair('api-surface'); + const syncId = trackSynchronizer(uniqueId('api-surface-sync')); + const sync = createZoomPanSynchronizer(syncId); + sync.add(toViewportId(a)); + sync.add(toViewportId(b)); + + expect(SynchronizerManager.getSynchronizer(syncId)).toBe(sync); + expect( + SynchronizerManager.getAllSynchronizers().some((s) => s.id === syncId) + ).toBe(true); + + const rendered = waitForEventOn(b.element, Events.IMAGE_RENDERED); + a.viewport.setZoom(2); + await rendered; + expect(round6(b.viewport.getZoom())).toBe(round6(2)); + + SynchronizerManager.destroySynchronizer(syncId); + expect(SynchronizerManager.getSynchronizer(syncId)).toBeUndefined(); + expect( + SynchronizerManager.getAllSynchronizers().some((s) => s.id === syncId) + ).toBe(false); + + // Destroying the synchronizer must also stop syncing. + const zoomAfterDestroy = round6(b.viewport.getZoom()); + a.viewport.setZoom(3); + // No event to wait on for this negative result (destroy removes the + // listeners entirely, so no CAMERA_MODIFIED-driven callback -- and + // therefore no IMAGE_RENDERED -- is expected on B at all). + await wait(NO_PROPAGATION_WAIT_MS); + expect(round6(b.viewport.getZoom())).toBe(zoomAfterDestroy); + }); + + test('remove() stops syncing for just that viewport; re-add() resumes it', async () => { + const { a, b } = await setupPair('remove-readd'); + const syncId = trackSynchronizer(uniqueId('remove-readd-sync')); + const sync = createZoomPanSynchronizer(syncId); + sync.add(toViewportId(a)); + sync.add(toViewportId(b)); + + const firstRendered = waitForEventOn(b.element, Events.IMAGE_RENDERED); + a.viewport.setZoom(2); + await firstRendered; + expect(round6(b.viewport.getZoom())).toBe(round6(2)); + + sync.remove(toViewportId(b)); + + const zoomAfterRemove = round6(b.viewport.getZoom()); + a.viewport.setZoom(2.5); + // Negative result: B was removed as both source and target, so nothing + // should arrive; no event to wait on. + await wait(NO_PROPAGATION_WAIT_MS); + expect(round6(b.viewport.getZoom())).toBe(zoomAfterRemove); + + sync.add(toViewportId(b)); + + const resumedRendered = waitForEventOn(b.element, Events.IMAGE_RENDERED); + a.viewport.setZoom(3); + await resumedRendered; + expect(round6(b.viewport.getZoom())).toBe(round6(3)); + }); + + test('setEnabled(false) pauses syncing; re-enabling resumes it', async () => { + const { a, b } = await setupPair('enabled-toggle'); + const syncId = trackSynchronizer(uniqueId('enabled-toggle-sync')); + const sync = createZoomPanSynchronizer(syncId); + sync.add(toViewportId(a)); + sync.add(toViewportId(b)); + + sync.setEnabled(false); + + const zoomWhileDisabled = round6(b.viewport.getZoom()); + a.viewport.setZoom(2); + // Negative result: the synchronizer's listeners stay attached while + // disabled (only its internal `_enabled` flag flips -- see + // Synchronizer.isDisabled()/fireEvent), so there genuinely is no event + // to wait on; the callback is a documented no-op while disabled. + await wait(NO_PROPAGATION_WAIT_MS); + expect(round6(b.viewport.getZoom())).toBe(zoomWhileDisabled); + + sync.setEnabled(true); + + const rendered = waitForEventOn(b.element, Events.IMAGE_RENDERED); + a.viewport.setZoom(2.5); + await rendered; + expect(round6(b.viewport.getZoom())).toBe(round6(2.5)); + }); + }); + + // ------------------------------------------------------------------------- + // 6. Custom synchronizer callback contract + // ------------------------------------------------------------------------- + describe('Custom synchronizer callback contract', () => { + test('callback receives (synchronizer, sourceViewport, targetViewport, sourceEvent)', async () => { + const { a, b } = await setupPair('custom-callback'); + const syncId = trackSynchronizer(uniqueId('custom-callback-sync')); + + type CallArgs = [unknown, Types.IViewportId, Types.IViewportId, Event, unknown?]; + const calls: CallArgs[] = []; + + function callbackSpy( + synchronizerArg: unknown, + sourceViewportArg: Types.IViewportId, + targetViewportArg: Types.IViewportId, + sourceEventArg: Event, + optionsArg?: unknown + ): void { + calls.push([ + synchronizerArg, + sourceViewportArg, + targetViewportArg, + sourceEventArg, + optionsArg, + ]); + } + + const sync = SynchronizerManager.createSynchronizer( + syncId, + Events.CAMERA_MODIFIED, + callbackSpy + ); + sync.addSource(toViewportId(a)); + sync.addTarget(toViewportId(b)); + + // CAMERA_MODIFIED fires SYNCHRONOUSLY inside setZoom (GenericViewport's + // `modified()` calls `triggerCameraModifiedEvent` right after scheduling + // the render -- see + // packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts), + // and the custom callback here does no async work, so the call is + // observable immediately with no wait needed. + a.viewport.setZoom(2); + + expect(calls.length).toBe(1); + const [ + synchronizerArg, + sourceViewportArg, + targetViewportArg, + sourceEventArg, + ] = calls[0]; + + expect(synchronizerArg).toBe(sync); + expect(sourceViewportArg).toEqual({ + renderingEngineId: a.renderingEngine.id, + viewportId: a.viewportId, + }); + expect(targetViewportArg).toEqual({ + renderingEngineId: b.renderingEngine.id, + viewportId: b.viewportId, + }); + expect(sourceEventArg).toBeInstanceOf(Event); + expect((sourceEventArg as CustomEvent).type).toBe(Events.CAMERA_MODIFIED); + expect((sourceEventArg as CustomEvent).detail?.viewportId).toBe( + a.viewportId + ); + }); + }); + + // ------------------------------------------------------------------------- + // 7. No sync storms / feedback loops + // ------------------------------------------------------------------------- + describe('No sync storms', () => { + test('a single zoom change converges without oscillation or event storms', async () => { + const { a, b } = await setupPair('no-storm'); + const syncId = trackSynchronizer(uniqueId('no-storm-sync')); + const sync = createZoomPanSynchronizer(syncId); + sync.add(toViewportId(a)); + sync.add(toViewportId(b)); + + const recorderA = recordEvents(a.element, [Events.IMAGE_RENDERED]); + const recorderB = recordEvents(b.element, [Events.IMAGE_RENDERED]); + + const bRendered = waitForEventOn(b.element, Events.IMAGE_RENDERED); + a.viewport.setZoom(2); + await bRendered; + + // Quiescence window: give any secondary/feedback renders time to + // surface before counting. Per the plan's own wording for this test, + // this is a bounded negative-result wait (proving nothing further + // happens), not a positive event-driven one. + await wait(500); + + expect(recorderA.count(Events.IMAGE_RENDERED)).toBeLessThanOrEqual(3); + expect(recorderB.count(Events.IMAGE_RENDERED)).toBeLessThanOrEqual(3); + + const zoomA1 = round6(a.viewport.getZoom()); + const zoomB1 = round6(b.viewport.getZoom()); + await doubleRAF(); + const zoomA2 = round6(a.viewport.getZoom()); + const zoomB2 = round6(b.viewport.getZoom()); + + expect(zoomA2).toBe(zoomA1); + expect(zoomB2).toBe(zoomB1); + expect(zoomB1).toBe(round6(2)); + }); + }); +}); diff --git a/tests/vitest-browser/toolGroupAndManipulation.browser.test.ts b/tests/vitest-browser/toolGroupAndManipulation.browser.test.ts new file mode 100644 index 0000000000..c2d854518c --- /dev/null +++ b/tests/vitest-browser/toolGroupAndManipulation.browser.test.ts @@ -0,0 +1,783 @@ +// State-based tests covering two contract areas of @cornerstonejs/tools against +// the GenericViewport (PLANAR_NEXT) harness: +// +// A. ToolGroupManager / tool-mode state machine as observable public state +// and events (createToolGroup/getToolGroup/destroyToolGroup, mode +// transitions, binding exclusivity, multi-viewport tool groups). +// B. Manipulation tools (Pan, Zoom, WindowLevel, StackScroll) driven by +// synthesized pointer/wheel input, asserted against exact viewport-state +// values derived from the same canvas points that were dispatched. +// +// Black-box rule: only public exports of @cornerstonejs/tools and +// @cornerstonejs/core are imported; no packages/tools/src/** deep imports, no +// underscore-prefixed field access. +// +// Event targets (verified against source, not guessed -- see report): +// - TOOL_MODE_CHANGED / TOOL_ACTIVATED fire on the core `eventTarget` +// singleton (ToolGroup.ts's setToolActive/setToolPassive/setToolEnabled/ +// setToolDisabled all call `triggerEvent(eventTarget, ...)`), never on the +// viewport element. Verified empirically in the first A2 test below by +// recording on both targets simultaneously. +// - VOI_MODIFIED fires on the viewport `element` (PlanarViewport's +// `notifyDataPresentationModified` calls `triggerEvent(this.element, ...)`). +// - IMAGE_RENDERED / STACK_NEW_IMAGE fire on the viewport `element`. +import { afterEach, describe, expect, test } from 'vitest'; +import { Enums, eventTarget, utilities } from '@cornerstonejs/core'; +import * as cornerstoneTools from '@cornerstonejs/tools'; +import type { Types as ToolsTypes } from '@cornerstonejs/tools'; +import { + createPlanarViewport, + mouseDrag, + mouseWheel, + recordEvents, + setupTools, + waitForAnnotationRendered, + type PlanarViewportContext, +} from './harness'; + +const { + ToolGroupManager, + PanTool, + ZoomTool, + WindowLevelTool, + StackScrollTool, + LengthTool, + annotation, + cancelActiveManipulations, +} = cornerstoneTools; +const { Events: ToolsEvents, MouseBindings, ToolModes } = cornerstoneTools.Enums; +const { Events: CoreEvents } = Enums; + +// --------------------------------------------------------------------------- +// Local helpers (spec-file-local; the frozen harness surface is untouched). +// --------------------------------------------------------------------------- + +/** + * Raw event recorder that keeps the FULL, unfiltered `detail` object. + * harness/recordEvents.ts intentionally keeps only an allowlist of primitive + * viewport-navigation detail keys (viewportId, range, etc.) and drops + * everything else -- which strips exactly the fields (`toolGroupId`, + * `toolName`, `mode`) this file needs to assert on ToolGroup lifecycle + * events. This local variant is unfiltered, for that narrow purpose only. + */ +function recordRawEvents(target: EventTarget, types: string[]) { + const events: Array<{ type: string; detail: unknown }> = []; + const listener = (evt: Event) => { + events.push({ type: evt.type, detail: (evt as CustomEvent).detail }); + }; + types.forEach((type) => target.addEventListener(type, listener)); + + return { + events, + last(type: string): { type: string; detail: unknown } | undefined { + const filtered = events.filter((event) => event.type === type); + return filtered[filtered.length - 1]; + }, + count(type: string): number { + return events.filter((event) => event.type === type).length; + }, + stop(): void { + types.forEach((type) => target.removeEventListener(type, listener)); + }, + }; +} + +function clientPointFromCanvasPoint( + element: HTMLElement, + canvasPoint: [number, number] +): [number, number] { + const rect = element.getBoundingClientRect(); + return [canvasPoint[0] + rect.left, canvasPoint[1] + rect.top]; +} + +/** + * Dispatches mousedown (element) + mousemove (document) WITHOUT a trailing + * mouseup, so the tool is left mid-draw/mid-drag. Mirrors the dispatch + * mechanics documented in harness/tools.ts (mousedown on element, move/up on + * document) minus the final mouseup -- needed for the + * cancelActiveManipulations test (B5), which must observe genuinely + * in-progress tool state. Not exposed by the frozen harness because every + * other consumer wants a completed gesture. + */ +function beginDragWithoutRelease( + element: HTMLElement, + fromCanvas: [number, number], + toCanvas: [number, number] +): void { + const from = [Math.round(fromCanvas[0]), Math.round(fromCanvas[1])] as [ + number, + number, + ]; + const to = [Math.round(toCanvas[0]), Math.round(toCanvas[1])] as [ + number, + number, + ]; + + const [downX, downY] = clientPointFromCanvasPoint(element, from); + element.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + view: window, + buttons: 1, + clientX: downX, + clientY: downY, + }) + ); + + const [moveX, moveY] = clientPointFromCanvasPoint(element, to); + document.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + view: window, + buttons: 1, + clientX: moveX, + clientY: moveY, + }) + ); +} + +function dispatchMouseUpOnDocument( + element: HTMLElement, + canvasPoint: [number, number] +): void { + const [clientX, clientY] = clientPointFromCanvasPoint( + element, + [Math.round(canvasPoint[0]), Math.round(canvasPoint[1])] + ); + document.dispatchEvent( + new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + view: window, + buttons: 0, + clientX, + clientY, + }) + ); +} + +interface TwoViewportToolGroupContext { + a: PlanarViewportContext; + b: PlanarViewportContext; + toolGroup: ToolsTypes.IToolGroup; + toolGroupId: string; + cleanup(): void; +} + +/** + * Builds a single tool group shared by TWO independent harness viewports + * (each with its own RenderingEngine). The frozen `setupTools` always + * creates exactly one viewport plus a dedicated tool group, so it cannot + * express the "two viewports, one tool group" scenarios in A4/A5; this is a + * spec-file-local composition of the same primitives `setupTools` uses + * internally (init/addTool BEFORE createPlanarViewport -- see + * harness/tools.ts's setupTools doc comment for why the ordering matters). + */ +async function setupTwoViewportToolGroup( + toolClass: { toolName: string } +): Promise { + cornerstoneTools.init(); + cornerstoneTools.addTool(toolClass); + + const a = await createPlanarViewport(); + const b = await createPlanarViewport(); + + const toolGroupId = `vitest-two-viewport-group-${utilities.uuidv4()}`; + const toolGroup = ToolGroupManager.createToolGroup( + toolGroupId + ) as ToolsTypes.IToolGroup; + + if (!toolGroup) { + a.cleanup(); + b.cleanup(); + cornerstoneTools.destroy(); + throw new Error(`Failed to create tool group ${toolGroupId}`); + } + + toolGroup.addTool(toolClass.toolName); + toolGroup.addViewport(a.viewportId, a.renderingEngine.id); + toolGroup.addViewport(b.viewportId, b.renderingEngine.id); + toolGroup.setToolActive(toolClass.toolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + + let cleanedUp = false; + const cleanup = (): void => { + if (cleanedUp) { + return; + } + cleanedUp = true; + + try { + cancelActiveManipulations(a.element); + } catch { + // Nothing active to cancel. + } + try { + cancelActiveManipulations(b.element); + } catch { + // Nothing active to cancel. + } + try { + ToolGroupManager.destroyToolGroup(toolGroupId); + } catch { + // Already destroyed. + } + try { + annotation.state.removeAllAnnotations(); + } catch { + // No annotation state to clear. + } + + a.cleanup(); + b.cleanup(); + + try { + cornerstoneTools.destroy(); + } catch { + // Already destroyed. + } + }; + + return { a, b, toolGroup, toolGroupId, cleanup }; +} + +// --------------------------------------------------------------------------- +// Shared per-test cleanup registration (mirrors toolMeasurements.browser.test.ts). +// --------------------------------------------------------------------------- + +let activeCleanup: (() => void) | null = null; + +afterEach(() => { + if (!activeCleanup) { + return; + } + const cleanup = activeCleanup; + activeCleanup = null; + cleanup(); +}); + +describe('ToolGroupManager state machine', () => { + test('createToolGroup / getToolGroup / getToolGroupForViewport / destroyToolGroup', async () => { + const ctx = await setupTools({ tools: [] }); + activeCleanup = ctx.cleanup; + const { toolGroup, toolGroupId, viewportId, renderingEngine } = ctx; + + // getToolGroup returns the SAME instance created by setupTools. + expect(ToolGroupManager.getToolGroup(toolGroupId)).toBe(toolGroup); + + // Contract (pinned from packages/tools/src/store/ToolGroupManager/createToolGroup.ts): + // calling createToolGroup with an id that already exists logs a + // console.warn and returns undefined. It does NOT return the existing + // group and does NOT throw. The existing group is left untouched. + const duplicateResult = ToolGroupManager.createToolGroup(toolGroupId); + expect(duplicateResult).toBeUndefined(); + expect(ToolGroupManager.getToolGroup(toolGroupId)).toBe(toolGroup); + + // getToolGroupForViewport resolves once addViewport has run (setupTools + // already did this as part of its bootstrap). + expect( + ToolGroupManager.getToolGroupForViewport(viewportId, renderingEngine.id) + ).toBe(toolGroup); + + // destroyToolGroup makes getToolGroup return undefined afterward. + ToolGroupManager.destroyToolGroup(toolGroupId); + expect(ToolGroupManager.getToolGroup(toolGroupId)).toBeUndefined(); + expect( + ToolGroupManager.getToolGroupForViewport(viewportId, renderingEngine.id) + ).toBeUndefined(); + }); + + test('mode transitions (Active -> Passive -> Enabled -> Disabled -> Active) are reflected in getToolOptions and fire TOOL_MODE_CHANGED/TOOL_ACTIVATED on eventTarget, not the element', async () => { + const ctx = await setupTools({ tools: [PanTool] }); + activeCleanup = ctx.cleanup; + const { element } = ctx; + const toolGroup = ctx.toolGroup as ToolsTypes.IToolGroup; + + // Verify the event target empirically: record on BOTH eventTarget and the + // element simultaneously and confirm only eventTarget ever sees these two + // tool-lifecycle events. + const onEventTarget = recordRawEvents(eventTarget, [ + ToolsEvents.TOOL_MODE_CHANGED, + ToolsEvents.TOOL_ACTIVATED, + ]); + const onElement = recordRawEvents(element, [ + ToolsEvents.TOOL_MODE_CHANGED, + ToolsEvents.TOOL_ACTIVATED, + ]); + + toolGroup.setToolActive(PanTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + expect(toolGroup.getToolOptions(PanTool.toolName).mode).toBe( + ToolModes.Active + ); + expect(onEventTarget.count(ToolsEvents.TOOL_ACTIVATED)).toBe(1); + expect(onEventTarget.count(ToolsEvents.TOOL_MODE_CHANGED)).toBe(1); + let lastModeChanged = onEventTarget.last(ToolsEvents.TOOL_MODE_CHANGED); + expect(lastModeChanged?.detail).toMatchObject({ + toolGroupId: ctx.toolGroupId, + toolName: PanTool.toolName, + mode: ToolModes.Active, + }); + + toolGroup.setToolPassive(PanTool.toolName); + expect(toolGroup.getToolOptions(PanTool.toolName).mode).toBe( + ToolModes.Passive + ); + expect(onEventTarget.count(ToolsEvents.TOOL_ACTIVATED)).toBe(1); // unchanged + expect(onEventTarget.count(ToolsEvents.TOOL_MODE_CHANGED)).toBe(2); + lastModeChanged = onEventTarget.last(ToolsEvents.TOOL_MODE_CHANGED); + expect(lastModeChanged?.detail).toMatchObject({ + toolGroupId: ctx.toolGroupId, + toolName: PanTool.toolName, + mode: ToolModes.Passive, + }); + + toolGroup.setToolEnabled(PanTool.toolName); + expect(toolGroup.getToolOptions(PanTool.toolName).mode).toBe( + ToolModes.Enabled + ); + expect(onEventTarget.count(ToolsEvents.TOOL_MODE_CHANGED)).toBe(3); + + toolGroup.setToolDisabled(PanTool.toolName); + expect(toolGroup.getToolOptions(PanTool.toolName).mode).toBe( + ToolModes.Disabled + ); + expect(onEventTarget.count(ToolsEvents.TOOL_MODE_CHANGED)).toBe(4); + + toolGroup.setToolActive(PanTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + expect(toolGroup.getToolOptions(PanTool.toolName).mode).toBe( + ToolModes.Active + ); + expect(onEventTarget.count(ToolsEvents.TOOL_ACTIVATED)).toBe(2); + expect(onEventTarget.count(ToolsEvents.TOOL_MODE_CHANGED)).toBe(5); + + // Pinned event-target contract: neither event ever reached the element. + expect(onElement.count(ToolsEvents.TOOL_MODE_CHANGED)).toBe(0); + expect(onElement.count(ToolsEvents.TOOL_ACTIVATED)).toBe(0); + + onEventTarget.stop(); + onElement.stop(); + }); + + test('binding exclusivity: activating a second tool on the same Primary binding does NOT demote the first (contradicts a naive "last-activated wins" assumption)', async () => { + const ctx = await setupTools({ tools: [PanTool, ZoomTool] }); + activeCleanup = ctx.cleanup; + const { viewport, element } = ctx; + const toolGroup = ctx.toolGroup as ToolsTypes.IToolGroup; + + toolGroup.setToolActive(PanTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + toolGroup.setToolActive(ZoomTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + + // Pinned contract (ToolGroup.ts setToolActive): setToolActive never looks + // up or demotes any other tool. Both remain reported as Active. + expect(toolGroup.getToolOptions(PanTool.toolName).mode).toBe( + ToolModes.Active + ); + expect(toolGroup.getToolOptions(ZoomTool.toolName).mode).toBe( + ToolModes.Active + ); + + // Pinned contract (getActivePrimaryMouseButtonTool -> + // Object.keys(toolOptions).find(...)): resolution is FIRST-INSERTED-KEY, + // not most-recently-activated. Pan's toolOptions entry was inserted first + // (its setToolActive call ran first), so it -- not Zoom -- is reported + // here even though Zoom was activated afterward on the identical binding. + expect(toolGroup.getActivePrimaryMouseButtonTool()).toBe(PanTool.toolName); + + // Behavioral consequence: getActiveToolForMouseEvent (the actual mouse + // dispatcher used by mouseDown/mouseDrag) applies the SAME + // Object.keys(toolOptions) first-match rule, so a real Primary-button + // drag is routed to Pan, not Zoom -- the opposite of "the most recently + // activated tool on a binding wins". + const pan0 = viewport.getPan(); + const zoom0 = viewport.getZoom(); + + const events = recordEvents(element, [CoreEvents.IMAGE_RENDERED]); + mouseDrag(element, [200, 200], [240, 230]); + await events.waitFor(CoreEvents.IMAGE_RENDERED); + + const pan1 = viewport.getPan(); + const zoom1 = viewport.getZoom(); + + expect(pan1[0] - pan0[0]).toBeCloseTo(40, 0); + expect(pan1[1] - pan0[1]).toBeCloseTo(30, 0); + expect(zoom1).toBeCloseTo(zoom0, 6); + }); + + test('two viewports in one tool group: a drag on A only changes A; removeViewports isolates B', async () => { + const group = await setupTwoViewportToolGroup(PanTool); + activeCleanup = group.cleanup; + const { a, b, toolGroup } = group; + + expect(toolGroup.getViewportIds().sort()).toEqual( + [a.viewportId, b.viewportId].sort() + ); + + const panA0 = a.viewport.getPan(); + const panB0 = b.viewport.getPan(); + + const eventsA = recordEvents(a.element, [CoreEvents.IMAGE_RENDERED]); + mouseDrag(a.element, [200, 200], [240, 230]); + await eventsA.waitFor(CoreEvents.IMAGE_RENDERED); + + const panA1 = a.viewport.getPan(); + const panB1 = b.viewport.getPan(); + + expect(panA1[0] - panA0[0]).toBeCloseTo(40, 0); + expect(panA1[1] - panA0[1]).toBeCloseTo(30, 0); + // B is untouched: tool scope is per-interaction/per-viewport. + expect(panB1[0]).toBeCloseTo(panB0[0], 6); + expect(panB1[1]).toBeCloseTo(panB0[1], 6); + + toolGroup.removeViewports(b.renderingEngine.id, b.viewportId); + expect(toolGroup.getViewportIds()).toEqual([a.viewportId]); + + // A drag on B now changes nothing on either viewport (B is no longer + // bound to any tool group, so no tool receives its mouse events; A was + // never touched by this gesture). + const panA2 = a.viewport.getPan(); + const panB2 = b.viewport.getPan(); + + mouseDrag(b.element, [200, 200], [240, 230]); + // No event to await (no tool is listening on B anymore); read state + // synchronously since dispatchEvent is synchronous and there is no + // listener left to produce any async state change. + const panA3 = a.viewport.getPan(); + const panB3 = b.viewport.getPan(); + + expect(panA3[0]).toBeCloseTo(panA2[0], 6); + expect(panA3[1]).toBeCloseTo(panA2[1], 6); + expect(panB3[0]).toBeCloseTo(panB2[0], 6); + expect(panB3[1]).toBeCloseTo(panB2[1], 6); + }); + + test('getToolGroupForViewport resolution follows addViewport/removeViewports for each of two viewports', async () => { + const group = await setupTwoViewportToolGroup(PanTool); + activeCleanup = group.cleanup; + const { a, b, toolGroup } = group; + + expect( + ToolGroupManager.getToolGroupForViewport(a.viewportId, a.renderingEngine.id) + ).toBe(toolGroup); + expect( + ToolGroupManager.getToolGroupForViewport(b.viewportId, b.renderingEngine.id) + ).toBe(toolGroup); + + toolGroup.removeViewports(b.renderingEngine.id, b.viewportId); + + expect( + ToolGroupManager.getToolGroupForViewport(a.viewportId, a.renderingEngine.id) + ).toBe(toolGroup); + expect( + ToolGroupManager.getToolGroupForViewport(b.viewportId, b.renderingEngine.id) + ).toBeUndefined(); + }); +}); + +describe('Manipulation tools with exact viewport-state values', () => { + test('PanTool: drag delta equals the canvas-pixel pan delta (getPan/setPan round-trip in canvas px)', async () => { + const ctx = await setupTools({ + tools: [PanTool], + activeTool: PanTool.toolName, + }); + activeCleanup = ctx.cleanup; + const { viewport, element } = ctx; + + const pan0 = viewport.getPan(); + + const events = recordEvents(element, [CoreEvents.IMAGE_RENDERED]); + mouseDrag(element, [200, 200], [240, 230]); + await events.waitFor(CoreEvents.IMAGE_RENDERED); + + const pan1 = viewport.getPan(); + + // Pinned contract: PanTool's _dragCallback does + // `viewport.setPan([pan[0]+deltaCanvas[0], pan[1]+deltaCanvas[1]])` once + // per mousemove step (packages/tools/src/tools/PanTool.ts); deltaCanvas is + // the INCREMENTAL step delta (current - last), so across the whole + // gesture the steps telescope exactly to the total dispatched delta. + // Tolerance is 1 canvas px per the plan (rounding of dispatched integer + // client coordinates). + expect(pan1[0] - pan0[0]).toBeCloseTo(40, 0); + expect(pan1[1] - pan0[1]).toBeCloseTo(30, 0); + }); + + test('ZoomTool: drag direction pin + exact reversibility', async () => { + const ctx = await setupTools({ + tools: [ZoomTool], + activeTool: ZoomTool.toolName, + }); + activeCleanup = ctx.cleanup; + const { viewport, element } = ctx; + + const zoom0 = viewport.getZoom(); + + // Small total delta (6 canvas px) spread over many steps (20): ZoomTool's + // per-step update is `zoom = zoom / max(1 - deltaY*zoomScale, 0.01)` + // (packages/tools/src/tools/ZoomTool.ts, _applyViewportZoomDelta) -- + // MULTIPLICATIVE, not additive, so unlike Pan/WindowLevel it does not + // telescope exactly across a multi-step drag; a big single-shot delta (as + // a naive reading of the plan's own example, e.g. a 80px drag over only 2 + // steps) accumulates a double-digit-percent round-trip error from pure + // discretization, which would fail the "within 1 percent" reversibility + // contract below through no fault of the tool. Keeping the per-step + // fraction small (each step here moves zoom by well under 1 percent) + // keeps the discretization error far below 1 percent while the 6px total + // still produces an unambiguous, clearly-measurable (~5-10 percent) + // direction signal. + const eventsUp = recordEvents(element, [CoreEvents.IMAGE_RENDERED]); + mouseDrag(element, [200, 200], [200, 194], { steps: 20 }); + await eventsUp.waitFor(CoreEvents.IMAGE_RENDERED); + + const zoomAfterUpDrag = viewport.getZoom(); + + // Pinned direction contract: dragging UP (decreasing canvas Y) DECREASES + // zoom with the tool's default configuration (invert: false) -- the + // opposite of the plan's example guess ("upward drag" -> increase). See + // _applyViewportZoomDelta: deltaY is negative for an upward drag, so + // k = deltaY*zoomScale is negative, denominator = 1-k > 1, and + // zoom/denominator < zoom. + expect(zoomAfterUpDrag).toBeLessThan(zoom0); + + const eventsDown = recordEvents(element, [CoreEvents.IMAGE_RENDERED]); + mouseDrag(element, [200, 194], [200, 200], { steps: 20 }); + await eventsDown.waitFor(CoreEvents.IMAGE_RENDERED); + + const zoomAfterReverse = viewport.getZoom(); + + // Reversibility contract: dragging the exact reverse path returns zoom to + // zoom0 within 1 percent. + const relativeError = Math.abs(zoomAfterReverse - zoom0) / zoom0; + expect(relativeError).toBeLessThan(0.01); + }); + + test('WindowLevelTool: horizontal drag widens the window, vertical drag shifts the center, VOI_MODIFIED detail matches stored presentation, and the round trip is reversible', async () => { + const ctx = await setupTools({ + tools: [WindowLevelTool], + activeTool: WindowLevelTool.toolName, + }); + activeCleanup = ctx.cleanup; + const { viewport, element, displaySetId } = ctx; + + // getDisplaySetPresentation(displaySetId).voiRange is only populated once + // something has explicitly written it (e.g. a WindowLevel drag calling + // setDisplaySetPresentation). Before the first drag, the presentation + // store only holds `{ visible: true }` (set by the mount-time + // setDefaultDataPresentation call) -- the mounted default VOI is + // observable only via getDefaultVOIRange, never through + // getDisplaySetPresentation, until it is written for real. This mirrors + // WindowLevelTool's own getViewportVOIProperties helper, which reads + // `dataPresentation?.voiRange ?? defaultVOIRange`. + function getVoiRange() { + const presentation = viewport.getDisplaySetPresentation(displaySetId) as + | { voiRange?: { lower: number; upper: number } } + | undefined; + const voiRange = + presentation?.voiRange ?? viewport.getDefaultVOIRange(displaySetId); + expect(voiRange).toBeDefined(); + return voiRange as { lower: number; upper: number }; + } + + const voiRange0 = { ...getVoiRange() }; + const width0 = voiRange0.upper - voiRange0.lower; + + // Horizontal-only drag (deltaY = 0): isolates the width-only change. + // Pinned contract (WindowLevelTool.getNewRange): wwDelta = + // deltaCanvas.x * multiplier, added directly to windowWidth -- a + // positive (rightward) horizontal delta increases window width. + const events1 = recordEvents(element, [ + CoreEvents.VOI_MODIFIED, + CoreEvents.IMAGE_RENDERED, + ]); + mouseDrag(element, [200, 200], [280, 200]); + await events1.waitFor(CoreEvents.IMAGE_RENDERED); + + const voiRange1 = { ...getVoiRange() }; + const width1 = voiRange1.upper - voiRange1.lower; + expect(width1).toBeGreaterThan(width0); + + // Exact-value contract: VOI_MODIFIED's event detail range equals the + // presentation's stored voiRange exactly (event/state consistency). + const voiModifiedEvents = events1.events.filter( + (event) => event.type === CoreEvents.VOI_MODIFIED + ); + expect(voiModifiedEvents.length).toBeGreaterThan(0); + const lastVoiModifiedDetail = voiModifiedEvents[ + voiModifiedEvents.length - 1 + ].detail as { range?: { lower: number; upper: number } }; + expect(lastVoiModifiedDetail.range).toEqual(voiRange1); + + // Vertical-only drag (deltaX = 0): isolates the center-only shift. + // Pinned contract: wcDelta = deltaCanvas.y * multiplier, added to + // windowCenter -- a positive (downward) vertical delta increases center, + // shifting both lower and upper by the same amount (width unchanged). + const events2 = recordEvents(element, [CoreEvents.IMAGE_RENDERED]); + mouseDrag(element, [200, 200], [200, 280]); + await events2.waitFor(CoreEvents.IMAGE_RENDERED); + + const voiRange2 = { ...getVoiRange() }; + const width2 = voiRange2.upper - voiRange2.lower; + const center1 = (voiRange1.lower + voiRange1.upper) / 2; + const center2 = (voiRange2.lower + voiRange2.upper) / 2; + + expect(center2).toBeGreaterThan(center1); + expect(width2).toBeCloseTo(width1, 6); + + // Reversibility: the exact reverse of both drags (order-independent, + // since each gesture only touched one of width/center) returns voiRange + // to voiRange0 within 1 unit. WindowLevelTool's ww/wc <-> lower/upper + // conversion (utilities.windowLevel.toWindowLevel/toLowHighRange) is an + // exact affine bijection, so unlike Zoom this telescopes precisely + // regardless of step count. + const events3 = recordEvents(element, [CoreEvents.IMAGE_RENDERED]); + mouseDrag(element, [280, 200], [200, 200]); + await events3.waitFor(CoreEvents.IMAGE_RENDERED); + + const events4 = recordEvents(element, [CoreEvents.IMAGE_RENDERED]); + mouseDrag(element, [200, 280], [200, 200]); + await events4.waitFor(CoreEvents.IMAGE_RENDERED); + + const voiRangeFinal = getVoiRange(); + expect(Math.abs(voiRangeFinal.lower - voiRange0.lower)).toBeLessThan(1); + expect(Math.abs(voiRangeFinal.upper - voiRange0.upper)).toBeLessThan(1); + }); + + test('StackScrollTool on the Wheel binding: exact slice arithmetic with clamping at the last slice', async () => { + const ctx = await setupTools({ tools: [StackScrollTool] }); + activeCleanup = ctx.cleanup; + const { viewport, element, toolGroup } = ctx; + const tg = toolGroup as ToolsTypes.IToolGroup; + + tg.setToolActive(StackScrollTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Wheel }], + }); + + expect(viewport.getSliceIndex()).toBe(0); + expect(viewport.getNumberOfSlices()).toBe(5); + + async function wheelAndWait(deltaY: number): Promise { + const events = recordEvents(element, [CoreEvents.IMAGE_RENDERED]); + mouseWheel(element, deltaY); + await events.waitFor(CoreEvents.IMAGE_RENDERED); + } + + // Empirically verified sign convention for this harness (see report): + // negative deltaY advances forward, positive deltaY scrolls backward. + // This is the OPPOSITE of a naive reading of + // packages/tools/test/StackScrollToolTool_test.js's `deltaY: 12` + // forward-scroll fixture, whose direction depends on the browser's + // legacy `wheelDelta` emulation (normalizeWheel.ts prefers + // `wheelDelta`/`wheelDeltaY` over `deltaY` when present); that legacy + // emulation differs between the Karma test's browser and this + // Playwright-driven headless Chromium, so the sign was confirmed by + // direct observation rather than assumed from the Karma fixture. + const FORWARD = -12; + const BACKWARD = 12; + + await wheelAndWait(FORWARD); + expect(viewport.getSliceIndex()).toBe(1); + + // Three more forward ticks reach the last slice (index 4 of 5 slices). + await wheelAndWait(FORWARD); + await wheelAndWait(FORWARD); + await wheelAndWait(FORWARD); + expect(viewport.getSliceIndex()).toBe(4); + + // One more forward tick past the end stays clamped at the last slice + // (PlanarViewport.setImageIdIndex clamps to [0, numberOfSlices-1]). + await wheelAndWait(FORWARD); + expect(viewport.getSliceIndex()).toBe(4); + + // Backward scrolls back exactly one slice. + await wheelAndWait(BACKWARD); + expect(viewport.getSliceIndex()).toBe(3); + }); + + test('cancelActiveManipulations leaves an in-progress annotation completed (not deleted) at its last dragged position, and subsequent drawing is unaffected', async () => { + const ctx = await setupTools({ + tools: [LengthTool], + activeTool: LengthTool.toolName, + }); + activeCleanup = ctx.cleanup; + const { viewport, element } = ctx; + + const p1: [number, number] = [100, 150]; + const pMidDrag: [number, number] = [180, 150]; + + beginDragWithoutRelease(element, p1, pMidDrag); + + // Mid-draw: the annotation already exists (LengthTool creates it on + // mousedown) but has not been completed by a mouseup yet. + const midDrawAnnotations = annotation.state.getAnnotations( + LengthTool.toolName, + element + ); + expect(midDrawAnnotations.length).toBe(1); + + const canceledUID = cancelActiveManipulations(element); + expect(canceledUID).toBeDefined(); + + dispatchMouseUpOnDocument(element, pMidDrag); + + // Pinned contract (packages/tools/src/store/cancelActiveManipulations.ts + // delegating to LengthTool.cancel): the in-progress annotation is NOT + // deleted. It is left in annotation state, completed (ANNOTATION_COMPLETED + // fires internally for a brand-new annotation), with its handles frozen + // at their last dragged position -- not reverted to the mousedown point. + const afterCancelAnnotations = annotation.state.getAnnotations( + LengthTool.toolName, + element + ); + expect(afterCancelAnnotations.length).toBe(1); + expect(afterCancelAnnotations[0].annotationUID).toBe(canceledUID); + + const expectedFrozenWorldPoint = viewport.canvasToWorld(pMidDrag); + const frozenHandlePoints = afterCancelAnnotations[0].data.handles.points; + // One handle sits at the drag's last position (pMidDrag); the other at + // the mousedown point (p1). Assert the moving handle landed at pMidDrag + // rather than assuming handle ordering. + const matchesFrozenPoint = frozenHandlePoints.some( + (point: [number, number, number]) => + Math.abs(point[0] - expectedFrozenWorldPoint[0]) < 1e-2 && + Math.abs(point[1] - expectedFrozenWorldPoint[1]) < 1e-2 && + Math.abs(point[2] - expectedFrozenWorldPoint[2]) < 1e-2 + ); + expect(matchesFrozenPoint).toBe(true); + + // Subsequent drawing works normally: a fresh, fully-completed gesture + // adds a SECOND annotation alongside the canceled one. + const p2a: [number, number] = [50, 50]; + const p2b: [number, number] = [50, 90]; + const rendered = waitForAnnotationRendered(element); + mouseDrag(element, p2a, p2b); + await rendered; + + const finalAnnotations = annotation.state.getAnnotations( + LengthTool.toolName, + element + ); + expect(finalAnnotations.length).toBe(2); + + const newAnnotation = finalAnnotations.find( + (candidate) => candidate.annotationUID !== canceledUID + ); + expect(newAnnotation).toBeDefined(); + const newHandlePoints = newAnnotation!.data.handles.points; + const expectedP2a = viewport.canvasToWorld(p2a); + const expectedP2b = viewport.canvasToWorld(p2b); + expect(newHandlePoints[0][0]).toBeCloseTo(expectedP2a[0], 2); + expect(newHandlePoints[0][1]).toBeCloseTo(expectedP2a[1], 2); + expect(newHandlePoints[1][0]).toBeCloseTo(expectedP2b[0], 2); + expect(newHandlePoints[1][1]).toBeCloseTo(expectedP2b[1], 2); + }); +}); diff --git a/tests/vitest-browser/toolMeasurements.browser.test.ts b/tests/vitest-browser/toolMeasurements.browser.test.ts new file mode 100644 index 0000000000..022319a35f --- /dev/null +++ b/tests/vitest-browser/toolMeasurements.browser.test.ts @@ -0,0 +1,408 @@ +// State-based tests driving @cornerstonejs/tools annotation tools with +// synthetic pointer events against the shared harness's deterministic fake +// stack, asserting on annotation STATE (world coordinates, cachedStats) +// against closed-form expected values. No pixels. +// +// Input synthesis mirrors packages/tools/test/LengthTool_test.js exactly: +// a native `mousedown` dispatched on the viewport element followed by a +// `mousemove` + `mouseup` dispatched on `document` (mirroring +// packages/tools/src/eventListeners/mouse/mouseDownListener.ts, which adds +// its move/up listeners to `document`, not the element), all carrying +// `buttons: 1` while the button is logically down. Coordinates are derived +// from the target canvas point plus `element.getBoundingClientRect()`, +// matching how `getMouseEventPoints` (same file) recovers canvas coordinates +// from `clientX`/`clientY`. Canvas points are rounded to integers before +// dispatch (same reason as the Karma tests' `createNormalizedMouseEvent`: +// client/page coordinates round-trip cleanly only at integer canvas pixels), +// and every expected value used in assertions is derived from the SAME +// rounded canvas point via `viewport.canvasToWorld`, so rounding cannot +// introduce a mismatch between what was dispatched and what is asserted. +import { afterEach, describe, expect, test } from 'vitest'; +import type { Types } from '@cornerstonejs/core'; +import * as cornerstoneTools from '@cornerstonejs/tools'; +import { + createPlanarViewport, + renderAndWait, + type PlanarViewportContext, +} from './harness'; + +const { LengthTool, RectangleROITool, ToolGroupManager, annotation } = + cornerstoneTools; +const { Events: ToolsEvents, MouseBindings } = cornerstoneTools.Enums; +const { filterAnnotationsForDisplay } = cornerstoneTools.utilities.planar; + +function round2(point: Types.Point2): [number, number] { + return [Math.round(point[0]), Math.round(point[1])]; +} + +function distance3(a: Types.Point3, b: Types.Point3): number { + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + const dz = a[2] - b[2]; + return Math.sqrt(dx * dx + dy * dy + dz * dz); +} + +function clientPointFromCanvasPoint( + element: HTMLDivElement, + canvasPoint: [number, number] +): [number, number] { + // Mirrors getMouseEventPoints' `_pagePointsToCanvasPoints`, which recovers + // the canvas point from clientX/clientY using the viewport element's (not + // the inner canvas') bounding rect -- inverted here to go canvas -> client. + const rect = element.getBoundingClientRect(); + return [canvasPoint[0] + rect.left, canvasPoint[1] + rect.top]; +} + +function dispatchMouseDown( + element: HTMLDivElement, + canvasPoint: [number, number] +): void { + const [clientX, clientY] = clientPointFromCanvasPoint(element, canvasPoint); + element.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + view: window, + buttons: 1, + clientX, + clientY, + }) + ); +} + +function dispatchMouseMove( + element: HTMLDivElement, + canvasPoint: [number, number] +): void { + const [clientX, clientY] = clientPointFromCanvasPoint(element, canvasPoint); + document.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + view: window, + buttons: 1, + clientX, + clientY, + }) + ); +} + +function dispatchMouseUp( + element: HTMLDivElement, + canvasPoint: [number, number] +): void { + const [clientX, clientY] = clientPointFromCanvasPoint(element, canvasPoint); + document.dispatchEvent( + new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + view: window, + buttons: 0, + clientX, + clientY, + }) + ); +} + +/** + * Draws an annotation via a synthetic pointer drag from p1 to p2 (canvas + * coordinates, rounded to integers -- see file header). Resolves once the + * tools' AnnotationRenderingEngine has completed a render pass following the + * draw: cachedStats are computed inside each tool's `renderAnnotation` + * (requestAnimationFrame-driven, see AnnotationRenderingEngine.ts), which + * fires strictly after ANNOTATION_COMPLETED/ANNOTATION_MODIFIED (both + * dispatched synchronously inside the mouse-up handler, before that RAF), so + * ANNOTATION_RENDERED is the only event that reliably gates cachedStats + * being populated. This matches what packages/tools/test/LengthTool_test.js + * actually waits on. + */ +async function drawAnnotationByDrag( + element: HTMLDivElement, + p1: [number, number], + p2: [number, number] +): Promise<{ p1: [number, number]; p2: [number, number] }> { + const roundedP1 = round2(p1); + const roundedP2 = round2(p2); + + const rendered = new Promise((resolve) => { + element.addEventListener( + ToolsEvents.ANNOTATION_RENDERED, + () => resolve(), + { once: true } + ); + }); + + dispatchMouseDown(element, roundedP1); + dispatchMouseMove(element, roundedP2); + dispatchMouseUp(element, roundedP2); + + await rendered; + + return { p1: roundedP1, p2: roundedP2 }; +} + +interface ToolTestSetup { + ctx: PlanarViewportContext; + toolGroupId: string; +} + +let active: ToolTestSetup | null = null; + +/** + * Boots @cornerstonejs/tools against a fresh harness viewport: init(), + * addTool, a dedicated tool group (one per test, keyed by the harness's + * unique viewportId so parallel afterEach failures cannot collide), the + * requested tool bound to the primary mouse button, and the viewport + * attached to the group. Mirrors the incantation from + * packages/tools/test/LengthTool_test.js / utils/test/testUtils.js + * `setupTestEnvironment`. + */ +async function setupToolTest( + toolClass: { toolName: string }, + createOpts?: Parameters[0] +): Promise { + // cornerstoneTools.init() MUST run before createPlanarViewport(): tools + // wires its mouse/keyboard listeners onto the viewport element from an + // ELEMENT_ENABLED listener registered by init() (see + // packages/tools/src/init.ts + store/addEnabledElement.ts). enableElement() + // (inside createPlanarViewport) fires ELEMENT_ENABLED synchronously, so + // initializing tools afterwards misses that event entirely and the element + // ends up with no tools event listeners at all (mousedown/mousemove + // dispatch silently does nothing). Mirrors utils/test/testUtils.js + // `setupTestEnvironment`, which always calls initTools() before any + // viewport is created. + cornerstoneTools.init(); + cornerstoneTools.addTool(toolClass); + + const ctx = await createPlanarViewport(createOpts); + + const toolGroupId = `vitest-tool-measurements:${ctx.viewportId}`; + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + + if (!toolGroup) { + ctx.cleanup(); + throw new Error(`Failed to create tool group ${toolGroupId}`); + } + + toolGroup.addTool(toolClass.toolName); + toolGroup.addViewport(ctx.viewportId, ctx.renderingEngine.id); + toolGroup.setToolActive(toolClass.toolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + + active = { ctx, toolGroupId }; + return active; +} + +afterEach(() => { + if (!active) { + return; + } + + const { ctx, toolGroupId } = active; + active = null; + + // Tear down tools state (tool group, global tool registry, event + // listeners, annotation manager) BEFORE the harness destroys the + // rendering engine/element, so tools' own element-scoped listener + // removal (mouseEventListeners.disable, etc.) still has a live element. + ToolGroupManager.destroyToolGroup(toolGroupId); + cornerstoneTools.destroy(); + ctx.cleanup(); +}); + +describe('toolMeasurements', () => { + test('Length tool measures exactly', async () => { + const { ctx } = await setupToolTest(LengthTool); + const { viewport, element } = ctx; + + const p1: [number, number] = [100, 200]; + const p2: [number, number] = [200, 200]; + + await drawAnnotationByDrag(element, p1, p2); + + const lengthAnnotations = annotation.state.getAnnotations( + LengthTool.toolName, + element + ); + + expect(lengthAnnotations).toBeDefined(); + expect(lengthAnnotations.length).toBe(1); + + const lengthAnnotation = lengthAnnotations[0]; + const expectedWorld1 = viewport.canvasToWorld(p1); + const expectedWorld2 = viewport.canvasToWorld(p2); + + const handlePoints = lengthAnnotation.data.handles.points; + expect(handlePoints.length).toBe(2); + + expect(handlePoints[0][0]).toBeCloseTo(expectedWorld1[0], 2); + expect(handlePoints[0][1]).toBeCloseTo(expectedWorld1[1], 2); + expect(handlePoints[0][2]).toBeCloseTo(expectedWorld1[2], 2); + expect(handlePoints[1][0]).toBeCloseTo(expectedWorld2[0], 2); + expect(handlePoints[1][1]).toBeCloseTo(expectedWorld2[1], 2); + expect(handlePoints[1][2]).toBeCloseTo(expectedWorld2[2], 2); + + const cachedStats = lengthAnnotation.data.cachedStats; + const targetIds = Object.keys(cachedStats); + expect(targetIds.length).toBe(1); + + const expectedLength = distance3(expectedWorld1, expectedWorld2); + expect(cachedStats[targetIds[0]].length).toBeCloseTo(expectedLength, 3); + }); + + test('annotation survives navigation state changes', async () => { + const { ctx } = await setupToolTest(LengthTool); + const { viewport, element } = ctx; + + const p1: [number, number] = [100, 200]; + const p2: [number, number] = [200, 200]; + + await drawAnnotationByDrag(element, p1, p2); + + const lengthAnnotations = annotation.state.getAnnotations( + LengthTool.toolName, + element + ); + expect(lengthAnnotations.length).toBe(1); + + const lengthAnnotation = lengthAnnotations[0]; + const preNavHandles = lengthAnnotation.data.handles.points.map( + (point: Types.Point3) => [...point] as Types.Point3 + ); + + viewport.setZoom(2); + viewport.setPan([15, 5]); + await renderAndWait(element, viewport); + + // Annotations are world-anchored: canvas projection changes with + // zoom/pan, but the stored WORLD coordinates must not. + const postNavHandles = lengthAnnotation.data.handles.points; + expect(postNavHandles.length).toBe(preNavHandles.length); + + for (let i = 0; i < preNavHandles.length; i++) { + expect(postNavHandles[i][0]).toBeCloseTo(preNavHandles[i][0], 3); + expect(postNavHandles[i][1]).toBeCloseTo(preNavHandles[i][1], 3); + expect(postNavHandles[i][2]).toBeCloseTo(preNavHandles[i][2], 3); + } + + expect(viewport.getZoom()).toBeCloseTo(2, 6); + }); + + test('length across slices is slice-bound', async () => { + const { ctx } = await setupToolTest(LengthTool); + const { viewport, element, imageIds } = ctx; + + expect(viewport.getCurrentImageIdIndex()).toBe(0); + + const p1: [number, number] = [100, 200]; + const p2: [number, number] = [200, 200]; + + await drawAnnotationByDrag(element, p1, p2); + + const allAnnotations = annotation.state.getAnnotations( + LengthTool.toolName, + element + ); + expect(allAnnotations.length).toBe(1); + + const drawnAnnotation = allAnnotations[0]; + expect(drawnAnnotation.metadata.referencedImageId).toBe(imageIds[0]); + + // Still on slice 0: the annotation must be filterable-in. + const filteredAtSlice0 = filterAnnotationsForDisplay( + viewport, + allAnnotations + ); + expect(filteredAtSlice0.map((a) => a.annotationUID)).toContain( + drawnAnnotation.annotationUID + ); + + // Navigate to slice 2: the slice-0 annotation must NOT be viewable here. + await viewport.setImageIdIndex(2); + expect(viewport.getCurrentImageIdIndex()).toBe(2); + + const filteredAtSlice2 = filterAnnotationsForDisplay( + viewport, + annotation.state.getAnnotations(LengthTool.toolName, element) + ); + expect(filteredAtSlice2.map((a) => a.annotationUID)).not.toContain( + drawnAnnotation.annotationUID + ); + + // Navigate back to slice 0: the annotation must be filterable-in again. + await viewport.setImageIdIndex(0); + expect(viewport.getCurrentImageIdIndex()).toBe(0); + + const filteredBackAtSlice0 = filterAnnotationsForDisplay( + viewport, + annotation.state.getAnnotations(LengthTool.toolName, element) + ); + expect(filteredBackAtSlice0.map((a) => a.annotationUID)).toContain( + drawnAnnotation.annotationUID + ); + }); + + describe('RectangleROI statistics (stretch)', () => { + // World geometry (see harness/fakeImageStack.ts, DEFAULT_OPTIONS): 64x64, + // 1mm spacing, identity orientation, imagePositionPatient=[0,0,sliceIndex] + // -- so voxel (column i, row j) on slice 0 sits at world [i, j, 0]. The + // vertical bar (pixel value 255) spans columns [20, 25); everything else + // on slice 0 is background (value 10). Canvas points are derived FROM + // these world targets via worldToCanvas (never hard-coded), each with a + // full 1mm margin inside the region so a canvas-point-rounding error of + // up to 0.5 canvas px (well under a world mm at this viewport's zoom) + // cannot cross into the neighboring region. + async function drawRectangleAndGetMean( + ctx: PlanarViewportContext, + worldP1: Types.Point3, + worldP2: Types.Point3 + ): Promise { + const { viewport, element } = ctx; + const canvasP1 = viewport.worldToCanvas(worldP1); + const canvasP2 = viewport.worldToCanvas(worldP2); + + await drawAnnotationByDrag( + element, + [canvasP1[0], canvasP1[1]], + [canvasP2[0], canvasP2[1]] + ); + + const roiAnnotations = annotation.state.getAnnotations( + RectangleROITool.toolName, + element + ); + expect(roiAnnotations.length).toBe(1); + + const cachedStats = roiAnnotations[0].data.cachedStats; + const targetIds = Object.keys(cachedStats); + expect(targetIds.length).toBe(1); + + return cachedStats[targetIds[0]].mean; + } + + test('rectangle entirely inside the bar has mean 255', async () => { + const { ctx } = await setupToolTest(RectangleROITool); + + const mean = await drawRectangleAndGetMean( + ctx, + [21, 10, 0], + [24, 50, 0] + ); + + expect(mean).toBe(255); + }); + + test('rectangle entirely in the background has mean 10', async () => { + const { ctx } = await setupToolTest(RectangleROITool); + + const mean = await drawRectangleAndGetMean( + ctx, + [40, 10, 0], + [50, 50, 0] + ); + + expect(mean).toBe(10); + }); + }); +}); diff --git a/tests/vitest-browser/undoRedoHistory.browser.test.ts b/tests/vitest-browser/undoRedoHistory.browser.test.ts new file mode 100644 index 0000000000..92c7eb6cca --- /dev/null +++ b/tests/vitest-browser/undoRedoHistory.browser.test.ts @@ -0,0 +1,673 @@ +// State-based tests pinning the undo/redo history contract exposed through +// `@cornerstonejs/core`'s `utilities.HistoryMemo.DefaultHistoryMemo` singleton +// and the `HISTORY_UNDO`/`HISTORY_REDO` events `@cornerstonejs/tools` fires +// around it. No pixels: every assertion reads labelmap scalar data (a public +// core `IImage`'s `voxelManager.getScalarData()`), annotation state, or event +// detail. +// +// Discovered facts (from packages/core/src/utilities/historyMemo/index.ts and +// packages/tools/src/tools/{base,segmentation}/*.ts -- read, not modified): +// +// - `DefaultHistoryMemo` is a ring buffer (`HistoryMemo`, default size 50) +// holding `Memo` items, each with an optional `id`/`operationType`. `push()` +// always clears the redo side (`redoAvailable = 0`) before writing, i.e. +// pushing a new item after an undo permanently truncates the redo branch -- +// there is no "redo re-applies stale item on top" bug, by construction. +// - `undo()`/`redo()` are fully SYNCHRONOUS: they run every affected memo's +// `restoreMemo()` (which itself synchronously calls +// `triggerSegmentationDataModified`/annotation state mutators) and then, if +// the memo has a truthy `id`, synchronously dispatch `HISTORY_UNDO`/ +// `HISTORY_REDO` on `eventTarget` -- the CORE `eventTarget` singleton, not +// the viewport element (see historyMemo/index.ts `dispatchHistoryEvent`). +// - `DefaultHistoryMemo` is a core-level singleton: `cornerstoneTools.destroy()` +// (packages/tools/src/init.ts) resets tool/annotation/segmentation state but +// never touches it. Tests below reset it explicitly via the `size` setter +// (the only public API that clears the ring) before/after every test to +// avoid cross-test leakage within this file. +// - Labelmap brush strokes ARE memo-integrated by default: `BrushTool`'s +// default `preview.enabled` is `false`, so each stroke commits synchronously +// on mouse-up (`doneEditMemo` -> `LabelmapMemo.commitMemo` -> push), with +// `operationType: 'labelmap'` and a uuid `id`. +// - Annotation creation-via-drag is ALSO memo-integrated by default: every +// annotation tool's `_dragCallback` calls `this.createMemo(element, +// annotation, { newAnnotation: true })` on the first drag step (see e.g. +// LengthTool.ts), which pushes an `operationType: 'annotation'` memo +// immediately (not deferred to mouse-up). That memo's `restoreMemo` is a +// pure toggle around a captured `deleting` flag: since it was pushed with +// `deleting: false`, the FIRST `undo()` call removes the just-drawn +// annotation entirely (not a partial rollback to a half-drawn state), and +// the following `redo()` re-adds it with the exact original `data` (a +// `safeStructuredClone`, so redo is byte-exact). This is pinned as test 8 +// below. +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { cache, eventTarget, imageLoader, utilities } from '@cornerstonejs/core'; +import type { Types } from '@cornerstonejs/core'; +import * as cornerstoneTools from '@cornerstonejs/tools'; +import { + mouseClick, + mouseDrag, + renderAndWait, + setupTools, + waitForAnnotationRendered, + waitForToolsEvent, + type ToolsContext, +} from './harness'; + +const { BrushTool, LengthTool, annotation, segmentation } = cornerstoneTools; +const { Events: ToolsEvents, SegmentationRepresentations } = + cornerstoneTools.Enums; +const { DefaultHistoryMemo } = utilities.HistoryMemo; + +// Captured once at module load so the reset below never hard-codes the +// library's current default ring size. +const DEFAULT_HISTORY_SIZE = DefaultHistoryMemo.size; + +/** + * The `size` setter is the only public API that clears `DefaultHistoryMemo`'s + * ring (see historyMemo/index.ts): re-assigning it re-initializes + * `position`/`redoAvailable`/`undoAvailable` unconditionally, even to the + * same value. Needed because this singleton outlives any one test/tool-group + * and `cornerstoneTools.destroy()` never touches it. + */ +function resetHistory(): void { + DefaultHistoryMemo.size = DEFAULT_HISTORY_SIZE; +} + +interface LabelmapHistoryContext extends ToolsContext { + segmentationId: string; + labelmapImageIds: string[]; +} + +/** + * Local labelmap-on-generic-stack setup helper, derived from the public + * incantation in packages/tools/examples/genericStackLabelmapSegmentation + * (and mirrored by the Playwright spec + * tests/genericViewport/genericStackLabelmapSegmentation.spec.ts): a stack + * PLANAR_NEXT viewport, one derived Uint8Array labelmap image per stack + * imageId, a single Labelmap segmentation registered and attached to the + * viewport, and BrushTool active with a small (world-mm) radius so several + * disjoint strokes fit inside the 64x64mm synthetic slice without + * overlapping. Kept local to this file (not imported from a sibling spec) per + * the plan's isolation requirement. + */ +async function setupLabelmapHistoryTest(): Promise { + resetHistory(); + + const ctx = await setupTools({ + tools: [BrushTool], + activeTool: BrushTool.toolName, + viewport: { width: 400, height: 400 }, + }); + + const labelmapImages = await imageLoader.createAndCacheDerivedLabelmapImages( + ctx.imageIds + ); + const labelmapImageIds = labelmapImages.map((image) => image.imageId); + const segmentationId = `vitest-history-seg-${utilities.uuidv4()}`; + + segmentation.addSegmentations([ + { + segmentationId, + representation: { + type: SegmentationRepresentations.Labelmap, + data: { imageIds: labelmapImageIds }, + }, + }, + ]); + + await segmentation.addSegmentationRepresentations(ctx.viewportId, [ + { segmentationId, type: SegmentationRepresentations.Labelmap }, + ]); + + // Default BrushTool brushSize is a 25mm WORLD-space radius (see + // circularCursor.ts composition) -- far too large for the 64x64mm + // synthetic slice to fit multiple disjoint strokes, so shrink it. + cornerstoneTools.utilities.segmentation.setBrushSizeForToolGroup( + ctx.toolGroupId, + 3 + ); + + await renderAndWait(ctx.element, ctx.viewport); + + return { ...ctx, segmentationId, labelmapImageIds }; +} + +function readSliceScalarData(imageId: string): Uint8Array { + const image = cache.getImage(imageId); + return image.voxelManager.getScalarData() as Uint8Array; +} + +/** index -> segment value, for byte-exact before/after comparisons. */ +function nonZeroMap(scalarData: Uint8Array): Map { + const map = new Map(); + for (let i = 0; i < scalarData.length; i++) { + if (scalarData[i] !== 0) { + map.set(i, scalarData[i]); + } + } + return map; +} + +function mapsEqual(a: Map, b: Map): boolean { + if (a.size !== b.size) { + return false; + } + for (const [key, value] of a) { + if (b.get(key) !== value) { + return false; + } + } + return true; +} + +function canvasPointForWorld( + viewport: LabelmapHistoryContext['viewport'], + worldXY: [number, number] +): [number, number] { + const canvas = viewport.worldToCanvas([worldXY[0], worldXY[1], 0]); + return [canvas[0], canvas[1]]; +} + +/** + * Paints one brush stroke (a single click -- BrushTool's default + * `preview.enabled: false` config applies the fill synchronously on + * mouse-up, see BrushTool.ts `_endCallback`) at the given WORLD xy on slice + * 0, waiting for the SEGMENTATION_DATA_MODIFIED it triggers. + */ +async function paintAt( + ctx: LabelmapHistoryContext, + worldXY: [number, number] +): Promise { + const canvasPoint = canvasPointForWorld(ctx.viewport, worldXY); + const modified = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + mouseClick(ctx.element, canvasPoint); + await modified; +} + +interface FullRecordedEvent { + type: string; + detail: unknown; +} + +/** + * Full-fidelity event recorder (unlike harness/recordEvents.ts, which only + * retains a fixed allowlist of shallow detail keys that does not include + * HISTORY_UNDO/REDO's `id`/`operationType`/`isUndo` or + * SEGMENTATION_DATA_MODIFIED's `segmentationId`). Local to this file. + */ +function recordFullEvents(target: EventTarget, types: string[]) { + const events: FullRecordedEvent[] = []; + const listener = (evt: Event) => { + events.push({ type: evt.type, detail: (evt as CustomEvent).detail }); + }; + types.forEach((type) => target.addEventListener(type, listener)); + + return { + events, + clear(): void { + events.length = 0; + }, + stop(): void { + types.forEach((type) => target.removeEventListener(type, listener)); + }, + }; +} + +let active: { cleanup(): void } | null = null; + +beforeEach(() => { + resetHistory(); +}); + +afterEach(() => { + resetHistory(); + + try { + segmentation.removeAllSegmentations(); + } catch { + // Nothing to remove -- not an error. + } + + if (active) { + const ctx = active; + active = null; + ctx.cleanup(); + } +}); + +describe('undoRedoHistory', () => { + describe('labelmap brush strokes', () => { + test('brush stroke undo restores exact voxel state', async () => { + const ctx = await setupLabelmapHistoryTest(); + active = ctx; + const sliceImageId = ctx.labelmapImageIds[0]; + + await paintAt(ctx, [10, 10]); + + const afterPaint = nonZeroMap(readSliceScalarData(sliceImageId)); + const countAfterPaint = afterPaint.size; + expect(countAfterPaint).toBeGreaterThan(0); + expect(DefaultHistoryMemo.canUndo).toBe(true); + + const undone = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.undo(); + await undone; + + const afterUndo = nonZeroMap(readSliceScalarData(sliceImageId)); + expect(afterUndo.size).toBe(0); + }); + + test('redo restores the exact stroke (byte-exact)', async () => { + const ctx = await setupLabelmapHistoryTest(); + active = ctx; + const sliceImageId = ctx.labelmapImageIds[0]; + + await paintAt(ctx, [10, 10]); + const afterPaint = nonZeroMap(readSliceScalarData(sliceImageId)); + expect(afterPaint.size).toBeGreaterThan(0); + + const undone = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.undo(); + await undone; + expect(nonZeroMap(readSliceScalarData(sliceImageId)).size).toBe(0); + + const redone = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.redo(); + await redone; + + const afterRedo = nonZeroMap(readSliceScalarData(sliceImageId)); + expect(afterRedo.size).toBe(afterPaint.size); + expect(mapsEqual(afterRedo, afterPaint)).toBe(true); + }); + + test('multi-step undo/redo follows strict LIFO ordering, region-wise', async () => { + const ctx = await setupLabelmapHistoryTest(); + active = ctx; + const sliceImageId = ctx.labelmapImageIds[0]; + + // Three strokes 40mm apart (radius 3mm, see setupLabelmapHistoryTest) + // so their painted discs cannot overlap. + await paintAt(ctx, [10, 10]); + const map1 = nonZeroMap(readSliceScalarData(sliceImageId)); + + await paintAt(ctx, [50, 10]); + const map12 = nonZeroMap(readSliceScalarData(sliceImageId)); + + await paintAt(ctx, [10, 50]); + const map123 = nonZeroMap(readSliceScalarData(sliceImageId)); + + expect(map1.size).toBeGreaterThan(0); + expect(map12.size).toBeGreaterThan(map1.size); + expect(map123.size).toBeGreaterThan(map12.size); + + // Undo once -> back to exactly the post-stroke-2 state: stroke 3's + // region is empty, strokes 1-2 are untouched (byte-exact map equality, + // not just a total-count check). + let pending = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.undo(); + await pending; + expect( + mapsEqual(nonZeroMap(readSliceScalarData(sliceImageId)), map12) + ).toBe(true); + + // Undo twice more -> stroke 1 only, then empty. + pending = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.undo(); + await pending; + expect( + mapsEqual(nonZeroMap(readSliceScalarData(sliceImageId)), map1) + ).toBe(true); + + pending = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.undo(); + await pending; + expect(nonZeroMap(readSliceScalarData(sliceImageId)).size).toBe(0); + expect(DefaultHistoryMemo.canUndo).toBe(false); + + // Redo three times -> exact byte-for-byte replay of each recorded + // state, all three regions intact. + for (const expectedMap of [map1, map12, map123]) { + pending = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.redo(); + await pending; + expect( + mapsEqual(nonZeroMap(readSliceScalarData(sliceImageId)), expectedMap) + ).toBe(true); + } + expect(DefaultHistoryMemo.canRedo).toBe(false); + }); + + test('undo across segment-index switches restores segment-scoped voxel state', async () => { + const ctx = await setupLabelmapHistoryTest(); + active = ctx; + const sliceImageId = ctx.labelmapImageIds[0]; + const { segmentationId } = ctx; + + await paintAt(ctx, [10, 10]); // default active segment index: 1 + const mapSeg1 = nonZeroMap(readSliceScalarData(sliceImageId)); + expect(mapSeg1.size).toBeGreaterThan(0); + for (const value of mapSeg1.values()) { + expect(value).toBe(1); + } + + segmentation.segmentIndex.setActiveSegmentIndex(segmentationId, 2); + await paintAt(ctx, [50, 50]); // disjoint location, segment 2 + + const mapBoth = nonZeroMap(readSliceScalarData(sliceImageId)); + expect(mapBoth.size).toBeGreaterThan(mapSeg1.size); + for (const [index, value] of mapBoth) { + if (mapSeg1.has(index)) { + expect(value).toBe(1); + } else { + expect(value).toBe(2); + } + } + + const undone = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.undo(); + await undone; + + const afterUndo = nonZeroMap(readSliceScalarData(sliceImageId)); + expect(mapsEqual(afterUndo, mapSeg1)).toBe(true); + for (const value of afterUndo.values()) { + expect(value).toBe(1); + } + + const redone = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.redo(); + await redone; + + const afterRedo = nonZeroMap(readSliceScalarData(sliceImageId)); + expect(mapsEqual(afterRedo, mapBoth)).toBe(true); + }); + + // `getUniqueSegmentIndices` (packages/tools/src/utilities/segmentation/ + // getUniqueSegmentIndices.ts, exposed publicly as + // `cornerstoneTools.utilities.segmentation.getUniqueSegmentIndices`) is + // METADATA-driven for Labelmap segmentations: it returns + // `Object.keys(segmentation.segments)`, a dict of segment entries created + // by `setActiveSegmentIndex` (see stateManagement/segmentation/ + // segmentIndex.ts), never by an actual voxel scan and never pruned by + // undo. This is a divergence from the plan's expectation ("[1] after + // undo, [1,2] after redo") -- undoing away segment 2's only voxels does + // NOT remove 2 from this list, because nothing deletes the + // `segments[2]` metadata entry the earlier `setActiveSegmentIndex(id, 2)` + // call created. Kept as its own `test.fails` (not folded into the + // byte-exact voxel test above, which passes cleanly) so the genuine + // engine behavior is documented without weakening either assertion. + test.fails( + 'getUniqueSegmentIndices reflects actual voxel presence after undo (observed: does not)', + async () => { + const ctx = await setupLabelmapHistoryTest(); + active = ctx; + const { segmentationId } = ctx; + + await paintAt(ctx, [10, 10]); + segmentation.segmentIndex.setActiveSegmentIndex(segmentationId, 2); + await paintAt(ctx, [50, 50]); + + const indicesAfterPaint = + cornerstoneTools.utilities.segmentation.getUniqueSegmentIndices( + segmentationId + ); + expect(indicesAfterPaint).toEqual([1, 2]); + + const undone = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.undo(); + await undone; + + // Expected (per plan 11): segment 2's voxels are gone, so the unique + // segment list should shrink back to [1]. Observed: it stays [1, 2] + // because getUniqueSegmentIndices never scans voxel data -- see + // comment above. + const indicesAfterUndo = + cornerstoneTools.utilities.segmentation.getUniqueSegmentIndices( + segmentationId + ); + expect(indicesAfterUndo).toEqual([1]); + } + ); + + test('HISTORY_UNDO/HISTORY_REDO fire on the core eventTarget with the expected payload', async () => { + const ctx = await setupLabelmapHistoryTest(); + active = ctx; + + await paintAt(ctx, [30, 30]); + + const recorder = recordFullEvents(eventTarget, [ + ToolsEvents.HISTORY_UNDO, + ToolsEvents.HISTORY_REDO, + ]); + + const modifiedByUndo = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.undo(); + await modifiedByUndo; + + const undoEvents = recorder.events.filter( + (e) => e.type === ToolsEvents.HISTORY_UNDO + ); + expect(undoEvents.length).toBe(1); + expect( + recorder.events.filter((e) => e.type === ToolsEvents.HISTORY_REDO) + .length + ).toBe(0); + + const undoDetail = undoEvents[0].detail as { + isUndo: boolean; + id: string; + operationType: string; + }; + expect(undoDetail.isUndo).toBe(true); + expect(typeof undoDetail.id).toBe('string'); + expect(undoDetail.id.length).toBeGreaterThan(0); + expect(undoDetail.operationType).toBe('labelmap'); + + recorder.clear(); + + const modifiedByRedo = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.redo(); + await modifiedByRedo; + + const redoEvents = recorder.events.filter( + (e) => e.type === ToolsEvents.HISTORY_REDO + ); + expect(redoEvents.length).toBe(1); + expect( + recorder.events.filter((e) => e.type === ToolsEvents.HISTORY_UNDO) + .length + ).toBe(0); + + const redoDetail = redoEvents[0].detail as { + isUndo: boolean; + id: string; + operationType: string; + }; + expect(redoDetail.isUndo).toBe(false); + expect(redoDetail.id).toBe(undoDetail.id); + expect(redoDetail.operationType).toBe('labelmap'); + + recorder.stop(); + }); + + test('undo/redo are safe no-ops on an empty history stack', async () => { + const ctx = await setupLabelmapHistoryTest(); + active = ctx; + const sliceImageId = ctx.labelmapImageIds[0]; + + expect(DefaultHistoryMemo.canUndo).toBe(false); + expect(DefaultHistoryMemo.canRedo).toBe(false); + + const recorder = recordFullEvents(eventTarget, [ + ToolsEvents.HISTORY_UNDO, + ToolsEvents.HISTORY_REDO, + ToolsEvents.SEGMENTATION_DATA_MODIFIED, + ]); + + // undo()/redo() are fully synchronous (see file header): if nothing + // fires, it has already not-fired by the time the call returns, so no + // event-driven wait is needed to assert absence. + expect(() => DefaultHistoryMemo.undo()).not.toThrow(); + expect(() => DefaultHistoryMemo.redo()).not.toThrow(); + + expect(recorder.events.length).toBe(0); + expect(nonZeroMap(readSliceScalarData(sliceImageId)).size).toBe(0); + + // Now: paint, undo (consumes the only entry), undo again past the + // beginning -> second undo must also be a safe no-op. + await paintAt(ctx, [10, 10]); + expect(nonZeroMap(readSliceScalarData(sliceImageId)).size).toBeGreaterThan( + 0 + ); + + const undone = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.undo(); + await undone; + expect(nonZeroMap(readSliceScalarData(sliceImageId)).size).toBe(0); + expect(DefaultHistoryMemo.canUndo).toBe(false); + + recorder.clear(); + expect(() => DefaultHistoryMemo.undo()).not.toThrow(); + expect(recorder.events.length).toBe(0); + expect(nonZeroMap(readSliceScalarData(sliceImageId)).size).toBe(0); + + recorder.stop(); + }); + + test('a new stroke after undo truncates the redo branch', async () => { + const ctx = await setupLabelmapHistoryTest(); + active = ctx; + const sliceImageId = ctx.labelmapImageIds[0]; + + await paintAt(ctx, [10, 10]); // stroke A + const mapA = nonZeroMap(readSliceScalarData(sliceImageId)); + expect(mapA.size).toBeGreaterThan(0); + + const undone = waitForToolsEvent( + eventTarget, + ToolsEvents.SEGMENTATION_DATA_MODIFIED + ); + DefaultHistoryMemo.undo(); + await undone; + expect(nonZeroMap(readSliceScalarData(sliceImageId)).size).toBe(0); + expect(DefaultHistoryMemo.canRedo).toBe(true); + + await paintAt(ctx, [50, 50]); // stroke B, disjoint location + const mapB = nonZeroMap(readSliceScalarData(sliceImageId)); + expect(mapB.size).toBeGreaterThan(0); + + // Pushing B must have cleared the redo side (HistoryMemo.push always + // zeroes redoAvailable before writing -- see file header). + expect(DefaultHistoryMemo.canRedo).toBe(false); + + const recorder = recordFullEvents(eventTarget, [ + ToolsEvents.HISTORY_REDO, + ToolsEvents.SEGMENTATION_DATA_MODIFIED, + ]); + DefaultHistoryMemo.redo(); + expect(recorder.events.length).toBe(0); + recorder.stop(); + + // Voxel state must be untouched: exactly stroke B, nothing from A + // reappeared on top of it. + const afterRedoAttempt = nonZeroMap(readSliceScalarData(sliceImageId)); + expect(mapsEqual(afterRedoAttempt, mapB)).toBe(true); + for (const index of mapA.keys()) { + expect(afterRedoAttempt.has(index)).toBe(false); + } + }); + }); + + describe('annotation undo/redo', () => { + test('annotation creation via drag participates in the memo system: undo removes it, redo restores exact handle positions', async () => { + const ctx = await setupTools({ + tools: [LengthTool], + activeTool: LengthTool.toolName, + viewport: { width: 400, height: 400 }, + }); + active = ctx; + const { element } = ctx; + + const p1: [number, number] = [100, 200]; + const p2: [number, number] = [200, 200]; + + const rendered = waitForAnnotationRendered(element); + mouseDrag(element, p1, p2); + await rendered; + + const drawnAnnotations = annotation.state.getAnnotations( + LengthTool.toolName, + element + ); + expect(drawnAnnotations.length).toBe(1); + + const annotationUID = drawnAnnotations[0].annotationUID; + const originalHandlePoints = drawnAnnotations[0].data.handles.points.map( + (point: Types.Point3) => [...point] + ); + + // Pinned finding (see file header): the drag's first move already + // pushed a memo for this annotation's creation. + expect(DefaultHistoryMemo.canUndo).toBe(true); + + DefaultHistoryMemo.undo(); + + const afterUndo = + annotation.state.getAnnotations(LengthTool.toolName, element) ?? []; + expect(afterUndo.length).toBe(0); + + DefaultHistoryMemo.redo(); + + const afterRedo = + annotation.state.getAnnotations(LengthTool.toolName, element) ?? []; + expect(afterRedo.length).toBe(1); + expect(afterRedo[0].annotationUID).toBe(annotationUID); + + const restoredHandlePoints = afterRedo[0].data.handles.points.map( + (point: Types.Point3) => [...point] + ); + expect(restoredHandlePoints).toEqual(originalHandlePoints); + }); + }); +}); diff --git a/tests/vitest-browser/viewStateSerialization.browser.test.ts b/tests/vitest-browser/viewStateSerialization.browser.test.ts new file mode 100644 index 0000000000..c3ccfc99e6 --- /dev/null +++ b/tests/vitest-browser/viewStateSerialization.browser.test.ts @@ -0,0 +1,510 @@ +// Plan 6: serialization and session restore. +// +// Pins the persistence contract downstream apps (OHIF hanging protocols, +// session restore) depend on: the publicly readable state of a viewport can +// be serialized to JSON, the viewport destroyed, and a fresh viewport +// restored to an equivalent state from that JSON alone. + +import { afterEach, describe, expect, test } from 'vitest'; +import { Enums, type PlanarViewport } from '@cornerstonejs/core'; +import { + captureViewportState, + createPlanarViewport, + recordEvents, + renderAndWait, + round6, + type CreatePlanarViewportOptions, + type FakeStackOptions, + type PlanarViewportContext, +} from './harness'; + +const { Events, InterpolationType } = Enums; + +/** + * The serializable payload under test, exactly as specified by plan 06. + */ +interface PersistedViewportState { + viewState: ReturnType; + presentation: ReturnType; + reference: ReturnType; +} + +// Safety net matching the pattern in renderPathParity.browser.test.ts: track +// every context created by a test so afterEach can always tear it down, even +// if the test throws before reaching its own cleanup call. +let activeCleanups: Array<() => void> = []; + +afterEach(() => { + while (activeCleanups.length) { + const cleanup = activeCleanups.pop(); + + try { + cleanup?.(); + } catch { + // best-effort safety net only + } + } +}); + +async function makeViewport( + opts?: CreatePlanarViewportOptions +): Promise { + const ctx = await createPlanarViewport(opts); + activeCleanups.push(ctx.cleanup); + return ctx; +} + +function teardown(ctx: PlanarViewportContext): void { + ctx.cleanup(); + activeCleanups = activeCleanups.filter((cleanup) => cleanup !== ctx.cleanup); +} + +/** + * Builds the raw (un-normalized) persistence payload straight off the public + * getters, exactly as a downstream app would before doing any sanitation of + * its own. Used by the JSON-safety test to inspect what the API actually + * hands back. + */ +function buildRawPayload( + viewport: PlanarViewport, + displaySetId: string +): PersistedViewportState { + return { + viewState: viewport.getViewState(), + presentation: viewport.getDisplaySetPresentation(displaySetId), + reference: viewport.getViewReference(), + }; +} + +/** + * Deep-clones and rounds every number, converts typed arrays to plain + * arrays, and drops undefined-valued object properties. This mirrors the + * (unexported) normalization walker inside + * harness/captureViewportState.ts -- duplicated locally per the plan 06 + * instruction to add local helpers rather than edit harness files. + */ +function normalizeDeep(value: unknown): unknown { + if (typeof value === 'number') { + return round6(value); + } + + if ( + value instanceof Float32Array || + value instanceof Float64Array || + value instanceof Int32Array || + value instanceof Uint32Array || + value instanceof Int16Array || + value instanceof Uint16Array || + value instanceof Uint8Array + ) { + return Array.from(value, (item) => round6(item)); + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeDeep(item)); + } + + if (value && typeof value === 'object') { + const source = value as Record; + const out: Record = {}; + + for (const key of Object.keys(source)) { + const normalized = normalizeDeep(source[key]); + + if (normalized !== undefined) { + out[key] = normalized; + } + } + + return out; + } + + return value; +} + +/** + * Builds the serializable payload and returns it as a JSON string. + * + * Normalizes before stringifying. Test 1 below probes whether the raw + * public state (viewState/presentation/reference straight off the getters) + * is JSON-safe as-is; whatever it finds is recorded in the suite report. + * Regardless of that finding, `persist` normalizes up front (typed array -> + * plain array, numbers rounded) so every downstream test in this file + * restores from a genuinely JSON-safe string -- matching what a real app + * would ship after its own serialization step. + */ +function persist(viewport: PlanarViewport, displaySetId: string): string { + return JSON.stringify( + normalizeDeep(buildRawPayload(viewport, displaySetId)) + ); +} + +/** + * Applies a persisted payload to a viewport. + * + * Only `setViewState` and `setDisplaySetPresentation` are used -- + * `setViewReference` is intentionally NOT called on top of `setViewState`. + * + * Decision (verified empirically by the round-trip test below, and by + * reading PlanarViewport.setImageIdIndex / + * PlanarViewReferenceController.applyImageViewReference): for image-backed + * render paths (vtkImage, cpuImage -- the two modes this suite exercises), + * `PlanarViewState.slice` already carries `{ kind: 'stackIndex', + * imageIdIndex }`, and that field is a direct, clamped index into the + * display set's imageIds array. `setViewState(payload.viewState)` restores + * it verbatim, which is exactly what `setViewReference` would end up doing + * internally (it resolves a referencedImageId/sliceIndex back into the same + * imageIdIndex and calls the same `setImageIdIndex`). So for these two + * render modes, `setViewState` alone fully encodes and restores the slice; + * calling `setViewReference` afterward would be redundant. (Volume-backed + * modes, where `slice` is a `volumePoint` and the mapping is less direct, + * are out of scope for this suite -- see renderPathParity.browser.test.ts + * for their documented divergences.) + */ +function restore( + viewport: PlanarViewport, + displaySetId: string, + json: string +): void { + const payload = JSON.parse(json) as PersistedViewportState; + + viewport.setViewState(payload.viewState); + + if (payload.presentation) { + viewport.setDisplaySetPresentation(displaySetId, payload.presentation); + } +} + +/** + * Non-trivially navigates a viewport: slice 3 (of the default 5-slice + * stack), zoom 1.7, pan [12, -8], rotation 90, flipHorizontal true, plus a + * non-default interpolation type and VOI range via setDisplaySetPresentation. + */ +async function applyNavigatedState(ctx: PlanarViewportContext): Promise { + const { viewport, element, displaySetId } = ctx; + + await viewport.setImageIdIndex(3); + viewport.setZoom(1.7); + viewport.setPan([12, -8]); + viewport.setViewState({ rotation: 90, flipHorizontal: true }); + viewport.setDisplaySetPresentation(displaySetId, { + interpolationType: InterpolationType.NEAREST, + voiRange: { lower: 0, upper: 120 }, + }); + + await renderAndWait(element, viewport); +} + +async function createNavigatedViewport( + opts: CreatePlanarViewportOptions = {} +): Promise { + const ctx = await makeViewport(opts); + await applyNavigatedState(ctx); + return ctx; +} + +/** + * Fields legitimately excluded from a core-state equality check after a + * destroy/recreate or cross-mode restore round trip. An empty list is the + * goal; every entry here is a finding, not a convenience -- see the suite + * report for justification of anything listed. + */ +const VOLATILE_FIELDS: string[] = []; + +function omitVolatileFields(core: unknown): unknown { + if (!VOLATILE_FIELDS.length) { + return core; + } + + const clone = JSON.parse(JSON.stringify(core)); + + for (const path of VOLATILE_FIELDS) { + const segments = path.split('.'); + let target: Record | undefined = clone; + + for (let i = 0; i < segments.length - 1 && target; i++) { + target = target[segments[i]] as Record | undefined; + } + + if (target) { + delete target[segments[segments.length - 1]]; + } + } + + return clone; +} + +function sharedStackAndId(name: string): { + stack: FakeStackOptions; + displaySetId: string; +} { + return { + stack: { + name: `serialization-${name}`, + frameOfReferenceUID: `VITEST_SERIALIZATION_${name.toUpperCase()}_FRAME_OF_REFERENCE`, + }, + displaySetId: `vitest-serialization-${name}-displayset`, + }; +} + +describe('viewStateSerialization', () => { + // Real finding, kept as test.fails per shared-context rule 5 (confirmed by + // dumping the raw payload, not a test mistake -- see the report for the + // full field list). `viewState` and `presentation` are perfectly clean: + // every number round-trips through JSON.stringify byte-identical to its + // round6'd form (rotation: 90, flipHorizontal: true, anchorCanvas: + // [0.53, 0.48], scale: [1.7, 1.7], interpolationType: 0, + // voiRange: {lower: 0, upper: 120}). The mismatch is entirely inside + // `reference` (getViewReference()), in two independent ways: + // 1. Float32 promotion noise on world-space coordinates: + // reference.cameraFocalPoint = [32.2529411315918, 30.370590209960938, 3] + // reference.planeRestriction.point = the same pair. + // 32.2529411315918 is the float64 value nearest to a float32 number + // (~7 significant digits) -- consistent with gl-matrix's vec3 + // defaulting to Float32Array internally (see + // Planar/planarViewReference.ts, which builds these via `vec3.cross`/ + // `vec3.negate` on `vec3.create()` buffers) even though the value is + // handed back as a plain number, not a typed array. + // 2. Floating-point trig round-off ("should be zero" values that are + // not exactly zero) on orientation vectors, from the 90-degree + // rotation in the navigated state: + // reference.viewUp = [-1, -6.123234262925839e-17, 0] + // reference.planeRestriction.inPlaneVector1 = the same vector + // reference.planeRestriction.inPlaneVector2 = [-6.123234262925839e-17, 1, 0] + // -6.123234262925839e-17 is the classic `Math.cos(Math.PI / 2)` + // double-precision artifact, not an exact 0. + // Neither issue is a typed array / NaN / Infinity / undefined -- the + // values are ordinary JSON-safe numbers -- but they are real precision + // noise that a naive persist-as-is would bake into a session file. `persist` + // below already runs every field through `normalizeDeep` (round6), which + // fixes both cases (round6 folds the near-zero trig noise to exactly 0 via + // its `|| 0` step and rounds the float32 world coordinates to 6 decimals), + // so the rest of this suite restores from a genuinely stable JSON string. + test.fails('1. public state payload is JSON-safe', async () => { + const ctx = await createNavigatedViewport({ renderMode: 'vtkImage' }); + + try { + const raw = buildRawPayload(ctx.viewport, ctx.displaySetId); + const normalized = normalizeDeep(raw); + const roundTripped = JSON.parse(JSON.stringify(raw)); + + expect(roundTripped).toEqual(normalized); + } finally { + teardown(ctx); + } + }); + + test('2. full destroy/recreate round trip restores core state', async () => { + const { stack, displaySetId } = sharedStackAndId('roundtrip'); + + const a = await createNavigatedViewport({ + renderMode: 'vtkImage', + stack, + displaySetId, + }); + const snapA = captureViewportState(a.viewport, displaySetId); + const json = persist(a.viewport, displaySetId); + teardown(a); + + const b = await makeViewport({ + renderMode: 'vtkImage', + stack, + displaySetId, + }); + restore(b.viewport, displaySetId, json); + await renderAndWait(b.element, b.viewport); + const snapB = captureViewportState(b.viewport, displaySetId); + + // Sanity: the restore actually navigated to the persisted slice, not + // just coincidentally matching a default. + expect(snapB.core.currentImageIdIndex).toBe(3); + + expect(omitVolatileFields(snapB.core)).toEqual( + omitVolatileFields(snapA.core) + ); + }); + + test('3. cross-render-mode restore (vtkImage -> cpuImage) preserves core state', async () => { + const { stack, displaySetId } = sharedStackAndId('crossmode'); + + const a = await createNavigatedViewport({ + renderMode: 'vtkImage', + stack, + displaySetId, + }); + const snapA = captureViewportState(a.viewport, displaySetId); + const json = persist(a.viewport, displaySetId); + teardown(a); + + const b = await makeViewport({ + renderMode: 'cpuImage', + stack, + displaySetId, + }); + restore(b.viewport, displaySetId, json); + await renderAndWait(b.element, b.viewport); + const snapB = captureViewportState(b.viewport, displaySetId); + + expect(snapB.core.currentImageIdIndex).toBe(3); + + expect(omitVolatileFields(snapB.core)).toEqual( + omitVolatileFields(snapA.core) + ); + }); + + describe('4. partial restore is non-destructive', () => { + test('viewState-only restore leaves presentation at defaults', async () => { + const { stack, displaySetId } = sharedStackAndId('partial-viewstate'); + + const source = await createNavigatedViewport({ + renderMode: 'vtkImage', + stack, + displaySetId, + }); + const snapSource = captureViewportState(source.viewport, displaySetId); + const json = persist(source.viewport, displaySetId); + teardown(source); + + const fresh = await makeViewport({ + renderMode: 'vtkImage', + stack, + displaySetId, + }); + const defaultSnap = captureViewportState(fresh.viewport, displaySetId); + + const payload = JSON.parse(json) as PersistedViewportState; + fresh.viewport.setViewState(payload.viewState); + await renderAndWait(fresh.element, fresh.viewport); + const afterSnap = captureViewportState(fresh.viewport, displaySetId); + + // Presentation must remain untouched at its pre-restore default. + expect(afterSnap.core.presentation).toEqual( + defaultSnap.core.presentation + ); + + // Navigation must match the persisted state. + expect(afterSnap.core.currentImageIdIndex).toBe( + snapSource.core.currentImageIdIndex + ); + expect(afterSnap.core.zoom).toEqual(snapSource.core.zoom); + expect(afterSnap.core.pan).toEqual(snapSource.core.pan); + expect(afterSnap.core.rotation).toEqual(snapSource.core.rotation); + expect(afterSnap.core.viewState).toEqual(snapSource.core.viewState); + }); + + test('presentation-only restore leaves navigation at defaults', async () => { + const { stack, displaySetId } = sharedStackAndId( + 'partial-presentation' + ); + + const source = await createNavigatedViewport({ + renderMode: 'vtkImage', + stack, + displaySetId, + }); + const snapSource = captureViewportState(source.viewport, displaySetId); + const json = persist(source.viewport, displaySetId); + teardown(source); + + const fresh = await makeViewport({ + renderMode: 'vtkImage', + stack, + displaySetId, + }); + const defaultSnap = captureViewportState(fresh.viewport, displaySetId); + + const payload = JSON.parse(json) as PersistedViewportState; + + if (payload.presentation) { + fresh.viewport.setDisplaySetPresentation( + displaySetId, + payload.presentation + ); + } + await renderAndWait(fresh.element, fresh.viewport); + const afterSnap = captureViewportState(fresh.viewport, displaySetId); + + // Navigation (slice 0, fit zoom, rotation 0) must be untouched. + expect(afterSnap.core.viewState).toEqual(defaultSnap.core.viewState); + expect(afterSnap.core.currentImageIdIndex).toBe(0); + expect(afterSnap.core.zoom).toEqual(defaultSnap.core.zoom); + expect(afterSnap.core.rotation).toBe(0); + + // Presentation must match the persisted state. + expect(afterSnap.core.presentation).toEqual( + snapSource.core.presentation + ); + }); + }); + + test('5. restoring the same json twice is idempotent', async () => { + const { stack, displaySetId } = sharedStackAndId('idempotent'); + + const source = await createNavigatedViewport({ + renderMode: 'vtkImage', + stack, + displaySetId, + }); + const json = persist(source.viewport, displaySetId); + teardown(source); + + const target = await makeViewport({ + renderMode: 'vtkImage', + stack, + displaySetId, + }); + + restore(target.viewport, displaySetId, json); + await renderAndWait(target.element, target.viewport); + const snapAfterFirst = captureViewportState(target.viewport, displaySetId); + + restore(target.viewport, displaySetId, json); + await renderAndWait(target.element, target.viewport); + const snapAfterSecond = captureViewportState( + target.viewport, + displaySetId + ); + + expect(snapAfterSecond.core).toEqual(snapAfterFirst.core); + }); + + // Real finding (no-op detection gap), kept as test.fails per shared-context + // rule 5 rather than weakened or deleted. Observed: GenericViewport + // .setViewState / PlanarViewport.setViewState unconditionally call + // modified() -> triggerCameraModifiedEvent() whenever a previous-camera + // snapshot was captured, with no equality check against the incoming + // patch (see packages/core/src/RenderingEngine/GenericViewport/ + // GenericViewport.ts setViewState and Planar/PlanarViewport.ts + // setViewState). So a second restore with byte-identical viewState still + // emits one CAMERA_MODIFIED event; a true no-op restore would emit zero. + test.fails( + '5b. a second identical restore does not emit CAMERA_MODIFIED (no-op detection)', + async () => { + const { stack, displaySetId } = sharedStackAndId('idempotent-events'); + + const source = await createNavigatedViewport({ + renderMode: 'vtkImage', + stack, + displaySetId, + }); + const json = persist(source.viewport, displaySetId); + teardown(source); + + const target = await makeViewport({ + renderMode: 'vtkImage', + stack, + displaySetId, + }); + + restore(target.viewport, displaySetId, json); + await renderAndWait(target.element, target.viewport); + + const events = recordEvents(target.element, [Events.CAMERA_MODIFIED]); + restore(target.viewport, displaySetId, json); + await renderAndWait(target.element, target.viewport); + events.stop(); + + expect(events.count(Events.CAMERA_MODIFIED)).toBe(0); + } + ); +}); diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 4d46733aaa..e65d800943 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ resolve: { alias: { '@cornerstonejs/core': path.resolve(__dirname, 'packages/core/src/index.ts'), + '@cornerstonejs/tools': path.resolve(__dirname, 'packages/tools/src/index.ts'), }, }, test: { From 2c6fc18f3f1f1371f2dc6fca7bd545fee529633d Mon Sep 17 00:00:00 2001 From: Alireza Date: Wed, 8 Jul 2026 22:31:20 -0400 Subject: [PATCH 2/2] test(e2e): retry remote DICOM fixtures to de-flake WADOURI specs The WADOURI examples fetch every image over HTTP from raw.githubusercontent.com (most from the cornerstone3D repo, the TG18 set from the external OHIF/viewer-testdata repo). Under the parallel Playwright workers on the self-hosted runner, GitHub raw intermittently rate-limits (429) or drops these requests. The DICOM loader issues a single XHR per image with no retry, so one bad response fails the whole load and surfaces as a flaky waitForImageRendered timeout (reproduced locally on TG18-LN-1k-01). Add a test-side retryRemoteFixtures helper that intercepts those requests and retries them with exponential backoff from the Node side via route.fetch, then replays the successful response to the browser. Retries stay off the browser's single-shot XHR, product code is untouched, and the example's public URLs are unchanged so the deployed docs demo is unaffected. Also give the remote-load render gate a 60s budget to absorb the retry backoff on the large 1k/2k images. Verified: TG18-LN-1k-01 (legacy + generic), previously a hard failure, now passes 3/3 repeats locally. Claude-Session: https://claude.ai/code/session_01NJNAWVWWtguh3aA8w1R9mp --- tests/dicomImageLoaderWADOURI.spec.ts | 11 +++ .../genericDicomImageLoaderWADOURI.spec.ts | 11 +++ tests/utils/index.ts | 1 + tests/utils/retryRemoteFixtures.ts | 83 +++++++++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 tests/utils/retryRemoteFixtures.ts diff --git a/tests/dicomImageLoaderWADOURI.spec.ts b/tests/dicomImageLoaderWADOURI.spec.ts index 1e5ba9dc6a..954c71df9b 100644 --- a/tests/dicomImageLoaderWADOURI.spec.ts +++ b/tests/dicomImageLoaderWADOURI.spec.ts @@ -5,10 +5,16 @@ import { visitExample, screenShotPaths, waitForImageRendered, + retryRemoteFixtures, } from './utils/index'; import { dicomDimensions } from '../packages/dicomImageLoader/examples/dicomImageLoaderWADOURI/dicomDimensions'; test.beforeEach(async ({ page }) => { + // Every image in this example is fetched over HTTP from + // raw.githubusercontent.com, which intermittently rate-limits/drops requests + // under the parallel workers on the self-hosted runner. Retry those fetches + // with backoff so a single dropped response doesn't fail the image load. + await retryRemoteFixtures(page); await visitExample(page, 'dicomImageLoaderWADOURI'); }); @@ -86,6 +92,11 @@ async function selectImageAndWaitForRender(page: Page, imagePath: string) { () => page.locator('#imageSelector').selectOption(imagePath), { expectedImageId: getExpectedWadoImageId(imagePath), + // Larger budget than the 30s default: the large TG18 1k/2k images can + // legitimately take a while to download+decode on the self-hosted + // runner, and retryRemoteFixtures may add a few seconds of backoff on a + // transient GitHub-raw failure. + timeout: 60000, } ); diff --git a/tests/genericViewport/genericDicomImageLoaderWADOURI.spec.ts b/tests/genericViewport/genericDicomImageLoaderWADOURI.spec.ts index 38cc1b0de2..e56b16a219 100644 --- a/tests/genericViewport/genericDicomImageLoaderWADOURI.spec.ts +++ b/tests/genericViewport/genericDicomImageLoaderWADOURI.spec.ts @@ -5,6 +5,7 @@ import { createExampleUrl, screenShotPaths, waitForImageRendered, + retryRemoteFixtures, } from '../utils/index'; import { dicomDimensions } from '../../packages/dicomImageLoader/examples/dicomImageLoaderWADOURI/dicomDimensions'; @@ -74,6 +75,11 @@ async function selectImageAndWaitForRender(page: Page, imagePath: string) { () => page.locator('#imageSelector').selectOption(imagePath), { expectedImageId: getExpectedWadoImageId(imagePath), + // Larger budget than the 30s default: the large TG18 1k/2k images can + // legitimately take a while to download+decode on the self-hosted + // runner, and retryRemoteFixtures may add a few seconds of backoff on a + // transient GitHub-raw failure. + timeout: 60000, } ); @@ -84,6 +90,11 @@ async function selectImageAndWaitForRender(page: Page, imagePath: string) { } test.beforeEach(async ({ page }) => { + // Every image in this example is fetched over HTTP from + // raw.githubusercontent.com, which intermittently rate-limits/drops requests + // under the parallel workers on the self-hosted runner. Retry those fetches + // with backoff so a single dropped response doesn't fail the image load. + await retryRemoteFixtures(page); const url = createExampleUrl(EXAMPLE + '.html'); url.searchParams.set('type', 'next'); await page.goto(url.toString()); diff --git a/tests/utils/index.ts b/tests/utils/index.ts index 9f453107ad..17ce4ae7af 100644 --- a/tests/utils/index.ts +++ b/tests/utils/index.ts @@ -12,6 +12,7 @@ export { createExampleUrl } from './createExampleUrl'; export { getSegmentationActorClassNames } from './getSegmentationActorClassNames'; export { expectGenericViewportRuntime } from './expectGenericViewportRuntime'; export { waitForImageRendered } from './waitForImageRendered'; +export { retryRemoteFixtures } from './retryRemoteFixtures'; export { setupRenderTracking, waitForViewportsRendered, diff --git a/tests/utils/retryRemoteFixtures.ts b/tests/utils/retryRemoteFixtures.ts new file mode 100644 index 0000000000..809d6a4f02 --- /dev/null +++ b/tests/utils/retryRemoteFixtures.ts @@ -0,0 +1,83 @@ +import type { APIResponse, Page, Route } from '@playwright/test'; + +const DEFAULT_HOSTS = [/raw\.githubusercontent\.com/]; + +interface RetryRemoteFixturesOptions { + /** Total attempts (including the first) before giving up. Default 4. */ + attempts?: number; + /** Host patterns to intercept. Default: raw.githubusercontent.com. */ + hosts?: RegExp[]; + /** Per-attempt fetch timeout in ms. Default 20000. */ + perAttemptTimeoutMs?: number; +} + +/** + * DICOM fixtures for the loader examples are fetched over HTTP from + * raw.githubusercontent.com — most images from the cornerstone3D repo, the + * TG18 set from the external OHIF/viewer-testdata repo. Under the parallel + * Playwright workers on the self-hosted runner, GitHub raw intermittently + * rate-limits (429) or drops these requests. The loader issues a single + * XMLHttpRequest per image with no retry (see + * packages/dicomImageLoader/src/imageLoader/internal/xhrRequest.ts), so one + * bad response fails the whole image load and surfaces as a flaky + * `waitForImageRendered` timeout. + * + * Intercept those requests and retry them with exponential backoff from the + * Node side via `route.fetch`, then replay the successful response to the + * browser. Retries happen off the browser's single-shot XHR, the product code + * is untouched, and the example's public URLs are left as-is so the deployed + * docs demo is unaffected. Range requests are preserved because `route.fetch` + * forwards the original request (headers included). + * + * Install in a spec's `beforeEach` BEFORE navigating to the example. + */ +export async function retryRemoteFixtures( + page: Page, + options: RetryRemoteFixturesOptions = {} +): Promise { + const { + attempts = 4, + hosts = DEFAULT_HOSTS, + perAttemptTimeoutMs = 20000, + } = options; + + await page.route( + (url) => hosts.some((host) => host.test(url.href)), + async (route: Route) => { + let lastResponse: APIResponse | undefined; + + for (let attempt = 0; attempt < attempts; attempt++) { + try { + const response = await route.fetch({ timeout: perAttemptTimeoutMs }); + const status = response.status(); + + // Only 429 and 5xx are transient; anything else (2xx, 3xx, 4xx + // other than 429) is a real answer we should replay immediately. + if (status !== 429 && status < 500) { + await route.fulfill({ response }); + return; + } + + lastResponse = response; + } catch { + // Network error / timeout: fall through to backoff and retry. + } + + // Backoff after every failed attempt except when we are about to give + // up, so the caller's render-gate budget is not spent needlessly. + if (attempt < attempts - 1) { + const backoffMs = 500 * 2 ** attempt; + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + } + + // Retries exhausted. Replay the last transient response if we have one, + // otherwise let the request proceed so the real network error surfaces. + if (lastResponse) { + await route.fulfill({ response: lastResponse }); + } else { + await route.continue(); + } + } + ); +}