fix: ohif tests to run with cornerstone 3d 5.0 (#6043)

* chore(tests): Update multiple screenshot test images for various specs

* feat(screenshot-reviewer): Add screenshot review tool and update package.json scripts

* fix(DICOMSRDisplayTool): Improve actor presence check in viewport

* chore(tests): Update multiple screenshot assets for various specs

* chore(tests): Integrate waitForPaintToSettle and waitForViewportsRendered in multiple specs for improved rendering stability

* chore(tests): Update screenshot assets for SEGHydration and SEGNoHydration specs

* test: update progressive loading screenshots

* jest 30 test fixes for compatibility with pnpm cs3d

* Use correct setDisplaySets instead of setDataId

* fix: Naming change for LegacyVolumeViewport3D

* Update to allow tolerance for contour tests

* update

* fix

* refactor: Replace instanceof checks with utility functions for viewport type validation

* fix: Update createSegmentationForViewport to handle undefined displaySetInstanceUID gracefully

* bun lock

* fix: Install cs3d with pnpm instead of bun

* Update node version for playwright

* Update to v5.0.0 of cs3d

* fix: Build dependency

* audit

* Change to a web await retry assert

* Fix timing related test failures

* fix: Freehand close

---------

Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
Alireza 2026-06-09 20:25:14 -04:00 committed by GitHub
parent a27f779f0b
commit 6dd150d401
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
172 changed files with 3081 additions and 1866 deletions

View File

@ -27,12 +27,15 @@ jobs:
strategy:
fail-fast: false
matrix:
node-version: [20]
node-version: [24]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.23
- uses: pnpm/action-setup@v4
with:
version: 11.4.0
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
@ -87,7 +90,7 @@ jobs:
- name: Install & Build CS3D
if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'branch'
working-directory: libs/@cornerstonejs
run: bun install --frozen-lockfile && bun run build:esm
run: pnpm install --frozen-lockfile && pnpm run build:esm
# ── Common: install OHIF dependencies ───────────────────────────────
- name: Install dependencies

View File

@ -10,7 +10,7 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@kitware/vtk.js": "34.15.1",
"@kitware/vtk.js": "35.5.3",
"clsx": "2.1.1",
"core-js": "3.45.1",
"moment": "2.30.1"
@ -52,10 +52,10 @@
"extract-css-chunks-webpack-plugin": "4.10.0",
"html-webpack-plugin": "5.6.3",
"husky": "3.1.0",
"jest": "29.7.0",
"jest": "30.2.0",
"jest-canvas-mock": "2.5.2",
"jest-environment-jsdom": "29.7.0",
"jest-junit": "6.4.0",
"jest-environment-jsdom": "30.2.0",
"jest-junit": "16.0.0",
"lerna": "9.0.4",
"lint-staged": "9.5.0",
"mini-css-extract-plugin": "2.9.2",

View File

@ -26,7 +26,9 @@ module.exports = {
'@babel/preset-typescript',
],
plugins: [
'babel-plugin-istanbul',
// jest's babel coverage provider injects babel-plugin-istanbul when
// --collectCoverage is set; adding it here too makes babel 7 (pulled in
// by jest 30) throw "Duplicate plugin/preset detected".
'@babel/plugin-transform-object-rest-spread',
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-transform-regenerator',

1112
bun.lock

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +1,16 @@
[install]
frozenLockfile = true
minimumReleaseAge = 172800
# bun's excludes match exact package names (no glob support). Only the main
# @cornerstonejs/* packages pinned to 5.0.0 are exempted; codecs version separately.
minimumReleaseAgeExcludes = [
"@cornerstonejs/adapters",
"@cornerstonejs/ai",
"@cornerstonejs/core",
"@cornerstonejs/dicom-image-loader",
"@cornerstonejs/labelmap-interpolation",
"@cornerstonejs/metadata",
"@cornerstonejs/polymorphic-segmentation",
"@cornerstonejs/tools",
"@cornerstonejs/utils",
]

View File

@ -1,2 +1,16 @@
[install]
frozenLockfile = false
minimumReleaseAge = 172800
# Mirror of bunfig.toml: exempt the main @cornerstonejs/* packages (just bumped
# to 5.0.0) from the release-age check. Exact names only (no glob support).
minimumReleaseAgeExcludes = [
"@cornerstonejs/adapters",
"@cornerstonejs/ai",
"@cornerstonejs/core",
"@cornerstonejs/dicom-image-loader",
"@cornerstonejs/labelmap-interpolation",
"@cornerstonejs/metadata",
"@cornerstonejs/polymorphic-segmentation",
"@cornerstonejs/tools",
"@cornerstonejs/utils",
]

View File

@ -46,8 +46,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "4.22.10",
"@cornerstonejs/core": "4.22.10",
"@kitware/vtk.js": "34.15.1"
"@cornerstonejs/adapters": "5.0.0",
"@cornerstonejs/core": "5.0.0",
"@kitware/vtk.js": "35.5.3"
}
}

View File

@ -46,8 +46,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "4.22.10",
"@cornerstonejs/core": "4.22.10",
"@kitware/vtk.js": "34.15.1"
"@cornerstonejs/adapters": "5.0.0",
"@cornerstonejs/core": "5.0.0",
"@kitware/vtk.js": "35.5.3"
}
}

View File

@ -46,9 +46,9 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "4.22.10",
"@cornerstonejs/core": "4.22.10",
"@cornerstonejs/tools": "4.22.10",
"@cornerstonejs/adapters": "5.0.0",
"@cornerstonejs/core": "5.0.0",
"@cornerstonejs/tools": "5.0.0",
"classnames": "2.5.1"
}
}

View File

@ -69,7 +69,12 @@ export default class DICOMSRDisplayTool extends AnnotationTool {
trackingUniqueIdentifiers.includes(annotation.data?.TrackingUniqueIdentifier)
);
if (!viewport._actors?.size) {
const hasActors =
typeof viewport.getActors === 'function'
? viewport.getActors().length > 0
: Boolean(viewport._actors?.size);
if (!hasActors) {
return;
}

View File

@ -42,8 +42,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/core": "4.22.10",
"@cornerstonejs/tools": "4.22.10",
"@cornerstonejs/core": "5.0.0",
"@cornerstonejs/tools": "5.0.0",
"classnames": "2.5.1"
}
}

View File

@ -40,7 +40,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2",
"@cornerstonejs/codec-openjpeg": "1.3.0",
"@cornerstonejs/codec-openjph": "2.4.7",
"@cornerstonejs/dicom-image-loader": "4.22.10",
"@cornerstonejs/dicom-image-loader": "5.0.0",
"@ohif/core": "3.13.0-beta.88",
"@ohif/ui": "3.13.0-beta.88",
"dcmjs": "0.49.4",
@ -53,15 +53,16 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "4.22.10",
"@cornerstonejs/ai": "4.22.10",
"@cornerstonejs/core": "4.22.10",
"@cornerstonejs/labelmap-interpolation": "4.22.10",
"@cornerstonejs/polymorphic-segmentation": "4.22.10",
"@cornerstonejs/tools": "4.22.10",
"@cornerstonejs/adapters": "5.0.0",
"@cornerstonejs/ai": "5.0.0",
"@cornerstonejs/core": "5.0.0",
"@cornerstonejs/labelmap-interpolation": "5.0.0",
"@cornerstonejs/metadata": "5.0.0",
"@cornerstonejs/polymorphic-segmentation": "5.0.0",
"@cornerstonejs/tools": "5.0.0",
"@icr/polyseg-wasm": "0.4.0",
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
"@kitware/vtk.js": "34.15.1",
"@kitware/vtk.js": "35.5.3",
"html2canvas": "1.4.1",
"immutability-helper": "3.1.1",
"lodash.compact": "3.0.1",

View File

@ -1,7 +1,8 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { Enums, VolumeViewport3D, utilities as csUtils } from '@cornerstonejs/core';
import { Enums, utilities as csUtils } from '@cornerstonejs/core';
import { ImageScrollbar } from '@ohif/ui-next';
import { isVolume3DViewportType } from '../../utils/getLegacyViewportType';
function CornerstoneImageScrollbar({
viewportData,
@ -40,7 +41,7 @@ function CornerstoneImageScrollbar({
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || viewport instanceof VolumeViewport3D) {
if (!viewport || isVolume3DViewportType(viewport)) {
return;
}
@ -69,7 +70,7 @@ function CornerstoneImageScrollbar({
const updateIndex = event => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || viewport instanceof VolumeViewport3D) {
if (!viewport || isVolume3DViewportType(viewport)) {
return;
}
const { imageIndex, newImageIdIndex = imageIndex, imageIdIndex } = event.detail;

View File

@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { VolumeViewport3D, utilities as csUtils } from '@cornerstonejs/core';
import { utilities as csUtils } from '@cornerstonejs/core';
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
import {
SmartScrollbar,
SmartScrollbarTrack,
@ -102,7 +103,7 @@ function ViewportSliceProgressScrollbar({
const onScrollbarValueChange = targetImageIndex => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || viewport instanceof VolumeViewport3D) {
if (!viewport || isVolume3DViewportType(viewport)) {
return;
}

View File

@ -1,5 +1,6 @@
import { Enums, VolumeViewport3D } from '@cornerstonejs/core';
import { Enums } from '@cornerstonejs/core';
import { ViewportData } from './types';
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
export function getImageIndexFromEvent(event): number | undefined {
const { imageIndex, newImageIdIndex = imageIndex, imageIdIndex } = event.detail;
@ -19,7 +20,7 @@ export function getViewportImageIds(viewportData: ViewportData): string[] {
}
export function isProgressFullMode(viewportData: ViewportData, viewport): boolean {
if (!viewportData || !viewport || viewport instanceof VolumeViewport3D) {
if (!viewportData || !viewport || isVolume3DViewportType(viewport)) {
return false;
}

View File

@ -4,9 +4,9 @@ import {
Enums,
eventTarget,
utilities,
VolumeViewport3D,
} from '@cornerstonejs/core';
import { useByteArray } from '@ohif/ui-next';
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
import { getImageIdFromCacheEvent, getImageIndexFromEvent, isProgressFullMode } from './helpers';
import { ImageSliceData, ViewportData } from './types';
@ -98,7 +98,7 @@ export function useViewportSliceSync({
}
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (viewport && !(viewport instanceof VolumeViewport3D)) {
if (viewport && !isVolume3DViewportType(viewport)) {
try {
const currentImageIndex = viewport.getCurrentImageIdIndex();
const currentNumberOfSlices = viewport.getNumberOfSlices();
@ -120,7 +120,7 @@ export function useViewportSliceSync({
const updateIndex = event => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || viewport instanceof VolumeViewport3D) {
if (!viewport || isVolume3DViewportType(viewport)) {
return;
}

View File

@ -1,11 +1,7 @@
import {
getEnabledElement,
StackViewport,
VolumeViewport,
utilities as csUtils,
Enums as CoreEnums,
Types as CoreTypes,
BaseVolumeViewport,
getRenderingEngines,
} from '@cornerstonejs/core';
import {
@ -38,6 +34,12 @@ import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/u
import { getViewportEnabledElement } from './utils/getViewportEnabledElement';
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
import toggleVOISliceSync from './utils/toggleVOISliceSync';
import {
isStackViewportType,
isOrthographicViewportType,
isVolume3DViewportType,
isVolumeViewportType,
} from './utils/getLegacyViewportType';
import {
usePositionPresentationStore,
useSegmentationPresentationStore,
@ -324,7 +326,7 @@ function commandsModule({
// Todo: check if PMAP modality should be handled such as SEG
displaySet.Modality !== 'SEG'
? SegmentationRepresentations.Contour
: viewport.type === CoreEnums.ViewportType.VOLUME_3D
: isVolume3DViewportType(viewport)
? SegmentationRepresentations.Surface
: SegmentationRepresentations.Labelmap;
@ -910,7 +912,7 @@ function commandsModule({
const { lower, upper } = csUtils.windowLevel.toLowHighRange(windowWidthNum, windowCenterNum);
if (viewport instanceof BaseVolumeViewport) {
if (isVolumeViewportType(viewport)) {
const volumeId = actions.getVolumeIdForDisplaySet({
viewportId,
displaySetInstanceUID,
@ -986,8 +988,12 @@ function commandsModule({
},
getVolumeIdForDisplaySet: ({ viewportId, displaySetInstanceUID }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (viewport instanceof BaseVolumeViewport) {
const volumeIds = viewport.getAllVolumeIds();
// `instanceof BaseVolumeViewport` is false for GenericViewport compat adapters,
// so use the capability guard instead (cornerstone 5.x generic-viewport guide).
// A stack-mode adapter passes the guard but getAllVolumeIds() returns [], so the
// result is the same null as before on stacks.
if (csUtils.viewportSupportsVolumeId(viewport)) {
const volumeIds = (viewport as CoreTypes.IVolumeViewport).getAllVolumeIds();
const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID));
return volumeId;
}
@ -1256,7 +1262,7 @@ function commandsModule({
}
const { viewport } = enabledElement;
if (viewport instanceof StackViewport) {
if (isStackViewportType(viewport)) {
if (direction) {
const { parallelScale } = viewport.getCamera();
viewport.setCamera({ parallelScale: parallelScale * scaleFactor });
@ -1286,9 +1292,9 @@ function commandsModule({
// -> Copied from cornerstone3D jumpToSlice\_getImageSliceData()
let numberOfSlices = 0;
if (viewport instanceof StackViewport) {
if (isStackViewportType(viewport)) {
numberOfSlices = viewport.getImageIds().length;
} else if (viewport instanceof VolumeViewport) {
} else if (isOrthographicViewportType(viewport)) {
numberOfSlices = csUtils.getImageSliceDataForVolumeViewport(viewport).numberOfSlices;
} else {
throw new Error('Unsupported viewport type');
@ -1344,11 +1350,11 @@ function commandsModule({
// HP takes priority over the default opacity
colormap = { ...colormap, opacity: hpOpacity || opacity };
if (viewport instanceof StackViewport) {
if (isStackViewportType(viewport)) {
viewport.setProperties({ colormap });
}
if (viewport instanceof VolumeViewport) {
if (isOrthographicViewportType(viewport)) {
if (!displaySetInstanceUID) {
const { viewports } = viewportGridService.getState();
displaySetInstanceUID = viewports.get(viewportId)?.displaySetInstanceUIDs[0];
@ -2145,7 +2151,7 @@ function commandsModule({
setViewportOrientation: ({ viewportId, orientation }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || viewport.type !== CoreEnums.ViewportType.ORTHOGRAPHIC) {
if (!viewport || !isOrthographicViewportType(viewport)) {
console.warn('Orientation can only be set on volume viewports');
return;
}
@ -2227,7 +2233,7 @@ function commandsModule({
const { viewport } = enabledElement;
if (viewport instanceof BaseVolumeViewport) {
if (isVolumeViewportType(viewport)) {
const camera = viewport.getCamera();
const rotAngle = (rotation * Math.PI) / 180;
const rotMat = mat4.identity(new Float32Array(16));

View File

@ -2,7 +2,7 @@ import React, { useEffect, useCallback, useState, ReactElement, useMemo } from '
import PropTypes from 'prop-types';
import debounce from 'lodash.debounce';
import { PanelSection, WindowLevel } from '@ohif/ui-next';
import { BaseVolumeViewport, Enums, eventTarget } from '@cornerstonejs/core';
import { Enums, eventTarget, utilities as csUtils, Types } from '@cornerstonejs/core';
import { useActiveViewportDisplaySets } from '@ohif/core';
import {
getNodeOpacity,
@ -27,10 +27,16 @@ const ViewportWindowLevel = ({
const getViewportsWithVolumeIds = useCallback(
(volumeIds: string[]) => {
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
const viewports = renderingEngine.getVolumeViewports();
// getVolumeViewports() was removed in the GenericViewport architecture
// (a PLANAR_NEXT viewport can be volume-capable without being a VolumeViewport).
// Official replacement: getViewports() + the viewportSupportsVolumeCompatibility
// capability guard (cornerstone codemod cornerstone3d/5/generic-viewport).
const viewports = renderingEngine
.getViewports()
.filter(csUtils.viewportSupportsVolumeCompatibility);
return viewports.filter(vp => {
const viewportVolumeIds = vp instanceof BaseVolumeViewport ? vp.getAllVolumeIds() : [];
const viewportVolumeIds = (vp as Types.IVolumeViewport).getAllVolumeIds();
return (
volumeIds.length === viewportVolumeIds.length &&
volumeIds.every(volumeId => viewportVolumeIds.includes(volumeId))
@ -124,8 +130,9 @@ const ViewportWindowLevel = ({
return;
}
const viewportVolumeIds =
viewport instanceof BaseVolumeViewport ? viewport.getAllVolumeIds() : [];
const viewportVolumeIds = csUtils.viewportSupportsVolumeId(viewport)
? (viewport as Types.IVolumeViewport).getAllVolumeIds()
: [];
const viewports = getViewportsWithVolumeIds(viewportVolumeIds);
viewports.forEach(vp => {

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useSystem } from '@ohif/core/src';
import { useViewportDisplaySets } from './useViewportDisplaySets';
import { BaseVolumeViewport } from '@cornerstonejs/core';
import { isVolumeViewportType } from '../utils/getLegacyViewportType';
/**
* Hook that provides measurement tracking information for a viewport
@ -54,7 +54,7 @@ export function useMeasurementTracking({ viewportId }: { viewportId: string }) {
const viewport = cornerstoneViewportService?.getCornerstoneViewport(viewportId);
const SeriesInstanceUID = backgroundDisplaySet.SeriesInstanceUID;
if (viewport instanceof BaseVolumeViewport) {
if (isVolumeViewportType(viewport)) {
const currentImageId = viewport?.getCurrentImageId();
if (!currentImageId) {

View File

@ -1,16 +1,13 @@
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import { useSystem } from '@ohif/core';
import { useViewportDisplaySets } from './useViewportDisplaySets';
import {
StackViewport,
Types,
VolumeViewport3D,
utilities,
Enums,
BaseVolumeViewport,
cache,
} from '@cornerstonejs/core';
import { Types, utilities, Enums, cache } from '@cornerstonejs/core';
import { getDataIdForViewport } from '../utils/getDataIdForViewport';
import {
isStackViewportType,
isVolumeViewportType,
isVolume3DViewportType,
} from '../utils/getLegacyViewportType';
import { WindowLevelPreset } from '../types/WindowLevel';
import { ColorbarPositionType, ColorbarOptions, ColorbarProperties } from '../types/Colorbar';
import { VolumeRenderingConfig } from '../types/VolumeRenderingConfig';
@ -130,7 +127,7 @@ export function useViewportRendering(
const [viewport, setViewport] = useState<Types.IViewport | null>(() =>
viewportId ? (cornerstoneViewportService.getCornerstoneViewport(viewportId) ?? null) : null
);
const [is3DVolume, setIs3DVolume] = useState(viewport instanceof VolumeViewport3D);
const [is3DVolume, setIs3DVolume] = useState(isVolume3DViewportType(viewport));
const [opacity, setOpacityState] = useState<number | undefined>();
const [opacityLinear, setOpacityLinearState] = useState<number | undefined>();
const [threshold, setThresholdState] = useState<number | undefined>();
@ -219,7 +216,7 @@ export function useViewportRendering(
return;
}
if (!(viewport instanceof BaseVolumeViewport)) {
if (!isVolumeViewportType(viewport)) {
return;
}
@ -264,7 +261,7 @@ export function useViewportRendering(
}, [allWindowLevelPresets, activeDisplaySetInstanceUID]);
useEffect(() => {
setIs3DVolume(viewport instanceof VolumeViewport3D);
setIs3DVolume(isVolume3DViewportType(viewport));
if (!viewport || !activeDisplaySetInstanceUID) {
return;
@ -573,7 +570,7 @@ export function useViewportRendering(
const setOpacity = useCallback(
(opacityValue: number) => {
if (!viewport || !(viewport instanceof BaseVolumeViewport)) {
if (!viewport || !isVolumeViewportType(viewport)) {
return;
}
@ -624,7 +621,7 @@ export function useViewportRendering(
const setThreshold = useCallback(
(thresholdValue: number) => {
if (!viewport || !(viewport instanceof BaseVolumeViewport)) {
if (!viewport || !isVolumeViewportType(viewport)) {
return;
}
@ -665,7 +662,7 @@ export function useViewportRendering(
return null;
}
if (viewport instanceof StackViewport) {
if (isStackViewportType(viewport)) {
const { colormap } = viewport.getProperties();
if (!colormap) {
return (

View File

@ -74,6 +74,10 @@ export default async function init({
rendering: {
...cornerstone.getConfiguration().rendering,
strictZSpacingForVolumeViewport: appConfig.strictZSpacingForVolumeViewport,
// Opt-in: route legacy viewport types through the new GenericViewport render
// paths while keeping the legacy public API via compatibility adapters.
// No-op on cornerstone builds that predate the GenericViewport architecture.
useGenericViewport: Boolean(appConfig.useGenericViewport),
},
});

View File

@ -10,6 +10,10 @@ import {
metaData,
} from '@cornerstonejs/core';
import { ViewportType } from '@cornerstonejs/core/enums';
import {
isVolume3DViewportType,
isVolumeViewportType,
} from '../../utils/getLegacyViewportType';
import {
Enums as csToolsEnums,
@ -312,7 +316,7 @@ class SegmentationService extends PubSubService {
let isConverted = false;
const defaultRepresentationType: csToolsEnums.SegmentationRepresentations =
csViewport.type === ViewportType.VOLUME_3D ? SURFACE : LABELMAP;
isVolume3DViewportType(csViewport) ? SURFACE : LABELMAP;
let representationTypeToUse = type || defaultRepresentationType;
if (representationTypeToUse === LABELMAP) {
@ -1591,8 +1595,7 @@ class SegmentationService extends PubSubService {
}
private determineViewportAndSegmentationType(csViewport, segmentation) {
const isVolumeViewport =
csViewport.type === ViewportType.ORTHOGRAPHIC || csViewport.type === ViewportType.VOLUME_3D;
const isVolumeViewport = isVolumeViewportType(csViewport);
const isVolumeSegmentation = 'volumeId' in segmentation.representationData[LABELMAP];
return { isVolumeViewport, isVolumeSegmentation };
}
@ -1623,7 +1626,7 @@ class SegmentationService extends PubSubService {
}
private async handleVolumeViewportCase(csViewport, segmentation, isVolumeSegmentation) {
if (csViewport.type === ViewportType.VOLUME_3D) {
if (isVolume3DViewportType(csViewport)) {
return {
representationTypeToUse: SURFACE,
isConverted: false,

View File

@ -7,6 +7,7 @@ import {
} from '@cornerstonejs/tools';
import { isAnyDisplaySetCommon } from '../../utils/isAnyDisplaySetCommon';
import { isVolume3DViewportType } from '../../utils/getLegacyViewportType';
const { createSynchronizer } = SynchronizerManager;
const { SEGMENTATION_REPRESENTATION_MODIFIED } = Enums.Events;
@ -89,7 +90,7 @@ const segmentationRepresentationModifiedCallback = async (
// Ensure the segmentation representation aligns with the target viewport type.
const type: Enums.SegmentationRepresentations =
viewport.type === CoreEnums.ViewportType.VOLUME_3D
isVolume3DViewportType(viewport)
? Enums.SegmentationRepresentations.Surface
: ((segmentationRepresentationType as Enums.SegmentationRepresentations) ??
Enums.SegmentationRepresentations.Labelmap);

View File

@ -3,16 +3,11 @@ import { PubSubService } from '@ohif/core';
import { Types as OhifTypes } from '@ohif/core';
import {
RenderingEngine,
StackViewport,
Types,
getRenderingEngine,
utilities as csUtils,
VolumeViewport,
VolumeViewport3D,
ECGViewport,
cache,
Enums as csEnums,
BaseVolumeViewport,
} from '@cornerstonejs/core';
import { utilities as csToolsUtils, Enums as csToolsEnums } from '@cornerstonejs/tools';
@ -39,6 +34,12 @@ import { usePositionPresentationStore } from '../../stores/usePositionPresentati
import { useSynchronizersStore } from '../../stores/useSynchronizersStore';
import { useSegmentationPresentationStore } from '../../stores/useSegmentationPresentationStore';
import getClosestOrientationFromIOP from '../../utils/isReferenceViewable';
import {
getLegacyViewportType,
isStackViewportType,
isVolume3DViewportType,
isVolumeViewportType,
} from '../../utils/getLegacyViewportType';
import { BlendModes } from '@cornerstonejs/core/enums';
const EVENTS = {
@ -49,10 +50,19 @@ const EVENTS = {
const MIN_STACK_VIEWPORTS_TO_ENQUEUE_RESIZE = 12;
const MIN_VOLUME_VIEWPORTS_TO_ENQUEUE_RESIZE = 6;
// Actor class names that represent a primary volume binding in a viewport.
// Legacy ORTHOGRAPHIC / VOLUME_3D render the source volume as a 'vtkVolume'
// actor. Under the GenericViewport-backed compat path (useGenericViewport),
// the MPR slice render path emits 'vtkImageSlice' actors instead, so the
// volume-set-vs-add optimization below must recognize both. (3D still uses
// 'vtkVolume'.) Without this, the optimization silently no-ops and a full
// setVolumes() teardown blanks MPR during SEG hydration.
const VOLUME_ACTOR_CLASS_NAMES = new Set(['vtkVolume', 'vtkImageSlice']);
function getVolumeActorReferencedIds(viewport: Types.IVolumeViewport): string[] {
const actors = viewport.getActors?.() ?? [];
return actors
.filter(ac => ac.actor?.getClassName?.() === 'vtkVolume')
.filter(ac => VOLUME_ACTOR_CLASS_NAMES.has(ac.actor?.getClassName?.() ?? ''))
.map(ac => ac.referencedId)
.filter(Boolean) as string[];
}
@ -81,7 +91,7 @@ function viewportMatchesDesiredVolumePresentation(
desiredViewportInfo: ViewportInfo
): boolean {
const desiredType = desiredViewportInfo.getViewportType();
if (viewport.type !== desiredType) {
if (getLegacyViewportType(viewport) !== desiredType) {
return false;
}
return (
@ -370,7 +380,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
return {
viewportType: viewportInfo.getViewportType(),
viewReference: csViewport instanceof VolumeViewport3D ? null : csViewport.getViewReference(),
viewReference: isVolume3DViewportType(csViewport) ? null : csViewport.getViewReference(),
viewPresentation: csViewport.getViewPresentation({ pan: true, zoom: true }),
viewportId,
};
@ -398,10 +408,9 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
return properties;
};
const properties =
csViewport instanceof BaseVolumeViewport
? new Map()
: cleanProperties(csViewport.getProperties());
const properties = isVolumeViewportType(csViewport)
? new Map()
: cleanProperties(csViewport.getProperties());
if (properties instanceof Map) {
const volumeIds = (csViewport as Types.IBaseVolumeViewport).getAllVolumeIds();
@ -840,10 +849,14 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
_presentations: Presentations = {}
): Promise<void> {
const [displaySet] = viewportData.data;
return viewport.setDataIds(displaySet.imageIds, {
groupId: displaySet.displaySetInstanceUID,
viewReference: viewportInfo.getViewReference(),
});
// CS3D's "redo viewports" replaced setDataIds with the generic
// setDisplaySets({ displaySetId }) API; the legacy adapters key off
// imageIds[0] as the displaySetId, so do the same here.
await viewport.setDisplaySets({ displaySetId: displaySet.imageIds[0] });
const viewReference = viewportInfo.getViewReference();
if (viewReference) {
viewport.setViewReference(viewReference);
}
}
private async _setStackViewport(
@ -1141,7 +1154,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
throw new Error('Background display set not found');
}
if (viewport.type === csEnums.ViewportType.VOLUME_3D) {
if (isVolume3DViewportType(viewport)) {
timeoutViewportCallback(() => {
viewportGridService.setDisplaySetsForViewport({
viewportId: viewport.id,
@ -1311,7 +1324,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
let displaySetPromise;
if (viewport instanceof VolumeViewport || viewport instanceof VolumeViewport3D) {
if (isVolumeViewportType(viewport)) {
displaySetPromise = this._setVolumeViewport(viewport, viewportData, viewportInfo).then(() => {
if (keepCamera) {
viewport.setCamera(viewportCamera);
@ -1320,7 +1333,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
});
}
if (viewport instanceof StackViewport) {
if (isStackViewportType(viewport)) {
displaySetPromise = this._setStackViewport(viewport, viewportData, viewportInfo);
}
@ -1338,7 +1351,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
viewportInfo: ViewportInfo,
presentations: Presentations = {}
): Promise<void> {
if (viewport instanceof StackViewport) {
if (isStackViewportType(viewport)) {
return this._setStackViewport(
viewport,
viewportData as StackViewportData,
@ -1347,7 +1360,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
);
}
if ([VolumeViewport, VolumeViewport3D].some(type => viewport instanceof type)) {
if (isVolumeViewportType(viewport)) {
return this._setVolumeViewport(
viewport as Types.IVolumeViewport,
viewportData as VolumeViewportData,
@ -1356,7 +1369,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
);
}
if (viewport instanceof ECGViewport) {
if (getLegacyViewportType(viewport) === csEnums.ViewportType.ECG) {
return this._setEcgViewport(
viewport as unknown as Types.IECGViewport,
viewportData as StackViewportData
@ -1508,7 +1521,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
}
const { properties } = lutPresentation;
if (viewport instanceof BaseVolumeViewport) {
if (isVolumeViewportType(viewport)) {
if (properties instanceof Map) {
properties.forEach((propertiesEntry, volumeId) => {
viewport.setProperties(propertiesEntry, volumeId);
@ -1557,7 +1570,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
const { segmentationId, type, hydrated } = presentationItem;
const { Labelmap, Surface } = csToolsEnums.SegmentationRepresentations;
const isVolume3D = viewport.type === csEnums.ViewportType.VOLUME_3D;
const isVolume3D = isVolume3DViewportType(viewport);
// Determine the appropriate segmentation representation for the viewport.
// If the current type is Surface but the viewport is not 3D, fallback to Labelmap.

View File

@ -2,10 +2,10 @@ import {
Types,
Enums,
getEnabledElementByViewportId,
VolumeViewport,
utilities,
} from '@cornerstonejs/core';
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
import { isOrthographicViewportType } from '../../utils/getLegacyViewportType';
import getCornerstoneBlendMode from '../../utils/getCornerstoneBlendMode';
import getCornerstoneOrientation from '../../utils/getCornerstoneOrientation';
import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType';
@ -105,7 +105,7 @@ const dataContains = ({ data, displaySetUID, imageId, viewport }): boolean => {
return !!data.imageIds.find(dataId => dataId === imageId);
}
if (imageId && (data.volumeId || viewport instanceof VolumeViewport)) {
if (imageId && (data.volumeId || isOrthographicViewportType(viewport))) {
const isAcquisition = !!viewport.getCurrentImageId();
if (!isAcquisition) {

View File

@ -1,8 +1,9 @@
import { VolumeViewport, metaData, utilities } from '@cornerstonejs/core';
import { metaData, utilities } from '@cornerstonejs/core';
import { IStackViewport, IVolumeViewport } from '@cornerstonejs/core/types';
import { AnnotationDisplayTool, drawing } from '@cornerstonejs/tools';
import { guid, b64toBlob } from '@ohif/core/src/utils';
import OverlayPlaneModuleProvider from './OverlayPlaneModuleProvider';
import { isOrthographicViewportType } from '../utils/getLegacyViewportType';
interface CachedStat {
color: number[]; // [r, g, b, a]
@ -49,7 +50,7 @@ class ImageOverlayViewerTool extends AnnotationDisplayTool {
onSetToolDisabled = (): void => {};
protected getReferencedImageId(viewport: IStackViewport | IVolumeViewport): string {
if (viewport instanceof VolumeViewport) {
if (isOrthographicViewportType(viewport)) {
return;
}

View File

@ -1,9 +1,10 @@
import { utils } from '@ohif/core';
import React, { useEffect, useState } from 'react';
import html2canvas from 'html2canvas';
import { getEnabledElement, StackViewport, BaseVolumeViewport } from '@cornerstonejs/core';
import { getEnabledElement } from '@cornerstonejs/core';
import { ToolGroupManager, segmentation, Enums } from '@cornerstonejs/tools';
import { getEnabledElement as OHIFgetEnabledElement } from '../state';
import { isStackViewportType, isVolumeViewportType } from './getLegacyViewportType';
import { useSystem } from '@ohif/core/src';
const { downloadUrl } = utils;
@ -127,10 +128,10 @@ const CornerstoneViewportDownloadForm = ({
const viewPresentation = viewport.getViewPresentation?.();
const viewRef = viewport.getViewReference?.();
if (downloadViewport instanceof StackViewport) {
if (isStackViewportType(downloadViewport)) {
const imageId = viewport.getCurrentImageId();
await downloadViewport.setStack([imageId]);
} else if (downloadViewport instanceof BaseVolumeViewport) {
} else if (isVolumeViewportType(downloadViewport)) {
const volumeIds = viewport.getAllVolumeIds();
await downloadViewport.setVolumes([{ volumeId: volumeIds[0] }]);
}

View File

@ -36,7 +36,7 @@ type CreateSegmentationForViewportParams = {
export async function createSegmentationForViewport(
servicesManager: ServicesManager,
{ viewportId, options = {}, segmentationType }: CreateSegmentationForViewportParams
): Promise<string> {
): Promise<string | undefined> {
const { viewportGridService, displaySetService, segmentationService } = servicesManager.services;
const { viewports } = viewportGridService.getState();
const targetViewportId = viewportId;
@ -44,7 +44,15 @@ export async function createSegmentationForViewport(
const viewport = viewports.get(targetViewportId);
// Todo: add support for multiple display sets
const displaySetInstanceUID = options.displaySetInstanceUID || viewport.displaySetInstanceUIDs[0];
// Guard against the grid not having this viewport yet (a transient race when
// "Add segmentation" runs before the viewport is registered in the grid). Bail
// gracefully instead of throwing, which would surface an uncaught error overlay.
const displaySetInstanceUID =
options.displaySetInstanceUID || viewport?.displaySetInstanceUIDs?.[0];
if (!displaySetInstanceUID) {
return undefined;
}
const segs = segmentationService.getSegmentations();

View File

@ -30,10 +30,14 @@ const mockDocument = {
},
};
Object.defineProperty(global, 'document', {
writable: true,
value: mockDocument,
});
// jsdom (jest 30) makes the global `document` non-configurable, so it can't be
// replaced wholesale. Route the methods the code under test uses onto the real
// jsdom document; assertions still target mockDocument's jest.fn() references.
const doc = document as unknown as typeof mockDocument;
doc.addEventListener = mockDocument.addEventListener;
doc.createElement = mockDocument.createElement;
document.body.appendChild = mockDocument.body.appendChild as typeof document.body.appendChild;
document.body.removeChild = mockDocument.body.removeChild as typeof document.body.removeChild;
describe('generateSegmentationCSVReport', () => {
const mockInfo = {

View File

@ -0,0 +1,66 @@
import { Enums as csEnums, Types as csTypes } from '@cornerstonejs/core';
const { ViewportType } = csEnums;
type ViewportLike = {
requestedType?: csEnums.ViewportType;
type?: csEnums.ViewportType;
};
/**
* Returns the legacy/requested viewport type, transparent across GenericViewport
* compatibility remapping.
*
* When `rendering.useGenericViewport` is enabled, a viewport requested as
* `STACK` or `ORTHOGRAPHIC` has runtime `viewport.type === PLANAR_NEXT`, and a
* `VOLUME_3D` request has `VOLUME_3D_NEXT`. The rendering engine records the
* original request on `viewport.requestedType`, which this reads. Falls back to
* `viewport.type` for legacy/non-remapped viewports (and older cornerstone builds
* that do not populate `requestedType`).
*
* Use this instead of `viewport instanceof StackViewport/VolumeViewport/...` or
* raw `viewport.type === ViewportType.X` when branching on the legacy viewport
* type. For "does this viewport support operation X" questions, prefer the
* cornerstone capability guards (`utilities.viewportSupports*`) instead.
*/
export function getLegacyViewportType(
viewport: unknown
): csEnums.ViewportType | undefined {
const vp = viewport as ViewportLike | null | undefined;
return vp?.requestedType ?? vp?.type;
}
/** Legacy STACK viewport (image stack). Replaces `instanceof StackViewport`. */
export function isStackViewportType(
viewport: unknown
): viewport is csTypes.IStackViewport {
return getLegacyViewportType(viewport) === ViewportType.STACK;
}
/** Legacy ORTHOGRAPHIC (MPR) viewport. Replaces `instanceof VolumeViewport`. */
export function isOrthographicViewportType(
viewport: unknown
): viewport is csTypes.IVolumeViewport {
return getLegacyViewportType(viewport) === ViewportType.ORTHOGRAPHIC;
}
/** Legacy VOLUME_3D viewport. Replaces `instanceof VolumeViewport3D`. */
export function isVolume3DViewportType(
viewport: unknown
): viewport is csTypes.IVolumeViewport {
return getLegacyViewportType(viewport) === ViewportType.VOLUME_3D;
}
/**
* Legacy ORTHOGRAPHIC or VOLUME_3D viewport (i.e. a `BaseVolumeViewport`).
* Replaces `instanceof BaseVolumeViewport`.
*/
export function isVolumeViewportType(
viewport: unknown
): viewport is csTypes.IVolumeViewport {
const legacyType = getLegacyViewportType(viewport);
return (
legacyType === ViewportType.ORTHOGRAPHIC ||
legacyType === ViewportType.VOLUME_3D
);
}

View File

@ -32,8 +32,8 @@
"start": "yarn run dev"
},
"peerDependencies": {
"@cornerstonejs/core": "4.22.10",
"@cornerstonejs/tools": "4.22.10",
"@cornerstonejs/core": "5.0.0",
"@cornerstonejs/tools": "5.0.0",
"@ohif/core": "3.13.0-beta.88",
"@ohif/extension-cornerstone-dicom-sr": "3.13.0-beta.88",
"@ohif/extension-default": "3.13.0-beta.88",

View File

@ -362,9 +362,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }:
}
const referencedVolume =
csTools.utilities.segmentation.getReferenceVolumeForSegmentationVolume(
labelmapVolume.volumeId
);
csTools.utilities.segmentation.getReferenceVolumeForSegmentation(segmentationId);
if (!referencedVolume) {
report[id] = segReport;

View File

@ -42,8 +42,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/core": "4.22.10",
"@cornerstonejs/tools": "4.22.10",
"@cornerstonejs/core": "5.0.0",
"@cornerstonejs/tools": "5.0.0",
"@ohif/core": "3.13.0-beta.88",
"@ohif/extension-cornerstone": "3.13.0-beta.88",
"@ohif/extension-default": "3.13.0-beta.88",

View File

@ -34,8 +34,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/core": "4.22.10",
"@cornerstonejs/tools": "4.22.10",
"@cornerstonejs/core": "5.0.0",
"@cornerstonejs/tools": "5.0.0",
"@ohif/core": "3.13.0-beta.88",
"@ohif/extension-cornerstone-dicom-sr": "3.13.0-beta.88",
"@ohif/extension-ultrasound-pleura-bline": "3.13.0-beta.88",

View File

@ -68,6 +68,7 @@
"test:e2e:debug": "cross-env TEST_ENV=true npx playwright test --debug",
"test:e2e:dist": "lerna run test:e2e:dist --stream",
"test:e2e:serve": "yarn test:data && lerna run test:e2e:serve --stream",
"review:screenshots": "node scripts/screenshot-reviewer.mjs",
"see-changed": "lerna changed",
"docs:preview": "lerna run docs:preview --stream",
"docs:publish": "chmod +x ./build-and-publish-docs.sh && ./build-and-publish-docs.sh",
@ -169,7 +170,8 @@
"flatted": "3.4.2",
"handlebars": "4.7.9",
"protobufjs": "7.5.7",
"tmp": "0.2.6"
"tmp": "0.2.6",
"shell-quote": "1.8.4"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@ -54,7 +54,7 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2",
"@cornerstonejs/codec-openjpeg": "1.3.0",
"@cornerstonejs/codec-openjph": "2.4.7",
"@cornerstonejs/dicom-image-loader": "4.22.10",
"@cornerstonejs/dicom-image-loader": "5.0.0",
"@emotion/serialize": "1.3.3",
"@ohif/core": "3.13.0-beta.88",
"@ohif/extension-cornerstone": "3.13.0-beta.88",

View File

@ -37,8 +37,9 @@
"@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2",
"@cornerstonejs/codec-openjpeg": "1.3.0",
"@cornerstonejs/codec-openjph": "2.4.7",
"@cornerstonejs/core": "4.22.10",
"@cornerstonejs/dicom-image-loader": "4.22.10",
"@cornerstonejs/core": "5.0.0",
"@cornerstonejs/dicom-image-loader": "5.0.0",
"@cornerstonejs/metadata": "5.0.0",
"@ohif/ui": "3.13.0-beta.88",
"cornerstone-math": "0.1.10",
"dicom-parser": "1.8.21"

View File

@ -95,6 +95,14 @@ declare global {
useNorm16Texture?: boolean;
useCPURendering?: boolean;
strictZSpacingForVolumeViewport?: boolean;
/**
* Routes legacy cornerstone viewport types (STACK / ORTHOGRAPHIC / VOLUME_3D / etc.)
* through the new GenericViewport-backed render paths while preserving the legacy
* public API via compatibility adapters. Maps to cornerstone
* config.rendering.useGenericViewport. Defaults to false (legacy classes).
* Requires @cornerstonejs/core >= 5.0.0-beta (GenericViewport architecture).
*/
useGenericViewport?: boolean;
useCursors?: boolean;
maxCacheSize?: number;
max3DTextureSize?: number;

View File

@ -30,7 +30,7 @@ describe('Queue', () => {
// });
// const end = await timer(threshold);
// expect(end - start >= 2 * threshold).toBe(true);
// expect(mockedTimeout).toBeCalledTimes(2);
// expect(mockedTimeout).toHaveBeenCalledTimes(2);
// });
it('should prevent task execution when queue limit is reached', async () => {
const queue = new Queue(1);
@ -46,7 +46,7 @@ describe('Queue', () => {
}
const elapsed = await promise;
expect(elapsed >= threshold && elapsed < 2 * threshold).toBe(true);
expect(mockedTimeout).toBeCalledTimes(1);
expect(mockedTimeout).toHaveBeenCalledTimes(1);
});
it('should safely bind tasks to the queue', async () => {
const queue = new Queue(1);
@ -57,13 +57,13 @@ describe('Queue', () => {
const promise = timer(threshold).then(time => time - start);
await timer(threshold);
expect(Date.now() - start < threshold).toBe(true);
expect(mockedErrorHandler).toBeCalledTimes(1);
expect(mockedErrorHandler).nthCalledWith(
expect(mockedErrorHandler).toHaveBeenCalledTimes(1);
expect(mockedErrorHandler).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ message: 'Queue limit reached' })
);
const elapsed = await promise;
expect(elapsed >= threshold && elapsed < 2 * threshold).toBe(true);
expect(mockedTimeout).toBeCalledTimes(1);
expect(mockedTimeout).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,17 +1,24 @@
/**
* @jest-environment node
*
* Runs in node (not jsdom): jsdom (jest 30) exposes `window.location` as an
* unforgeable, non-configurable property that can't be replaced or stubbed.
* absoluteUrl only reads `window.location.origin` and this suite imports nothing
* that needs the DOM, so we run without jsdom and provide a plain `window`.
*/
import absoluteUrl from './absoluteUrl';
describe('absoluteUrl', () => {
const setOrigin = url => {
global.window = { location: { origin: url } };
};
afterEach(() => {
delete global.window;
});
test('should return /path_1/path_2/path_3/path_to_destination when the window.location.origin is http://dummy.com/path_1/path_2 and the path is /path_3/path_to_destination', () => {
let global = {
window: Object.create(window),
};
const url = 'http://dummy.com/path_1/path_2';
Object.defineProperty(window, 'location', {
value: {
origin: url,
},
writable: true,
});
setOrigin('http://dummy.com/path_1/path_2');
const absoluteUrlOutput = absoluteUrl('/path_3/path_to_destination');
expect(absoluteUrlOutput).toEqual('/path_1/path_2/path_3/path_to_destination');
});
@ -22,22 +29,13 @@ describe('absoluteUrl', () => {
});
test('should return the original path when there path in the window.origin after the domain and port', () => {
delete global.window.location;
const url = 'http://dummy.com';
global.window.location = {
origin: url,
};
setOrigin('http://dummy.com');
const absoluteUrlOutput = absoluteUrl('path_1/path_2/path_3');
expect(absoluteUrlOutput).toEqual('/path_1/path_2/path_3');
});
test('should be able to return the absolute path even when the path contains duplicates', () => {
global.window ||= Object.create(window);
const url = 'http://dummy.com';
delete global.window.location;
global.window.location = {
origin: url,
};
setOrigin('http://dummy.com');
const absoluteUrlOutput = absoluteUrl('path_1/path_1/path_1');
expect(absoluteUrlOutput).toEqual('/path_1/path_1/path_1');
});

View File

@ -31,7 +31,7 @@ describe('addServers', () => {
test('should be able to add a server and dispatch to the store successfuly', () => {
addServers(servers, store);
expect(store.dispatch).toBeCalledWith({
expect(store.dispatch).toHaveBeenCalledWith({
server: {
authority: 'http://127.0.0.1/auth/realms/ohif',
client_id: 'ohif-viewer',
@ -43,7 +43,7 @@ describe('addServers', () => {
},
type: 'ADD_SERVER',
});
expect(store.dispatch).toBeCalledWith({
expect(store.dispatch).toHaveBeenCalledWith({
server: {
imageRendering: 'wadors',
name: 'DCM4CHEE',
@ -59,19 +59,19 @@ describe('addServers', () => {
});
test('should throw an error if servers list is not defined', () => {
expect(() => addServers(undefined, store)).toThrowError(
expect(() => addServers(undefined, store)).toThrow(
new Error('The servers and store must be defined')
);
});
test('should throw an error if store is not defined', () => {
expect(() => addServers(servers, undefined)).toThrowError(
expect(() => addServers(servers, undefined)).toThrow(
new Error('The servers and store must be defined')
);
});
test('should throw an error when both server and store are not defined', () => {
expect(() => addServers(undefined, undefined)).toThrowError(
expect(() => addServers(undefined, undefined)).toThrow(
new Error('The servers and store must be defined')
);
});

View File

@ -69,12 +69,12 @@ describe('hierarchicalListUtils', function () {
const fn = jest.fn();
forEach(sharedList, fn);
expect(fn).toHaveBeenCalledTimes(6);
expect(fn).nthCalledWith(1, '1.2.3.1', '1.2.3.1.1');
expect(fn).nthCalledWith(2, '1.2.3.1', '1.2.3.1.2');
expect(fn).nthCalledWith(3, '1.2.3.2');
expect(fn).nthCalledWith(4, '1.2.3.3', '1.2.3.3.1');
expect(fn).nthCalledWith(5, '1.2.3.3', '1.2.3.3.2', '1.2.3.3.2.1');
expect(fn).nthCalledWith(6, '1.2.3.3', '1.2.3.3.2', '1.2.3.3.2.2');
expect(fn).toHaveBeenNthCalledWith(1, '1.2.3.1', '1.2.3.1.1');
expect(fn).toHaveBeenNthCalledWith(2, '1.2.3.1', '1.2.3.1.2');
expect(fn).toHaveBeenNthCalledWith(3, '1.2.3.2');
expect(fn).toHaveBeenNthCalledWith(4, '1.2.3.3', '1.2.3.3.1');
expect(fn).toHaveBeenNthCalledWith(5, '1.2.3.3', '1.2.3.3.2', '1.2.3.3.2.1');
expect(fn).toHaveBeenNthCalledWith(6, '1.2.3.3', '1.2.3.3.2', '1.2.3.3.2.2');
});
});

View File

@ -43,7 +43,7 @@ describe('progressTrackingUtils', () => {
const promises = [Promise.resolve('A'), Promise.resolve('B'), Promise.resolve('C')];
promises.forEach(promise => void utils.waitOn(list, promise));
return Promise.all(promises).then(() => {
expect(observer).toBeCalledTimes(6);
expect(observer).toHaveBeenCalledTimes(6);
[
{
failures: 0,
@ -83,7 +83,7 @@ describe('progressTrackingUtils', () => {
},
].forEach((item, i) => {
const result = expect.objectContaining(item);
expect(observer).nthCalledWith(i + 1, result, list);
expect(observer).toHaveBeenNthCalledWith(i + 1, result, list);
});
expect(utils.getOverallProgress(list)).toStrictEqual({
failures: 0,
@ -102,11 +102,11 @@ describe('progressTrackingUtils', () => {
download.deferred.resolve(fakeRequest(update));
utils.waitOn(list, processing);
return processing.then(() => {
expect(update).toBeCalledTimes(4);
expect(update).toHaveBeenCalledTimes(4);
[0.25, 0.5, 0.75, 1.0].forEach(
(value, i) => void expect(update).nthCalledWith(i + 1, value)
(value, i) => void expect(update).toHaveBeenNthCalledWith(i + 1, value)
);
expect(observer).toBeCalledTimes(7);
expect(observer).toHaveBeenCalledTimes(7);
[
{
failures: 0,
@ -152,7 +152,7 @@ describe('progressTrackingUtils', () => {
},
].forEach((item, i) => {
const result = expect.objectContaining(item);
expect(observer).nthCalledWith(i + 1, result, list);
expect(observer).toHaveBeenNthCalledWith(i + 1, result, list);
});
});
});

View File

@ -29,6 +29,7 @@ const OHIF_OPEN = process.env.OHIF_OPEN !== 'false';
// Ignore node_modules except @cornerstonejs (symlinked local development).
const WATCH_IGNORED = /node_modules[\\/](?!@cornerstonejs(?:[\\/]|$))/;
const WATCH_AGGREGATE_TIMEOUT = Number(process.env.WATCH_AGGREGATE_TIMEOUT || 1500);
export default defineConfig({
dev: {
@ -91,6 +92,7 @@ export default defineConfig({
watchOptions: {
ignored: WATCH_IGNORED,
followSymlinks: true,
aggregateTimeout: WATCH_AGGREGATE_TIMEOUT,
},
},
},

View File

@ -0,0 +1,700 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Screenshot Review</title>
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
<style>
:root {
color-scheme: dark;
--bg: #101214;
--panel: #191d21;
--panel-2: #22272e;
--line: #38414a;
--text: #eef2f5;
--muted: #a8b3bd;
--accent: #4cb5ae;
--accent-2: #d7a84d;
--danger: #d85f68;
--ok: #6fca7f;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}
button,
input,
textarea {
font: inherit;
}
button {
border: 1px solid var(--line);
background: var(--panel-2);
color: var(--text);
border-radius: 6px;
padding: 7px 10px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #061110;
font-weight: 700;
}
button.ghost {
background: transparent;
}
button.active {
background: #293d3d;
border-color: var(--accent);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.app {
display: grid;
grid-template-columns: 330px minmax(0, 1fr);
height: 100vh;
min-height: 620px;
}
.sidebar {
min-width: 0;
border-right: 1px solid var(--line);
background: var(--panel);
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 14px;
border-bottom: 1px solid var(--line);
display: grid;
gap: 10px;
}
.title-row,
.toolbar,
.meta-row {
display: flex;
align-items: center;
gap: 8px;
}
.title-row {
justify-content: space-between;
}
h1 {
margin: 0;
font-size: 16px;
line-height: 1.2;
}
.count {
color: var(--muted);
font-size: 12px;
}
.search {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
background: #0d0f11;
color: var(--text);
padding: 8px 10px;
}
.check-row {
color: var(--muted);
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.list {
overflow: auto;
padding: 8px;
}
.item {
display: grid;
gap: 5px;
width: 100%;
text-align: left;
margin-bottom: 6px;
padding: 9px;
border-radius: 6px;
}
.item.active {
outline: 1px solid var(--accent);
background: #203030;
}
.item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 700;
}
.item-path {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.section-title {
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0;
margin: 12px 4px 7px;
text-transform: uppercase;
}
.badge {
border-radius: 999px;
padding: 2px 7px;
font-size: 11px;
color: #07100b;
background: var(--ok);
font-weight: 700;
}
.badge.warn {
background: var(--accent-2);
}
.main {
min-width: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
background: #0b0d0f;
}
.topbar {
border-bottom: 1px solid var(--line);
padding: 12px 14px;
background: var(--panel);
display: grid;
gap: 10px;
}
.toolbar {
flex-wrap: wrap;
}
.segmented {
display: inline-flex;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
}
.segmented button {
border: 0;
border-radius: 0;
border-right: 1px solid var(--line);
}
.segmented button:last-child {
border-right: 0;
}
.spacer {
flex: 1;
}
.path {
color: var(--muted);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.viewer {
min-height: 0;
overflow: auto;
padding: 14px;
}
.side-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
min-height: 0;
align-items: start;
}
.frame {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto auto;
border: 1px solid var(--line);
background: #050607;
border-radius: 6px;
overflow: hidden;
}
.frame-label {
padding: 8px 10px;
border-bottom: 1px solid var(--line);
background: #111418;
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.image-wrap {
min-height: 0;
display: grid;
place-items: center;
overflow: auto;
}
img {
display: block;
max-width: 100%;
height: auto;
image-rendering: auto;
}
.image-wrap img {
width: auto;
height: auto;
max-width: 100%;
max-height: calc(100vh - 250px);
object-fit: contain;
}
.compare {
width: min(100%, 1280px);
margin: 0 auto;
display: grid;
gap: 10px;
}
.slider-stage,
.blink-stage {
position: relative;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 6px;
background: #050607;
}
.slider-stage img,
.blink-stage img {
width: 100%;
max-width: none;
}
.slider-stage .after {
position: absolute;
inset: 0 auto 0 0;
overflow: hidden;
border-right: 2px solid var(--accent);
}
.slider-stage .after img {
width: var(--image-width, 100%);
}
.slider-range,
.blink-range {
width: 100%;
}
.empty {
color: var(--muted);
display: grid;
place-items: center;
height: 100%;
text-align: center;
}
.notes {
border-top: 1px solid var(--line);
background: var(--panel);
padding: 12px 14px;
display: grid;
gap: 8px;
}
textarea {
width: 100%;
min-height: 88px;
resize: vertical;
border: 1px solid var(--line);
border-radius: 6px;
background: #0d0f11;
color: var(--text);
padding: 10px;
}
.status {
min-height: 18px;
color: var(--muted);
font-size: 12px;
}
.error {
color: var(--danger);
}
</style>
</head>
<body>
<div class="app" x-data="reviewer()" x-init="init()">
<aside class="sidebar">
<div class="sidebar-header">
<div class="title-row">
<h1>Screenshot Review</h1>
<button class="ghost" @click="refresh()">Refresh</button>
</div>
<input class="search" type="search" placeholder="Filter" x-model="query" />
<label class="check-row">
<input type="checkbox" x-model="hideStaged" />
<span>Hide staged</span>
</label>
<div class="count" x-text="`${filteredEntries.length} changed screenshots`"></div>
</div>
<div class="list">
<div class="section-title" x-show="unstagedEntries.length">Unstaged</div>
<template x-for="entry in unstagedEntries" :key="entry.path">
<button
class="item"
:class="{ active: selected?.path === entry.path }"
@click="select(entry)"
>
<div class="meta-row">
<div class="item-name" x-text="entry.name"></div>
<span class="badge warn" x-show="entry.partiallyStaged">partial</span>
</div>
<div class="item-path" x-text="entry.spec"></div>
</button>
</template>
<div class="section-title" x-show="stagedEntries.length">Staged</div>
<template x-for="entry in stagedEntries" :key="entry.path">
<button
class="item"
:class="{ active: selected?.path === entry.path }"
@click="select(entry)"
>
<div class="item-name" x-text="entry.name"></div>
<div class="item-path" x-text="entry.spec"></div>
</button>
</template>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="toolbar">
<div class="segmented">
<button :class="{ active: mode === 'side' }" @click="mode = 'side'">Side by side</button>
<button :class="{ active: mode === 'slider' }" @click="mode = 'slider'">Slider</button>
<button :class="{ active: mode === 'blink' }" @click="mode = 'blink'">Blink</button>
</div>
<template x-if="mode === 'blink'">
<label class="check-row">
<span>Seconds</span>
<input class="blink-range" type="range" min="0.15" max="3" step="0.05" x-model.number="blinkSeconds" />
<span x-text="blinkSeconds.toFixed(2)"></span>
</label>
</template>
<div class="spacer"></div>
<button class="primary" :disabled="!selected || selected.staged" @click="stageSelected()">git add</button>
<button @click="copyCurrentNote()" :disabled="!selected">Copy note</button>
<button @click="copyAllNotes()">Copy all notes</button>
</div>
<div class="path" x-text="selected?.path || 'No screenshot selected'"></div>
<div class="status" :class="{ error: statusIsError }" x-text="status"></div>
</header>
<section class="viewer" x-show="selected">
<template x-if="mode === 'side'">
<div class="side-grid">
<div class="frame">
<div class="frame-label">Before</div>
<div class="image-wrap">
<img :src="beforeUrl()" alt="Before screenshot" />
</div>
</div>
<div class="frame">
<div class="frame-label">After</div>
<div class="image-wrap">
<img :src="afterUrl()" alt="After screenshot" />
</div>
</div>
</div>
</template>
<template x-if="mode === 'slider'">
<div class="compare">
<div class="slider-stage" x-ref="sliderStage" :style="`--image-width: ${sliderWidth}px`">
<img :src="beforeUrl()" alt="Before screenshot" @load="syncSliderWidth()" />
<div class="after" :style="`width: ${slider}%`">
<img :src="afterUrl()" alt="After screenshot" />
</div>
</div>
<input class="slider-range" type="range" min="0" max="100" x-model.number="slider" />
</div>
</template>
<template x-if="mode === 'blink'">
<div class="compare">
<div class="blink-stage">
<img :src="blinkShowAfter ? afterUrl() : beforeUrl()" alt="Blink screenshot" />
</div>
<div class="meta-row">
<span class="badge" x-text="blinkShowAfter ? 'after' : 'before'"></span>
<button @click="blinkPaused = !blinkPaused" x-text="blinkPaused ? 'Resume' : 'Pause'"></button>
</div>
</div>
</template>
</section>
<section class="empty" x-show="!selected">
<div>No changed screenshots found.</div>
</section>
<footer class="notes">
<div class="meta-row">
<strong>Notes</strong>
<span class="count" x-show="selected" x-text="selected.name"></span>
</div>
<textarea
:disabled="!selected"
x-model="currentNote"
@input="saveNote()"
placeholder="Review note"
></textarea>
</footer>
</main>
</div>
<script>
function reviewer() {
return {
entries: [],
selected: null,
query: '',
hideStaged: false,
mode: 'side',
slider: 50,
sliderWidth: 1280,
blinkSeconds: 0.6,
blinkShowAfter: false,
blinkPaused: false,
blinkTimer: null,
status: '',
statusIsError: false,
cacheKey: Date.now(),
notes: JSON.parse(localStorage.getItem('screenshot-review-notes') || '{}'),
get filteredEntries() {
const q = this.query.trim().toLowerCase();
return this.entries.filter((entry) => {
if (this.hideStaged && entry.staged) {
return false;
}
if (!q) {
return true;
}
return `${entry.path} ${entry.name} ${entry.spec}`.toLowerCase().includes(q);
});
},
get unstagedEntries() {
return this.filteredEntries.filter((entry) => !entry.staged);
},
get stagedEntries() {
return this.hideStaged
? []
: this.filteredEntries.filter((entry) => entry.staged);
},
get currentNote() {
return this.selected ? this.notes[this.selected.path] || '' : '';
},
set currentNote(value) {
if (!this.selected) {
return;
}
this.notes[this.selected.path] = value;
},
async init() {
await this.refresh();
this.startBlinkTimer();
this.$watch('hideStaged', () => this.ensureVisibleSelection());
this.$watch('query', () => this.ensureVisibleSelection());
this.$watch('blinkSeconds', () => this.startBlinkTimer());
this.$watch('mode', () => {
if (this.mode === 'slider') {
this.$nextTick(() => this.syncSliderWidth());
}
});
},
async refresh() {
this.setStatus('Loading screenshots...');
const response = await fetch('/api/list');
if (!response.ok) {
this.setStatus('Could not load screenshots', true);
return;
}
const data = await response.json();
this.entries = data.entries;
this.cacheKey = Date.now();
this.ensureVisibleSelection();
this.setStatus(this.entries.length ? 'Ready' : 'No changed screenshots');
},
select(entry) {
this.selected = entry;
this.cacheKey = Date.now();
this.$nextTick(() => this.syncSliderWidth());
},
ensureVisibleSelection() {
const currentPath = this.selected?.path;
const visibleEntries = this.filteredEntries;
this.selected =
visibleEntries.find((entry) => entry.path === currentPath) ||
visibleEntries[0] ||
null;
},
beforeUrl() {
return this.selected
? `/api/image?version=head&path=${encodeURIComponent(this.selected.path)}&t=${this.cacheKey}`
: '';
},
afterUrl() {
return this.selected
? `/api/image?version=worktree&path=${encodeURIComponent(this.selected.path)}&t=${this.cacheKey}`
: '';
},
syncSliderWidth() {
const stage = this.$refs.sliderStage;
if (!stage) {
return;
}
this.sliderWidth = stage.getBoundingClientRect().width || 1280;
},
startBlinkTimer() {
if (this.blinkTimer) {
clearInterval(this.blinkTimer);
}
this.blinkTimer = setInterval(() => {
if (!this.blinkPaused) {
this.blinkShowAfter = !this.blinkShowAfter;
}
}, Math.max(150, this.blinkSeconds * 1000));
},
saveNote() {
localStorage.setItem('screenshot-review-notes', JSON.stringify(this.notes));
},
notesMarkdown(paths = Object.keys(this.notes)) {
return paths
.filter((path) => (this.notes[path] || '').trim())
.map((path) => `## ${path}\n\n${this.notes[path].trim()}`)
.join('\n\n');
},
async copyCurrentNote() {
if (!this.selected) {
return;
}
await navigator.clipboard.writeText(this.notesMarkdown([this.selected.path]));
this.setStatus('Copied current note');
},
async copyAllNotes() {
await navigator.clipboard.writeText(this.notesMarkdown());
this.setStatus('Copied all notes');
},
async stageSelected() {
if (!this.selected) {
return;
}
const stagedPath = this.selected.path;
const visibleBeforeStage = this.filteredEntries;
const stagedIndex = visibleBeforeStage.findIndex((entry) => entry.path === stagedPath);
const nextPath =
visibleBeforeStage[stagedIndex + 1]?.path ||
visibleBeforeStage[stagedIndex - 1]?.path ||
null;
this.setStatus(`Staging ${this.selected.name}...`);
const response = await fetch('/api/stage', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: this.selected.path }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
this.setStatus(data.error || 'git add failed', true);
return;
}
this.setStatus(`Staged ${this.selected.name}`);
await this.refresh();
this.selected =
this.filteredEntries.find((entry) => entry.path === nextPath) ||
this.unstagedEntries[0] ||
this.filteredEntries[0] ||
null;
},
setStatus(message, isError = false) {
this.status = message;
this.statusIsError = isError;
},
};
}
</script>
</body>
</html>

View File

@ -0,0 +1,218 @@
#!/usr/bin/env node
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { extname, join, normalize, relative, resolve } from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
const htmlPath = resolve(
fileURLToPath(new URL('./screenshot-reviewer.html', import.meta.url))
);
const screenshotRoot = 'tests/screenshots';
const port = Number(process.env.SCREENSHOT_REVIEW_PORT || 4777);
function runGit(args, options = {}) {
return new Promise((resolvePromise, reject) => {
const child = spawn('git', args, {
cwd: repoRoot,
stdio: ['ignore', 'pipe', 'pipe'],
...options,
});
const stdout = [];
const stderr = [];
child.stdout.on('data', (chunk) => stdout.push(chunk));
child.stderr.on('data', (chunk) => stderr.push(chunk));
child.on('error', reject);
child.on('close', (code) => {
const out = Buffer.concat(stdout);
const err = Buffer.concat(stderr).toString('utf8').trim();
if (code !== 0) {
const error = new Error(err || `git exited with status ${code}`);
error.stdout = out;
error.stderr = err;
reject(error);
return;
}
resolvePromise(out);
});
});
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
});
response.end(JSON.stringify(payload));
}
function sendText(response, statusCode, text) {
response.writeHead(statusCode, {
'content-type': 'text/plain; charset=utf-8',
'cache-control': 'no-store',
});
response.end(text);
}
function parseStatusPorcelain(buffer) {
const entries = [];
const parts = buffer.toString('utf8').split('\0').filter(Boolean);
for (let index = 0; index < parts.length; index++) {
const record = parts[index];
const x = record[0];
const y = record[1];
const rawPath = record.slice(3);
let filePath = rawPath;
if (x === 'R' || x === 'C') {
index += 1;
filePath = parts[index] || rawPath;
}
if (!filePath.endsWith('.png') || !filePath.startsWith(`${screenshotRoot}/`)) {
continue;
}
const segments = filePath.split('/');
entries.push({
path: filePath,
name: segments.at(-1),
spec: segments.slice(0, -1).join('/'),
x,
y,
staged: x !== ' ' && x !== '?',
partiallyStaged: x !== ' ' && y !== ' ',
worktreeChanged: y !== ' ',
});
}
entries.sort((a, b) => a.path.localeCompare(b.path));
return entries;
}
function resolveScreenshotPath(rawPath) {
if (!rawPath || rawPath.includes('\0')) {
throw new Error('Invalid path');
}
const normalizedPath = normalize(rawPath).replaceAll('\\', '/');
const absolutePath = resolve(repoRoot, normalizedPath);
const relativePath = relative(repoRoot, absolutePath).replaceAll('\\', '/');
if (
relativePath.startsWith('..') ||
relativePath === '' ||
!relativePath.startsWith(`${screenshotRoot}/`) ||
extname(relativePath).toLowerCase() !== '.png'
) {
throw new Error('Path must be a PNG under tests/screenshots');
}
return {
absolutePath,
relativePath,
};
}
async function readRequestBody(request) {
const chunks = [];
for await (const chunk of request) {
chunks.push(chunk);
if (Buffer.concat(chunks).length > 1024 * 32) {
throw new Error('Request body too large');
}
}
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
}
async function handleImage(requestUrl, response) {
const path = requestUrl.searchParams.get('path');
const version = requestUrl.searchParams.get('version');
const { absolutePath, relativePath } = resolveScreenshotPath(path);
response.writeHead(200, {
'content-type': 'image/png',
'cache-control': 'no-store',
});
if (version === 'head') {
const buffer = await runGit(['show', `HEAD:${relativePath}`]);
response.end(buffer);
return;
}
if (version !== 'worktree') {
throw new Error('Invalid image version');
}
createReadStream(absolutePath).pipe(response);
}
async function handleRequest(request, response) {
const requestUrl = new URL(request.url, `http://${request.headers.host}`);
try {
if (request.method === 'GET' && requestUrl.pathname === '/') {
const html = await readFile(htmlPath, 'utf8');
response.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
});
response.end(html);
return;
}
if (request.method === 'GET' && requestUrl.pathname === '/api/list') {
const status = await runGit([
'status',
'--porcelain=v1',
'-z',
'--',
screenshotRoot,
]);
sendJson(response, 200, { entries: parseStatusPorcelain(status) });
return;
}
if (request.method === 'GET' && requestUrl.pathname === '/api/image') {
await handleImage(requestUrl, response);
return;
}
if (request.method === 'POST' && requestUrl.pathname === '/api/stage') {
const body = await readRequestBody(request);
const { relativePath } = resolveScreenshotPath(body.path);
await runGit(['add', '--', relativePath]);
sendJson(response, 200, { ok: true, path: relativePath });
return;
}
sendText(response, 404, 'Not found');
} catch (error) {
if (!response.headersSent) {
sendJson(response, 500, { error: error.message });
return;
}
response.destroy(error);
}
}
const server = createServer((request, response) => {
handleRequest(request, response);
});
server.listen(port, () => {
const url = `http://localhost:${port}`;
console.log(`Screenshot reviewer: ${url}`);
console.log(`Repository: ${repoRoot}`);
});

@ -1 +1 @@
Subproject commit bd743ed76b403e6d476e1af0bbfe7479d96b563d
Subproject commit 3f85fe843c9a1ffaccb826845f317dace9c06c45

View File

@ -1,4 +1,11 @@
import { expect, test, visitStudy, getSvgAttribute, navigateWithViewportArrow } from './utils';
import {
expect,
test,
visitStudy,
getSvgAttribute,
navigateWithViewportArrow,
waitForViewportsRendered,
} from './utils';
import { expectRowSelected } from './utils/assertions';
const studyInstanceUID = '1.2.840.113619.2.290.3.3767434740.226.1600859119.501';
@ -20,12 +27,21 @@ test.beforeEach(async ({
});
test('should navigate the contours when clicking each segments in the right panel', async ({
page,
rightPanelPageObject,
viewportPageObject,
}) => {
const getSegment = (index: number) =>
rightPanelPageObject.contourSegmentationPanel.panel.nthSegment(index);
// Clicking a segment navigates the viewport to that segment's slice and
// re-renders its contour. Wait for the render to settle before reading the
// SVG path so we don't capture the previous segment's path on slower CI.
const selectSegment = async (index: number) => {
await getSegment(index).click();
await waitForViewportsRendered(page);
};
const seg0 = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -33,7 +49,7 @@ test('should navigate the contours when clicking each segments in the right pane
});
expect(seg0, 'Segment at index 0: expected a non-null SVG path').not.toBeNull();
await getSegment(3).click();
await selectSegment(3);
const seg3 = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -42,7 +58,7 @@ test('should navigate the contours when clicking each segments in the right pane
expect(seg3, 'Segment at index 3: expected a non-null SVG path').not.toBeNull();
await expectRowSelected(getSegment(3));
await getSegment(2).click();
await selectSegment(2);
const seg2 = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -51,7 +67,7 @@ test('should navigate the contours when clicking each segments in the right pane
expect(seg2, 'Segment at index 2: expected a non-null SVG path').not.toBeNull();
await expectRowSelected(getSegment(2));
await getSegment(1).click();
await selectSegment(1);
const seg1 = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -61,7 +77,7 @@ test('should navigate the contours when clicking each segments in the right pane
await expectRowSelected(getSegment(1));
// Clicking segments again should return the original paths
await getSegment(2).click();
await selectSegment(2);
const seg2Again = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -70,7 +86,7 @@ test('should navigate the contours when clicking each segments in the right pane
expect(seg2Again, 'Segment 2 again: expected to match the original segment 2 path').toBe(seg2);
await expectRowSelected(getSegment(2));
await getSegment(1).click();
await selectSegment(1);
const seg1Again = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -79,7 +95,7 @@ test('should navigate the contours when clicking each segments in the right pane
expect(seg1Again, 'Segment 1 again: expected to match the original segment 1 path').toBe(seg1);
await expectRowSelected(getSegment(1));
await getSegment(0).click();
await selectSegment(0);
const seg0Again = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -88,7 +104,7 @@ test('should navigate the contours when clicking each segments in the right pane
expect(seg0Again, 'Segment 0 again: expected to match the original segment 0 path').toBe(seg0);
await expectRowSelected(getSegment(0));
await getSegment(3).click();
await selectSegment(3);
const seg3Again = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -99,12 +115,20 @@ test('should navigate the contours when clicking each segments in the right pane
});
test('should navigate the segmentations using the Viewport arrow buttons', async ({
page,
rightPanelPageObject,
viewportPageObject,
}) => {
const getSegment = (index: number) =>
rightPanelPageObject.contourSegmentationPanel.panel.nthSegment(index);
// Arrow navigation jumps to the next/prev segment and re-renders its contour.
// Wait for the render to settle before reading the SVG path.
const navigate = async (direction: 'next' | 'prev') => {
await navigateWithViewportArrow(viewportPageObject, direction);
await waitForViewportsRendered(page);
};
const initialSvgPath = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -114,7 +138,7 @@ test('should navigate the segmentations using the Viewport arrow buttons', async
// Expect the correct segment to be selected in the right panel
await expectRowSelected(getSegment(0));
await navigateWithViewportArrow(viewportPageObject, 'next');
await navigate('next');
const secondSvgPath = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -123,7 +147,7 @@ test('should navigate the segmentations using the Viewport arrow buttons', async
expect(secondSvgPath, 'Segment at index 1: expected a different SVG path from segment 0').not.toBe(initialSvgPath);
await expectRowSelected(getSegment(1));
await navigateWithViewportArrow(viewportPageObject, 'next');
await navigate('next');
const thirdSvgPath = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -132,7 +156,7 @@ test('should navigate the segmentations using the Viewport arrow buttons', async
expect(thirdSvgPath, 'Segment at index 2: expected a different SVG path from segment 1').not.toBe(secondSvgPath);
await expectRowSelected(getSegment(2));
await navigateWithViewportArrow(viewportPageObject, 'next');
await navigate('next');
const fourthSvgPath = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -142,7 +166,7 @@ test('should navigate the segmentations using the Viewport arrow buttons', async
await expectRowSelected(getSegment(3));
// Wraparound test — next from last should return to first segment
await navigateWithViewportArrow(viewportPageObject, 'next');
await navigate('next');
const svgPathWraparoundWithNext = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -155,7 +179,7 @@ test('should navigate the segmentations using the Viewport arrow buttons', async
await expectRowSelected(getSegment(0));
// Wraparound test — prev from first should return to last segment
await navigateWithViewportArrow(viewportPageObject, 'prev');
await navigate('prev');
const svgPathWraparoundWithPrev = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -167,7 +191,7 @@ test('should navigate the segmentations using the Viewport arrow buttons', async
).toBe(fourthSvgPath);
await expectRowSelected(getSegment(3));
await navigateWithViewportArrow(viewportPageObject, 'prev');
await navigate('prev');
const backToThirdSvgPath = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -176,7 +200,7 @@ test('should navigate the segmentations using the Viewport arrow buttons', async
expect(backToThirdSvgPath, 'Expected path to match third segment after going prev from fourth').toBe(thirdSvgPath);
await expectRowSelected(getSegment(2));
await navigateWithViewportArrow(viewportPageObject, 'prev');
await navigate('prev');
const backToSecondSvgPath = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',
@ -185,7 +209,7 @@ test('should navigate the segmentations using the Viewport arrow buttons', async
expect(backToSecondSvgPath, 'Expected path to match second segment after going prev from third').toBe(secondSvgPath);
await expectRowSelected(getSegment(1));
await navigateWithViewportArrow(viewportPageObject, 'prev');
await navigate('prev');
const backToFirstSvgPath = await getSvgAttribute({
viewportPageObject,
svgInnerElement: 'path',

View File

@ -126,8 +126,9 @@ test.describe('Crosshairs Test', async () => {
await viewportPageObject.crosshairs.axial.rotate();
await viewportPageObject.crosshairs.sagittal.rotate();
await viewportPageObject.crosshairs.coronal.rotate();
const afterRotate = await getCrosshairsSignature(page);
expect(afterRotate).not.toEqual(beforeRotate);
await expect
.poll(() => getCrosshairsSignature(page), { timeout: CROSSHAIRS_DOM_TIMEOUT_MS })
.not.toEqual(beforeRotate);
});
test('should allow the user to adjust the slab thickness', async ({
@ -143,8 +144,9 @@ test.describe('Crosshairs Test', async () => {
await viewportPageObject.crosshairs.axial.increase();
await viewportPageObject.crosshairs.sagittal.increase();
await viewportPageObject.crosshairs.coronal.increase();
const afterIncrease = await getLayerSignature(page, 'svg-layer-mpr-axial');
expect(afterIncrease).not.toEqual(beforeIncrease);
await expect
.poll(() => getLayerSignature(page, 'svg-layer-mpr-axial'), { timeout: CROSSHAIRS_DOM_TIMEOUT_MS })
.not.toEqual(beforeIncrease);
});
test('should reset the crosshairs to the initial position when reset is clicked', async ({
@ -160,14 +162,27 @@ test.describe('Crosshairs Test', async () => {
await viewportPageObject.crosshairs.axial.rotate();
await viewportPageObject.crosshairs.sagittal.rotate();
await viewportPageObject.crosshairs.coronal.rotate();
const rotatedPosition = await getCrosshairsSignature(page);
expect(rotatedPosition).not.toEqual(initialPosition);
await expect
.poll(() => getCrosshairsSignature(page), { timeout: CROSSHAIRS_DOM_TIMEOUT_MS })
.not.toEqual(initialPosition);
await mainToolbarPageObject.moreTools.reset.click();
await expectCrosshairsReady(page);
expect(await areLayerLinesAxisAligned(page, 'svg-layer-mpr-axial')).toBeTruthy();
expect(await areLayerLinesAxisAligned(page, 'svg-layer-mpr-sagittal')).toBeTruthy();
expect(await areLayerLinesAxisAligned(page, 'svg-layer-mpr-coronal')).toBeTruthy();
await expect
.poll(() => areLayerLinesAxisAligned(page, 'svg-layer-mpr-axial'), {
timeout: CROSSHAIRS_DOM_TIMEOUT_MS,
})
.toBe(true);
await expect
.poll(() => areLayerLinesAxisAligned(page, 'svg-layer-mpr-sagittal'), {
timeout: CROSSHAIRS_DOM_TIMEOUT_MS,
})
.toBe(true);
await expect
.poll(() => areLayerLinesAxisAligned(page, 'svg-layer-mpr-coronal'), {
timeout: CROSSHAIRS_DOM_TIMEOUT_MS,
})
.toBe(true);
});
test('should reset the crosshairs when a new displayset is loaded', async ({
@ -184,13 +199,26 @@ test.describe('Crosshairs Test', async () => {
await viewportPageObject.crosshairs.axial.rotate();
await viewportPageObject.crosshairs.sagittal.rotate();
await viewportPageObject.crosshairs.coronal.rotate();
const rotatedPosition = await getCrosshairsSignature(page);
expect(rotatedPosition).not.toEqual(initialPosition);
await expect
.poll(() => getCrosshairsSignature(page), { timeout: CROSSHAIRS_DOM_TIMEOUT_MS })
.not.toEqual(initialPosition);
await leftPanelPageObject.loadSeriesByDescription('Recon 3: LIVER 3 PHASE (AP)');
await expectCrosshairsReady(page);
expect(await areLayerLinesAxisAligned(page, 'svg-layer-mpr-axial')).toBeTruthy();
expect(await areLayerLinesAxisAligned(page, 'svg-layer-mpr-sagittal')).toBeTruthy();
expect(await areLayerLinesAxisAligned(page, 'svg-layer-mpr-coronal')).toBeTruthy();
await expect
.poll(() => areLayerLinesAxisAligned(page, 'svg-layer-mpr-axial'), {
timeout: CROSSHAIRS_DOM_TIMEOUT_MS,
})
.toBe(true);
await expect
.poll(() => areLayerLinesAxisAligned(page, 'svg-layer-mpr-sagittal'), {
timeout: CROSSHAIRS_DOM_TIMEOUT_MS,
})
.toBe(true);
await expect
.poll(() => areLayerLinesAxisAligned(page, 'svg-layer-mpr-coronal'), {
timeout: CROSSHAIRS_DOM_TIMEOUT_MS,
})
.toBe(true);
});
});

View File

@ -59,12 +59,25 @@ test('rectangle and freehand at identical coordinates should yield comparable ar
// ANNOTATION_COMPLETED). Ending at the start point would enter interactive close-
// preview mode and suppress the completion event; ending further away
// would draw a diagonal that distorts the rectangular shape.
//
// The closing gap must be a fixed number of canvas pixels, not a normalized
// fraction: cs3d's close-proximity threshold is in pixels, so a fractional gap
// (0.01 × height) grew past the threshold on taller CI viewports and the
// contour never auto-closed (intermittent "no cachedStats"). Derive it from the
// viewport bbox so the gap is viewport-size-independent.
const CLOSE_GAP_PX = 6;
const viewportBox = await activeViewport.pane.boundingBox();
if (!viewportBox) {
throw new Error('Active viewport bounding box not found');
}
const closeY = 0.3 + CLOSE_GAP_PX / viewportBox.height;
const corners = [
{ x: 0.3, y: 0.3 },
{ x: 0.55, y: 0.3 },
{ x: 0.55, y: 0.55 },
{ x: 0.3, y: 0.55 },
{ x: 0.3, y: 0.31 },
{ x: 0.3, y: closeY },
];
const [topLeft, , bottomRight] = corners;

View File

@ -1,4 +1,11 @@
import { checkForScreenshot, screenShotPaths, test, visitStudy } from './utils';
import {
checkForScreenshot,
screenShotPaths,
test,
visitStudy,
waitForPaintToSettle,
waitForViewportRenderCycle,
} from './utils';
test.beforeEach(async ({ page }) => {
const studyInstanceUID = '1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5';
@ -111,9 +118,17 @@ test('should hydrate in MPR correctly', async ({
screenShotPaths.jumpToMeasurementMPR.jumpInMPR
);
const seriesChangeRenderCycle = waitForViewportRenderCycle(page, { renderedTimeout: 30000 });
await leftPanelPageObject.loadSeriesByDescription('Lung 3.0 CE');
await page.waitForTimeout(5000);
await seriesChangeRenderCycle;
// Series change unloads the old volume and progressively streams the new
// one; the wait helper resolves when loadStatus.loaded flips true, but the
// MPR mappers can still be sampling stale low-res frames for one tick.
// Give the streaming tail a chance to upload before screenshotting.
await page.waitForTimeout(2000);
await waitForPaintToSettle(page);
await checkForScreenshot(
page,

View File

@ -43,11 +43,11 @@ test('should display the livewire tool', async ({
const stats = livewires[0].firstTargetStats!;
expect(stats.areaUnit).toBe('mm²');
expect(Math.round(stats.area as number)).toBe(28906);
expect(Math.round(stats.area as number)).toBe(30412);
const lines = activeViewport.getSvgAnnotationStatTextLines(livewires[0].annotationUID);
await expect(lines).toHaveCount(1);
await expect(lines.nth(0)).toHaveText('Area: 28906 mm²');
await expect(lines.nth(0)).toHaveText('Area: 30412 mm²');
});
test('should restore viewport interactivity after deleting an in-progress Livewire annotation via context menu', async ({

View File

@ -168,14 +168,14 @@ test('checks if measurement item can be deleted under Measurements panel', async
// Open measurement panel, confirm 3 measurements
await rightPanelPageObject.measurementsPanel.select();
expect(await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount()).toBe(3);
await expect(rightPanelPageObject.measurementsPanel.panel.rows).toHaveCount(3);
// Delete from measurement
await rightPanelPageObject.measurementsPanel.panel.nthMeasurement(0).actions.delete();
await page.waitForTimeout(200);
// Confirm one measurement is gone
expect(await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount()).toBe(2);
await expect(rightPanelPageObject.measurementsPanel.panel.rows).toHaveCount(2);
// Delete all measurements via main Measurement Panel delete button and untrack
await rightPanelPageObject.measurementsPanel.panel.deleteAll();
@ -189,7 +189,7 @@ test('checks if measurement item can be deleted under Measurements panel', async
// Check dialog closed and measurements gone
await expect(DOMOverlayPageObject.dialog.title).toBeHidden();
expect(await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount()).toBe(0);
await expect(rightPanelPageObject.measurementsPanel.panel.rows).toHaveCount(0);
const measurementsPanel = rightPanelPageObject.measurementsPanel.panel.locator;
await expect(measurementsPanel).toContainText('No tracked measurements');
@ -219,7 +219,7 @@ test('checks if measurement item can be deleted through the context menu on the
// Open measurement panel and confirm measurement is gone
await rightPanelPageObject.measurementsPanel.select();
await expect(activeViewport.nthAnnotation(0).locator).toBeHidden();
expect(await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount()).toBe(0);
await expect(rightPanelPageObject.measurementsPanel.panel.rows).toHaveCount(0);
});
test('checks that undo after delete-all restores measurements as tracked', async ({
@ -243,13 +243,13 @@ test('checks that undo after delete-all restores measurements as tracked', async
});
await rightPanelPageObject.measurementsPanel.select();
expect(await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount()).toBe(2);
await expect(rightPanelPageObject.measurementsPanel.panel.rows).toHaveCount(2);
await rightPanelPageObject.measurementsPanel.panel.deleteAll();
await expect(DOMOverlayPageObject.dialog.title).toHaveText('Untrack Study');
await DOMOverlayPageObject.dialog.confirmation.confirm.click();
await expect(DOMOverlayPageObject.dialog.title).toBeHidden();
expect(await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount()).toBe(0);
await expect(rightPanelPageObject.measurementsPanel.panel.rows).toHaveCount(0);
await mainToolbarPageObject.undo.click();
@ -326,14 +326,14 @@ test.describe('simplified tracking mode', () => {
});
await rightPanelPageObject.measurementsPanel.select();
expect(await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount()).toBe(2);
await expect(rightPanelPageObject.measurementsPanel.panel.rows).toHaveCount(2);
await rightPanelPageObject.measurementsPanel.panel.deleteAll();
await expect(DOMOverlayPageObject.dialog.title).toHaveText('Untrack Study');
await DOMOverlayPageObject.dialog.confirmation.confirm.click();
await expect(DOMOverlayPageObject.dialog.title).toBeHidden();
expect(await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount()).toBe(0);
await expect(rightPanelPageObject.measurementsPanel.panel.rows).toHaveCount(0);
await mainToolbarPageObject.undo.click();

View File

@ -5,6 +5,7 @@ import {
screenShotPaths,
test,
visitStudy,
waitForPaintToSettle,
waitForViewportsRendered,
waitForViewportRenderCycle,
expect,
@ -40,6 +41,12 @@ test.describe('3D four up SEG hydration', async () => {
await leftPanelPageObject.loadSeriesByDescription('SEG');
await viewportRenderCycle;
await expect(DOMOverlayPageObject.viewport.segmentationHydration.locator).toBeVisible({
timeout: 60000,
});
await waitForViewportsRendered(page, { timeout: 60000 });
await page.waitForTimeout(3000);
await waitForPaintToSettle(page);
await checkForScreenshot(
page,
@ -57,6 +64,12 @@ test.describe('3D four up SEG hydration', async () => {
// Wait until all viewports have finished rendering
await viewportRenderCycle;
// 3D volume rendering keeps streaming refined geometry after the load
// status flips; give the GPU a window to present the final frame before
// screenshotting.
await waitForViewportsRendered(page, { timeout: 180000 });
await page.waitForTimeout(3000);
await waitForPaintToSettle(page);
await checkForScreenshot({
page,

View File

@ -3,6 +3,7 @@ import {
screenShotPaths,
test,
visitStudy,
waitForPaintToSettle,
waitForViewportRenderCycle,
waitForViewportsRendered,
} from './utils';
@ -36,6 +37,10 @@ test('should properly display MPR for MR', async ({
await leftPanelPageObject.loadSeriesByDescription('SEG');
await waitForViewportsRendered(page);
// SEG load triggers an additional progressive labelmap upload after the
// viewports first report 'rendered'; let that finish before screenshotting.
await page.waitForTimeout(1500);
await waitForPaintToSettle(page);
await checkForScreenshot(
page,
@ -49,6 +54,11 @@ test('should properly display MPR for MR', async ({
await DOMOverlayPageObject.viewport.segmentationHydration.yes.click();
await viewportRenderCycle;
// Hydration propagates the labelmap volume to the sagittal/coronal MPR
// viewports asynchronously; wait for that propagation to render before
// capturing.
await page.waitForTimeout(1500);
await waitForPaintToSettle(page);
await checkForScreenshot(
page,
@ -61,6 +71,8 @@ test('should properly display MPR for MR', async ({
await mainToolbarPageObject.layoutSelection.axialPrimary.click();
await viewportRenderAfterLayoutChange;
await page.waitForTimeout(1000);
await waitForPaintToSettle(page);
await checkForScreenshot(
page,

View File

@ -1,4 +1,10 @@
import { checkForScreenshot, screenShotPaths, test, visitStudy } from './utils';
import {
checkForScreenshot,
screenShotPaths,
test,
visitStudy,
waitForPaintToSettle,
} from './utils';
test.beforeEach(async ({ page }) => {
const studyInstanceUID = '1.3.12.2.1107.5.2.32.35162.30000015050317233592200000046';
@ -27,6 +33,7 @@ test('should launch MPR with unhydrated SEG', async ({
await mainToolbarPageObject.layoutSelection.MPR.click();
await page.waitForTimeout(5000);
await waitForPaintToSettle(page);
await checkForScreenshot(
page,

View File

@ -1,4 +1,11 @@
import { checkForScreenshot, screenShotPaths, test, visitStudy } from './utils';
import {
checkForScreenshot,
screenShotPaths,
test,
visitStudy,
waitForPaintToSettle,
waitForViewportsRendered,
} from './utils';
test.beforeEach(async ({ page }) => {
const studyInstanceUID = '1.3.6.1.4.1.14519.5.2.1.7695.4007.324475281161490036195179843543';
@ -16,7 +23,13 @@ test('should hydrate SR reports correctly', async ({
await rightPanelPageObject.toggle();
await rightPanelPageObject.measurementsPanel.select();
await leftPanelPageObject.loadSeriesByModality('SR');
// The DICOMSRDisplayTool bails out when the viewport has no actors yet
// (see DICOMSRDisplayTool's hasActors guard), so we must wait until the
// underlying image has rendered an actor before screenshotting the SR
// overlay (line/rectangle).
await waitForViewportsRendered(page);
await page.waitForTimeout(2000);
await waitForPaintToSettle(page);
await checkForScreenshot(
page,
viewportPageObject.grid,

View File

@ -1,5 +1,13 @@
import { Locator } from '@playwright/test';
import { checkForScreenshot, expect, screenShotPaths, test, visitStudy } from './utils';
import {
checkForScreenshot,
expect,
screenShotPaths,
test,
visitStudy,
waitForPaintToSettle, waitForViewportRenderCycle,
waitForViewportsRendered,
} from './utils';
async function expectNonEmptyDetailLines(lines: Locator) {
const lineCount = await lines.count();
@ -27,7 +35,7 @@ test.beforeEach(async ({ page }) => {
}
});
});
test.describe.configure({ retries: 1 });
test('should hydrate SCOORD3D probe measurements correctly', async ({
page,
DOMOverlayPageObject,
@ -90,8 +98,12 @@ test('should hydrate SCOORD3D probe measurements correctly', async ({
// Click the hydrate button to load the SCOORD3D probe measurements
await DOMOverlayPageObject.viewport.segmentationHydration.yes.click();
// Wait for hydration to complete and rendering to stabilize
// Wait for hydration to complete and rendering to stabilize. SCOORD3D
// hydration can swap the displayed series (referenced vs. current volume),
// so we must wait for the new image set to render before screenshotting.
await waitForViewportsRendered(page);
await page.waitForTimeout(3000);
await waitForPaintToSettle(page);
// Take screenshot after hydration showing the probe measurements - use viewport locator
await checkForScreenshot(
@ -129,9 +141,16 @@ test('should hydrate SCOORD3D probe measurements correctly', async ({
viewport.render();
}
});
await waitForViewportsRendered(page, { waitVolumeLoad: false });
// Click on a data row to jump to the measurement
const jumpRenderCycle = waitForViewportRenderCycle(page, {
renderedTimeout: 30000,
waitVolumeLoad: false,
});
await rightPanelPageObject.measurementsPanel.panel.nthMeasurement(0).click();
await jumpRenderCycle;
await waitForPaintToSettle(page);
// Take screenshot showing the jump to measurement functionality - use viewport locator
await checkForScreenshot(

View File

@ -7,6 +7,8 @@ import {
screenShotPaths,
test,
visitStudy,
waitForPaintToSettle,
waitForViewportsRendered,
} from './utils';
async function expectNonEmptyDetailLines(lines: Locator) {
@ -53,10 +55,13 @@ test('should hydrate SCOORD rectangle measurements correctly', async ({
// Double-click on the study browser thumbnail to load the SR
await leftPanelPageObject.loadSeriesByModality('SR');
await page.waitForTimeout(2000);
// Wait for the SR to load and stabilize before taking screenshot
await page.waitForTimeout(1000);
// The DICOMSRDisplayTool only draws the SCOORD rectangle once the
// underlying image has been rendered (it bails out when the viewport
// has no actors). Wait for the image to render before checking the
// overlay, otherwise the screenshot captures a blank canvas.
await waitForViewportsRendered(page);
await page.waitForTimeout(3000);
await waitForPaintToSettle(page);
const activeViewport = await viewportPageObject.active;

View File

@ -42,11 +42,11 @@ test('should display the spline tool', async ({
const stats = splines[0].firstTargetStats!;
expect(stats.areaUnit).toBe('mm²');
expect(Math.round(stats.area as number)).toBe(38811);
expect(Math.round(stats.area as number)).toBe(38963);
const lines = activeViewport.getSvgAnnotationStatTextLines(splines[0].annotationUID);
await expect(lines).toHaveCount(1);
await expect(lines.nth(0)).toHaveText('Area: 38811 mm²');
await expect(lines.nth(0)).toHaveText('Area: 38963 mm²');
});
test('should restore viewport interactivity after deleting an in-progress Spline annotation via context menu', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 997 B

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Some files were not shown because too many files have changed in this diff Show More