diff --git a/e2e/drivers/svelte.ts b/e2e/drivers/svelte.ts index 7cc8c18..e1549f2 100644 --- a/e2e/drivers/svelte.ts +++ b/e2e/drivers/svelte.ts @@ -13,7 +13,7 @@ import type { } from './types'; // Every slider's `data-testid` equals its `SliderName` directly (see -// ConfigurationTab.svelte/ExportTab.svelte) - unlike DashDriver, no lookup +// the Preview/Ground/Slices panels and ExportDialog.svelte) - unlike DashDriver, no lookup // table of distinct DOM ids is needed. /** @@ -24,11 +24,10 @@ import type { * returns `true`. */ /** The workflow step whose Inspector panel is each former main tab. */ -const STEP_FOR_TAB: Record, string> = { +const STEP_FOR_TAB: Record, string> = { Mode: 'depth', Segmentation: 'slices', Inpainting: 'inpaint', - Export: 'export', }; /** The workflow step whose Inspector panel holds each slider. */ @@ -59,19 +58,49 @@ export class SvelteDriver implements UiDriver { } /** - * The redesigned UI has no workflow tabs: each former tab is a workflow - * step's Inspector panel, and Configuration is the Settings panel. + * The redesigned UI has no workflow tabs: Mode, Segmentation and + * Inpainting are workflow steps' Inspector panels, while Export and + * Configuration are the Export and Settings dialogs. */ async openTab(tab: MainTab): Promise { - const inspector = this.page.getByTestId('inspector'); if (tab === 'Configuration') { - if ((await inspector.getAttribute('data-panel')) !== 'Configuration') { - await this.page.getByTestId('open-settings').click(); - } + await this.openDialog('settings'); + } else if (tab === 'Export') { + await this.openDialog('export'); } else { + await this.closeDialogs(); await this.page.getByTestId(`step-${STEP_FOR_TAB[tab]}`).click(); + await expect(this.page.getByTestId('inspector')).toHaveAttribute('data-panel', tab); } - await expect(inspector).toHaveAttribute('data-panel', tab); + } + + private openDialogs(): Locator { + return this.page.locator('dialog[open]'); + } + + /** Closes an open dialog (Esc), so the workspace behind it takes clicks again. */ + private async closeDialogs(): Promise { + if ((await this.openDialogs().count()) === 0) return; + await this.page.keyboard.press('Escape'); + await expect(this.openDialogs()).toHaveCount(0); + } + + private async openDialog(name: 'export' | 'settings'): Promise { + const dialog = this.page.getByTestId(`${name}-dialog`); + if (await dialog.isVisible()) return; + await this.closeDialogs(); + await this.page.getByTestId(name === 'export' ? 'step-export' : 'open-settings').click(); + await expect(dialog).toBeVisible(); + } + + /** Opens the Export dialog on the pane (3D scene or Animation) holding `testId`. */ + private async revealInExport(testId: string): Promise { + await this.openDialog('export'); + const control = this.page.getByTestId(testId); + if (await control.isVisible()) return; + const tab = ['number-of-frames', 'animation-export'].includes(testId) ? 'animation' : 'gltf'; + await this.page.getByTestId(`export-tab-${tab}`).click(); + await expect(control).toBeVisible(); } // Observable elements @@ -103,6 +132,7 @@ export class SvelteDriver implements UiDriver { /** Picks a canvas tool (Pan, Segment, Brush, Horizon) unless it is already active. */ private async ensureTool(tool: 'pan' | 'segment' | 'brush' | 'horizon'): Promise { + await this.closeDialogs(); const button = this.page.getByTestId(`tool-${tool}`); if ((await button.getAttribute('aria-pressed')) !== 'true') { await button.click(); @@ -112,6 +142,7 @@ export class SvelteDriver implements UiDriver { /** Shows a canvas view (Input, Depth, Slice, Composite, Parallax 2D, 3D). */ private async ensureView(view: 'input' | 'depth' | 'slice' | 'composite' | 'parallax' | '3d'): Promise { + await this.closeDialogs(); const tab = this.page.getByTestId(`view-${view}`); if ((await tab.getAttribute('aria-selected')) !== 'true') { await tab.click(); @@ -121,6 +152,7 @@ export class SvelteDriver implements UiDriver { /** Split by depth is a collapsible section of the Slices panel. */ private async openSplitByDepth(): Promise { + await this.closeDialogs(); const toggle = this.page.getByTestId('split-toggle'); if (!(await toggle.isVisible())) await this.openTab('Segmentation'); if ((await toggle.getAttribute('aria-expanded')) !== 'true') await toggle.click(); @@ -129,6 +161,7 @@ export class SvelteDriver implements UiDriver { /** Per-slice actions live in the Inspector's header for the selected slice. */ private async ensureSelected(index: number): Promise { + await this.closeDialogs(); const row = this.sliceRow(index); if ((await row.getAttribute('aria-selected')) !== 'true') { await row.getByTestId('slice-thumbnail').click(); @@ -213,8 +246,10 @@ export class SvelteDriver implements UiDriver { } private async activeMainTab(): Promise { + if (await this.page.getByTestId('settings-dialog').isVisible()) return 'Configuration'; + if (await this.page.getByTestId('export-dialog').isVisible()) return 'Export'; const panel = await this.page.getByTestId('inspector').getAttribute('data-panel'); - const known: MainTab[] = ['Mode', 'Segmentation', 'Inpainting', 'Export', 'Configuration']; + const known: MainTab[] = ['Mode', 'Segmentation', 'Inpainting']; return (known.find((tab) => tab === panel) as MainTab | undefined) ?? null; } @@ -233,6 +268,7 @@ export class SvelteDriver implements UiDriver { } async clickImagePixel(x: number, y: number, modifiers: Modifier[] = []): Promise { + await this.closeDialogs(); // Same position computation as DashDriver.clickImagePixel: the main // image renders at `width: 100%; height: auto` (see // InputImagePanel.svelte), so this scale is the exact inverse of the @@ -259,6 +295,7 @@ export class SvelteDriver implements UiDriver { * same gesture Dash's own JS-03 `handleWheel` responds to. */ async zoomIn(): Promise { + await this.closeDialogs(); const box = await this.page.getByTestId('input-image-panel').boundingBox(); if (!box) throw new Error('Input image panel has no bounding box'); await this.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); @@ -271,6 +308,7 @@ export class SvelteDriver implements UiDriver { * always pans regardless of the active tab (see viewport.svelte.ts). */ async panBy(dx: number, dy: number): Promise { + await this.closeDialogs(); const box = await this.page.getByTestId('input-image-panel').boundingBox(); if (!box) throw new Error('Input image panel has no bounding box'); const startX = box.x + box.width / 2; @@ -282,10 +320,12 @@ export class SvelteDriver implements UiDriver { } async resetZoom(): Promise { + await this.closeDialogs(); await this.page.getByTestId('zoom-reset').click(); } async selectSlice(projectId: string, index: number): Promise { + await this.closeDialogs(); const image = this.sliceImage(index); await expect(image).toBeVisible(); await image.click(); @@ -295,6 +335,7 @@ export class SvelteDriver implements UiDriver { } async toggleMultiPoint(): Promise { + await this.closeDialogs(); await this.page.getByTestId('multi-point').click(); } @@ -303,6 +344,7 @@ export class SvelteDriver implements UiDriver { } async commitMultiPoint(): Promise { + await this.closeDialogs(); await this.page.getByTestId('multi-commit').click(); await expect(this.log()).toContainText(/Committed points/); } @@ -334,6 +376,7 @@ export class SvelteDriver implements UiDriver { * `pointermove`/`pointerup` events the canvas actually listens for. */ async drawMaskStroke(): Promise { + await this.closeDialogs(); await this.ensureTool('brush'); const canvas = this.page.getByTestId('mask-canvas'); await expect(canvas).toBeVisible(); @@ -372,6 +415,7 @@ export class SvelteDriver implements UiDriver { } async fillPrompts(positive: string, negative: string): Promise { + await this.closeDialogs(); await this.page.getByTestId('positive-prompt').fill(positive); await this.page.getByTestId('negative-prompt').fill(negative); } @@ -382,23 +426,28 @@ export class SvelteDriver implements UiDriver { } async generateInpainting(): Promise { + await this.closeDialogs(); // Deliberately does not wait for candidates: scenarios assert counts themselves. await this.page.getByTestId('generate-inpainting').click(); } async fillInpainting(): Promise { + await this.closeDialogs(); await this.page.getByTestId('fill-inpainting').click(); } async enhance(): Promise { + await this.closeDialogs(); await this.page.getByTestId('enhance-inpainting').click(); } async erase(): Promise { + await this.closeDialogs(); await this.page.getByTestId('erase-inpainting').click(); } async selectCandidate(index: number): Promise { + await this.closeDialogs(); const candidate = this.candidateImages().nth(index); await candidate.click(); await expect(candidate).toHaveAttribute('aria-selected', 'true'); @@ -406,6 +455,7 @@ export class SvelteDriver implements UiDriver { } async applyCandidate(): Promise { + await this.closeDialogs(); await this.page.getByTestId('apply-inpainting').click(); } @@ -436,35 +486,43 @@ export class SvelteDriver implements UiDriver { } async createSlice(): Promise { + await this.closeDialogs(); await this.clickAndWaitForLogChange(this.page.getByTestId('create-slice')); } async deleteSlice(): Promise { + await this.closeDialogs(); await this.clickAndWaitForLogChange(this.page.getByTestId('delete-slice')); } async addMaskToSlice(): Promise { + await this.closeDialogs(); await this.clickAndWaitForLogChange(this.page.getByTestId('add-mask-to-slice')); } async removeMaskFromSlice(): Promise { + await this.closeDialogs(); await this.clickAndWaitForLogChange(this.page.getByTestId('remove-mask-from-slice')); } async copySlice(): Promise { + await this.closeDialogs(); await this.clickAndWaitForLogChange(this.page.getByTestId('copy-slice')); } async pasteSlice(): Promise { + await this.closeDialogs(); await this.clickAndWaitForLogChange(this.page.getByTestId('paste-slice')); } async balanceSlices(): Promise { + await this.closeDialogs(); await this.openSplitByDepth(); await this.page.getByTestId('balance-slices').click(); } async setSliceDepth(index: number, depth: number): Promise { + await this.closeDialogs(); const display = this.sliceRow(index).getByTestId('slice-depth-display'); await expect(display).toBeVisible(); await display.click(); @@ -485,6 +543,7 @@ export class SvelteDriver implements UiDriver { index: number, file: { name: string; mimeType: string; buffer: Buffer }, ): Promise { + await this.closeDialogs(); await this.ensureSelected(index); const before = await this.log().innerText(); const input = this.page.getByTestId('slice-upload-input'); @@ -493,14 +552,17 @@ export class SvelteDriver implements UiDriver { } async invertMask(): Promise { + await this.closeDialogs(); await this.clickAndWaitForLogChange(this.page.getByTestId('invert-mask')); } async featherMask(): Promise { + await this.closeDialogs(); await this.clickAndWaitForLogChange(this.page.getByTestId('feather-mask')); } async toggleCheckerboard(): Promise { + await this.closeDialogs(); const button = this.page.getByTestId('toggle-checkerboard'); const wasSelected = (await button.getAttribute('aria-pressed')) === 'true'; await button.click(); @@ -515,6 +577,11 @@ export class SvelteDriver implements UiDriver { /** Shows the step panel that holds `testId`, unless it is already visible. */ private async revealStep(step: string, testId: string): Promise { + if (step === 'export') { + await this.revealInExport(testId); + return; + } + await this.closeDialogs(); if (await this.page.getByTestId(testId).isVisible()) return; await this.page.getByTestId(`step-${step}`).click(); await expect(this.page.getByTestId(testId)).toBeVisible(); @@ -560,6 +627,7 @@ export class SvelteDriver implements UiDriver { } async toggleGroundPlane(): Promise { + await this.closeDialogs(); const toggle = this.page.getByTestId('ground-toggle'); const before = await toggle.getAttribute('aria-pressed'); await toggle.click(); @@ -604,6 +672,7 @@ export class SvelteDriver implements UiDriver { } async navigateCamera(direction: CameraDirection): Promise { + await this.closeDialogs(); // 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') { @@ -634,10 +703,12 @@ export class SvelteDriver implements UiDriver { } async selectInpaintingModel(label: string): Promise { + await this.openDialog('settings'); await this.page.getByTestId('inpainting-model').selectOption({ label }); } async setExternalServer(address: string): Promise { + await this.openDialog('settings'); const input = this.page.getByTestId('external-server-address'); await input.fill(address); // Commits on blur (ConfigurationTab.svelte's `onchange`), same as @@ -646,6 +717,7 @@ export class SvelteDriver implements UiDriver { } async testExternalConnection(): Promise { + await this.openDialog('settings'); await this.page.getByTestId('external-test-connection').click(); } @@ -661,12 +733,14 @@ export class SvelteDriver implements UiDriver { } async setApiKey(key: string): Promise { + await this.openDialog('settings'); const input = this.page.getByTestId('api-key'); await input.fill(key); await input.press('Tab'); } async validateApiKey(): Promise { + await this.openDialog('settings'); await this.page.getByTestId('validate-api-key').click(); } @@ -677,6 +751,7 @@ export class SvelteDriver implements UiDriver { // Project lifecycle async saveState(): Promise { + await this.openDialog('settings'); await this.clickAndWaitForLogChange(this.page.getByTestId('save-state')); } @@ -698,16 +773,19 @@ export class SvelteDriver implements UiDriver { // Export async exportGltf(): Promise { + await this.revealInExport('gltf-export'); const downloadPromise = this.page.waitForEvent('download'); await this.page.getByTestId('gltf-export').click(); return downloadPromise; } async exportAnimation(): Promise { + await this.revealInExport('animation-export'); await this.page.getByTestId('animation-export').click(); } async setDofEnabled(enabled: boolean): Promise { + await this.revealInExport('toggle-dof'); const checkbox = this.page.getByTestId('toggle-dof'); if ((await checkbox.isChecked()) !== enabled) { await checkbox.click(); @@ -716,10 +794,12 @@ export class SvelteDriver implements UiDriver { } async upscaleTextures(): Promise { + await this.revealInExport('upscale-textures'); await this.clickAndWaitForLogChange(this.page.getByTestId('upscale-textures')); } async downloadSlice(index: number): Promise { + await this.closeDialogs(); await this.ensureSelected(index); const downloadPromise = this.page.waitForEvent('download'); await this.page.getByTestId('slice-download').click(); diff --git a/frontend/src/lib/components/config/ConfigurationTab.svelte b/frontend/src/lib/components/config/ConfigurationTab.svelte deleted file mode 100644 index fae1a7f..0000000 --- a/frontend/src/lib/components/config/ConfigurationTab.svelte +++ /dev/null @@ -1,336 +0,0 @@ - - -
-
- -
-
- - -
- - {#if showServerPanel} -
- -
- - -
- - {#if showWorkflowUpload} -
- - - {#if projectStore.view?.inpainting.hasWorkflow} - Workflow uploaded - {/if} -
- {/if} -
- {/if} - - {#if showApiKeyPanel} -
- -
- - -
-
- {/if} - -
- - - {projectStore.view?.inpainting.padding ?? 50} -
- -
- - - {projectStore.view?.inpainting.blur ?? 50} -
- -
- Export/Import State -
- - -
-
-
- - diff --git a/frontend/src/lib/components/config/ConfigurationTab.test.ts b/frontend/src/lib/components/config/ConfigurationTab.test.ts deleted file mode 100644 index ee83db2..0000000 --- a/frontend/src/lib/components/config/ConfigurationTab.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -import ConfigurationTab from './ConfigurationTab.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 } from '../../api/types'; - -function jsonResponse(status: number, body: unknown): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} - -function makeView(overrides: Partial = {}): ProjectView { - return { - id: 'appstate-test', - revision: 1, - image: { width: 320, height: 240 }, - assets: { input: { url: '/input' }, depth: { url: '/depth' } }, - depthModel: 'dinov2', - numSlices: 3, - thresholds: [0, 85, 170, 255], - slices: [], - selectedSlice: null, - segmentation: { multiPointMode: false, queuedPoints: [], hasMask: false }, - inpainting: { - model: 'automatic1111', - strength: 0.8, - guidanceScale: 7.5, - padding: 50, - blur: 50, - externalServer: 'localhost:7860', - hasWorkflow: false, - candidates: null, - selectedCandidate: null, - }, - busy: null, - settings: { - darkMode: false, - camera: { distance: 100, focalLength: 100, maxDistance: 200 }, - meshDisplacement: 0, - depthModel: 'dinov2', - }, - exports: { gltf: null, upscaled: false }, - ...overrides, - }; -} - -describe('ConfigurationTab', () => { - beforeEach(() => { - projectStore.reset(); - jobStore.end(); - logStore.reset(); - uiStore.reset(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('shows the Automatic1111/ComfyUI server panel (and its workflow upload) only for those models', () => { - projectStore.applyView(makeView({ inpainting: { ...makeView().inpainting, model: 'automatic1111' } })); - render(ConfigurationTab); - expect(screen.getByTestId('external-server-panel')).toBeInTheDocument(); - expect(screen.queryByTestId('comfyui-workflow-panel')).not.toBeInTheDocument(); - expect(screen.queryByTestId('api-key-panel')).not.toBeInTheDocument(); - }); - - it('shows the ComfyUI workflow upload only for comfyui, and the API key panel only for stabilityai/fal.ai', () => { - projectStore.applyView(makeView({ inpainting: { ...makeView().inpainting, model: 'comfyui' } })); - const { unmount } = render(ConfigurationTab); - expect(screen.getByTestId('external-server-panel')).toBeInTheDocument(); - expect(screen.getByTestId('comfyui-workflow-panel')).toBeInTheDocument(); - unmount(); - - projectStore.applyView(makeView({ revision: 2, inpainting: { ...makeView().inpainting, model: 'stabilityai' } })); - render(ConfigurationTab); - expect(screen.queryByTestId('external-server-panel')).not.toBeInTheDocument(); - expect(screen.getByTestId('api-key-panel')).toBeInTheDocument(); - }); - - it('Test Connection highlights success and resets to none when the server address is edited again', async () => { - const projectId = 'appstate-test'; - projectStore.applyView(makeView()); - - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const url = String(input); - const method = init?.method ?? 'GET'; - if (url === '/api/v1/config/probe-server' && method === 'POST') { - expect(JSON.parse(init!.body as string)).toEqual({ model: 'automatic1111', serverAddress: 'localhost:7860' }); - return jsonResponse(200, { ok: true, message: 'Connection to automatic1111 successful: []' }); - } - if (url === `/api/v1/projects/${projectId}/inpainting/settings` && method === 'PUT') { - return jsonResponse(200, { ...makeView({ revision: 2 }), changed: true }); - } - if (url.startsWith(`/api/v1/projects/${projectId}/logs`)) { - return jsonResponse(200, { entries: [], next: 0 }); - } - throw new Error(`Unexpected fetch: ${method} ${url}`); - }); - vi.stubGlobal('fetch', fetchMock); - - render(ConfigurationTab); - expect(screen.getByTestId('external-server-address')).toHaveAttribute('data-status', 'none'); - - await fireEvent.click(screen.getByTestId('external-test-connection')); - await waitFor(() => expect(screen.getByTestId('external-server-address')).toHaveAttribute('data-status', 'success')); - - await fireEvent.change(screen.getByTestId('external-server-address'), { target: { value: 'localhost:9999' } }); - await waitFor(() => expect(screen.getByTestId('external-server-address')).toHaveAttribute('data-status', 'none')); - }); - - it('Test Connection highlights failure when the probe fails', async () => { - projectStore.applyView(makeView()); - const fetchMock = vi.fn(async (input: RequestInfo | URL): Promise => { - const url = String(input); - if (url === '/api/v1/config/probe-server') { - return jsonResponse(200, { ok: false, message: 'Connection to automatic1111 failed' }); - } - throw new Error(`Unexpected fetch: ${url}`); - }); - vi.stubGlobal('fetch', fetchMock); - - render(ConfigurationTab); - await fireEvent.click(screen.getByTestId('external-test-connection')); - await waitFor(() => expect(screen.getByTestId('external-server-address')).toHaveAttribute('data-status', 'failure')); - }); - - it('the API key field never displays a previously-saved key back', () => { - projectStore.applyView(makeView({ inpainting: { ...makeView().inpainting, model: 'stabilityai' } })); - render(ConfigurationTab); - expect(screen.getByTestId('api-key')).toHaveValue(''); - }); - - it('Save State calls POST /save and does not trigger any browser download', async () => { - const projectId = 'appstate-test'; - projectStore.applyView(makeView()); - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const url = String(input); - const method = init?.method ?? 'GET'; - if (url === `/api/v1/projects/${projectId}/save` && method === 'POST') { - return jsonResponse(200, makeView({ revision: 2 })); - } - if (url.startsWith(`/api/v1/projects/${projectId}/logs`)) { - return jsonResponse(200, { entries: [{ seq: 1, level: 'info', message: `Saved state to ${projectId}` }], next: 1 }); - } - throw new Error(`Unexpected fetch: ${method} ${url}`); - }); - vi.stubGlobal('fetch', fetchMock); - - render(ConfigurationTab); - await fireEvent.click(screen.getByTestId('save-state')); - - await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith(`/api/v1/projects/${projectId}/save`, expect.objectContaining({ method: 'POST' })), - ); - }); -}); diff --git a/frontend/src/lib/components/dialogs/ExportDialog.svelte b/frontend/src/lib/components/dialogs/ExportDialog.svelte new file mode 100644 index 0000000..6c853fb --- /dev/null +++ b/frontend/src/lib/components/dialogs/ExportDialog.svelte @@ -0,0 +1,351 @@ + + + +
+
+ + +
+ + + + +
+
+ + diff --git a/frontend/src/lib/components/export/ExportTab.test.ts b/frontend/src/lib/components/dialogs/ExportDialog.test.ts similarity index 53% rename from frontend/src/lib/components/export/ExportTab.test.ts rename to frontend/src/lib/components/dialogs/ExportDialog.test.ts index d5781e9..4557a97 100644 --- a/frontend/src/lib/components/export/ExportTab.test.ts +++ b/frontend/src/lib/components/dialogs/ExportDialog.test.ts @@ -4,12 +4,29 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; vi.mock('../../download', () => ({ triggerDownload: vi.fn() })); import { triggerDownload } from '../../download'; -import ExportTab from './ExportTab.svelte'; +import ExportTab from './ExportDialog.svelte'; import { projectStore } from '../../state/project.svelte'; import { jobStore } from '../../state/jobs.svelte'; import { logStore } from '../../state/logs.svelte'; import { cameraDraftStore } from '../../state/cameraDraft.svelte'; -import type { ProjectView } from '../../api/types'; +import { uiStore } from '../../state/ui.svelte'; +import type { ProjectView, SliceView } from '../../api/types'; + +function makeSlice(index: number, overrides: Partial = {}): SliceView { + return { + index, + depth: index * 50, + version: 1, + canUndo: false, + canRedo: false, + positivePrompt: '', + negativePrompt: '', + isGround: false, + image: { url: `/api/v1/projects/appstate-test/assets/slice-${index}` }, + thumbnail: { url: `/api/v1/projects/appstate-test/assets/slice-${index}-thumb` }, + ...overrides, + } as SliceView; +} function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { @@ -59,12 +76,148 @@ describe('ExportTab', () => { jobStore.end(); logStore.reset(); cameraDraftStore.reset(); + uiStore.reset(); vi.mocked(triggerDownload).mockClear(); }); afterEach(() => { vi.unstubAllGlobals(); cameraDraftStore.reset(); + uiStore.reset(); + }); + + describe('tab switching', () => { + it('starts on the 3D scene tab, with the animation pane hidden', () => { + projectStore.applyView(makeView()); + render(ExportTab); + + expect(screen.getByTestId('export-tab-gltf')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('export-tab-animation')).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByTestId('tab-export')).not.toHaveAttribute('hidden'); + expect(screen.getByTestId('animation-export').closest('[role="tabpanel"]')).toHaveAttribute('hidden'); + }); + + it('switches panes when the Animation tab is clicked, and back', async () => { + projectStore.applyView(makeView()); + render(ExportTab); + + await fireEvent.click(screen.getByTestId('export-tab-animation')); + expect(screen.getByTestId('export-tab-animation')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('export-tab-gltf')).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByTestId('tab-export')).toHaveAttribute('hidden'); + expect(screen.getByTestId('animation-export').closest('[role="tabpanel"]')).not.toHaveAttribute('hidden'); + + await fireEvent.click(screen.getByTestId('export-tab-gltf')); + expect(screen.getByTestId('tab-export')).not.toHaveAttribute('hidden'); + expect(screen.getByTestId('animation-export').closest('[role="tabpanel"]')).toHaveAttribute('hidden'); + }); + }); + + describe('mirrored camera sliders', () => { + it('reads distance, max distance and focal length straight from the shared camera draft', () => { + projectStore.applyView(makeView()); + render(ExportTab); + + expect(screen.getByTestId('export-camera-distance')).toHaveValue('125'); + expect(screen.getByTestId('export-max-distance')).toHaveValue('140'); + expect(screen.getByTestId('export-focal-length')).toHaveValue('475'); + }); + + it('committing the camera distance here sends all camera fields, same as the Preview panel', async () => { + const projectId = 'appstate-test'; + projectStore.applyView(makeView()); + + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const method = init?.method ?? 'GET'; + if (url === `/api/v1/projects/${projectId}/settings` && method === 'PUT') { + return jsonResponse(200, { ...makeView({ revision: 2 }), changed: true }); + } + if (url.startsWith(`/api/v1/projects/${projectId}/logs`)) { + return jsonResponse(200, { entries: [], next: 0 }); + } + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + render(ExportTab); + const distance = screen.getByTestId('export-camera-distance'); + await fireEvent.input(distance, { target: { value: '77' } }); + await fireEvent.change(distance); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + `/api/v1/projects/${projectId}/settings`, + expect.objectContaining({ method: 'PUT' }), + ), + ); + const call = fetchMock.mock.calls.find(([reqUrl]) => String(reqUrl) === `/api/v1/projects/${projectId}/settings`); + expect(JSON.parse(call![1]!.body as string)).toEqual({ + camera: { distance: 77, maxDistance: 140, focalLength: 475, groundNear: 0 }, + meshDisplacement: 15, + }); + // The draft is shared, so the slider itself now reflects the commit. + expect(cameraDraftStore.draft.distance).toBe(77); + }); + }); + + describe('ground summary', () => { + it('shows "No ground plane" without a ground slice', () => { + projectStore.applyView(makeView({ slices: [makeSlice(0)] })); + render(ExportTab); + expect(screen.getByTestId('export-ground-summary')).toHaveTextContent('No ground plane'); + }); + + it('names the ground slice and its distance once one is set', () => { + projectStore.applyView( + makeView({ + slices: [makeSlice(0), makeSlice(1, { isGround: true })], + settings: { + darkMode: false, + camera: { distance: 125, focalLength: 475, maxDistance: 140, groundNear: 42 }, + meshDisplacement: 15, + depthModel: 'dinov2', + }, + }), + ); + render(ExportTab); + const summary = screen.getByTestId('export-ground-summary'); + expect(summary).toHaveTextContent('image_slice_1'); + expect(summary).toHaveTextContent('42'); + }); + + it('Edit moves to the Ground step and closes the dialog', async () => { + projectStore.applyView(makeView({ slices: [makeSlice(0), makeSlice(1, { isGround: true })] })); + uiStore.setStep('slices'); + uiStore.openDialog('export'); + render(ExportTab); + + await fireEvent.click(screen.getByTestId('export-edit-ground')); + + expect(uiStore.step).toBe('ground'); + expect(uiStore.dialog).toBeNull(); + }); + }); + + describe('View in 3D', () => { + it('is absent until the scene has been exported', () => { + projectStore.applyView(makeView()); + render(ExportTab); + expect(screen.queryByTestId('export-view-3d')).not.toBeInTheDocument(); + }); + + it('sets the view to 3D and closes the dialog once the scene is ready', async () => { + projectStore.applyView( + makeView({ exports: { gltf: { url: '/api/v1/projects/appstate-test/export/gltf?v=1' }, upscaled: false } }), + ); + uiStore.openDialog('export'); + render(ExportTab); + + await fireEvent.click(screen.getByTestId('export-view-3d')); + + expect(uiStore.view).toBe('3d'); + expect(uiStore.dialog).toBeNull(); + }); }); it('reads the displacement slider straight from the persisted settings', () => { diff --git a/frontend/src/lib/components/dialogs/SettingsDialog.svelte b/frontend/src/lib/components/dialogs/SettingsDialog.svelte new file mode 100644 index 0000000..004dda9 --- /dev/null +++ b/frontend/src/lib/components/dialogs/SettingsDialog.svelte @@ -0,0 +1,529 @@ + + + +
+ +
+
+

Inpainting

+ + + + {#if showServerPanel} +
+ +
+ + +
+ + {#if showWorkflowUpload} +
+ ComfyUI workflow + + {#if projectStore.view?.inpainting.hasWorkflow} + Workflow uploaded + {/if} +
+ {/if} +
+ {/if} + + {#if showApiKeyPanel} +
+ +
+ + +
+
+ {/if} +
+ +
+

Depth

+ + +
+ +
+

Masks

+
+ + + {projectStore.view?.inpainting.padding ?? 50} + + + {projectStore.view?.inpainting.blur ?? 50} +
+
+ +
+

Slicing

+
+ + + {uiStore.pendingNumSlices} +
+

How many depth bands Slices › Split by depth makes.

+
+ +
+

Project

+
+ + +
+
+ +
+

Appearance

+
+ + +
+
+
+
+
+ + diff --git a/frontend/src/lib/components/dialogs/SettingsDialog.test.ts b/frontend/src/lib/components/dialogs/SettingsDialog.test.ts new file mode 100644 index 0000000..0853146 --- /dev/null +++ b/frontend/src/lib/components/dialogs/SettingsDialog.test.ts @@ -0,0 +1,366 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import ConfigurationTab from './SettingsDialog.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 } from '../../api/types'; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function makeView(overrides: Partial = {}): ProjectView { + return { + id: 'appstate-test', + revision: 1, + image: { width: 320, height: 240 }, + assets: { input: { url: '/input' }, depth: { url: '/depth' } }, + depthModel: 'dinov2', + numSlices: 3, + thresholds: [0, 85, 170, 255], + slices: [], + selectedSlice: null, + segmentation: { multiPointMode: false, queuedPoints: [], hasMask: false }, + inpainting: { + model: 'automatic1111', + strength: 0.8, + guidanceScale: 7.5, + padding: 50, + blur: 50, + externalServer: 'localhost:7860', + hasWorkflow: false, + candidates: null, + selectedCandidate: null, + }, + busy: null, + settings: { + darkMode: false, + camera: { distance: 100, focalLength: 100, maxDistance: 200 }, + meshDisplacement: 0, + depthModel: 'dinov2', + }, + exports: { gltf: null, upscaled: false }, + ...overrides, + }; +} + +describe('ConfigurationTab', () => { + beforeEach(() => { + projectStore.reset(); + jobStore.end(); + logStore.reset(); + uiStore.reset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('shows the Automatic1111/ComfyUI server panel (and its workflow upload) only for those models', () => { + projectStore.applyView(makeView({ inpainting: { ...makeView().inpainting, model: 'automatic1111' } })); + render(ConfigurationTab); + expect(screen.getByTestId('external-server-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('comfyui-workflow-panel')).not.toBeInTheDocument(); + expect(screen.queryByTestId('api-key-panel')).not.toBeInTheDocument(); + }); + + it('shows the ComfyUI workflow upload only for comfyui, and the API key panel only for stabilityai/fal.ai', () => { + projectStore.applyView(makeView({ inpainting: { ...makeView().inpainting, model: 'comfyui' } })); + const { unmount } = render(ConfigurationTab); + expect(screen.getByTestId('external-server-panel')).toBeInTheDocument(); + expect(screen.getByTestId('comfyui-workflow-panel')).toBeInTheDocument(); + unmount(); + + projectStore.applyView(makeView({ revision: 2, inpainting: { ...makeView().inpainting, model: 'stabilityai' } })); + render(ConfigurationTab); + expect(screen.queryByTestId('external-server-panel')).not.toBeInTheDocument(); + expect(screen.getByTestId('api-key-panel')).toBeInTheDocument(); + }); + + it('Test Connection highlights success and resets to none when the server address is edited again', async () => { + const projectId = 'appstate-test'; + projectStore.applyView(makeView()); + + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const method = init?.method ?? 'GET'; + if (url === '/api/v1/config/probe-server' && method === 'POST') { + expect(JSON.parse(init!.body as string)).toEqual({ model: 'automatic1111', serverAddress: 'localhost:7860' }); + return jsonResponse(200, { ok: true, message: 'Connection to automatic1111 successful: []' }); + } + if (url === `/api/v1/projects/${projectId}/inpainting/settings` && method === 'PUT') { + return jsonResponse(200, { ...makeView({ revision: 2 }), changed: true }); + } + if (url.startsWith(`/api/v1/projects/${projectId}/logs`)) { + return jsonResponse(200, { entries: [], next: 0 }); + } + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + render(ConfigurationTab); + expect(screen.getByTestId('external-server-address')).toHaveAttribute('data-status', 'none'); + + await fireEvent.click(screen.getByTestId('external-test-connection')); + await waitFor(() => expect(screen.getByTestId('external-server-address')).toHaveAttribute('data-status', 'success')); + + await fireEvent.change(screen.getByTestId('external-server-address'), { target: { value: 'localhost:9999' } }); + await waitFor(() => expect(screen.getByTestId('external-server-address')).toHaveAttribute('data-status', 'none')); + }); + + it('Test Connection highlights failure when the probe fails', async () => { + projectStore.applyView(makeView()); + const fetchMock = vi.fn(async (input: RequestInfo | URL): Promise => { + const url = String(input); + if (url === '/api/v1/config/probe-server') { + return jsonResponse(200, { ok: false, message: 'Connection to automatic1111 failed' }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + render(ConfigurationTab); + await fireEvent.click(screen.getByTestId('external-test-connection')); + await waitFor(() => expect(screen.getByTestId('external-server-address')).toHaveAttribute('data-status', 'failure')); + }); + + it('the API key field never displays a previously-saved key back', () => { + projectStore.applyView(makeView({ inpainting: { ...makeView().inpainting, model: 'stabilityai' } })); + render(ConfigurationTab); + expect(screen.getByTestId('api-key')).toHaveValue(''); + }); + + describe('nav', () => { + it('starts on the Inpainting section, marked current', () => { + render(ConfigurationTab); + expect(screen.getByTestId('settings-nav-inpainting')).toHaveAttribute('aria-current', 'true'); + expect(screen.getByTestId('settings-nav-depth')).not.toHaveAttribute('aria-current'); + expect(uiStore.settingsSection).toBe('inpainting'); + }); + + it('clicking a nav link sets uiStore.settingsSection and moves aria-current', async () => { + render(ConfigurationTab); + + await fireEvent.click(screen.getByTestId('settings-nav-appearance')); + + expect(uiStore.settingsSection).toBe('appearance'); + expect(screen.getByTestId('settings-nav-appearance')).toHaveAttribute('aria-current', 'true'); + expect(screen.getByTestId('settings-nav-inpainting')).not.toHaveAttribute('aria-current'); + }); + + it('opening at a section (openSettings) is reflected in the nav', () => { + uiStore.openSettings('slicing'); + render(ConfigurationTab); + expect(screen.getByTestId('settings-nav-slicing')).toHaveAttribute('aria-current', 'true'); + }); + }); + + describe('depth model', () => { + it('reads uiStore.depthModel', () => { + render(ConfigurationTab); + expect(screen.getByTestId('settings-depth-model')).toHaveValue('dinov2'); + }); + + it('without a project, only updates uiStore.depthModel', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + render(ConfigurationTab); + + await fireEvent.change(screen.getByTestId('settings-depth-model'), { target: { value: 'midas' } }); + + expect(uiStore.depthModel).toBe('midas'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('with a project, also PUTs the new depth model to /settings', async () => { + const projectId = 'appstate-test'; + projectStore.applyView(makeView()); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const method = init?.method ?? 'GET'; + if (url === `/api/v1/projects/${projectId}/settings` && method === 'PUT') { + expect(JSON.parse(init!.body as string)).toEqual({ depthModel: 'midas' }); + return jsonResponse(200, { ...makeView({ revision: 2 }), changed: true }); + } + if (url.startsWith(`/api/v1/projects/${projectId}/logs`)) return jsonResponse(200, { entries: [], next: 0 }); + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + render(ConfigurationTab); + await fireEvent.change(screen.getByTestId('settings-depth-model'), { target: { value: 'midas' } }); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + `/api/v1/projects/${projectId}/settings`, + expect.objectContaining({ method: 'PUT' }), + ), + ); + expect(uiStore.depthModel).toBe('midas'); + }); + }); + + describe('num slices', () => { + it('reads uiStore.pendingNumSlices', () => { + render(ConfigurationTab); + expect(screen.getByTestId('settings-num-slices')).toHaveValue('3'); + }); + + it('without a project, only updates uiStore.pendingNumSlices', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + render(ConfigurationTab); + + await fireEvent.change(screen.getByTestId('settings-num-slices'), { target: { value: '6' } }); + + expect(uiStore.pendingNumSlices).toBe(6); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('with a project, also PUTs the new count to /slice-count', async () => { + const projectId = 'appstate-test'; + projectStore.applyView(makeView()); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const method = init?.method ?? 'GET'; + if (url === `/api/v1/projects/${projectId}/slice-count` && method === 'PUT') { + expect(JSON.parse(init!.body as string)).toEqual({ numSlices: 6 }); + return jsonResponse(200, { ...makeView({ revision: 2 }), changed: true }); + } + if (url.startsWith(`/api/v1/projects/${projectId}/logs`)) return jsonResponse(200, { entries: [], next: 0 }); + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + render(ConfigurationTab); + await fireEvent.change(screen.getByTestId('settings-num-slices'), { target: { value: '6' } }); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + `/api/v1/projects/${projectId}/slice-count`, + expect.objectContaining({ method: 'PUT' }), + ), + ); + }); + }); + + describe('theme radios', () => { + it('reflects uiStore.theme with aria-checked', () => { + render(ConfigurationTab); + expect(screen.getByTestId('settings-theme-dark')).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByTestId('settings-theme-light')).toHaveAttribute('aria-checked', 'false'); + }); + + it('clicking the already-active theme is a no-op (no toggle, no fetch)', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + render(ConfigurationTab); + + await fireEvent.click(screen.getByTestId('settings-theme-dark')); + + expect(uiStore.theme).toBe('dark'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('clicking the other theme flips uiStore.theme, and persists it with a project', async () => { + const projectId = 'appstate-test'; + projectStore.applyView(makeView()); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const method = init?.method ?? 'GET'; + if (url === `/api/v1/projects/${projectId}/settings` && method === 'PUT') { + expect(JSON.parse(init!.body as string)).toEqual({ darkMode: false }); + return jsonResponse(200, { ...makeView({ revision: 2 }), changed: true }); + } + if (url.startsWith(`/api/v1/projects/${projectId}/logs`)) return jsonResponse(200, { entries: [], next: 0 }); + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + render(ConfigurationTab); + await fireEvent.click(screen.getByTestId('settings-theme-light')); + + expect(uiStore.theme).toBe('light'); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + `/api/v1/projects/${projectId}/settings`, + expect.objectContaining({ method: 'PUT' }), + ), + ); + }); + }); + + describe('restore state', () => { + it('closes the dialog once a different project has loaded', async () => { + projectStore.applyView(makeView()); + uiStore.openDialog('settings'); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const method = init?.method ?? 'GET'; + if (url === '/api/v1/projects/restore' && method === 'POST') { + return jsonResponse(200, makeView({ id: 'appstate-restored', revision: 1 })); + } + if (url.startsWith('/api/v1/projects/appstate-restored/logs')) return jsonResponse(200, { entries: [], next: 0 }); + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + render(ConfigurationTab); + const file = new File(['{}'], 'appstate.json', { type: 'application/json' }); + await fireEvent.change(screen.getByTestId('restore-state-input'), { target: { files: [file] } }); + + await waitFor(() => expect(uiStore.dialog).toBeNull()); + expect(projectStore.view?.id).toBe('appstate-restored'); + }); + + it('leaves the dialog open if the restore fails', async () => { + projectStore.applyView(makeView()); + uiStore.openDialog('settings'); + const fetchMock = vi.fn(async (input: RequestInfo | URL): Promise => { + const url = String(input); + if (url === '/api/v1/projects/restore') { + return jsonResponse(400, { error: { code: 'bad_request', message: 'not a valid state file' } }); + } + if (url.startsWith('/api/v1/projects/appstate-test/logs')) return jsonResponse(200, { entries: [], next: 0 }); + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + render(ConfigurationTab); + const file = new File(['{}'], 'appstate.json', { type: 'application/json' }); + await fireEvent.change(screen.getByTestId('restore-state-input'), { target: { files: [file] } }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(uiStore.dialog).toBe('settings'); + }); + }); + + it('Save State calls POST /save and does not trigger any browser download', async () => { + const projectId = 'appstate-test'; + projectStore.applyView(makeView()); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const method = init?.method ?? 'GET'; + if (url === `/api/v1/projects/${projectId}/save` && method === 'POST') { + return jsonResponse(200, makeView({ revision: 2 })); + } + if (url.startsWith(`/api/v1/projects/${projectId}/logs`)) { + return jsonResponse(200, { entries: [{ seq: 1, level: 'info', message: `Saved state to ${projectId}` }], next: 1 }); + } + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + render(ConfigurationTab); + await fireEvent.click(screen.getByTestId('save-state')); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith(`/api/v1/projects/${projectId}/save`, expect.objectContaining({ method: 'POST' })), + ); + }); +}); diff --git a/frontend/src/lib/components/dialogs/ShortcutsDialog.svelte b/frontend/src/lib/components/dialogs/ShortcutsDialog.svelte new file mode 100644 index 0000000..1306c09 --- /dev/null +++ b/frontend/src/lib/components/dialogs/ShortcutsDialog.svelte @@ -0,0 +1,50 @@ + + + +
+ {#each SHORTCUTS as { keys, action } (keys)} +
+
{action}
+
{keys}
+
+ {/each} +
+
+ + diff --git a/frontend/src/lib/components/dialogs/ShortcutsDialog.test.ts b/frontend/src/lib/components/dialogs/ShortcutsDialog.test.ts new file mode 100644 index 0000000..ae62d75 --- /dev/null +++ b/frontend/src/lib/components/dialogs/ShortcutsDialog.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { flushSync } from 'svelte'; +import ShortcutsDialog from './ShortcutsDialog.svelte'; +import { uiStore } from '../../state/ui.svelte'; +import { SHORTCUTS } from '../../shortcuts'; + +describe('ShortcutsDialog', () => { + beforeEach(() => { + uiStore.reset(); + }); + + it('stays closed until uiStore.dialog is "shortcuts"', () => { + render(ShortcutsDialog); + expect(screen.getByTestId('shortcuts-dialog')).not.toHaveAttribute('open'); + }); + + it('opens once uiStore.dialog is "shortcuts"', () => { + render(ShortcutsDialog); + uiStore.openDialog('shortcuts'); + flushSync(); + expect(screen.getByTestId('shortcuts-dialog')).toHaveAttribute('open'); + }); + + it('lists every shortcut from shortcuts.ts, keys and action both', () => { + uiStore.openDialog('shortcuts'); + render(ShortcutsDialog); + + expect(SHORTCUTS.length).toBeGreaterThan(0); + // Some `keys` strings (e.g. "[ ]") use double spaces for alignment, + // which testing-library's default whitespace normalizer collapses, so + // compare against the dialog's own normalized text content instead of + // an exact getByText match. + const text = screen.getByTestId('shortcuts-dialog').textContent!.replace(/\s+/g, ' '); + for (const { keys, action } of SHORTCUTS) { + expect(text).toContain(action); + expect(text).toContain(keys.replace(/\s+/g, ' ')); + } + }); +}); diff --git a/frontend/src/lib/components/export/ExportTab.svelte b/frontend/src/lib/components/export/ExportTab.svelte deleted file mode 100644 index 1dd70a1..0000000 --- a/frontend/src/lib/components/export/ExportTab.svelte +++ /dev/null @@ -1,225 +0,0 @@ - - -
-
-

glTF scene

-

Cards at their depths, for Blender, Unity or a web viewer.

- - -
- - -
- -
-
-
-
- -
-

Animation

-
- - (numFrames = Number((event.currentTarget as HTMLInputElement).value))} - /> - {numFrames} -
- -
-
-
-
-
- - diff --git a/frontend/src/lib/components/inpainting/InpaintPanel.svelte b/frontend/src/lib/components/inpainting/InpaintPanel.svelte index 42277dd..da3c84a 100644 --- a/frontend/src/lib/components/inpainting/InpaintPanel.svelte +++ b/frontend/src/lib/components/inpainting/InpaintPanel.svelte @@ -12,6 +12,7 @@ import { canvasSaveStore } from '../../state/canvas.svelte'; import { uiStore } from '../../state/ui.svelte'; import * as workflow from '../../workflow'; + import { registerInpaintActions } from '../../shortcuts'; const view = $derived(projectStore.view); const selectedSlice = $derived(view?.selectedSlice ?? null); @@ -135,13 +136,24 @@ if (!canApply) return; void workflow.applyInpaintingCandidate(); } + + // Ctrl+Enter, 1 2 3 and A (lib/shortcuts.ts). + $effect(() => + registerInpaintActions({ + generate, + pick: (index) => { + if (index < (candidates?.images.length ?? 0)) selectCandidate(index); + }, + apply, + }), + );

Inpaint

-
@@ -245,6 +257,7 @@ class="btn" class:btn-primary={!hasCandidates} data-testid="generate-inpainting" + title="Generate three candidates (Ctrl+Enter)" disabled={!canGenerate} onclick={generate} > @@ -293,6 +306,7 @@ class="btn" class:btn-primary={hasCandidates} data-testid="apply-inpainting" + title="Apply the picked candidate to the slice (A)" disabled={!canApply} onclick={apply} > diff --git a/frontend/src/lib/components/shared/Dialog.svelte b/frontend/src/lib/components/shared/Dialog.svelte new file mode 100644 index 0000000..b6ae583 --- /dev/null +++ b/frontend/src/lib/components/shared/Dialog.svelte @@ -0,0 +1,129 @@ + + + +
+
+

{title}

+ +
+
+ {@render children()} +
+
+
+ + diff --git a/frontend/src/lib/components/shared/Dialog.test.ts b/frontend/src/lib/components/shared/Dialog.test.ts new file mode 100644 index 0000000..cc9124e --- /dev/null +++ b/frontend/src/lib/components/shared/Dialog.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { createRawSnippet, flushSync } from 'svelte'; +import Dialog from './Dialog.svelte'; +import { uiStore } from '../../state/ui.svelte'; + +function childrenSnippet(text = 'dialog body') { + return createRawSnippet(() => ({ + render: () => `

${text}

`, + })); +} + +describe('Dialog', () => { + beforeEach(() => { + uiStore.reset(); + }); + + it('stays in the DOM (without the open attribute) while closed', () => { + render(Dialog, { + props: { name: 'export', title: 'Export', testId: 'export-dialog', children: childrenSnippet() }, + }); + expect(screen.getByTestId('export-dialog')).not.toHaveAttribute('open'); + // Content stays mounted even while closed. + expect(screen.getByTestId('dialog-body')).toBeInTheDocument(); + }); + + it('opens (gains the open attribute) once uiStore.dialog names it', () => { + render(Dialog, { + props: { name: 'export', title: 'Export', testId: 'export-dialog', children: childrenSnippet() }, + }); + uiStore.openDialog('export'); + flushSync(); + expect(screen.getByTestId('export-dialog')).toHaveAttribute('open'); + }); + + it('does not open for a different dialog name', () => { + render(Dialog, { + props: { name: 'export', title: 'Export', testId: 'export-dialog', children: childrenSnippet() }, + }); + uiStore.openDialog('settings'); + flushSync(); + expect(screen.getByTestId('export-dialog')).not.toHaveAttribute('open'); + }); + + it('closes (loses the open attribute) once uiStore.dialog is cleared', () => { + uiStore.openDialog('export'); + render(Dialog, { + props: { name: 'export', title: 'Export', testId: 'export-dialog', children: childrenSnippet() }, + }); + expect(screen.getByTestId('export-dialog')).toHaveAttribute('open'); + + uiStore.closeDialog(); + flushSync(); + expect(screen.getByTestId('export-dialog')).not.toHaveAttribute('open'); + }); + + it('renders the title and links it via aria-labelledby', () => { + uiStore.openDialog('export'); + render(Dialog, { + props: { name: 'export', title: 'Export', testId: 'export-dialog', children: childrenSnippet() }, + }); + const dialog = screen.getByTestId('export-dialog'); + const titleId = dialog.getAttribute('aria-labelledby'); + expect(titleId).toBe('export-dialog-title'); + expect(document.getElementById(titleId!)).toHaveTextContent('Export'); + }); + + it('the close button calls uiStore.closeDialog()', async () => { + uiStore.openDialog('export'); + render(Dialog, { + props: { name: 'export', title: 'Export', testId: 'export-dialog', children: childrenSnippet() }, + }); + expect(uiStore.dialog).toBe('export'); + + await fireEvent.click(screen.getByTestId('export-dialog-close')); + expect(uiStore.dialog).toBeNull(); + }); + + it('a click on the dialog element itself (the backdrop) calls uiStore.closeDialog()', async () => { + uiStore.openDialog('export'); + render(Dialog, { + props: { name: 'export', title: 'Export', testId: 'export-dialog', children: childrenSnippet() }, + }); + const dialog = screen.getByTestId('export-dialog'); + + await fireEvent.click(dialog); + expect(uiStore.dialog).toBeNull(); + }); + + it('a click inside the dialog content does not close it', async () => { + uiStore.openDialog('export'); + render(Dialog, { + props: { name: 'export', title: 'Export', testId: 'export-dialog', children: childrenSnippet() }, + }); + + await fireEvent.click(screen.getByTestId('dialog-body')); + expect(uiStore.dialog).toBe('export'); + }); + + it('the native close event calls uiStore.closeDialog() (e.g. Esc)', () => { + uiStore.openDialog('export'); + render(Dialog, { + props: { name: 'export', title: 'Export', testId: 'export-dialog', children: childrenSnippet() }, + }); + const dialog = screen.getByTestId('export-dialog'); + + dialog.dispatchEvent(new Event('close')); + expect(uiStore.dialog).toBeNull(); + }); + + it('a stray close event for a dialog that is not the open one leaves the store alone', () => { + uiStore.openDialog('settings'); + render(Dialog, { + props: { name: 'export', title: 'Export', testId: 'export-dialog', children: childrenSnippet() }, + }); + const dialog = screen.getByTestId('export-dialog'); + + dialog.dispatchEvent(new Event('close')); + expect(uiStore.dialog).toBe('settings'); + }); + + it('applies a numeric width/height as inline style', () => { + uiStore.openDialog('export'); + render(Dialog, { + props: { name: 'export', title: 'Export', testId: 'export-dialog', width: 500, height: 300, children: childrenSnippet() }, + }); + const dialog = screen.getByTestId('export-dialog'); + // jsdom normalizes calc() text, so just check the pieces that matter. + expect(dialog.style.width).toContain('500px'); + expect(dialog.style.width).toContain('100vw'); + expect(dialog.style.height).toContain('300px'); + expect(dialog.style.height).toContain('100dvh'); + }); +}); diff --git a/frontend/src/lib/components/shared/HelpTooltip.svelte b/frontend/src/lib/components/shared/HelpTooltip.svelte deleted file mode 100644 index 677928b..0000000 --- a/frontend/src/lib/components/shared/HelpTooltip.svelte +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - - {#if open} - - {/if} - - - diff --git a/frontend/src/lib/components/shared/HelpTooltip.test.ts b/frontend/src/lib/components/shared/HelpTooltip.test.ts deleted file mode 100644 index 3e5ce6e..0000000 --- a/frontend/src/lib/components/shared/HelpTooltip.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import HelpTooltip from './HelpTooltip.svelte'; - -const TEXTS = ['First help text', 'Second help text']; - -describe('HelpTooltip', () => { - it('starts closed, with no tooltip panel in the DOM', () => { - render(HelpTooltip, { label: 'Segmentation', texts: TEXTS }); - expect(screen.queryByTestId('help-panel')).toBeNull(); - expect(screen.getByTestId('help-button')).toHaveAttribute('aria-expanded', 'false'); - }); - - it('opens the panel on click and links it via aria-describedby', async () => { - render(HelpTooltip, { label: 'Segmentation', texts: TEXTS }); - const button = screen.getByTestId('help-button'); - - await fireEvent.click(button); - - const panel = screen.getByTestId('help-panel'); - expect(panel).toBeInTheDocument(); - expect(button).toHaveAttribute('aria-expanded', 'true'); - expect(button.getAttribute('aria-describedby')).toBe(panel.id); - expect(panel).toHaveTextContent('First help text'); - expect(panel).toHaveTextContent('Second help text'); - }); - - it('has an accessible name that includes the context label', () => { - render(HelpTooltip, { label: 'Inpainting', texts: TEXTS }); - expect(screen.getByRole('button', { name: 'Help: Inpainting' })).toBeInTheDocument(); - }); - - it('closes again on a second click (toggle)', async () => { - render(HelpTooltip, { label: 'Segmentation', texts: TEXTS }); - const button = screen.getByTestId('help-button'); - - await fireEvent.click(button); - expect(screen.getByTestId('help-panel')).toBeInTheDocument(); - - await fireEvent.click(button); - expect(screen.queryByTestId('help-panel')).toBeNull(); - }); - - it('closes on Escape and returns focus to the button', async () => { - render(HelpTooltip, { label: 'Segmentation', texts: TEXTS }); - const button = screen.getByTestId('help-button') as HTMLButtonElement; - - await fireEvent.click(button); - expect(screen.getByTestId('help-panel')).toBeInTheDocument(); - - await fireEvent.keyDown(button, { key: 'Escape' }); - - expect(screen.queryByTestId('help-panel')).toBeNull(); - expect(document.activeElement).toBe(button); - }); - - it('closes when a click happens outside the tooltip', async () => { - render(HelpTooltip, { label: 'Segmentation', texts: TEXTS }); - const button = screen.getByTestId('help-button'); - await fireEvent.click(button); - expect(screen.getByTestId('help-panel')).toBeInTheDocument(); - - await fireEvent.click(document.body); - - expect(screen.queryByTestId('help-panel')).toBeNull(); - }); -}); diff --git a/frontend/src/lib/components/shell/AppHeader.svelte b/frontend/src/lib/components/shell/AppHeader.svelte index a12784a..e9e8319 100644 --- a/frontend/src/lib/components/shell/AppHeader.svelte +++ b/frontend/src/lib/components/shell/AppHeader.svelte @@ -77,13 +77,11 @@ @@ -91,8 +89,10 @@ type="button" class="btn btn-primary export" data-testid="open-export" + aria-haspopup="dialog" + title="Export (Ctrl+E)" disabled={!hasSlices} - onclick={() => uiStore.setStep('export')} + onclick={() => uiStore.openDialog('export')} > Export… diff --git a/frontend/src/lib/components/shell/AppShell.svelte b/frontend/src/lib/components/shell/AppShell.svelte index c2f5883..529814a 100644 --- a/frontend/src/lib/components/shell/AppShell.svelte +++ b/frontend/src/lib/components/shell/AppShell.svelte @@ -11,9 +11,33 @@ import StatusBar from './StatusBar.svelte'; import CanvasArea from '../canvas/CanvasArea.svelte'; import LayerPanel from '../layers/LayerPanel.svelte'; + import ExportDialog from '../dialogs/ExportDialog.svelte'; + import SettingsDialog from '../dialogs/SettingsDialog.svelte'; + import ShortcutsDialog from '../dialogs/ShortcutsDialog.svelte'; + import { handleShortcut } from '../../shortcuts'; + + // Keyboard shortcuts are handled on the app root, which takes focus when + // anything non-focusable inside it is clicked (tabindex -1), so keys reach + // it without a window-wide listener. + let root: HTMLDivElement | undefined = $state(); + $effect(() => { + if (root && (document.activeElement === document.body || !document.activeElement)) root.focus({ preventScroll: true }); + }); + + function onKeydown(event: KeyboardEvent): void { + if (handleShortcut(event)) event.preventDefault(); + } -
+ +
@@ -22,6 +46,9 @@
+ + +