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>
7
.github/workflows/playwright.yml
vendored
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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',
|
||||
|
||||
14
bunfig.toml
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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] }]);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
66
extensions/cornerstone/src/utils/getLegacyViewportType.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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')
|
||||
);
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
700
scripts/screenshot-reviewer.html
Normal 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>
|
||||
218
scripts/screenshot-reviewer.mjs
Normal 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}`);
|
||||
});
|
||||
2
testdata
@ -1 +1 @@
|
||||
Subproject commit bd743ed76b403e6d476e1af0bbfe7479d96b563d
|
||||
Subproject commit 3f85fe843c9a1ffaccb826845f317dace9c06c45
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ({
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 ({
|
||||
|
||||
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 997 B After Width: | Height: | Size: 748 B |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |