diff --git a/e2e/drivers/svelte.ts b/e2e/drivers/svelte.ts index 08e3618..418f4ca 100644 --- a/e2e/drivers/svelte.ts +++ b/e2e/drivers/svelte.ts @@ -77,6 +77,24 @@ export class SvelteDriver implements UiDriver { return this.page.getByTestId('slice-thumbnail'); } + /** The layer panel lists slices nearest first; rows carry their slice index. */ + private sliceRow(index: number): Locator { + return this.page.locator(`[data-testid="slice-thumbnail-wrapper"][data-slice-index="${index}"]`); + } + + sliceImage(index: number): Locator { + return this.sliceRow(index).getByTestId('slice-thumbnail'); + } + + /** Per-slice actions live in the Inspector's header for the selected slice. */ + private async ensureSelected(index: number): Promise { + const row = this.sliceRow(index); + if ((await row.getAttribute('aria-selected')) !== 'true') { + await row.getByTestId('slice-thumbnail').click(); + await expect(row).toHaveAttribute('aria-selected', 'true'); + } + } + candidateImages(): Locator { return this.page.getByTestId('candidate-image'); } @@ -241,15 +259,10 @@ export class SvelteDriver implements UiDriver { } async selectSlice(projectId: string, index: number): Promise { - const image = this.sliceImages().nth(index); + const image = this.sliceImage(index); await expect(image).toBeVisible(); - const bounds = await image.boundingBox(); - if (!bounds) throw new Error(`Slice ${index} has no clickable bounds`); - // The depth-number overlay covers the center and the label covers the bottom. - // Click the unobstructed upper-left area with normal browser hit-testing. - await image.click({ position: { x: bounds.width * 0.1, y: bounds.height * 0.15 } }); - const wrapper = this.page.getByTestId('slice-thumbnail-wrapper').nth(index); - await expect(wrapper).toHaveAttribute('aria-selected', 'true'); + await image.click(); + await expect(this.sliceRow(index)).toHaveAttribute('aria-selected', 'true'); await expect.poll(async () => (await readE2EState(this.page, projectId)).selected_slice).toBe(index); return image; } @@ -368,12 +381,14 @@ export class SvelteDriver implements UiDriver { await this.page.getByTestId('apply-inpainting').click(); } - undoButton(index: number): Locator { - return this.page.getByTestId('slice-undo').nth(index); + // Undo and redo act on the selected slice (the header's buttons), so + // `index` must be the selected slice. + undoButton(_index: number): Locator { + return this.page.getByTestId('header-undo'); } - redoButton(index: number): Locator { - return this.page.getByTestId('slice-redo').nth(index); + redoButton(_index: number): Locator { + return this.page.getByTestId('header-redo'); } // Slice editing / mask tools @@ -421,7 +436,7 @@ export class SvelteDriver implements UiDriver { } async setSliceDepth(index: number, depth: number): Promise { - const display = this.page.getByTestId('slice-depth-display').nth(index); + const display = this.sliceRow(index).getByTestId('slice-depth-display'); await expect(display).toBeVisible(); await display.click(); const input = this.page.getByTestId('slice-depth-input'); @@ -441,8 +456,9 @@ export class SvelteDriver implements UiDriver { index: number, file: { name: string; mimeType: string; buffer: Buffer }, ): Promise { + await this.ensureSelected(index); const before = await this.log().innerText(); - const input = this.page.getByTestId('slice-upload-input').nth(index); + const input = this.page.getByTestId('slice-upload-input'); await input.setInputFiles(file); await expect.poll(() => this.log().innerText()).not.toBe(before); } @@ -513,7 +529,7 @@ export class SvelteDriver implements UiDriver { } async expectGroundSlice(index: number, isGround: boolean): Promise { - const badge = this.page.getByTestId('slice-thumbnail-wrapper').nth(index).getByTestId('ground-badge'); + const badge = this.sliceRow(index).getByTestId('ground-badge'); await expect(badge).toHaveCount(isGround ? 1 : 0); } @@ -657,8 +673,9 @@ export class SvelteDriver implements UiDriver { } async downloadSlice(index: number): Promise { + await this.ensureSelected(index); const downloadPromise = this.page.waitForEvent('download'); - await this.page.getByTestId('slice-download').nth(index).click(); + await this.page.getByTestId('slice-download').click(); return downloadPromise; } } diff --git a/e2e/drivers/types.ts b/e2e/drivers/types.ts index e610ab2..d3cbf34 100644 --- a/e2e/drivers/types.ts +++ b/e2e/drivers/types.ts @@ -53,8 +53,10 @@ export interface UiDriver { // Observable elements mainImage(): Locator; depthImage(): Locator; - /** Slice thumbnails as displayed (checkerboard-composited), in slice order. */ + /** Every slice thumbnail as displayed (checkerboard-composited), in no particular order. */ sliceImages(): Locator; + /** The displayed thumbnail of the slice at `index` (slice order, farthest first). */ + sliceImage(index: number): Locator; /** Inpainting candidate images, in candidate order. */ candidateImages(): Locator; /** Threshold handles; there are `num_slices - 1` of them. */ @@ -124,7 +126,7 @@ export interface UiDriver { /** Selects a candidate and waits until it is visibly selected and Apply is enabled. */ selectCandidate(index: number): Promise; applyCandidate(): Promise; - /** Undo/redo controls of the slice at `index` in the slice list. */ + /** Undo/redo controls for the slice at `index`, which must be the selected slice. */ undoButton(index: number): Locator; redoButton(index: number): Locator; diff --git a/e2e/parallax-maker.spec.ts b/e2e/parallax-maker.spec.ts index 080d615..e5a4554 100644 --- a/e2e/parallax-maker.spec.ts +++ b/e2e/parallax-maker.spec.ts @@ -35,7 +35,7 @@ test('upload generates deterministic depth and three real slices', async ({ ui } await ui.openTab('Segmentation'); await ui.generateSlices(); await expect(ui.sliceImages()).toHaveCount(3); - const slices = await ui.sliceImages().all(); + const slices = [0, 1, 2].map((index) => ui.sliceImage(index)); for (const image of slices) { expect(await imageDimensions(image)).toEqual({ width: 320, height: 240 }); } @@ -275,9 +275,9 @@ test('painted mask drives three checkerboard candidates, apply, and undo', async await ui.openTab('Segmentation'); const undo = ui.undoButton(1); await expect(undo).toBeEnabled(); - expect(await imageHash(ui.sliceImages().nth(1))).not.toBe(originalHash); + expect(await imageHash(ui.sliceImage(1))).not.toBe(originalHash); await undo.click(); - await expect.poll(() => imageHash(ui.sliceImages().nth(1))).toBe(originalHash); + await expect.poll(() => imageHash(ui.sliceImage(1))).toBe(originalHash); // Undo restores the previous version together with its saved mask. await ui.openTab('Inpainting'); await expect.poll(() => ui.maskCanvasPainted()).toBe(true); @@ -394,12 +394,12 @@ test('erase removes painted alpha and supports undo and redo', async ({ page, ui expect(originalInside[3]).toBeGreaterThan(0); await ui.openTab('Segmentation'); - const erasedHash = await imageHash(ui.sliceImages().nth(1)); + const erasedHash = await imageHash(ui.sliceImage(1)); expect(erasedHash).not.toBe(originalHash); const undo = ui.undoButton(1); await expect(undo).toBeEnabled(); await undo.click(); - await expect.poll(() => imageHash(ui.sliceImages().nth(1))).toBe(originalHash); + await expect.poll(() => imageHash(ui.sliceImage(1))).toBe(originalHash); await expect .poll(async () => (await readE2EState(page, projectId)).slice_filenames[1]) .toBe('image_slice_1.png'); @@ -407,7 +407,7 @@ test('erase removes painted alpha and supports undo and redo', async ({ page, ui const redo = ui.redoButton(1); await expect(redo).toBeEnabled(); await redo.click(); - await expect.poll(() => imageHash(ui.sliceImages().nth(1))).toBe(erasedHash); + await expect.poll(() => imageHash(ui.sliceImage(1))).toBe(erasedHash); await expect .poll(async () => (await readE2EState(page, projectId)).slice_filenames[1]) .toBe('image_slice_1_v2.png'); diff --git a/e2e/slice-editing.spec.ts b/e2e/slice-editing.spec.ts index 4ccffc0..7736cfa 100644 --- a/e2e/slice-editing.spec.ts +++ b/e2e/slice-editing.spec.ts @@ -291,7 +291,7 @@ test('uploading a matching-aspect image replaces the slice content and bumps its requireWorkflow(ui, 'slice-editing'); const projectId = await ui.restoreFixtureState(); await ui.openTab('Segmentation'); - const thumbnailBefore = await imageHash(ui.sliceImages().nth(1)); + const thumbnailBefore = await imageHash(ui.sliceImage(1)); // The 320x240 input fixture shares the slice canvas's exact aspect ratio // (every slice image is full-canvas size, only alpha differs), so this @@ -308,7 +308,7 @@ test('uploading a matching-aspect image replaces the slice content and bumps its .poll(async () => (await readE2EState(page, projectId)).slice_filenames[1]) .toBe('image_slice_1_v2.png'); expect((await readE2EState(page, projectId)).slice_versions[1]).toBe(2); - await expect.poll(() => imageHash(ui.sliceImages().nth(1))).not.toBe(thumbnailBefore); + await expect.poll(() => imageHash(ui.sliceImage(1))).not.toBe(thumbnailBefore); // The fixture draws a solid red rectangle at (24,24)-(104,96); (30, 30) is // inside it. No resize occurred, so the uploaded pixel lands unchanged and diff --git a/frontend/src/lib/components/layers/DepthRuler.svelte b/frontend/src/lib/components/layers/DepthRuler.svelte new file mode 100644 index 0000000..372036b --- /dev/null +++ b/frontend/src/lib/components/layers/DepthRuler.svelte @@ -0,0 +1,206 @@ + + + + +
+ 255 near +
+ 0 far + + + + {#each entries as entry (entry.index)} + {@const depth = depthOf(entry)} + + {/each} +
+ + diff --git a/frontend/src/lib/components/layers/LayerPanel.svelte b/frontend/src/lib/components/layers/LayerPanel.svelte new file mode 100644 index 0000000..818ab4c --- /dev/null +++ b/frontend/src/lib/components/layers/LayerPanel.svelte @@ -0,0 +1,388 @@ + + + + + diff --git a/frontend/src/lib/components/layers/LayerPanel.test.ts b/frontend/src/lib/components/layers/LayerPanel.test.ts new file mode 100644 index 0000000..bf84c77 --- /dev/null +++ b/frontend/src/lib/components/layers/LayerPanel.test.ts @@ -0,0 +1,428 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import LayerPanel from './LayerPanel.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'; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function makeSlice(index: number, depth: number, overrides: Partial = {}): SliceView { + return { + index, + depth, + version: 1, + canUndo: false, + canRedo: false, + positivePrompt: '', + negativePrompt: '', + image: { url: `/api/v1/projects/appstate-test/assets/slice-${index}` }, + thumbnail: { url: `/api/v1/projects/appstate-test/assets/slice-${index}-thumb` }, + ...overrides, + }; +} + +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: 'diffusers/stable-diffusion-xl-1.0-inpainting-0.1', + 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, + }; +} + +/** A fetch stub that answers known URLs and throws on anything unexpected. */ +function makeFetchMock(handlers: Record Response>) { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + if (url.startsWith('/api/v1/projects/appstate-test/logs')) return jsonResponse(200, { entries: [], next: 0 }); + const handler = handlers[url]; + if (handler) return handler(init); + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${url}`); + }); +} + +describe('LayerPanel', () => { + beforeEach(() => { + projectStore.reset(); + jobStore.end(); + logStore.reset(); + uiStore.reset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders one thumbnail per slice', () => { + projectStore.applyView( + makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170), makeSlice(2, 255)] }), + ); + render(LayerPanel); + + const thumbnails = screen.getAllByTestId('slice-thumbnail'); + expect(thumbnails).toHaveLength(3); + expect(thumbnails.map((img) => img.getAttribute('alt'))).toEqual( + expect.arrayContaining(['image_slice_0', 'image_slice_1', 'image_slice_2']), + ); + }); + + it('shows the empty state with no slices', () => { + projectStore.applyView(makeView({ slices: [] })); + render(LayerPanel); + + expect(screen.getByTestId('layers-empty')).toBeInTheDocument(); + expect(screen.queryByTestId('slice-thumbnail-wrapper')).not.toBeInTheDocument(); + }); + + it('orders rows nearest first (depth descending)', () => { + projectStore.applyView( + makeView({ slices: [makeSlice(0, 10), makeSlice(1, 250), makeSlice(2, 130)] }), + ); + const { container } = render(LayerPanel); + + const wrappers = Array.from(container.querySelectorAll('[data-testid="slice-thumbnail-wrapper"]')); + expect(wrappers.map((el) => el.getAttribute('data-slice-index'))).toEqual(['1', '2', '0']); + }); + + describe('slice selection', () => { + it('sends {slice: index} on the first click, then {slice: null} to deselect the same slice', async () => { + const view = makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170), makeSlice(2, 255)] }); + projectStore.applyView(view); + + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { ...view, revision: 2, selectedSlice: 1, changed: true }), + ); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); + vi.stubGlobal('fetch', fetchMock); + + render(LayerPanel); + const wrapperFor1 = document.querySelector('[data-testid="slice-thumbnail-wrapper"][data-slice-index="1"]') as HTMLElement; + await fireEvent.click(wrapperFor1); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + const [firstUrl, firstInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(firstUrl).toBe('/api/v1/projects/appstate-test/selection'); + expect(firstInit.method).toBe('PUT'); + expect(JSON.parse(firstInit.body as string)).toEqual({ slice: 1 }); + + // The applied view now reports slice 1 as selected; clicking it again + // must send `{slice: null}` (click-to-toggle), not `{slice: 1}`. + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { ...view, revision: 3, selectedSlice: null, changed: true }), + ); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); + await fireEvent.click(wrapperFor1); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); + const [secondUrl, secondInit] = fetchMock.mock.calls[2] as [string, RequestInit]; + expect(secondUrl).toBe('/api/v1/projects/appstate-test/selection'); + expect(JSON.parse(secondInit.body as string)).toEqual({ slice: null }); + }); + + it('marks the selected thumbnail wrapper with aria-selected and data-selected', () => { + const view = makeView({ + slices: [makeSlice(0, 85), makeSlice(1, 170)], + selectedSlice: 1, + }); + projectStore.applyView(view); + render(LayerPanel); + + const wrapper0 = document.querySelector('[data-testid="slice-thumbnail-wrapper"][data-slice-index="0"]') as HTMLElement; + const wrapper1 = document.querySelector('[data-testid="slice-thumbnail-wrapper"][data-slice-index="1"]') as HTMLElement; + expect(wrapper0).toHaveAttribute('aria-selected', 'false'); + expect(wrapper0).toHaveAttribute('data-selected', 'false'); + expect(wrapper1).toHaveAttribute('aria-selected', 'true'); + expect(wrapper1).toHaveAttribute('data-selected', 'true'); + }); + }); + + describe('depth badge editing', () => { + it('reveals a numeric input on click and commits the new depth on Enter', async () => { + projectStore.applyView(makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)] })); + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { ...makeView({ revision: 2, slices: [makeSlice(0, 200), makeSlice(1, 170)] }), changed: true }), + ); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); + vi.stubGlobal('fetch', fetchMock); + + render(LayerPanel); + const wrapper0 = document.querySelector('[data-testid="slice-thumbnail-wrapper"][data-slice-index="0"]') as HTMLElement; + const badge = wrapper0.querySelector('[data-testid="slice-depth-display"]') as HTMLElement; + expect(screen.queryByTestId('slice-depth-input')).toBeNull(); + + await fireEvent.click(badge); + const input = screen.getByTestId('slice-depth-input') as HTMLInputElement; + expect(input).toBeInTheDocument(); + + await fireEvent.input(input, { target: { value: '200' } }); + await fireEvent.keyDown(input, { key: 'Enter' }); + await fireEvent.blur(input); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('/api/v1/projects/appstate-test/slices/0/depth'); + expect(JSON.parse(init.body as string)).toEqual({ depth: 200 }); + // Enter/blur is a single commit, not two. + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['300', 255], + ['-5', 0], + ])('clamps an out-of-range depth (%s) to %i', async (draft, expected) => { + projectStore.applyView(makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)] })); + 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(LayerPanel); + const wrapper0 = document.querySelector('[data-testid="slice-thumbnail-wrapper"][data-slice-index="0"]') as HTMLElement; + await fireEvent.click(wrapper0.querySelector('[data-testid="slice-depth-display"]') as HTMLElement); + const input = screen.getByTestId('slice-depth-input') as HTMLInputElement; + await fireEvent.input(input, { target: { value: draft } }); + await fireEvent.keyDown(input, { key: 'Enter' }); + await fireEvent.blur(input); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ depth: expected }); + }); + + it.each(['', ' ', '12.5', 'abc'])('does not commit a blank or non-integer depth (%j)', async (draft) => { + projectStore.applyView(makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)] })); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + render(LayerPanel); + const wrapper0 = document.querySelector('[data-testid="slice-thumbnail-wrapper"][data-slice-index="0"]') as HTMLElement; + await fireEvent.click(wrapper0.querySelector('[data-testid="slice-depth-display"]') as HTMLElement); + const input = screen.getByTestId('slice-depth-input') as HTMLInputElement; + await fireEvent.input(input, { target: { value: draft } }); + await fireEvent.keyDown(input, { key: 'Enter' }); + await fireEvent.blur(input); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('clicking a depth badge does not also (de)select its slice', async () => { + const view = makeView({ slices: [makeSlice(0, 85)], selectedSlice: null }); + projectStore.applyView(view); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + render(LayerPanel); + await fireEvent.click(screen.getByTestId('slice-depth-display')); + + expect(fetchMock).not.toHaveBeenCalled(); + const wrapper0 = document.querySelector('[data-testid="slice-thumbnail-wrapper"][data-slice-index="0"]') as HTMLElement; + expect(wrapper0).toHaveAttribute('data-selected', 'false'); + }); + }); + + describe('ground badge', () => { + it('shows GROUND only on the ground slice', () => { + projectStore.applyView( + makeView({ + slices: [makeSlice(0, 85), makeSlice(1, 170, { isGround: true })], + }), + ); + render(LayerPanel); + + const wrapper0 = document.querySelector('[data-testid="slice-thumbnail-wrapper"][data-slice-index="0"]') as HTMLElement; + const wrapper1 = document.querySelector('[data-testid="slice-thumbnail-wrapper"][data-slice-index="1"]') as HTMLElement; + expect(wrapper0.querySelector('[data-testid="ground-badge"]')).toBeNull(); + expect(wrapper1.querySelector('[data-testid="ground-badge"]')).not.toBeNull(); + }); + }); + + describe('header actions (Copy/Paste/Delete)', () => { + // Matches Dash exactly: these never disable themselves based on + // selection/mask/clipboard state -- only "is a project loaded" and "is + // nothing else in flight" gate them. Preconditions are enforced by + // workflow.ts at click time. + const actionTestIds = ['copy-slice', 'paste-slice', 'delete-slice']; + + it('disables every header action when there is no project', () => { + render(LayerPanel); + for (const testId of actionTestIds) { + expect(screen.getByTestId(testId)).toBeDisabled(); + } + }); + + it('enables every header action with a project loaded, regardless of selection or mask state', () => { + projectStore.applyView( + makeView({ selectedSlice: null, segmentation: { multiPointMode: false, queuedPoints: [], hasMask: false } }), + ); + render(LayerPanel); + for (const testId of actionTestIds) { + expect(screen.getByTestId(testId)).toBeEnabled(); + } + }); + + it('disables every header action while a job is in flight', async () => { + projectStore.applyView(makeView()); + render(LayerPanel); + jobStore.begin('slice-editing'); + await waitFor(() => { + for (const testId of actionTestIds) { + expect(screen.getByTestId(testId)).toBeDisabled(); + } + }); + }); + }); + + describe('keyboard delete', () => { + it('Delete on the focused list deletes the selected slice', async () => { + projectStore.applyView( + makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)], selectedSlice: 1 }), + ); + const fetchMock = makeFetchMock({ + '/api/v1/projects/appstate-test/slices/1': () => + jsonResponse(200, { ...makeView({ slices: [makeSlice(0, 85)] }), changed: true }), + }); + vi.stubGlobal('fetch', fetchMock); + + render(LayerPanel); + const list = screen.getByRole('listbox'); + await fireEvent.keyDown(list, { key: 'Delete' }); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/projects/appstate-test/slices/1', + expect.objectContaining({ method: 'DELETE' }), + ), + ); + }); + }); + + describe('depth ruler', () => { + it('ArrowUp x3 then keyup commits setSliceDepth with depth+3', async () => { + projectStore.applyView(makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)] })); + const fetchMock = makeFetchMock({ + '/api/v1/projects/appstate-test/slices/1/depth': () => + jsonResponse(200, { ...makeView({ slices: [makeSlice(0, 85), makeSlice(1, 173)] }), changed: true }), + }); + vi.stubGlobal('fetch', fetchMock); + + render(LayerPanel); + const handle = document.querySelector('[data-testid="depth-handle"][data-slice-index="1"]') as HTMLElement; + handle.focus(); + await fireEvent.keyDown(handle, { key: 'ArrowUp' }); + await fireEvent.keyDown(handle, { key: 'ArrowUp' }); + await fireEvent.keyDown(handle, { key: 'ArrowUp' }); + expect(fetchMock).not.toHaveBeenCalled(); + await fireEvent.keyUp(handle, { key: 'ArrowUp' }); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/projects/appstate-test/slices/1/depth', + expect.objectContaining({ method: 'PUT' }), + ), + ); + const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/slices/1/depth')); + expect(JSON.parse(call![1]!.body as string)).toEqual({ depth: 173 }); + }); + + it('Shift+ArrowDown moves the depth by 10', async () => { + projectStore.applyView(makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)] })); + const fetchMock = makeFetchMock({ + '/api/v1/projects/appstate-test/slices/1/depth': () => + jsonResponse(200, { ...makeView({ slices: [makeSlice(0, 85), makeSlice(1, 160)] }), changed: true }), + }); + vi.stubGlobal('fetch', fetchMock); + + render(LayerPanel); + const handle = document.querySelector('[data-testid="depth-handle"][data-slice-index="1"]') as HTMLElement; + handle.focus(); + await fireEvent.keyDown(handle, { key: 'ArrowDown', shiftKey: true }); + await fireEvent.keyUp(handle, { key: 'ArrowDown', shiftKey: true }); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/projects/appstate-test/slices/1/depth', + expect.objectContaining({ method: 'PUT' }), + ), + ); + const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/slices/1/depth')); + expect(JSON.parse(call![1]!.body as string)).toEqual({ depth: 160 }); + }); + + it('pointer drag commits the depth under the pointer', async () => { + projectStore.applyView(makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)] })); + const fetchMock = makeFetchMock({ + '/api/v1/projects/appstate-test/slices/1/depth': () => + jsonResponse(200, { ...makeView({ slices: [makeSlice(0, 85), makeSlice(1, 180)] }), changed: true }), + }); + vi.stubGlobal('fetch', fetchMock); + + const { container } = render(LayerPanel); + const axis = container.querySelector('.axis') as HTMLElement; + // jsdom has no layout: give the ruler axis a fixed 0-255px box so + // `depthAt(clientY)` maps 1:1 to a depth (255 - clientY). + vi.spyOn(axis, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + width: 4, + height: 255, + right: 4, + bottom: 255, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + const handle = document.querySelector('[data-testid="depth-handle"][data-slice-index="1"]') as HTMLElement; + await fireEvent.pointerDown(handle, { pointerId: 1, button: 0, clientY: 255 - 170 }); + await fireEvent.pointerMove(handle, { pointerId: 1, clientY: 255 - 180 }); + expect(handle).toHaveAttribute('aria-valuenow', '180'); + expect(fetchMock).not.toHaveBeenCalled(); + await fireEvent.pointerUp(handle, { pointerId: 1, clientY: 255 - 180 }); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/projects/appstate-test/slices/1/depth', + expect.objectContaining({ method: 'PUT' }), + ), + ); + const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/slices/1/depth')); + expect(JSON.parse(call![1]!.body as string)).toEqual({ depth: 180 }); + }); + }); +}); diff --git a/frontend/src/lib/components/layers/SelectedSliceHeader.svelte b/frontend/src/lib/components/layers/SelectedSliceHeader.svelte new file mode 100644 index 0000000..ed8b425 --- /dev/null +++ b/frontend/src/lib/components/layers/SelectedSliceHeader.svelte @@ -0,0 +1,129 @@ + + +{#if slice} +
+ +
+
image_slice_{slice.index}
+
+ {slice.depth} + {#if slice.isGround}GROUND{/if} +
+
+ + + +
+{/if} + + diff --git a/frontend/src/lib/components/layers/SelectedSliceHeader.test.ts b/frontend/src/lib/components/layers/SelectedSliceHeader.test.ts new file mode 100644 index 0000000..9194bca --- /dev/null +++ b/frontend/src/lib/components/layers/SelectedSliceHeader.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; + +vi.mock('../../download', () => ({ triggerDownload: vi.fn() })); +import { triggerDownload } from '../../download'; + +import SelectedSliceHeader from './SelectedSliceHeader.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'; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function makeSlice(index: number, depth: number, overrides: Partial = {}): SliceView { + return { + index, + depth, + version: 1, + canUndo: false, + canRedo: false, + positivePrompt: '', + negativePrompt: '', + image: { url: `/api/v1/projects/appstate-test/assets/slice-${index}` }, + thumbnail: { url: `/api/v1/projects/appstate-test/assets/slice-${index}-thumb` }, + ...overrides, + }; +} + +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: 'diffusers/stable-diffusion-xl-1.0-inpainting-0.1', + 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('SelectedSliceHeader', () => { + beforeEach(() => { + projectStore.reset(); + jobStore.end(); + logStore.reset(); + uiStore.reset(); + vi.mocked(triggerDownload).mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders nothing without a selected slice', () => { + projectStore.applyView( + makeView({ slices: [makeSlice(0, 85)], selectedSlice: null }), + ); + render(SelectedSliceHeader); + expect(screen.queryByTestId('selected-slice-header')).not.toBeInTheDocument(); + }); + + it('renders the selected slice once one is selected', () => { + projectStore.applyView( + makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)], selectedSlice: 1 }), + ); + render(SelectedSliceHeader); + const header = screen.getByTestId('selected-slice-header'); + expect(header).toHaveTextContent('image_slice_1'); + }); + + it('Download triggers a real click against the raw-slice endpoint, not window.open', async () => { + projectStore.applyView( + makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)], selectedSlice: 1 }), + ); + render(SelectedSliceHeader); + + await fireEvent.click(screen.getByTestId('slice-download')); + + expect(triggerDownload).toHaveBeenCalledWith( + '/api/v1/projects/appstate-test/slices/1/download', + 'image_slice_1.png', + ); + }); + + describe('replace image', () => { + it('clicking Replace opens the hidden file chooser', async () => { + projectStore.applyView( + makeView({ slices: [makeSlice(0, 85)], selectedSlice: 0 }), + ); + render(SelectedSliceHeader); + + const input = screen.getByTestId('slice-upload-input') as HTMLInputElement; + const clickSpy = vi.spyOn(input, 'click'); + await fireEvent.click(screen.getByTestId('slice-replace')); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('uploads the chosen file to the selected slice index', async () => { + projectStore.applyView( + makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)], selectedSlice: 1 }), + ); + 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(SelectedSliceHeader); + const input = screen.getByTestId('slice-upload-input') as HTMLInputElement; + const file = new File(['bytes'], 'replacement.png', { type: 'image/png' }); + await fireEvent.change(input, { target: { files: [file] } }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('/api/v1/projects/appstate-test/slices/1/image'); + expect(init.method).toBe('PUT'); + expect((init.body as FormData).get('image')).toBe(file); + }); + }); +}); diff --git a/frontend/src/lib/components/segmentation/SegmentationTab.svelte b/frontend/src/lib/components/segmentation/SegmentationTab.svelte index f1f24f3..dfdf826 100644 --- a/frontend/src/lib/components/segmentation/SegmentationTab.svelte +++ b/frontend/src/lib/components/segmentation/SegmentationTab.svelte @@ -2,30 +2,22 @@ import { projectStore } from '../../state/project.svelte'; import { isBusy } from '../../state/busy.svelte'; import * as workflow from '../../workflow'; - import * as api from '../../api/client'; - import { triggerDownload } from '../../download'; import HelpTooltip from '../shared/HelpTooltip.svelte'; import { SEGMENTATION_HELP_TEXTS } from '../../helpTexts'; type ActionButton = { label: string; testId: string; onClick: () => void }; - // Same 8 buttons as components.py's make_slice_generation_container Actions - // panel. None of them are gated on selection/mask/clipboard state here -- - // matching Dash exactly, none of webui.py's delete/add-mask/remove-mask/ - // copy/paste/balance button elements are ever `disabled` for that reason - // either; each one just runs and logs a no-op message when a precondition - // (selection/mask/clipboard) is missing (see workflow.ts's slice-editing - // section for the exact per-button precedence). The only gating here is - // "is there a project, and is nothing else in flight", same as Generate. + // Slice-generation actions (Copy, Paste and Delete are in the layer + // panel's header). None of them are gated on selection or mask state: each + // one runs and logs a no-op message when a precondition is missing (see + // workflow.ts's slice-editing section). The only gating is "is there a + // project, and is nothing else in flight". const actions: ActionButton[] = [ { label: 'Generate', testId: 'generate-slices', onClick: () => void workflow.generateSlices() }, { label: 'Balance', testId: 'balance-slices', onClick: () => void workflow.balanceSlices() }, { label: 'Create', testId: 'create-slice', onClick: () => void workflow.createSlice() }, - { label: 'Delete', testId: 'delete-slice', onClick: () => void workflow.deleteSlice() }, { label: 'Add', testId: 'add-mask-to-slice', onClick: () => void workflow.addMaskToSlice() }, { label: 'Remove', testId: 'remove-mask-from-slice', onClick: () => void workflow.removeMaskFromSlice() }, - { label: 'Copy', testId: 'copy-slice', onClick: () => void workflow.copySlice() }, - { label: 'Paste', testId: 'paste-slice', onClick: () => void workflow.pasteSlice() }, ]; // Ground plane: the selected slice becomes (or stops being) the horizontal @@ -35,86 +27,6 @@ ); const hasGround = $derived(projectStore.view?.slices.some((s) => s.isGround) ?? false); - // -- Per-slice depth editing: click the badge to reveal a numeric input, - // Enter or blur commits it (webui.py's WEB-22/WEB-23: `display_depth_input` - // un-hides the input, `record_depth_input` commits on Enter). Unlike Dash, - // the selection overlay uses `pointer-events: none` (see `.slice-overlay` - // below) so the badge stays clickable on a selected slice too -- Dash's own - // depth badge is unreachable through normal hit-testing once its slice is - // selected (see PARITY.md "Known quirks"); this UI does not reproduce that. - let editingDepthIndex = $state(null); - let depthDraft = $state(''); - - function startEditingDepth(index: number, currentDepth: number, event: Event): void { - event.stopPropagation(); - if (isBusy()) return; - editingDepthIndex = index; - depthDraft = String(currentDepth); - } - - function commitDepth(index: number): void { - if (editingDepthIndex !== index) return; - editingDepthIndex = null; - // Dash's record_depth_input uses int(value): blank or non-integer input - // does not commit (an empty string must not become depth 0). - const draft = depthDraft.trim(); - if (!/^-?\d+$/.test(draft)) return; - void workflow.setSliceDepth(index, Number.parseInt(draft, 10)); - } - - function onDepthInputKeydown(index: number, event: KeyboardEvent): void { - event.stopPropagation(); - if (event.key === 'Enter') { - event.preventDefault(); - (event.currentTarget as HTMLInputElement).blur(); - } else if (event.key === 'Escape') { - event.preventDefault(); - editingDepthIndex = null; - } - } - - function onDepthInputBlur(index: number): void { - commitDepth(index); - } - - // -- Undo/redo carets (webui.py's WEB-24 `undo_slice`). - - function onUndo(index: number, event: Event): void { - event.stopPropagation(); - if (isBusy()) return; - void workflow.undoSlice(index); - } - - function onRedo(index: number, event: Event): void { - event.stopPropagation(); - if (isBusy()) return; - void workflow.redoSlice(index); - } - - // -- Per-thumbnail image upload/drop target (webui.py's WEB-34 `slice_upload`). - - function uploadFile(index: number, file: File | undefined | null): void { - if (!file || isBusy()) return; - void workflow.uploadSliceImage(index, file); - } - - function onUploadInputChange(index: number, event: Event): void { - event.stopPropagation(); - const target = event.currentTarget as HTMLInputElement; - uploadFile(index, target.files?.[0]); - target.value = ''; - } - - function onSliceDrop(index: number, event: DragEvent): void { - event.preventDefault(); - event.stopPropagation(); - uploadFile(index, event.dataTransfer?.files?.[0]); - } - - function onSliceDragOver(event: DragEvent): void { - event.preventDefault(); - } - // Interior threshold boundaries only (length numSlices - 1); the backend // rejects any other length (see UpdateThresholdValues.update_threshold_values). const interiorThresholds = $derived.by((): number[] => { @@ -138,27 +50,6 @@ function onSliderChange(): void { void workflow.updateThresholds([...localValues]); } - - // Clicking a slice thumbnail selects it, or deselects (sends `slice: - // null`) if it is already the selected slice -- Dash's click-to-toggle - // (display_slice), reproduced here since the API's selection endpoint - // itself is not a toggle (see ARCHITECTURE.md's segmentation endpoints). - function onSliceClick(index: number): void { - if (isBusy() || !projectStore.view) return; - const alreadySelected = projectStore.view.selectedSlice === index; - void workflow.selectSlice(alreadySelected ? null : index); - } - - // Slice download (webui.py's WEB-33 `download_image`, triggered by - // clicking the label the way Dash's `#slice-info` button does): a real - // `` click against the raw-slice-PNG endpoint, not - // `window.open` (see ExportTab.svelte's glTF-export comment for why). - function onDownloadSlice(index: number, event: Event): void { - event.stopPropagation(); - const view = projectStore.view; - if (!view) return; - triggerDownload(api.getSliceDownloadUrl(view.id, index), `image_slice_${index}.png`); - }
@@ -231,99 +122,6 @@
- -
- {#each projectStore.view?.slices ?? [] as slice (slice.index)} - {@const selected = projectStore.view?.selectedSlice === slice.index} -
onSliceClick(slice.index)} - onkeydown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - onSliceClick(slice.index); - } - }} - ondrop={(event) => onSliceDrop(slice.index, event)} - ondragover={onSliceDragOver} - > - {#if editingDepthIndex === slice.index} - - (depthDraft = (event.currentTarget as HTMLInputElement).value)} - onclick={(event) => event.stopPropagation()} - onkeydown={(event) => onDepthInputKeydown(slice.index, event)} - onblur={() => onDepthInputBlur(slice.index)} - /> - {:else} - - {/if} - {`image_slice_${slice.index}`} - {#if slice.isGround} - Ground - {/if} - event.stopPropagation()} - onchange={(event) => onUploadInputChange(slice.index, event)} - /> -
- - - -
- {#if selected} -
- {/if} -
- {/each} -
diff --git a/frontend/src/lib/components/segmentation/SegmentationTab.test.ts b/frontend/src/lib/components/segmentation/SegmentationTab.test.ts index 75d2bb0..8dd6e13 100644 --- a/frontend/src/lib/components/segmentation/SegmentationTab.test.ts +++ b/frontend/src/lib/components/segmentation/SegmentationTab.test.ts @@ -73,21 +73,6 @@ describe('SegmentationTab', () => { vi.unstubAllGlobals(); }); - it('renders one thumbnail per slice', () => { - projectStore.applyView( - makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170), makeSlice(2, 255)] }), - ); - render(SegmentationTab); - - const thumbnails = screen.getAllByTestId('slice-thumbnail'); - expect(thumbnails).toHaveLength(3); - expect(thumbnails.map((img) => img.getAttribute('alt'))).toEqual([ - 'image_slice_0', - 'image_slice_1', - 'image_slice_2', - ]); - }); - it('disables the Generate button while a job is in flight, and re-enables it after', async () => { projectStore.applyView(makeView()); render(SegmentationTab); @@ -133,72 +118,13 @@ describe('SegmentationTab', () => { expect(JSON.parse(thirdInit.body as string)).toMatchObject({ baseRevision: 2, values: [90, 170] }); }); - describe('slice selection', () => { - it('sends {slice: index} on the first click, then {slice: null} to deselect the same slice', async () => { - const view = makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170), makeSlice(2, 255)] }); - projectStore.applyView(view); - - const fetchMock = vi.fn(); - fetchMock.mockResolvedValueOnce( - jsonResponse(200, { ...view, revision: 2, selectedSlice: 1, changed: true }), - ); - fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); - vi.stubGlobal('fetch', fetchMock); - - render(SegmentationTab); - const wrappers = screen.getAllByTestId('slice-thumbnail-wrapper'); - await fireEvent.click(wrappers[1]); - - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); - const [firstUrl, firstInit] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(firstUrl).toBe('/api/v1/projects/appstate-test/selection'); - expect(firstInit.method).toBe('PUT'); - expect(JSON.parse(firstInit.body as string)).toEqual({ slice: 1 }); - - // The applied view now reports slice 1 as selected; clicking it again - // must send `{slice: null}` (Dash's click-to-toggle), not `{slice: 1}`. - fetchMock.mockResolvedValueOnce( - jsonResponse(200, { ...view, revision: 3, selectedSlice: null, changed: true }), - ); - fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); - await fireEvent.click(wrappers[1]); - - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); - const [secondUrl, secondInit] = fetchMock.mock.calls[2] as [string, RequestInit]; - expect(secondUrl).toBe('/api/v1/projects/appstate-test/selection'); - expect(JSON.parse(secondInit.body as string)).toEqual({ slice: null }); - }); - - it('marks the selected thumbnail wrapper with aria-selected and data-selected', () => { - const view = makeView({ - slices: [makeSlice(0, 85), makeSlice(1, 170)], - selectedSlice: 1, - }); - projectStore.applyView(view); - render(SegmentationTab); - - const wrappers = screen.getAllByTestId('slice-thumbnail-wrapper'); - expect(wrappers[0]).toHaveAttribute('aria-selected', 'false'); - expect(wrappers[0]).toHaveAttribute('data-selected', 'false'); - expect(wrappers[1]).toHaveAttribute('aria-selected', 'true'); - expect(wrappers[1]).toHaveAttribute('data-selected', 'true'); - }); - }); - describe('Actions panel enablement', () => { - // Matches Dash exactly: webui.py never disables Create/Delete/Add/Remove/ - // Copy/Paste/Balance based on selection/mask/clipboard state -- only "is - // a project loaded" and "is nothing else in flight" gate them, same as - // Generate. Preconditions are enforced by workflow.ts at click time. - const actionTestIds = [ - 'balance-slices', - 'create-slice', - 'delete-slice', - 'add-mask-to-slice', - 'remove-mask-from-slice', - 'copy-slice', - 'paste-slice', - ]; + // Matches Dash exactly: webui.py never disables Create/Add/Remove/Balance + // based on selection/mask state -- only "is a project loaded" and "is + // nothing else in flight" gate them, same as Generate. Copy/Paste/Delete + // moved to LayerPanel's header (see layers/LayerPanel.test.ts). + // Preconditions are enforced by workflow.ts at click time. + const actionTestIds = ['balance-slices', 'create-slice', 'add-mask-to-slice', 'remove-mask-from-slice']; it('disables every action button when there is no project', () => { render(SegmentationTab); @@ -229,127 +155,8 @@ describe('SegmentationTab', () => { }); }); - describe('depth badge editing', () => { - it('reveals a numeric input on click and commits the new depth on Enter', async () => { - projectStore.applyView(makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)] })); - const fetchMock = vi.fn(); - fetchMock.mockResolvedValueOnce( - jsonResponse(200, { ...makeView({ revision: 2, slices: [makeSlice(0, 200), makeSlice(1, 170)] }), changed: true }), - ); - fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); - vi.stubGlobal('fetch', fetchMock); - - render(SegmentationTab); - const badges = screen.getAllByTestId('slice-depth-display'); - expect(screen.queryByTestId('slice-depth-input')).toBeNull(); - - await fireEvent.click(badges[0]); - const input = screen.getByTestId('slice-depth-input') as HTMLInputElement; - expect(input).toBeInTheDocument(); - - await fireEvent.input(input, { target: { value: '200' } }); - await fireEvent.keyDown(input, { key: 'Enter' }); - await fireEvent.blur(input); - - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe('/api/v1/projects/appstate-test/slices/0/depth'); - expect(JSON.parse(init.body as string)).toEqual({ depth: 200 }); - // Enter/blur is a single commit, not two. - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it.each(['', ' ', '12.5', 'abc'])('does not commit a blank or non-integer depth (%j)', async (draft) => { - projectStore.applyView(makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)] })); - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - - render(SegmentationTab); - await fireEvent.click(screen.getAllByTestId('slice-depth-display')[0]); - const input = screen.getByTestId('slice-depth-input') as HTMLInputElement; - await fireEvent.input(input, { target: { value: draft } }); - await fireEvent.keyDown(input, { key: 'Enter' }); - await fireEvent.blur(input); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('clicking a depth badge does not also (de)select its slice', async () => { - const view = makeView({ slices: [makeSlice(0, 85)], selectedSlice: null }); - projectStore.applyView(view); - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - - render(SegmentationTab); - await fireEvent.click(screen.getByTestId('slice-depth-display')); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - }); - - describe('undo/redo carets', () => { - it('disables undo/redo per slice according to canUndo/canRedo', () => { - projectStore.applyView( - makeView({ - slices: [ - { ...makeSlice(0, 85), canUndo: false, canRedo: true }, - { ...makeSlice(1, 170), canUndo: true, canRedo: false }, - ], - }), - ); - render(SegmentationTab); - - const undoButtons = screen.getAllByTestId('slice-undo'); - const redoButtons = screen.getAllByTestId('slice-redo'); - expect(undoButtons[0]).toBeDisabled(); - expect(redoButtons[0]).toBeEnabled(); - expect(undoButtons[1]).toBeEnabled(); - expect(redoButtons[1]).toBeDisabled(); - }); - - it('clicking Undo calls the per-index undo endpoint without selecting the slice', async () => { - projectStore.applyView( - makeView({ slices: [{ ...makeSlice(0, 85), canUndo: true }], selectedSlice: null }), - ); - 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(SegmentationTab); - await fireEvent.click(screen.getByTestId('slice-undo')); - - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe('/api/v1/projects/appstate-test/slices/0/undo'); - expect(init.method).toBe('POST'); - }); - }); - - describe('per-thumbnail image upload', () => { - it('uploads the chosen file to the matching slice index', async () => { - projectStore.applyView(makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)] })); - 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(SegmentationTab); - const inputs = document.querySelectorAll('[data-testid="slice-upload-input"]'); - expect(inputs).toHaveLength(2); - const file = new File(['bytes'], 'replacement.png', { type: 'image/png' }); - await fireEvent.change(inputs[1], { target: { files: [file] } }); - - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe('/api/v1/projects/appstate-test/slices/1/image'); - expect(init.method).toBe('PUT'); - expect((init.body as FormData).get('image')).toBe(file); - }); - }); - describe('ground plane', () => { - it('marks the selected slice as the ground, shows the badge, and fits it', async () => { + it('marks the selected slice as the ground and fits it', async () => { const ground = { ...makeSlice(1, 170), isGround: true }; projectStore.applyView( makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)], selectedSlice: 1 }), @@ -371,15 +178,15 @@ describe('SegmentationTab', () => { vi.stubGlobal('fetch', fetchMock); render(SegmentationTab); - expect(screen.queryByTestId('ground-badge')).not.toBeInTheDocument(); + expect(screen.getByTestId('ground-toggle')).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByTestId('ground-fit')).toBeDisabled(); await fireEvent.click(screen.getByTestId('ground-toggle')); - await waitFor(() => expect(screen.getByTestId('ground-badge')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId('ground-toggle')).toHaveAttribute('aria-pressed', 'true')); const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/slices/1/ground')); expect(JSON.parse(call![1]!.body as string)).toEqual({ isGround: true }); - expect(screen.getByTestId('ground-toggle')).toHaveAttribute('aria-pressed', 'true'); + await waitFor(() => expect(screen.getByTestId('ground-fit')).toBeEnabled()); await fireEvent.click(screen.getByTestId('ground-fit')); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( diff --git a/frontend/src/lib/components/shell/AppShell.svelte b/frontend/src/lib/components/shell/AppShell.svelte index 81a39ed..20728c2 100644 --- a/frontend/src/lib/components/shell/AppShell.svelte +++ b/frontend/src/lib/components/shell/AppShell.svelte @@ -1,9 +1,8 @@
+
@@ -49,7 +50,7 @@ display: grid; /* The Inspector still hosts the pre-redesign tab bodies, which need more than the design's --inspector-w; it narrows once they are rebuilt. */ - grid-template-columns: minmax(0, 1fr) 440px; + grid-template-columns: var(--layers-w) minmax(0, 1fr) 360px; grid-template-rows: minmax(0, 1fr); } @@ -70,7 +71,7 @@ /* Below the design's 1280px minimum, give the canvas more of the width. */ @media (max-width: 1100px) { .app-main { - grid-template-columns: minmax(0, 1fr) 360px; + grid-template-columns: 240px minmax(0, 1fr) 320px; } } diff --git a/frontend/src/lib/components/shell/Inspector.svelte b/frontend/src/lib/components/shell/Inspector.svelte index 3c35602..6d9674c 100644 --- a/frontend/src/lib/components/shell/Inspector.svelte +++ b/frontend/src/lib/components/shell/Inspector.svelte @@ -12,6 +12,7 @@ import InpaintingTab from '../inpainting/InpaintingTab.svelte'; import ExportTab from '../export/ExportTab.svelte'; import ConfigurationTab from '../config/ConfigurationTab.svelte'; + import SelectedSliceHeader from '../layers/SelectedSliceHeader.svelte'; const title = $derived( uiStore.mainTab === 'Configuration' @@ -27,6 +28,7 @@

{title}

+ {#if uiStore.mainTab !== 'Configuration'}{/if}
diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index bb02c60..02361e8 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -1 +1,10 @@ import '@testing-library/jest-dom/vitest'; + +// jsdom has no ResizeObserver; Svelte's `bind:clientHeight` needs one. +if (!('ResizeObserver' in globalThis)) { + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; +}