diff --git a/e2e/drivers/svelte.ts b/e2e/drivers/svelte.ts index 418f4ca..ae8856c 100644 --- a/e2e/drivers/svelte.ts +++ b/e2e/drivers/svelte.ts @@ -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'); } @@ -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 { + 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 { + 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 { const row = this.sliceRow(index); @@ -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 ` - -
- - - - {Math.round(viewportStore.scale * 100)}% - - -
- - {#each CAMERA_BUTTONS as button (button.direction)} - - {/each} - +
-
- - -
- - - - - - {#if uiStore.mainTab === 'Inpainting'} - - - - {/if} -
+ {/if} + + diff --git a/frontend/src/lib/components/upload/InputImagePanel.test.ts b/frontend/src/lib/components/upload/InputImagePanel.test.ts index 2ecd0ed..d75d745 100644 --- a/frontend/src/lib/components/upload/InputImagePanel.test.ts +++ b/frontend/src/lib/components/upload/InputImagePanel.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { flushSync } from 'svelte'; import InputImagePanel from './InputImagePanel.svelte'; import { projectStore } from '../../state/project.svelte'; import { jobStore } from '../../state/jobs.svelte'; import { logStore } from '../../state/logs.svelte'; import { uiStore } from '../../state/ui.svelte'; -import type { ProjectView, SliceView } from '../../api/types'; -import { maskToolsStore } from '../../state/maskTools.svelte'; +import { viewportStore } from '../../state/viewport.svelte'; +import type { ProjectView } from '../../api/types'; function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { @@ -44,7 +45,7 @@ function makeView(overrides: Partial = {}): ProjectView { darkMode: false, camera: { distance: 100, focalLength: 100, maxDistance: 200 }, meshDisplacement: 0, - depthModel: "dinov2", + depthModel: 'dinov2', }, exports: { gltf: null, upscaled: false }, ...overrides, @@ -52,7 +53,7 @@ function makeView(overrides: Partial = {}): ProjectView { } /** Gives the (jsdom) a stable, non-letterboxed 320x240 box for click math. */ -function mockImageGeometry(img: HTMLImageElement): void { +function mockImageGeometry(img: HTMLElement): void { vi.spyOn(img, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, @@ -79,311 +80,241 @@ describe('InputImagePanel', () => { jobStore.end(); logStore.reset(); uiStore.reset(); + viewportStore.reset(); }); afterEach(() => { vi.unstubAllGlobals(); }); - it('sends a depth-mode click with no modifiers by default', async () => { - projectStore.applyView(makeView()); - const fetchMock = vi.fn(); - fetchMock.mockResolvedValueOnce( - jsonResponse(202, { job: { id: 'job-1', kind: 'segmentation', status: 'queued', progress: 0 } }), - ); - fetchMock.mockResolvedValueOnce(jsonResponse(200, succeededJob(makeView({ revision: 2 })))); - fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); - vi.stubGlobal('fetch', fetchMock); - - render(InputImagePanel); - const img = screen.getByTestId('main-image') as HTMLImageElement; - mockImageGeometry(img); - await fireEvent.click(img, { clientX: 160, clientY: 120 }); - - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe('/api/v1/projects/appstate-test/segmentation/click'); - expect(init.method).toBe('POST'); - expect(JSON.parse(init.body as string)).toEqual({ - x: 160, - y: 120, - mode: 'depth', - shiftKey: false, - ctrlKey: false, - }); - }); - - it('sends instance mode with shiftKey when the Mode Selector is Instance Segmentation', async () => { - uiStore.setSegmentationMode('segment'); - projectStore.applyView(makeView()); - const fetchMock = vi.fn(); - fetchMock.mockResolvedValueOnce( - jsonResponse(202, { job: { id: 'job-1', kind: 'segmentation', status: 'queued', progress: 0 } }), - ); - fetchMock.mockResolvedValueOnce(jsonResponse(200, succeededJob(makeView({ revision: 2 })))); - fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); - vi.stubGlobal('fetch', fetchMock); - - render(InputImagePanel); - const img = screen.getByTestId('main-image') as HTMLImageElement; - mockImageGeometry(img); - await fireEvent.click(img, { clientX: 80, clientY: 96, shiftKey: true }); - - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(JSON.parse(init.body as string)).toEqual({ - x: 80, - y: 96, - mode: 'instance', - shiftKey: true, - ctrlKey: false, - }); - }); - - it('sends ctrlKey through untouched (not metaKey)', async () => { - uiStore.setSegmentationMode('segment'); - projectStore.applyView(makeView()); - const fetchMock = vi.fn(); - fetchMock.mockResolvedValueOnce( - jsonResponse(202, { job: { id: 'job-1', kind: 'segmentation', status: 'queued', progress: 0 } }), - ); - fetchMock.mockResolvedValueOnce(jsonResponse(200, succeededJob(makeView({ revision: 2 })))); - fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); - vi.stubGlobal('fetch', fetchMock); - - render(InputImagePanel); - const img = screen.getByTestId('main-image') as HTMLImageElement; - mockImageGeometry(img); - await fireEvent.click(img, { clientX: 80, clientY: 96, ctrlKey: true, metaKey: true }); - - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(JSON.parse(init.body as string)).toMatchObject({ shiftKey: false, ctrlKey: true }); - }); - - it('does not send a click while busy', async () => { - projectStore.applyView(makeView()); - jobStore.begin('depth'); - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - - render(InputImagePanel); - const img = screen.getByTestId('main-image') as HTMLImageElement; - mockImageGeometry(img); - await fireEvent.click(img, { clientX: 160, clientY: 120 }); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('does not send a click when there is no project', async () => { - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - - render(InputImagePanel); - const img = screen.getByTestId('main-image') as HTMLImageElement; - mockImageGeometry(img); - await fireEvent.click(img, { clientX: 160, clientY: 120 }); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('ignores a click that truncates to a pixel outside the image', async () => { - projectStore.applyView(makeView()); - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - - render(InputImagePanel); - const img = screen.getByTestId('main-image') as HTMLImageElement; - mockImageGeometry(img); - // clientX == rect.width truncates to naturalWidth, outside [0, naturalWidth). - await fireEvent.click(img, { clientX: 320, clientY: 120 }); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - describe('Multi/Commit enablement', () => { - it('disables Multi in Depth Map mode even with a project loaded', () => { + describe('segmentation clicks (Segment tool only)', () => { + it('sends a depth-mode click when Select by Depth band is chosen', async () => { + uiStore.setTool('segment'); + uiStore.setSegmentationMode('depth'); projectStore.applyView(makeView()); - render(InputImagePanel); - expect(screen.getByTestId('multi-point')).toBeDisabled(); - }); + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce( + jsonResponse(202, { job: { id: 'job-1', kind: 'segmentation', status: 'queued', progress: 0 } }), + ); + fetchMock.mockResolvedValueOnce(jsonResponse(200, succeededJob(makeView({ revision: 2 })))); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); + vi.stubGlobal('fetch', fetchMock); - it('enables Multi once Instance Segmentation mode and a project are both present', () => { - uiStore.setSegmentationMode('segment'); - projectStore.applyView(makeView()); render(InputImagePanel); - expect(screen.getByTestId('multi-point')).toBeEnabled(); - }); + const img = screen.getByTestId('main-image') as HTMLImageElement; + mockImageGeometry(img); + await fireEvent.click(img, { clientX: 160, clientY: 120 }); - it('reflects multiPointMode as aria-pressed', () => { - uiStore.setSegmentationMode('segment'); - projectStore.applyView( - makeView({ segmentation: { multiPointMode: true, queuedPoints: [], hasMask: false } }), - ); - render(InputImagePanel); - expect(screen.getByTestId('multi-point')).toHaveAttribute('aria-pressed', 'true'); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('/api/v1/projects/appstate-test/segmentation/click'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ + x: 160, + y: 120, + mode: 'depth', + shiftKey: false, + ctrlKey: false, + }); }); - it('keeps Commit disabled without queued points, even in multi-point instance mode', () => { - uiStore.setSegmentationMode('segment'); - projectStore.applyView( - makeView({ segmentation: { multiPointMode: true, queuedPoints: [], hasMask: false } }), + it('sends instance mode with shiftKey (Select by Object, the default)', async () => { + uiStore.setTool('segment'); + expect(uiStore.segmentationMode).toBe('segment'); + projectStore.applyView(makeView()); + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce( + jsonResponse(202, { job: { id: 'job-1', kind: 'segmentation', status: 'queued', progress: 0 } }), ); - render(InputImagePanel); - expect(screen.getByTestId('multi-commit')).toBeDisabled(); - }); + fetchMock.mockResolvedValueOnce(jsonResponse(200, succeededJob(makeView({ revision: 2 })))); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); + vi.stubGlobal('fetch', fetchMock); - it('enables Commit once points are queued in multi-point instance mode', () => { - uiStore.setSegmentationMode('segment'); - projectStore.applyView( - makeView({ - segmentation: { - multiPointMode: true, - queuedPoints: [{ x: 1, y: 2, negative: false }], - hasMask: false, - }, - }), - ); render(InputImagePanel); - expect(screen.getByTestId('multi-commit')).toBeEnabled(); + const img = screen.getByTestId('main-image') as HTMLImageElement; + mockImageGeometry(img); + await fireEvent.click(img, { clientX: 80, clientY: 96, shiftKey: true }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + x: 80, + y: 96, + mode: 'instance', + shiftKey: true, + ctrlKey: false, + }); }); - it('disables Commit outside Instance Segmentation mode even with queued points', () => { - projectStore.applyView( - makeView({ - segmentation: { - multiPointMode: true, - queuedPoints: [{ x: 1, y: 2, negative: false }], - hasMask: false, - }, - }), + it('sends ctrlKey through untouched (not metaKey)', async () => { + uiStore.setTool('segment'); + projectStore.applyView(makeView()); + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce( + jsonResponse(202, { job: { id: 'job-1', kind: 'segmentation', status: 'queued', progress: 0 } }), ); - render(InputImagePanel); - expect(screen.getByTestId('multi-commit')).toBeDisabled(); - }); - }); + fetchMock.mockResolvedValueOnce(jsonResponse(200, succeededJob(makeView({ revision: 2 })))); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); + vi.stubGlobal('fetch', fetchMock); - describe('Checkerboard/Invert/Feather mask tools', () => { - it('disables all three without a project', () => { render(InputImagePanel); - expect(screen.getByTestId('toggle-checkerboard')).toBeDisabled(); - expect(screen.getByTestId('invert-mask')).toBeDisabled(); - expect(screen.getByTestId('feather-mask')).toBeDisabled(); + const img = screen.getByTestId('main-image') as HTMLImageElement; + mockImageGeometry(img); + await fireEvent.click(img, { clientX: 80, clientY: 96, ctrlKey: true, metaKey: true }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ shiftKey: false, ctrlKey: true }); }); - it('enables all three with a project loaded', () => { + it('does not send a click while busy', async () => { + uiStore.setTool('segment'); projectStore.applyView(makeView()); - render(InputImagePanel); - expect(screen.getByTestId('toggle-checkerboard')).toBeEnabled(); - expect(screen.getByTestId('invert-mask')).toBeEnabled(); - expect(screen.getByTestId('feather-mask')).toBeEnabled(); - }); + jobStore.begin('depth'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); - it('reflects useCheckerboard as aria-pressed', () => { - projectStore.applyView(makeView({ useCheckerboard: true })); render(InputImagePanel); - expect(screen.getByTestId('toggle-checkerboard')).toHaveAttribute('aria-pressed', 'true'); + const img = screen.getByTestId('main-image') as HTMLImageElement; + mockImageGeometry(img); + await fireEvent.click(img, { clientX: 160, clientY: 120 }); + + expect(fetchMock).not.toHaveBeenCalled(); }); - it('toggle-checkerboard PUTs the inverse of the current flag', async () => { - projectStore.applyView(makeView({ useCheckerboard: false })); + it('does not send a click when there is no project', async () => { + uiStore.setTool('segment'); const fetchMock = vi.fn(); - fetchMock.mockResolvedValueOnce( - jsonResponse(200, { ...makeView({ revision: 2, useCheckerboard: true }), changed: true }), - ); - fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); vi.stubGlobal('fetch', fetchMock); render(InputImagePanel); - await fireEvent.click(screen.getByTestId('toggle-checkerboard')); + const img = screen.getByTestId('main-image') as HTMLImageElement; + mockImageGeometry(img); + await fireEvent.click(img, { clientX: 160, clientY: 120 }); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe('/api/v1/projects/appstate-test/display'); - expect(JSON.parse(init.body as string)).toEqual({ useCheckerboard: true }); + expect(fetchMock).not.toHaveBeenCalled(); }); - it('invert-mask POSTs .../mask/invert', async () => { + it('ignores a click that truncates to a pixel outside the image', async () => { + uiStore.setTool('segment'); projectStore.applyView(makeView()); const fetchMock = vi.fn(); - fetchMock.mockResolvedValueOnce(jsonResponse(200, { ...makeView({ revision: 2 }), changed: true })); - fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); vi.stubGlobal('fetch', fetchMock); render(InputImagePanel); - await fireEvent.click(screen.getByTestId('invert-mask')); + const img = screen.getByTestId('main-image') as HTMLImageElement; + mockImageGeometry(img); + // clientX == rect.width truncates to naturalWidth, outside [0, naturalWidth). + await fireEvent.click(img, { clientX: 320, clientY: 120 }); - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); - expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/projects/appstate-test/mask/invert'); + expect(fetchMock).not.toHaveBeenCalled(); }); - it('feather-mask logs a no-op without calling the API when there is no mask', async () => { - projectStore.applyView( - makeView({ segmentation: { multiPointMode: false, queuedPoints: [], hasMask: false } }), - ); + it('does nothing with the Pan tool, even with a project loaded', async () => { + // uiStore.reset() leaves the default tool, Pan. + expect(uiStore.tool).toBe('pan'); + projectStore.applyView(makeView()); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); render(InputImagePanel); - await fireEvent.click(screen.getByTestId('feather-mask')); + const img = screen.getByTestId('main-image') as HTMLImageElement; + mockImageGeometry(img); + await fireEvent.click(img, { clientX: 160, clientY: 120 }); expect(fetchMock).not.toHaveBeenCalled(); - expect(logStore.entries.at(-1)?.message).toBe('No mask to feather'); }); }); - describe('camera navigation', () => { - const slice = { index: 0, depth: 0 } as unknown as SliceView; + describe('empty state (no input image)', () => { + it('shows the drop zone and choose-image affordance', () => { + render(InputImagePanel); + expect(screen.getByTestId('empty-state')).toBeInTheDocument(); + expect(screen.getByTestId('choose-image')).toBeEnabled(); + }); - it('is disabled until there are slices to navigate', () => { - projectStore.applyView(makeView()); + it('choose-image opens the hidden file input', async () => { render(InputImagePanel); - expect(screen.getByTestId('camera-up')).toBeDisabled(); - expect(screen.getByTestId('camera-reset')).toBeDisabled(); + const input = screen.getByTestId('upload-image-input') as HTMLInputElement; + const clickSpy = vi.spyOn(input, 'click'); + await fireEvent.click(screen.getByTestId('choose-image')); + expect(clickSpy).toHaveBeenCalledOnce(); }); - it('posts the clicked direction and shows the re-rendered view', async () => { - projectStore.applyView(makeView({ slices: [slice] })); - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const url = String(input); - if (url === '/api/v1/projects/appstate-test/camera/navigate') { - return jsonResponse(200, { ...makeView({ slices: [slice], mainImage: { url: '/main?v=2' } }), changed: true }); - } - if (url.startsWith('/api/v1/projects/appstate-test/logs')) return jsonResponse(200, { entries: [], next: 0 }); - throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${url}`); - }); - vi.stubGlobal('fetch', fetchMock); + it('disables choose-image while busy', () => { + jobStore.begin('upload'); render(InputImagePanel); + expect(screen.getByTestId('choose-image')).toBeDisabled(); + }); + }); - await fireEvent.click(screen.getByTestId('camera-left')); + describe('zoom and pan', () => { + it('zooms in about the cursor on an upward wheel tick', async () => { + projectStore.applyView(makeView()); + render(InputImagePanel); + const stage = screen.getByTestId('input-image-panel'); + const fitEl = stage.querySelector('.image-fit') as HTMLElement; + vi.spyOn(fitEl, 'getBoundingClientRect').mockReturnValue({ + left: 0, top: 0, width: 320, height: 240, right: 320, bottom: 240, x: 0, y: 0, toJSON: () => ({}), + } as DOMRect); - await waitFor(() => expect(screen.getByTestId('main-image')).toHaveAttribute('src', '/main?v=2')); - const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/camera/navigate')); - expect(call![1]).toMatchObject({ method: 'POST' }); - expect(JSON.parse(call![1]!.body as string)).toEqual({ direction: 'left' }); + expect(viewportStore.scale).toBe(1); + await fireEvent.wheel(stage, { clientX: 160, clientY: 120, deltaY: -100 }); + expect(viewportStore.scale).toBeGreaterThan(1); }); - }); - describe('canvas tools', () => { - afterEach(() => maskToolsStore.reset()); + it('ignores wheel events without an input image', async () => { + render(InputImagePanel); + const stage = screen.getByTestId('input-image-panel'); + await fireEvent.wheel(stage, { clientX: 160, clientY: 120, deltaY: -100 }); + expect(viewportStore.scale).toBe(1); + }); - it('sit in the tool row below the image, only on the Inpainting tab', () => { + it('pans on a primary-button drag past the threshold, and suppresses the click that follows', async () => { projectStore.applyView(makeView()); + uiStore.setTool('segment'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + render(InputImagePanel); - expect(screen.queryByTestId('canvas-tools')).not.toBeInTheDocument(); - - uiStore.setMainTab('Inpainting'); - return waitFor(() => { - const tools = screen.getByTestId('canvas-tools'); - // Outside the zoomed image box, next to the other tool buttons. - expect(tools.closest('.image-stack')).toBeNull(); - expect(tools.parentElement).toContainElement(screen.getByTestId('invert-mask')); - }); + const stage = screen.getByTestId('input-image-panel'); + const img = screen.getByTestId('main-image') as HTMLImageElement; + mockImageGeometry(img); + Object.defineProperty(stage, 'setPointerCapture', { value: vi.fn(), configurable: true }); + Object.defineProperty(stage, 'releasePointerCapture', { value: vi.fn(), configurable: true }); + + await fireEvent.pointerDown(stage, { pointerId: 1, button: 0, clientX: 100, clientY: 100 }); + // panBy reads movementX/movementY (not the clientX/clientY delta), so + // the fired event must carry them explicitly -- jsdom never computes + // mouse-movement deltas between synthetic events on its own. clientX/Y + // still need to move past PAN_THRESHOLD_PX to engage the drag at all. + await fireEvent.pointerMove(stage, { pointerId: 1, clientX: 120, clientY: 130, movementX: 20, movementY: 30 }); + expect(viewportStore.panX).toBeCloseTo(20); + expect(viewportStore.panY).toBeCloseTo(30); + await fireEvent.pointerUp(stage, { pointerId: 1, clientX: 120, clientY: 130 }); + + // The drag suppresses the click the browser still fires on the . + await fireEvent.click(img, { clientX: 120, clientY: 130 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not engage drag-to-pan for a plain click (no movement)', async () => { + projectStore.applyView(makeView()); + render(InputImagePanel); + const stage = screen.getByTestId('input-image-panel'); + Object.defineProperty(stage, 'setPointerCapture', { value: vi.fn(), configurable: true }); + + await fireEvent.pointerDown(stage, { pointerId: 1, button: 0, clientX: 100, clientY: 100 }); + await fireEvent.pointerUp(stage, { pointerId: 1, clientX: 100, clientY: 100 }); + expect(viewportStore.panX).toBe(0); + expect(viewportStore.panY).toBe(0); }); + }); + describe('brush tool - mask canvas', () => { it('painting on the canvas never opens the upload file chooser', async () => { projectStore.applyView(makeView()); - uiStore.setMainTab('Inpainting'); + uiStore.setTool('brush'); render(InputImagePanel); const openChooser = vi.spyOn(screen.getByTestId('upload-image-input') as HTMLInputElement, 'click'); @@ -391,32 +322,9 @@ describe('InputImagePanel', () => { expect(openChooser).not.toHaveBeenCalled(); }); - - it('drive the shared brush state and the canvas actions', async () => { - projectStore.applyView(makeView()); - uiStore.setMainTab('Inpainting'); - const clear = vi.fn(async () => {}); - const load = vi.fn(async () => {}); - render(InputImagePanel); - // The real MaskCanvas has bound its own actions; rebind test doubles. - maskToolsStore.bindCanvas({ clear, load }); - - await fireEvent.input(screen.getByTestId('brush-size'), { target: { value: '25' } }); - expect(maskToolsStore.brushWidth).toBe(25); - await fireEvent.click(screen.getByTestId('canvas-erase-mode')); - expect(maskToolsStore.erasing).toBe(true); - expect(screen.getByTestId('canvas-erase-mode')).toHaveAttribute('aria-pressed', 'true'); - // The eraser keeps its own width. - expect(maskToolsStore.brushWidth).toBe(60); - - await fireEvent.click(screen.getByTestId('canvas-clear')); - await fireEvent.click(screen.getByTestId('canvas-load')); - expect(clear).toHaveBeenCalledOnce(); - expect(load).toHaveBeenCalledOnce(); - }); }); - describe('horizon line', () => { + describe('horizon overlay (Horizon tool only)', () => { const withHorizon = (row: number) => makeView({ settings: { @@ -439,12 +347,14 @@ describe('InputImagePanel', () => { }); } - it('is hidden until toggled, then sits on the horizon row', async () => { + it('is hidden until the Horizon tool is picked, then sits on the horizon row', () => { projectStore.applyView(withHorizon(120)); render(InputImagePanel); expect(screen.queryByTestId('horizon-line')).not.toBeInTheDocument(); - await fireEvent.click(screen.getByTestId('horizon-toggle')); + uiStore.setTool('horizon'); + flushSync(); + const line = screen.getByTestId('horizon-line'); expect(line).toHaveAttribute('data-row', '120'); expect(line.style.top).toBe('50%'); // 120 of 240 rows @@ -456,7 +366,8 @@ describe('InputImagePanel', () => { stubSettings(fetchMock); vi.stubGlobal('fetch', fetchMock); render(InputImagePanel); - await fireEvent.click(screen.getByTestId('horizon-toggle')); + uiStore.setTool('horizon'); + flushSync(); const line = screen.getByTestId('horizon-line'); // The image box is 240 CSS px tall for the 240-row image. @@ -481,7 +392,8 @@ describe('InputImagePanel', () => { stubSettings(fetchMock); vi.stubGlobal('fetch', fetchMock); render(InputImagePanel); - await fireEvent.click(screen.getByTestId('horizon-toggle')); + uiStore.setTool('horizon'); + flushSync(); await fireEvent.keyDown(screen.getByTestId('horizon-line'), { key: 'ArrowUp', shiftKey: true }); await waitFor(() => expect(fetchMock).toHaveBeenCalled()); diff --git a/frontend/src/lib/components/upload/ViewerTabs.svelte b/frontend/src/lib/components/upload/ViewerTabs.svelte deleted file mode 100644 index c11d8b5..0000000 --- a/frontend/src/lib/components/upload/ViewerTabs.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - -
-
uiStore.setViewerTab(tabs[index])} - > - {#each tabs as tab (tab)} - - {/each} -
- -
- -
-
- -
-
- - diff --git a/frontend/src/lib/components/upload/ViewerTabs.test.ts b/frontend/src/lib/components/upload/ViewerTabs.test.ts deleted file mode 100644 index 3eb7223..0000000 --- a/frontend/src/lib/components/upload/ViewerTabs.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import ViewerTabs from './ViewerTabs.svelte'; -import { uiStore } from '../../state/ui.svelte'; - -// Model3DViewer lazy-loads @google/model-viewer, whose real WebGL/Three.js -// internals jsdom cannot run (see Model3DViewer.test.ts); mock it so -// switching to the 3D tab here doesn't attempt that. -vi.mock('@google/model-viewer', () => ({})); - -describe('ViewerTabs', () => { - beforeEach(() => { - uiStore.reset(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('shows the 2D input-image panel by default, with the 2D tab selected', () => { - render(ViewerTabs); - expect(screen.getByRole('tab', { name: '2D' })).toHaveAttribute('aria-selected', 'true'); - expect(screen.getByRole('tab', { name: '3D' })).toHaveAttribute('aria-selected', 'false'); - expect(screen.getByTestId('viewer-2d')).not.toHaveClass('hidden'); - expect(screen.getByTestId('viewer-3d')).toHaveClass('hidden'); - }); - - it('clicking the 3D tab switches the active viewer tab and its own panel visibility', async () => { - render(ViewerTabs); - await fireEvent.click(screen.getByRole('tab', { name: '3D' })); - - expect(screen.getByRole('tab', { name: '3D' })).toHaveAttribute('aria-selected', 'true'); - expect(screen.getByRole('tab', { name: '2D' })).toHaveAttribute('aria-selected', 'false'); - expect(screen.getByTestId('viewer-3d')).not.toHaveClass('hidden'); - expect(screen.getByTestId('viewer-2d')).toHaveClass('hidden'); - }); -}); diff --git a/frontend/src/lib/components/viewer/Model3DViewer.svelte b/frontend/src/lib/components/viewer/Model3DViewer.svelte index dda7967..c67412b 100644 --- a/frontend/src/lib/components/viewer/Model3DViewer.svelte +++ b/frontend/src/lib/components/viewer/Model3DViewer.svelte @@ -19,7 +19,7 @@ let loadFailed = $state(false); $effect(() => { - if (uiStore.viewerTab !== '3D' || ready || loadFailed) return; + if (uiStore.view !== '3d' || ready || loadFailed) return; let cancelled = false; import('@google/model-viewer') .then(() => { diff --git a/frontend/src/lib/components/viewer/Model3DViewer.test.ts b/frontend/src/lib/components/viewer/Model3DViewer.test.ts index 6a1c2ae..76c6684 100644 --- a/frontend/src/lib/components/viewer/Model3DViewer.test.ts +++ b/frontend/src/lib/components/viewer/Model3DViewer.test.ts @@ -62,19 +62,19 @@ describe('Model3DViewer', () => { it('does not load the model-viewer module while the 2D tab is active', () => { render(Model3DViewer); - expect(uiStore.viewerTab).toBe('2D'); + expect(uiStore.view).toBe('input'); expect(screen.getByTestId('model-viewer-loading')).toBeInTheDocument(); expect(loadSpy).not.toHaveBeenCalled(); }); it('lazy-loads the model-viewer module once the 3D tab is opened', async () => { render(Model3DViewer); - uiStore.setViewerTab('3D'); + uiStore.setView('3d'); await waitFor(() => expect(loadSpy).toHaveBeenCalled()); }); it('shows a placeholder (matching Dash\'s "No glTF file available.") before any scene has been exported', async () => { - uiStore.setViewerTab('3D'); + uiStore.setView('3d'); projectStore.applyView(makeView()); render(Model3DViewer); await waitFor(() => @@ -83,7 +83,7 @@ describe('Model3DViewer', () => { }); it('renders pointing at the exported glTF asset once a scene exists', async () => { - uiStore.setViewerTab('3D'); + uiStore.setView('3d'); projectStore.applyView( makeView({ exports: { gltf: { url: '/api/v1/projects/appstate-test/export/gltf?v=1' }, upscaled: false } }), ); diff --git a/frontend/src/lib/state/ui.svelte.test.ts b/frontend/src/lib/state/ui.svelte.test.ts new file mode 100644 index 0000000..c0438d8 --- /dev/null +++ b/frontend/src/lib/state/ui.svelte.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { uiStore } from './ui.svelte'; + +describe('uiStore', () => { + beforeEach(() => { + uiStore.reset(); + }); + + it('defaults to the Image step, Input view, Pan tool and Object segmentation mode', () => { + expect(uiStore.step).toBe('image'); + expect(uiStore.view).toBe('input'); + expect(uiStore.tool).toBe('pan'); + expect(uiStore.segmentationMode).toBe('segment'); + }); + + describe('setStep applies each step\'s default view and tool', () => { + it('image -> Input view, Pan tool', () => { + uiStore.setStep('image'); + expect(uiStore.view).toBe('input'); + expect(uiStore.tool).toBe('pan'); + expect(uiStore.mainTab).toBe('Mode'); + }); + + it('depth -> Depth view, Pan tool', () => { + uiStore.setStep('depth'); + expect(uiStore.view).toBe('depth'); + expect(uiStore.tool).toBe('pan'); + expect(uiStore.mainTab).toBe('Mode'); + }); + + it('slices -> Input view, Segment tool', () => { + uiStore.setStep('slices'); + expect(uiStore.view).toBe('input'); + expect(uiStore.tool).toBe('segment'); + expect(uiStore.mainTab).toBe('Segmentation'); + }); + + it('inpaint -> Slice view, Brush tool', () => { + uiStore.setStep('inpaint'); + expect(uiStore.view).toBe('slice'); + expect(uiStore.tool).toBe('brush'); + expect(uiStore.mainTab).toBe('Inpainting'); + }); + + it('ground -> Composite view, Horizon tool', () => { + uiStore.setStep('ground'); + expect(uiStore.view).toBe('composite'); + expect(uiStore.tool).toBe('horizon'); + expect(uiStore.mainTab).toBe('Segmentation'); + }); + + it('preview -> Parallax view, Pan tool, and marks the session as previewed', () => { + expect(uiStore.previewed).toBe(false); + uiStore.setStep('preview'); + expect(uiStore.view).toBe('parallax'); + expect(uiStore.tool).toBe('pan'); + expect(uiStore.mainTab).toBe('Export'); + expect(uiStore.previewed).toBe(true); + }); + + it('export has no default view/tool of its own: it keeps whatever was active', () => { + uiStore.setStep('slices'); + expect(uiStore.view).toBe('input'); + expect(uiStore.tool).toBe('segment'); + + uiStore.setStep('export'); + expect(uiStore.step).toBe('export'); + expect(uiStore.mainTab).toBe('Export'); + // View/tool are untouched by a step with no STEP_DEFAULTS entry. + expect(uiStore.view).toBe('input'); + expect(uiStore.tool).toBe('segment'); + }); + }); + + describe('setTool moves the step and switches to a compatible view', () => { + it('segment -> Slices step; switches away from an incompatible view', () => { + uiStore.setStep('depth'); // view: 'depth', not in segment's views (['input']) + uiStore.setTool('segment'); + expect(uiStore.step).toBe('slices'); + expect(uiStore.view).toBe('input'); + expect(uiStore.tool).toBe('segment'); + }); + + it('segment keeps the view when it is already compatible', () => { + uiStore.setView('input'); + uiStore.setTool('segment'); + expect(uiStore.view).toBe('input'); + }); + + it('brush -> Inpaint step; falls back to its own first compatible view', () => { + uiStore.setStep('ground'); // view: 'composite', not in brush's views (['slice', 'input']) + uiStore.setTool('brush'); + expect(uiStore.step).toBe('inpaint'); + expect(uiStore.view).toBe('slice'); + }); + + it('brush keeps the view when it is already compatible (input)', () => { + uiStore.setView('input'); + uiStore.setTool('brush'); + expect(uiStore.view).toBe('input'); + }); + + it('horizon -> Ground step; falls back to its own first compatible view', () => { + uiStore.setStep('inpaint'); // view: 'slice', not in horizon's views (['composite','input','depth']) + uiStore.setTool('horizon'); + expect(uiStore.step).toBe('ground'); + expect(uiStore.view).toBe('composite'); + }); + + it('pan has no owning step and works in every view', () => { + uiStore.setStep('slices'); // step: 'slices', view: 'input', tool: 'segment' + uiStore.setTool('pan'); + // Pan has no TOOL_STEPS entry, so the step is left as-is. + expect(uiStore.step).toBe('slices'); + expect(uiStore.view).toBe('input'); + expect(uiStore.tool).toBe('pan'); + }); + }); + + describe('setView falls back to Pan when the current tool does not work there', () => { + it('switches to a view compatible with Segment (input only)', () => { + uiStore.setTool('segment'); // view becomes 'input' + uiStore.setView('depth'); // 'depth' not in segment's views + expect(uiStore.tool).toBe('pan'); + expect(uiStore.view).toBe('depth'); + }); + + it('keeps the tool when the new view is compatible', () => { + uiStore.setTool('brush'); // view becomes 'slice' + uiStore.setView('input'); // 'input' is in brush's views + expect(uiStore.tool).toBe('brush'); + expect(uiStore.view).toBe('input'); + }); + + it('Parallax and 3D move the step to Preview', () => { + uiStore.setStep('slices'); + uiStore.setView('parallax'); + expect(uiStore.step).toBe('preview'); + expect(uiStore.tool).toBe('pan'); // segment doesn't work in parallax + + uiStore.setStep('slices'); + uiStore.setView('3d'); + expect(uiStore.step).toBe('preview'); + }); + }); + + describe('reset', () => { + it('restores every default, including progress flags', () => { + uiStore.setStep('preview'); // marks previewed + uiStore.markInpainted(); + uiStore.markExported(); + uiStore.setSegmentationMode('depth'); + uiStore.setTheme('light'); + + uiStore.reset(); + + expect(uiStore.step).toBe('image'); + expect(uiStore.view).toBe('input'); + expect(uiStore.tool).toBe('pan'); + expect(uiStore.segmentationMode).toBe('segment'); + expect(uiStore.previewed).toBe(false); + expect(uiStore.inpainted).toBe(false); + expect(uiStore.exported).toBe(false); + expect(uiStore.renderedMainUrl).toBeNull(); + expect(uiStore.theme).toBe('dark'); + }); + }); + + describe('resetSession', () => { + it('forgets progress and returns to the first step for a new project', () => { + uiStore.setStep('inpaint'); + uiStore.markInpainted(); + uiStore.markPreviewed(); + uiStore.markExported(); + uiStore.setRenderedMainUrl('/api/v1/projects/old/assets/main?v=1'); + + uiStore.resetSession(); + + expect(uiStore.renderedMainUrl).toBeNull(); + + expect(uiStore.step).toBe('image'); + expect(uiStore.view).toBe('input'); + expect(uiStore.tool).toBe('pan'); + expect(uiStore.inpainted).toBe(false); + expect(uiStore.previewed).toBe(false); + expect(uiStore.exported).toBe(false); + }); + }); +}); diff --git a/frontend/src/lib/state/ui.svelte.ts b/frontend/src/lib/state/ui.svelte.ts index a9c0db9..8ec57a0 100644 --- a/frontend/src/lib/state/ui.svelte.ts +++ b/frontend/src/lib/state/ui.svelte.ts @@ -5,7 +5,10 @@ * store never holds anything the server would consider authoritative. */ -export type ViewerTab = '2D' | '3D'; +/** What the canvas shows (docs/redesign/HANDOFF.md §3). */ +export type CanvasView = 'input' | 'depth' | 'slice' | 'composite' | 'parallax' | '3d'; +/** The active canvas tool (HANDOFF.md §5). */ +export type CanvasTool = 'pan' | 'segment' | 'brush' | 'horizon'; export type MainTab = 'Mode' | 'Segmentation' | 'Inpainting' | 'Export' | 'Configuration'; /** The workflow stepper's steps (docs/redesign/HANDOFF.md §4). */ export type WorkflowStep = 'image' | 'depth' | 'slices' | 'inpaint' | 'ground' | 'preview' | 'export'; @@ -26,6 +29,40 @@ export const WORKFLOW_STEPS: { step: WorkflowStep; label: string }[] = [ { step: 'export', label: 'Export' }, ]; +export const CANVAS_VIEWS: { view: CanvasView; label: string; key: string }[] = [ + { view: 'input', label: 'Input', key: 'I' }, + { view: 'depth', label: 'Depth', key: 'M' }, + { view: 'slice', label: 'Slice', key: 'L' }, + { view: 'composite', label: 'Composite', key: 'C' }, + { view: 'parallax', label: 'Parallax 2D', key: 'P' }, + { view: '3d', label: '3D', key: 'Shift+P' }, +]; + +/** Each step's default view and tool (HANDOFF.md §4); Export keeps both. */ +const STEP_DEFAULTS: Partial> = { + image: { view: 'input', tool: 'pan' }, + depth: { view: 'depth', tool: 'pan' }, + slices: { view: 'input', tool: 'segment' }, + inpaint: { view: 'slice', tool: 'brush' }, + ground: { view: 'composite', tool: 'horizon' }, + preview: { view: 'parallax', tool: 'pan' }, +}; + +/** The step a tool belongs to (choosing the tool moves the step there). */ +const TOOL_STEPS: Partial> = { + segment: 'slices', + brush: 'inpaint', + horizon: 'ground', +}; + +/** The views each tool works in; choosing a tool elsewhere switches view. */ +const TOOL_VIEWS: Record = { + pan: ['input', 'depth', 'slice', 'composite', 'parallax', '3d'], + segment: ['input'], + brush: ['slice', 'input'], + horizon: ['composite', 'input', 'depth'], +}; + /** * Which of the pre-redesign tab bodies the Inspector shows for a step, * until each step gets its own panel (HANDOFF.md §12). @@ -46,7 +83,11 @@ const DEFAULT_DEPTH_MODEL = 'dinov2'; const DEFAULT_NUM_SLICES = 3; function createUiStore() { - let viewerTab = $state('2D'); + let view = $state('input'); + // The main-image URL of the latest camera render, so Parallax 2D can tell + // whether the server's display image is a render or something else. + let renderedMainUrl = $state(null); + let tool = $state('pan'); let mainTab = $state('Mode'); let step = $state('image'); let theme = $state('dark'); @@ -56,7 +97,8 @@ function createUiStore() { let inpainted = $state(false); let previewed = $state(false); let exported = $state(false); - let segmentationMode = $state('depth'); + // Segment Anything (object) selection is the main way to make slices. + let segmentationMode = $state('segment'); let depthModel = $state(DEFAULT_DEPTH_MODEL); let pendingNumSlices = $state(DEFAULT_NUM_SLICES); // Configuration tab probe highlights (CMP-11/CMP-12/CMP-13/CMP-14/CMP-15): @@ -73,13 +115,40 @@ function createUiStore() { // preview -- Dash's CLI-07/CMP-24). let cropToRoi = $state(true); + /** Makes `next` the current step and shows its panel (view/tool untouched). */ + function moveToStep(next: WorkflowStep): void { + step = next; + mainTab = STEP_PANELS[next]; + if (next === 'preview') previewed = true; + } + return { - get viewerTab(): ViewerTab { - return viewerTab; + get view(): CanvasView { + return view; + }, + /** Shows a view; a tool that doesn't work there falls back to Pan. */ + setView(next: CanvasView): void { + view = next; + if (!TOOL_VIEWS[tool].includes(next)) tool = 'pan'; + if (next === 'parallax' || next === '3d') moveToStep('preview'); + }, + + get renderedMainUrl(): string | null { + return renderedMainUrl; + }, + setRenderedMainUrl(url: string | null): void { + renderedMainUrl = url; + }, + + get tool(): CanvasTool { + return tool; }, - setViewerTab(tab: ViewerTab): void { - viewerTab = tab; - if (tab === '3D') previewed = true; + /** Picks a tool; its step becomes current and the view one it works in. */ + setTool(next: CanvasTool): void { + tool = next; + if (!TOOL_VIEWS[next].includes(view)) view = TOOL_VIEWS[next][0]; + const toolStep = TOOL_STEPS[next]; + if (toolStep) moveToStep(toolStep); }, /** The pre-redesign tab body the Inspector shows (see STEP_PANELS). */ @@ -93,11 +162,14 @@ function createUiStore() { get step(): WorkflowStep { return step; }, - /** Moves to a workflow step and shows that step's Inspector panel. */ + /** Moves to a workflow step: its Inspector panel, default view and tool. */ setStep(next: WorkflowStep): void { - step = next; - mainTab = STEP_PANELS[next]; - if (next === 'preview') previewed = true; + moveToStep(next); + const defaults = STEP_DEFAULTS[next]; + if (defaults) { + view = defaults.view; + tool = defaults.tool; + } }, get logOpen(): boolean { @@ -120,6 +192,9 @@ function createUiStore() { exported = false; step = 'image'; mainTab = STEP_PANELS.image; + view = 'input'; + tool = 'pan'; + renderedMainUrl = null; }, get inpainted(): boolean { @@ -195,7 +270,9 @@ function createUiStore() { /** Test-only: restores default values so stores don't leak between tests. */ reset(): void { - viewerTab = '2D'; + view = 'input'; + tool = 'pan'; + renderedMainUrl = null; mainTab = 'Mode'; step = 'image'; theme = 'dark'; @@ -203,7 +280,7 @@ function createUiStore() { inpainted = false; previewed = false; exported = false; - segmentationMode = 'depth'; + segmentationMode = 'segment'; depthModel = DEFAULT_DEPTH_MODEL; pendingNumSlices = DEFAULT_NUM_SLICES; externalConnectionStatus = 'none'; diff --git a/frontend/src/lib/workflow.ts b/frontend/src/lib/workflow.ts index 748ca30..f647916 100644 --- a/frontend/src/lib/workflow.ts +++ b/frontend/src/lib/workflow.ts @@ -295,6 +295,7 @@ async function runSliceMutation( export async function navigateCamera(direction: api.CameraDirection): Promise { if (await runSliceMutation('navigate', (id) => api.navigateCamera(id, direction))) { uiStore.markPreviewed(); + uiStore.setRenderedMainUrl(projectStore.view?.mainImage?.url ?? null); } }