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/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(); + } + } + ); +} 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: {