From 79cb2356e1711f80dc6f79d7e7779717c0ad2b0a Mon Sep 17 00:00:00 2001 From: diattamo Date: Wed, 24 Jun 2026 10:36:16 -0400 Subject: [PATCH] chore(testing): Playwright tests for contour segmentation create, update, delete interactions (#6091) --- ...ontourSegmentationCrudInteractions.spec.ts | 248 ++++++++++++++++++ tests/pages/RightPanelPageObject.ts | 58 +++- tests/utils/assertions.ts | 15 ++ 3 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 tests/ContourSegmentationCrudInteractions.spec.ts diff --git a/tests/ContourSegmentationCrudInteractions.spec.ts b/tests/ContourSegmentationCrudInteractions.spec.ts new file mode 100644 index 000000000..bdded419e --- /dev/null +++ b/tests/ContourSegmentationCrudInteractions.spec.ts @@ -0,0 +1,248 @@ +import { expect, test, visitStudy, visitStudyAndHydrate, waitForViewportsRendered } from './utils'; +import { + expectRowSelected, + expectRowNotSelected, + expectSegmentationLabels, +} from './utils/assertions'; + +const studyInstanceUID = '1.2.840.113619.2.290.3.3767434740.226.1600859119.501'; +test.describe('Contour Segmentation from an empty panel', () => { + test('should add a contour segmentation from the empty panel', async ({ + rightPanelPageObject, + page, + }) => { + await visitStudy(page, studyInstanceUID, 'segmentation', 2000); + await waitForViewportsRendered(page); + // Segmentation mode opens on the labelmap tab; switch to the contour tab. + await rightPanelPageObject.contourSegmentationPanel.select(); + + const { addSegmentationButton, panel, segmentationSelect } = + rightPanelPageObject.contourSegmentationPanel; + + // No contour segmentation exists yet. + await expect(panel.rows).toHaveCount(0); + await expect(addSegmentationButton.button).toBeVisible(); + + await addSegmentationButton.click(); + + // The segmentation is added and becomes the active selection. + await expect(segmentationSelect.selectedValue).toHaveText('Segmentation 1'); + await expect(panel.rows).toHaveCount(1); + const defaultSegment = panel.nthSegment(0); + await expect(defaultSegment.title).toHaveText('Segment 1'); + await expectRowSelected(defaultSegment); + }); +}); + +test.describe('Contour Segmentation interactions on RTSTRUCT panel', () => { + test.beforeEach(async ({ page, leftPanelPageObject, DOMOverlayPageObject }) => { + await visitStudyAndHydrate({ + page, + leftPanelPageObject, + DOMOverlayPageObject, + studyInstanceUID, + modality: 'RTSTRUCT', + }); + }); + + test('should create a new contour segmentation seeded with a default active segment', async ({ + rightPanelPageObject, + }) => { + const { panel, segmentationSelect } = rightPanelPageObject.contourSegmentationPanel; + + await expect(panel.rows).toHaveCount(4); + + await panel.moreMenu.createNewSegmentation(); + + await expect(segmentationSelect.selectedValue).toHaveText('Segmentation 2'); + await expect(panel.rows).toHaveCount(1); + const defaultSegment = panel.nthSegment(0); + await expect(defaultSegment.title).toHaveText('Segment 1'); + await expectRowSelected(defaultSegment); + }); + + test('should add multiple segments to a newly created contour segmentation', async ({ + rightPanelPageObject, + }) => { + const { panel, addSegmentButton } = rightPanelPageObject.contourSegmentationPanel; + + await panel.moreMenu.createNewSegmentation(); + await expect(panel.rows).toHaveCount(1); + + // Each added segment becomes the active one in place of the previous. + await addSegmentButton.click(); + await expect(panel.rows).toHaveCount(2); + await expectRowSelected(panel.nthSegment(1)); + await expectRowNotSelected(panel.nthSegment(0)); + + await addSegmentButton.click(); + await expect(panel.rows).toHaveCount(3); + await expectRowSelected(panel.nthSegment(2)); + await expectRowNotSelected(panel.nthSegment(1)); + + await expect(panel.getSegmentLabels()).toHaveText(['Segment 1', 'Segment 2', 'Segment 3']); + }); + + test('should list every created segmentation in the segmentation selector', async ({ + rightPanelPageObject, + }) => { + const { panel, segmentationSelect } = rightPanelPageObject.contourSegmentationPanel; + + // Only the hydrated RTSTRUCT segmentation exists at first. + await expectSegmentationLabels(segmentationSelect, ['Contours on PET']); + + await panel.moreMenu.createNewSegmentation(); + await panel.moreMenu.createNewSegmentation(); + + // The two newly created segmentations are added to the selector. + await expectSegmentationLabels(segmentationSelect, [ + 'Contours on PET', + 'Segmentation 2', + 'Segmentation 3', + ]); + }); + + test('should switch between segmentations and show the matching segments', async ({ + rightPanelPageObject, + }) => { + const { panel, segmentationSelect } = rightPanelPageObject.contourSegmentationPanel; + + // Create two more segmentations alongside the hydrated RTSTRUCT one, so the + // selector lists: [0] Contours on PET, [1] Segmentation 2, [2] Segmentation 3. + await panel.moreMenu.createNewSegmentation(); + await panel.moreMenu.createNewSegmentation(); + await expect(segmentationSelect.selectedValue).toHaveText('Segmentation 3'); + + // Select out of listed order (active is 2 → 0 → 2 → 1) to prove the active + // segmentation follows the selection, not the order the options are listed in. + + // Index 0: the RTSTRUCT segmentation shows its four segments. + await segmentationSelect.selectNthSegmentation(0); + await expect(segmentationSelect.selectedValue).toHaveText('Contours on PET'); + await expect(panel.rows).toHaveCount(4); + await expect(panel.getSegmentLabels()).toHaveText([ + 'Threshold', + 'Big Sphere', + 'Small Sphere', + 'Large Box', + ]); + + // Jump to the last-created segmentation (index 2), skipping over index 1. + await segmentationSelect.selectNthSegmentation(2); + await expect(segmentationSelect.selectedValue).toHaveText('Segmentation 3'); + await expect(panel.rows).toHaveCount(1); + await expect(panel.nthSegment(0).title).toHaveText('Segment 1'); + + // Back up to the middle segmentation (index 1). + await segmentationSelect.selectNthSegmentation(1); + await expect(segmentationSelect.selectedValue).toHaveText('Segmentation 2'); + await expect(panel.rows).toHaveCount(1); + await expect(panel.nthSegment(0).title).toHaveText('Segment 1'); + }); + + test('should rename a segmentation and reflect the new name in the selector', async ({ + rightPanelPageObject, + }) => { + const { panel, segmentationSelect } = rightPanelPageObject.contourSegmentationPanel; + + await panel.moreMenu.createNewSegmentation(); + await expect(segmentationSelect.selectedValue).toHaveText('Segmentation 2'); + + await panel.moreMenu.rename('Renamed Segmentation'); + + await expect(segmentationSelect.selectedValue).toHaveText('Renamed Segmentation'); + await expectSegmentationLabels(segmentationSelect, ['Contours on PET', 'Renamed Segmentation']); + }); + + test('should not rename a segmentation when the rename dialog is cancelled', async ({ + rightPanelPageObject, + }) => { + const { panel, segmentationSelect } = rightPanelPageObject.contourSegmentationPanel; + + await panel.moreMenu.createNewSegmentation(); + await expect(segmentationSelect.selectedValue).toHaveText('Segmentation 2'); + + // Cancelling with an untouched dialog leaves the name unchanged. + await panel.moreMenu.cancelRename(); + await expect(segmentationSelect.selectedValue).toHaveText('Segmentation 2'); + + // Cancelling after typing a new name also discards it. + await panel.moreMenu.cancelRename('Discarded Name'); + await expect(segmentationSelect.selectedValue).toHaveText('Segmentation 2'); + }); + + test('should delete a segmentation and remove it from the selector', async ({ + rightPanelPageObject, + }) => { + const { panel, segmentationSelect } = rightPanelPageObject.contourSegmentationPanel; + + await panel.moreMenu.createNewSegmentation(); + await expect(segmentationSelect.selectedValue).toHaveText('Segmentation 2'); + + await panel.moreMenu.delete(); + + // The remaining RTSTRUCT segmentation becomes active and is the only one left. + await expect(segmentationSelect.selectedValue).toHaveText('Contours on PET'); + await expectSegmentationLabels(segmentationSelect, ['Contours on PET']); + }); + + test('should delete every segmentation until none remain', async ({ rightPanelPageObject }) => { + const { panel, addSegmentationButton, segmentationSelect } = + rightPanelPageObject.contourSegmentationPanel; + + // Start with the hydrated RTSTRUCT plus two created segmentations. + await panel.moreMenu.createNewSegmentation(); + await panel.moreMenu.createNewSegmentation(); + + // Delete each of the three segmentations one by one. + await panel.moreMenu.delete(); + await expect(segmentationSelect.selectedValue).toHaveText('Contours on PET'); + await panel.moreMenu.delete(); + await expect(segmentationSelect.selectedValue).toHaveText('Segmentation 2'); + await panel.moreMenu.delete(); + + // No segmentation remains, so the panel has no segment rows and the + // "Add segmentation" entry point is shown again. + await expect(panel.rows).toHaveCount(0); + await expect(addSegmentationButton.button).toBeVisible(); + }); + + test('should remove a segmentation from the viewport and drop it from the selector', async ({ + rightPanelPageObject, + }) => { + const { panel, segmentationSelect } = rightPanelPageObject.contourSegmentationPanel; + + await panel.moreMenu.createNewSegmentation(); + await expect(segmentationSelect.selectedValue).toHaveText('Segmentation 2'); + + await panel.moreMenu.removeFromViewport(); + + // Removing from the viewport drops it from the selector; the RTSTRUCT one stays active. + await expect(segmentationSelect.selectedValue).toHaveText('Contours on PET'); + await expectSegmentationLabels(segmentationSelect, ['Contours on PET']); + }); + + // KNOWN BUG: the last remaining segmentation cannot be removed from the viewport + // ("Remove from Viewport" is a silent no-op once it is the only one left), so the + // panel keeps its segment row instead of clearing. Skipped until the bug is fixed. + // Tracked in https://github.com/OHIF/Viewers/issues/6090 + test.skip('should remove every segmentation from the viewport', async ({ + rightPanelPageObject, + }) => { + const { panel, addSegmentationButton } = rightPanelPageObject.contourSegmentationPanel; + + // Start with the hydrated RTSTRUCT plus two created segmentations. + await panel.moreMenu.createNewSegmentation(); + await panel.moreMenu.createNewSegmentation(); + + // Remove each segmentation from the viewport one by one. + await panel.moreMenu.removeFromViewport(); + await panel.moreMenu.removeFromViewport(); + await panel.moreMenu.removeFromViewport(); + + // The viewport should have no segmentations left: the selector is empty and the + // "Add segmentation" entry point is shown again. + await expect(panel.rows).toHaveCount(0); + await expect(addSegmentationButton.button).toBeVisible(); + }); +}); diff --git a/tests/pages/RightPanelPageObject.ts b/tests/pages/RightPanelPageObject.ts index 1757f7182..67f3e74f1 100644 --- a/tests/pages/RightPanelPageObject.ts +++ b/tests/pages/RightPanelPageObject.ts @@ -2,6 +2,16 @@ import { Locator, Page } from '@playwright/test'; import { DOMOverlayPageObject } from './DOMOverlayPageObject'; +/** The segmentation selector (dropdown) returned by the segmentation panels. */ +export type SegmentationSelect = { + locator: Locator; + selectedValue: Locator; + getSegmentationLabels: () => Promise; + close: () => Promise; + nthSegmentation: (n: number) => Promise; + selectNthSegmentation: (n: number) => Promise; +}; + export class RightPanelPageObject { readonly page: Page; private readonly DOMOverlayPageObject: DOMOverlayPageObject; @@ -27,11 +37,28 @@ export class RightPanelPageObject { await button.click(); await page.getByRole('menuitem', { name: 'Delete' }).click(); }, + removeFromViewport: async () => { + await button.click(); + await page.getByRole('menuitem', { name: 'Remove from Viewport' }).click(); + }, rename: async (text: string) => { await button.click(); await page.getByRole('menuitem', { name: 'Rename' }).click(); await this.DOMOverlayPageObject.dialog.input.fillAndSave(text); }, + cancelRename: async (newName?: string) => { + await button.click(); + await page.getByRole('menuitem', { name: 'Rename' }).click(); + if (newName) { + await this.DOMOverlayPageObject.dialog.input.fillAndCancel(newName); + } else { + await this.DOMOverlayPageObject.dialog.input.cancel(); + } + }, + createNewSegmentation: async () => { + await button.click(); + await page.getByRole('menuitem', { name: 'Create New Segmentation' }).click(); + }, }; } @@ -196,7 +223,7 @@ export class RightPanelPageObject { }; } - private getSegmentationSelect(type: string) { + private getSegmentationSelect(type: string): SegmentationSelect { const page = this.page; const suffix = type ? `-${type}` : ''; const locator = page.getByTestId(`segmentation-select${suffix}`); @@ -212,6 +239,15 @@ export class RightPanelPageObject { locator, /** The span showing the currently selected segmentation label */ selectedValue, + // Opens the dropdown and returns the option locator; leaves it open, so call close() after. + getSegmentationLabels: async () => { + await locator.click(); + return page.getByRole('option'); + }, + // Click the selected option to dismiss without changing the active segmentation. + close: async () => { + await page.getByRole('option', { selected: true }).click(); + }, /** Opens the dropdown and returns a locator for the nth option (0-based) */ nthSegmentation, /** Opens the dropdown and clicks the nth segmentation (0-based) */ @@ -231,6 +267,17 @@ export class RightPanelPageObject { }; } + /** The "Add Segment" row button of the active segmentation in the visible panel */ + private get addSegmentButton() { + const button = this.page.getByRole('button', { name: 'Add Segment' }); + return { + button, + click: async () => { + await button.click(); + }, + }; + } + private getSegmentsVisibilityToggle(type?: string) { const testId = type ? `all-segments-visibility-toggle-${type}` @@ -255,6 +302,11 @@ export class RightPanelPageObject { // Retrying-friendly locator for `expect(...).toHaveCount(n)` — prefer this // over the one-shot getSegmentCount() when asserting row counts. rows: page.getByTestId('data-row'), + /** + * @deprecated One-shot count that races the render. Prefer + * `expect(panel.rows).toHaveCount(n)` for assertions. Use this only to + * capture a stable baseline value (e.g. for a delta). + */ getSegmentCount: async () => { return await page.getByTestId('data-row').count(); }, @@ -276,6 +328,7 @@ export class RightPanelPageObject { get contourSegmentationPanel() { const page = this.page; const addSegmentationButton = this.addSegmentationButton; + const addSegmentButton = this.addSegmentButton; const panel = this.getSegmentationPanel('Contour'); const menuButton = page.getByTestId('panelSegmentationWithToolsContour-btn'); const segmentationSelect = this.getSegmentationSelect('Contour'); @@ -283,6 +336,7 @@ export class RightPanelPageObject { return { addSegmentationButton, + addSegmentButton, menuButton, segmentsVisibilityToggle, panel, @@ -295,12 +349,14 @@ export class RightPanelPageObject { get labelMapSegmentationPanel() { const page = this.page; const addSegmentationButton = this.addSegmentationButton; + const addSegmentButton = this.addSegmentButton; const panel = this.getSegmentationPanel('Labelmap'); const menuButton = page.getByTestId('panelSegmentationWithToolsLabelMap-btn'); const segmentationSelect = this.getSegmentationSelect('Labelmap'); return { addSegmentationButton, + addSegmentButton, menuButton, panel, segmentationSelect, diff --git a/tests/utils/assertions.ts b/tests/utils/assertions.ts index e1e26a367..c365a215f 100644 --- a/tests/utils/assertions.ts +++ b/tests/utils/assertions.ts @@ -2,6 +2,7 @@ import { Locator, Page } from '@playwright/test'; import { expect } from 'playwright-test-coverage'; import { DOMOverlayPageObject } from '../pages'; +import type { SegmentationSelect } from '../pages/RightPanelPageObject'; /** * Asserts the number of Modality Load Badges present on the page. @@ -73,3 +74,17 @@ export async function expectRowLocked(rowObject: { lockIcon: Locator }) { export async function expectRowUnlocked(rowObject: { lockIcon: Locator }) { await expect(rowObject.lockIcon, 'Expected the row to not show the lock icon').toHaveCount(0); } + +export async function expectRowNotSelected(rowObject) { + await expect(rowObject.locator).not.toContainClass('bg-popover'); +} + +// Opens the segmentation selector, asserts its options match `labels`, then closes it. +export async function expectSegmentationLabels( + segmentationSelect: SegmentationSelect, + labels: string[] +) { + const options = await segmentationSelect.getSegmentationLabels(); + await expect(options).toHaveText(labels); + await segmentationSelect.close(); +}