diff --git a/apps/server/src/routes/search.tsx b/apps/server/src/routes/search.tsx index 4932a2e2..ccf617e5 100644 --- a/apps/server/src/routes/search.tsx +++ b/apps/server/src/routes/search.tsx @@ -122,7 +122,7 @@ function SearchRoute() { renderMediaItem={(media) => } renderNavActions={({ openMobileFilters }) => ( - diff --git a/apps/server/src/tests/e2e/search-realtime-preservation.responsive.spec.ts b/apps/server/src/tests/e2e/search-realtime-preservation.responsive.spec.ts index bdebc214..3340bc49 100644 --- a/apps/server/src/tests/e2e/search-realtime-preservation.responsive.spec.ts +++ b/apps/server/src/tests/e2e/search-realtime-preservation.responsive.spec.ts @@ -6,6 +6,7 @@ import { E2E_SOURCE_NAME, getE2eMediaDir, getFixtureMediaPath, + sourcePath, } from "./support/fixture"; import { expect, test, waitForAppHydration } from "./support/test"; @@ -39,9 +40,12 @@ test("global search preserves the mobile filter dialog, input value, and focus a await fileNameInput.fill("e2e"); await fileNameInput.focus(); await expect(fileNameInput).toBeFocused(); - await expect( - page.locator("p").filter({ hasText: /^2 件の結果$/ }), - ).toBeVisible(); + const resultCount = page.locator("p").filter({ hasText: /^\d+ 件の結果$/ }); + await expect(resultCount).toBeVisible(); + const initialResultCount = Number.parseInt( + (await resultCount.textContent()) ?? "0", + 10, + ); const syncedFileName = `e2e-global-sse-${randomUUID()}.png`; await copyFile( @@ -64,9 +68,79 @@ test("global search preserves the mobile filter dialog, input value, and focus a await sourceCard.getByTestId("sync-source-btn").click(); await syncResponse; - await expect( - page.locator("p").filter({ hasText: /^3 件の結果$/ }), - ).toBeVisible({ timeout: 30_000 }); + await expect + .poll( + async () => Number.parseInt((await resultCount.textContent()) ?? "0", 10), + { timeout: 30_000 }, + ) + .toBeGreaterThan(initialResultCount); + await expect(filterDialog).toBeVisible(); + await expect(fileNameInput).toHaveValue("e2e"); + await expect(fileNameInput).toBeFocused(); +}); + +test("source media preserves the mobile filter draft and focus after an SSE refresh", async ({ + context, + page, +}, testInfo) => { + test.skip( + !["responsive-320", "responsive-375"].includes(testInfo.project.name), + "The mobile filter dialog is only rendered below the md breakpoint.", + ); + + const sourceEventsConnected = page.waitForResponse( + (response) => + sourceEventsEndpoint.test(new URL(response.url()).pathname) && + response.status() === 200, + ); + await page.goto(sourcePath()); + await expect(page.getByRole("button", { name: "Add media" })).toBeVisible(); + await waitForAppHydration(page); + await sourceEventsConnected; + + await page.getByRole("button", { name: "Filter results" }).click(); + const filterDialog = page.getByRole("dialog"); + const fileNameInput = filterDialog.getByPlaceholder("ファイル名を入力..."); + await expect(filterDialog).toBeVisible(); + await fileNameInput.fill("e2e"); + await fileNameInput.focus(); + await expect(fileNameInput).toBeFocused(); + const resultCount = page.locator("p").filter({ + hasText: /^\d+ 件の結果$/, + }); + await expect(resultCount).toBeVisible(); + const initialMediaCount = Number.parseInt( + (await resultCount.textContent()) ?? "0", + 10, + ); + + const syncedFileName = `e2e-source-filter-sse-${randomUUID()}.png`; + await copyFile( + getFixtureMediaPath(E2E_PRIMARY_FILE_NAME), + path.join(getE2eMediaDir(), syncedFileName), + ); + + const syncPage = await context.newPage(); + await syncPage.setViewportSize({ width: 1440, height: 900 }); + await syncPage.goto("/sources"); + const sourceCard = syncPage + .getByTestId("source-card") + .filter({ hasText: E2E_SOURCE_NAME }); + await expect(sourceCard).toBeVisible(); + await waitForAppHydration(syncPage); + const syncResponse = syncPage.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === "/api/rpc/sources/sync" && response.ok(); + }); + await sourceCard.getByTestId("sync-source-btn").click(); + await syncResponse; + + await expect + .poll( + async () => Number.parseInt((await resultCount.textContent()) ?? "0", 10), + { timeout: 30_000 }, + ) + .toBeGreaterThan(initialMediaCount); await expect(filterDialog).toBeVisible(); await expect(fileNameInput).toHaveValue("e2e"); await expect(fileNameInput).toBeFocused(); diff --git a/apps/server/src/tests/e2e/search.responsive.spec.ts b/apps/server/src/tests/e2e/search.responsive.spec.ts index dc4375ec..76ffb2cb 100644 --- a/apps/server/src/tests/e2e/search.responsive.spec.ts +++ b/apps/server/src/tests/e2e/search.responsive.spec.ts @@ -28,13 +28,64 @@ test("search keeps controls usable without horizontal overflow", async ({ ); if (usesMobileFilterDialog) { await page.getByRole("button", { name: "Filter results" }).click(); - await expect(page.getByRole("dialog")).toBeVisible(); + const filterDialog = page.getByRole("dialog"); + await expect(filterDialog).toBeVisible(); await expect( - page.getByRole("heading", { name: "検索フィルター", exact: true }), + filterDialog.getByRole("heading", { + name: "検索フィルター", + exact: true, + }), ).toBeVisible(); await expect( - page.getByRole("button", { name: "簡易", exact: true }), + filterDialog.getByRole("button", { name: "簡易", exact: true }), ).toBeVisible(); + const conditionSummary = filterDialog.getByRole("status"); + await expect(conditionSummary).toContainText("現在の条件"); + const resultCount = page.getByText(/^\d+ 件の結果$/); + const initialResultCount = await resultCount.textContent(); + + let fileNameInput = filterDialog.getByPlaceholder("ファイル名を入力..."); + await fileNameInput.fill("e2e"); + await expect(conditionSummary).toContainText("ファイル名: e2e"); + await page.keyboard.press("Escape"); + await expect(filterDialog).toBeHidden(); + await expect(resultCount).toHaveText(initialResultCount ?? ""); + + await page.getByRole("button", { name: "Filter results" }).click(); + await expect(filterDialog).toBeVisible(); + fileNameInput = filterDialog.getByPlaceholder("ファイル名を入力..."); + await expect(fileNameInput).toHaveValue(""); + await expect(filterDialog.getByRole("status")).not.toContainText( + "ファイル名:", + ); + + await fileNameInput.fill("e2e"); + await filterDialog.getByRole("button", { name: "Dismiss" }).click(); + await expect(filterDialog).toBeHidden(); + await expect(resultCount).toHaveText(initialResultCount ?? ""); + + await page.getByRole("button", { name: "Filter results" }).click(); + await expect(filterDialog).toBeVisible(); + fileNameInput = filterDialog.getByPlaceholder("ファイル名を入力..."); + await expect(fileNameInput).toHaveValue(""); + const applyButton = filterDialog.getByRole("button", { + name: "適用", + exact: true, + }); + await filterDialog + .getByRole("button", { name: "ベクトル類似", exact: true }) + .click(); + await expect(applyButton).toBeDisabled(); + await filterDialog + .getByRole("button", { name: "条件をクリア", exact: true }) + .click(); + await expect(applyButton).toBeEnabled(); + await expect(fileNameInput).toHaveValue(""); + await expect(conditionSummary).toContainText("条件は指定されていません。"); + + await fileNameInput.fill("e2e"); + await applyButton.click(); + await expect(filterDialog).toBeHidden(); } else { await expect( page.getByRole("heading", { name: "検索フィルター", exact: true }), diff --git a/apps/server/src/tests/e2e/sources-source-media.responsive.spec.ts b/apps/server/src/tests/e2e/sources-source-media.responsive.spec.ts new file mode 100644 index 00000000..13587b75 --- /dev/null +++ b/apps/server/src/tests/e2e/sources-source-media.responsive.spec.ts @@ -0,0 +1,177 @@ +import type { Locator, Page } from "@playwright/test"; +import { E2E_SOURCE_ID, E2E_SOURCE_NAME, sourcePath } from "./support/fixture"; +import { expect, test, waitForAppHydration } from "./support/test"; + +function usesMobileControls(projectName: string): boolean { + return ["responsive-320", "responsive-375"].includes(projectName); +} + +async function expectNoHorizontalOverflow(page: Page): Promise { + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(1); +} + +async function expectTouchTarget(locator: Locator): Promise { + const box = await locator.boundingBox(); + expect(box).not.toBeNull(); + expect(box?.width ?? 0).toBeGreaterThanOrEqual(44); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); +} + +async function expectInsideViewport( + page: Page, + locator: Locator, +): Promise { + const box = await locator.boundingBox(); + const viewport = page.viewportSize(); + expect(box).not.toBeNull(); + expect(viewport).not.toBeNull(); + if (!(box && viewport)) { + return; + } + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width); + expect(box.y + box.height).toBeLessThanOrEqual(viewport.height); +} + +test("sources actions stay operable without horizontal overflow", async ({ + page, +}) => { + await page.goto("/sources"); + await expect( + page.getByRole("heading", { name: "Media Sources", exact: true }), + ).toBeVisible(); + const sourceCard = page + .getByTestId("source-card") + .filter({ hasText: E2E_SOURCE_NAME }); + await expect(sourceCard).toBeVisible(); + await waitForAppHydration(page); + await expectNoHorizontalOverflow(page); + + const addSourceButton = page.getByRole("button", { + name: "Add Source", + exact: true, + }); + const syncAllButton = page.getByRole("button", { + name: "Sync All", + exact: true, + }); + await expectTouchTarget(addSourceButton); + await expectTouchTarget(syncAllButton); + await expectTouchTarget(sourceCard.getByTestId("sync-source-btn")); + await expectTouchTarget(sourceCard.getByTestId("edit-source-btn")); + await expectTouchTarget(sourceCard.getByTestId("delete-source-btn")); + + await addSourceButton.click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + + await sourceCard.getByTestId("edit-source-btn").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + + await sourceCard.getByTestId("delete-source-btn").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expectNoHorizontalOverflow(page); +}); + +test("source media exposes mobile filters and touch selection", async ({ + page, +}, testInfo) => { + await page.goto(sourcePath()); + await expect( + page.getByRole("heading", { + name: `Media in Source: ${E2E_SOURCE_ID}`, + exact: true, + }), + ).toBeVisible(); + await expect(page.locator("[data-media-id]").first()).toBeVisible(); + await waitForAppHydration(page); + await expect(page.getByTestId("media-load-more-sentinel")).toBeVisible(); + await expectNoHorizontalOverflow(page); + + const addMediaButton = page.getByRole("button", { name: "Add media" }); + await expectTouchTarget(addMediaButton); + await expectInsideViewport(page, addMediaButton); + + const selectModeButton = page.getByRole("button", { + name: "複数選択", + exact: true, + }); + await expectTouchTarget(selectModeButton); + await selectModeButton.click(); + const bulkToolbar = page.getByTestId("bulk-actions-bar"); + await expect(bulkToolbar).toContainText("0 件選択中"); + await expect( + bulkToolbar.getByRole("button", { name: "一括操作を実行", exact: true }), + ).toBeDisabled(); + await page.getByRole("button", { name: "解除", exact: true }).click(); + await expect(bulkToolbar).toBeHidden(); + await expect(addMediaButton).toBeVisible(); + + await selectModeButton.click(); + const selectableMedia = page.locator("button[data-media-id]").first(); + await expect(selectableMedia).toBeVisible(); + await selectableMedia.click(); + await expect(bulkToolbar).toContainText("1 件選択中"); + await expect(addMediaButton).toBeHidden(); + await expectInsideViewport(page, bulkToolbar); + await page.getByRole("button", { name: "解除", exact: true }).click(); + await expect(addMediaButton).toBeVisible(); + + if (usesMobileControls(testInfo.project.name)) { + const sourceActionsButton = page.getByRole("button", { + name: "ソース操作を開く", + }); + const filterResultsButton = page.getByRole("button", { + name: "Filter results", + }); + await expectTouchTarget(sourceActionsButton); + await expectTouchTarget(filterResultsButton); + await sourceActionsButton.click(); + const sourceActionsDialog = page.getByRole("dialog"); + await expect( + sourceActionsDialog.getByRole("button", { + name: "NDJSON メタデータを書き出す", + }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(sourceActionsDialog).toBeHidden(); + await sourceActionsButton.click(); + const restoreFileChooser = page.waitForEvent("filechooser"); + await sourceActionsDialog + .getByRole("button", { name: "ダンプを復元する", exact: true }) + .click(); + await restoreFileChooser; + await expect(sourceActionsDialog).toBeHidden(); + + await filterResultsButton.click(); + const filterDialog = page.getByRole("dialog"); + await expect(filterDialog).toBeVisible(); + await expect(filterDialog.getByRole("status")).toContainText("現在の条件"); + const fileNameInput = filterDialog.getByPlaceholder("ファイル名を入力..."); + await fileNameInput.fill("e2e"); + await expect(filterDialog.getByRole("status")).toContainText( + "ファイル名: e2e", + ); + await filterDialog + .getByRole("button", { name: "適用", exact: true }) + .click(); + await expect(filterDialog).toBeHidden(); + } else { + await expect( + page.getByRole("heading", { name: "検索フィルター", exact: true }), + ).toBeVisible(); + } + + await expectNoHorizontalOverflow(page); +}); diff --git a/apps/tauri/src/routes/search.tsx b/apps/tauri/src/routes/search.tsx index 7d9add8d..9d1ce748 100644 --- a/apps/tauri/src/routes/search.tsx +++ b/apps/tauri/src/routes/search.tsx @@ -94,7 +94,7 @@ function SearchRoute() { )} renderNavActions={({ openMobileFilters }) => ( - - + - + - -