Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 50 additions & 34 deletions e2e/drivers/svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export class SvelteDriver implements UiDriver {
return this.page.getByTestId('main-image');
}

canvasImage(): Locator {
return this.page.getByTestId('canvas-image');
}

depthImage(): Locator {
return this.page.getByTestId('depth-image');
}
Expand All @@ -86,6 +90,24 @@ export class SvelteDriver implements UiDriver {
return this.sliceRow(index).getByTestId('slice-thumbnail');
}

/** Picks a canvas tool (Pan, Segment, Brush, Horizon) unless it is already active. */
private async ensureTool(tool: 'pan' | 'segment' | 'brush' | 'horizon'): Promise<void> {
const button = this.page.getByTestId(`tool-${tool}`);
if ((await button.getAttribute('aria-pressed')) !== 'true') {
await button.click();
await expect(button).toHaveAttribute('aria-pressed', 'true');
}
}

/** Shows a canvas view (Input, Depth, Slice, Composite, Parallax 2D, 3D). */
private async ensureView(view: 'input' | 'depth' | 'slice' | 'composite' | 'parallax' | '3d'): Promise<void> {
const tab = this.page.getByTestId(`view-${view}`);
if ((await tab.getAttribute('aria-selected')) !== 'true') {
await tab.click();
await expect(tab).toHaveAttribute('aria-selected', 'true');
}
}

/** Per-slice actions live in the Inspector's header for the selected slice. */
private async ensureSelected(index: number): Promise<void> {
const row = this.sliceRow(index);
Expand Down Expand Up @@ -176,35 +198,18 @@ export class SvelteDriver implements UiDriver {
return (known.find((tab) => tab === panel) as MainTab | undefined) ?? null;
}

/**
* The Mode Selector lives in the Depth step's Inspector panel (Dash kept
* it inline, outside any tab). Runs `fn` with that panel visible, using
* real clicks (no forced actions on a hidden `<select>`), then restores
* whichever panel was showing before.
*/
private async withModeTabVisible<T>(fn: () => Promise<T>): Promise<T> {
const previousTab = await this.activeMainTab();
if (previousTab !== 'Mode') await this.openTab('Mode');
try {
return await fn();
} finally {
if (previousTab && previousTab !== 'Mode') await this.openTab(previousTab);
}
}

// Segmentation

/** The Segment tool's "Select by" option: Object (instance) or Depth band. */
async setSegmentationMode(mode: SegmentationMode): Promise<void> {
await this.withModeTabVisible(async () => {
await this.page.getByTestId('mode-selector').selectOption({ label: mode });
});
await this.ensureTool('segment');
await this.page.getByTestId(mode === 'Depth Map' ? 'select-by-depth' : 'select-by-object').click();
}

async expectSegmentationMode(mode: SegmentationMode): Promise<void> {
await this.withModeTabVisible(async () => {
const selected = this.page.getByTestId('mode-selector').locator('option:checked');
await expect(selected).toHaveText(mode);
});
await this.ensureTool('segment');
const option = this.page.getByTestId(mode === 'Depth Map' ? 'select-by-depth' : 'select-by-object');
await expect(option).toHaveAttribute('aria-checked', 'true');
}

async clickImagePixel(x: number, y: number, modifiers: Modifier[] = []): Promise<void> {
Expand All @@ -214,6 +219,8 @@ export class SvelteDriver implements UiDriver {
// backend's `find_pixel_from_click` ratio, and real click coordinates'
// sub-pixel rounding truncates the resulting pixel the same way on both
// UIs (see lib/geometry.ts's `findPixelFromClick`).
// Clicks select with the Segment tool, on the Input view.
await this.ensureTool('segment');
const image = this.mainImage();
const position = await image.evaluate(
(element: HTMLImageElement, point) => {
Expand Down Expand Up @@ -307,6 +314,7 @@ export class SvelteDriver implements UiDriver {
* `pointermove`/`pointerup` events the canvas actually listens for.
*/
async drawMaskStroke(): Promise<void> {
await this.ensureTool('brush');
const canvas = this.page.getByTestId('mask-canvas');
await expect(canvas).toBeVisible();
const box = await canvas.boundingBox();
Expand Down Expand Up @@ -539,18 +547,18 @@ export class SvelteDriver implements UiDriver {
}

async dragHorizonTo(row: number): Promise<void> {
const toggle = this.page.getByTestId('horizon-toggle');
if ((await toggle.getAttribute('aria-pressed')) !== 'true') await toggle.click();
await this.ensureTool('horizon');
const line = this.page.getByTestId('horizon-line');
await expect(line).toBeVisible();
const lineBox = await line.boundingBox();
const image = this.mainImage();
const target = await image.evaluate(
(element: HTMLImageElement, sourceRow) => {
const rect = element.getBoundingClientRect();
return rect.top + (sourceRow / element.naturalHeight) * rect.height;
// The line spans the image box; map the source row into it.
const sourceHeight = await this.mainImage().evaluate((element: HTMLImageElement) => element.naturalHeight);
const target = await line.evaluate(
(element: HTMLElement, { sourceRow, height }) => {
const rect = (element.parentElement as HTMLElement).getBoundingClientRect();
return rect.top + (sourceRow / height) * rect.height;
},
row,
{ sourceRow: row, height: sourceHeight },
);
if (!lineBox) throw new Error('Horizon line has no bounding box');
const x = lineBox.x + lineBox.width / 2;
Expand All @@ -565,6 +573,12 @@ export class SvelteDriver implements UiDriver {
}

async navigateCamera(direction: CameraDirection): Promise<void> {
// The camera pad is in the Parallax 2D view (entering it renders the
// reference view once; wait for that before moving on).
if ((await this.page.getByTestId('view-parallax').getAttribute('aria-selected')) !== 'true') {
await this.ensureView('parallax');
await expect(this.page.getByTestId(`camera-${direction}`)).toBeEnabled();
}
const before = await this.log().innerText();
await this.page.getByTestId(`camera-${direction}`).click();
await expect.poll(async () => (await this.log().innerText()) !== before).toBe(true);
Expand All @@ -581,9 +595,11 @@ export class SvelteDriver implements UiDriver {
}

async selectDepthModel(label: string): Promise<void> {
await this.withModeTabVisible(async () => {
await this.page.getByTestId('depth-model').selectOption({ label });
});
// The depth model select is in the Depth step's panel.
const previousTab = await this.activeMainTab();
if (previousTab !== 'Mode') await this.openTab('Mode');
await this.page.getByTestId('depth-model').selectOption({ label });
if (previousTab && previousTab !== 'Mode') await this.openTab(previousTab);
}

async selectInpaintingModel(label: string): Promise<void> {
Expand Down
5 changes: 5 additions & 0 deletions e2e/drivers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export interface UiDriver {

// Observable elements
mainImage(): Locator;
/**
* The image as currently displayed on the canvas, whichever view shows it
* (its box follows zoom/pan); use it for geometry, `mainImage()` for pixels.
*/
canvasImage(): Locator;
depthImage(): Locator;
/** Every slice thumbnail as displayed (checkerboard-composited), in no particular order. */
sliceImages(): Locator;
Expand Down
4 changes: 2 additions & 2 deletions e2e/layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ for (const image of IMAGES) {
for (const tab of TABS) {
await ui.openTab(tab);
expect(await pageOverflow(page), `page overflow on ${tab}`).toEqual({ vertical: 0, horizontal: 0 });
const box = await ui.mainImage().boundingBox();
const box = await ui.canvasImage().boundingBox();
if (!box) throw new Error('Main image has no bounding box');
expect(box.y, `main image top on ${tab}`).toBeGreaterThanOrEqual(0);
expect(box.y + box.height, `main image bottom on ${tab}`).toBeLessThanOrEqual(viewport.height);
Expand All @@ -53,7 +53,7 @@ for (const image of IMAGES) {
// cursor. The image is shown smaller than its source here, so one CSS
// pixel spans several source pixels and a real click (whole CSS
// pixels) can only land within that span.
const box = await ui.mainImage().boundingBox();
const box = await ui.canvasImage().boundingBox();
if (!box) throw new Error('Main image has no bounding box');
const sourcePerCssPixel = image.size[0] / box.width;
const [x, y] = [Math.floor(image.size[0] * 0.25), Math.floor(image.size[1] * 0.8)];
Expand Down
6 changes: 4 additions & 2 deletions e2e/parallax-maker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,12 @@ test('point segmentation honors positive and negative points through the UI', as
expect(negativeMask.nonzero).toBeGreaterThan(0);
});

test('default depth click records its pixel, depth, log, and mask', async ({ page, ui }) => {
test('a depth-band click records its pixel, depth, log, and mask', async ({ page, ui }) => {
requireWorkflow(ui, 'segmentation');
const projectId = await ui.restoreFixtureState();
await ui.expectSegmentationMode('Depth Map');
// Object selection (Segment Anything) is the default; pick depth bands.
await ui.expectSegmentationMode('Instance Segmentation');
await ui.setSegmentationMode('Depth Map');
await ui.openTab('Segmentation');

await ui.clickImagePixel(16, 16);
Expand Down
36 changes: 15 additions & 21 deletions e2e/ux-parity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,49 +17,43 @@ test('after zooming in and panning, a depth-mode click at a known source pixel l
}) => {
requireWorkflow(ui, 'segmentation');
const projectId = await ui.restoreFixtureState();
await ui.expectSegmentationMode('Depth Map');
await ui.setSegmentationMode('Depth Map');

// A real user zooming in before painting a precise mask is a completely
// natural flow, so visit the Inpainting tab first (segmentation clicks on
// the main image are not tab-gated: `InputImagePanel.svelte`'s
// `onImageClick` attaches unconditionally, so no tab switch is needed
// afterwards).
// natural flow, so visit the Inpainting step first (the click below
// switches back to the Segment tool; zoom/pan survives that).
await ui.openTab('Inpainting');

const beforeZoom = await ui.mainImage().boundingBox();
const beforeZoom = await ui.canvasImage().boundingBox();
if (!beforeZoom) throw new Error('Main image has no bounding box');

// Each real wheel gesture is one zoom "tick" (ZOOM_FACTOR = 1.1); five
// ticks comfortably clears a 30% growth threshold (1.1^5 ≈ 1.61x) with
// margin for rendered-box rounding.
for (let i = 0; i < 5; i += 1) await ui.zoomIn();

const afterZoom = await ui.mainImage().boundingBox();
const afterZoom = await ui.canvasImage().boundingBox();
if (!afterZoom) throw new Error('Main image has no bounding box after zoom');
// Zooming in must visibly enlarge the rendered box (state/viewport.svelte.ts).
expect(afterZoom.width, 'image width grows after zooming in').toBeGreaterThan(
beforeZoom.width * 1.3,
);

await ui.panBy(35, -20);
const afterPan = await ui.mainImage().boundingBox();
const afterPan = await ui.canvasImage().boundingBox();
if (!afterPan) throw new Error('Main image has no bounding box after pan');

expect(
Math.abs(afterPan.x - afterZoom.x) + Math.abs(afterPan.y - afterZoom.y),
'panning moves the rendered image box',
).toBeGreaterThan(5);

// Leave the Inpainting tab before clicking: the mask canvas is the
// topmost (interactive) element over the image while that tab is active
// (MaskCanvas.svelte's `interactive` class), so a click there would hit
// the canvas's own paint-stroke handling instead of segmentation - not a
// zoom/pan concern, just normal tab-based hit-testing. The zoom/pan
// transform itself is unaffected by the tab switch (proven by
// `afterZoom`/`afterPan` being measured before this point, and
// re-confirmed by `finalBox` below).
// Leave the Inpainting step before clicking: its Brush tool makes the mask
// canvas the topmost (interactive) element over the image, so a click
// there would paint instead of segmenting. The zoom/pan transform itself
// is unaffected by the switch (re-confirmed by `finalBox` below).
await ui.openTab('Segmentation');
const finalBox = await ui.mainImage().boundingBox();
const finalBox = await ui.canvasImage().boundingBox();
if (!finalBox) throw new Error('Main image has no bounding box after switching tabs');
expect(finalBox.width, 'zoom survives switching tabs').toBeCloseTo(afterZoom.width, 0);

Expand All @@ -71,7 +65,7 @@ test('after zooming in and panning, a depth-mode click at a known source pixel l
// mapping itself (lib/geometry.ts's `findPixelFromClick`) stays exact
// under zoom, independent of the pan gap just pinned above. The `<= 1`
// tolerance (not exact equality) is the same real sub-pixel truncation
// quirk `e2e/parallax-maker.spec.ts`'s own "default depth click" scenario
// quirk `e2e/parallax-maker.spec.ts`'s own "depth-band click" scenario
// pins for a *plain, unzoomed* click (a requested (16, 16) truncates to
// (15, 15)) - a fractional zoom scale only makes that rounding harder to
// predict exactly by hand, not any less exact in what the app itself does.
Expand Down Expand Up @@ -112,18 +106,18 @@ test('a reset returns the view to its original, unzoomed state', async ({ ui })
await ui.restoreFixtureState();
await ui.openTab('Inpainting');

const original = await ui.mainImage().boundingBox();
const original = await ui.canvasImage().boundingBox();
if (!original) throw new Error('Main image has no bounding box');

for (let i = 0; i < 5; i += 1) await ui.zoomIn();
const zoomed = await ui.mainImage().boundingBox();
const zoomed = await ui.canvasImage().boundingBox();
if (!zoomed) throw new Error('Main image has no bounding box after zoom');
expect(zoomed.width).toBeGreaterThan(original.width * 1.3);

// The Reset button (state/viewport.svelte.ts) returns the view to its
// original, unzoomed size exactly.
await ui.resetZoom();
const reset = await ui.mainImage().boundingBox();
const reset = await ui.canvasImage().boundingBox();
if (!reset) throw new Error('Main image has no bounding box after reset');
expect(reset.width).toBeCloseTo(original.width, 0);
expect(reset.x).toBeCloseTo(original.x, 0);
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/lib/a11y/rovingTabindex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* set by the component itself via `tabindex={tab === active ? 0 : -1}`);
* ArrowLeft/ArrowRight (and Home/End) move focus *and* activate the tab
* ("automatic activation"), matching how a plain click already behaves here
* (MainTabs.svelte/ViewerTabs.svelte have no separate "focus vs. select"
* state to preserve).
* (ViewModeBar.svelte has no separate "focus vs. select" state to
* preserve). Disabled tabs are skipped.
*
* Dash's own tab strips (components.py's `toggle_tab_container`, CMP-17/18)
* are plain `<label>` click targets with no keyboard support and no
Expand All @@ -21,7 +21,9 @@ export function rovingTabs(node: HTMLElement, onSelect: (index: number) => void)
}

function handleKeydown(event: KeyboardEvent): void {
const items = tabs();
const all = tabs();
// Disabled tabs can't be focused or selected; skip over them.
const items = all.filter((tab) => !(tab as HTMLButtonElement).disabled);
if (items.length === 0) return;
const current = items.indexOf(document.activeElement as HTMLElement);

Expand All @@ -47,7 +49,7 @@ export function rovingTabs(node: HTMLElement, onSelect: (index: number) => void)

event.preventDefault();
items[nextIndex].focus();
onSelect(nextIndex);
onSelect(all.indexOf(items[nextIndex]));
}

node.addEventListener('keydown', handleKeydown);
Expand Down
Loading
Loading