`, not a real heading element) resolves
+// it once, here, for the whole cluster - consolidated out of near-duplicate helpers previously
+// declared separately in ArtistSupportLink/VotePickers/ReportCard/AddCardToFavorites' own spec
+// files (each still assuming the classic grid's plain "click the grid image" flow).
+export const openDetailedView = async (
+ page: Page,
+ query: string,
+ cardIdentifier: string
+) => {
+ await page.getByTestId("display-search-mode-browse").click();
+ await page.getByTestId("display-browse-search-input").fill(query);
+ const tile = page.getByTestId(`catalog-browse-tile-${cardIdentifier}`);
+ await expect(tile).toBeVisible();
+ await tile.locator("img").click();
+ await expect(
+ page.getByTestId("detailed-view").getByText("Card Details")
+ ).toBeVisible();
+};
+
+export const closeDetailedView = async (page: Page) => {
+ await page.getByTestId("detailed-view").getByLabel("Close").click();
+ await expect(page.getByTestId("detailed-view")).not.toBeVisible();
+};
+
+// Proposal H parity port (2026-07-23, issue #272 wave 1): the classic grid's `front-slot`/
+// `back-slot` testids (and the "N / M" selected/total-image fraction `CardSlot.tsx` rendered
+// inline on each one) have no equivalent on the unified page - `PagePreview.tsx` renders each
+// sheet slot as a plain, un-testid'd `
![{cardName}]()
`, with no inline candidate-count
+// readout at all (that signal, where it exists, lives one layer deeper - the slot's own rail -
+// and isn't reconstructable slot-by-slot without opening each one individually, which these
+// import-cluster tests never needed to do before). These two helpers port the part that IS
+// cleanly available: which named card is showing, for a given (1-based, row-major) sheet slot and
+// face. `selectedImage`/`totalImages` numeric assertions are dropped, not silently - every
+// fixture in this suite gives each result-set index its own distinct `name` (Card 1/2/3/...), so
+// asserting the right NAME landed in the right slot already fully captures what those counts were
+// standing in for (which specific candidate got auto-selected); see this port's own report for
+// the one place that stops being true.
+export const ensureDisplayFace = async (page: Page, face: "front" | "back") => {
+ const wantLabel = face === "front" ? "Showing: Fronts" : "Showing: Backs";
+ const otherLabel = face === "front" ? "Showing: Backs" : "Showing: Fronts";
+ if (await page.getByText(otherLabel).isVisible()) {
+ await page.getByText(otherLabel).click();
+ }
+ await expect(page.getByText(wantLabel)).toBeVisible();
+};
+
+export const expectDisplaySheetSlotState = async (
+ page: Page,
+ slot: number,
+ face: "front" | "back",
+ cardName: string
+) => {
+ await ensureDisplayFace(page, face);
+ const sheetSlot = page.getByTestId("page-preview-slot").nth(slot - 1);
+ await expect(sheetSlot.locator("img")).toHaveAttribute("alt", cardName);
+};
+
+interface DisplaySheetSlotAssertion {
+ slot: number;
+ name: string;
+}
+
+export const expectDisplaySheetSlotStates = async (
+ page: Page,
+ fronts: Array
,
+ backs: Array
+) => {
+ for (const { slot, name } of fronts) {
+ await expectDisplaySheetSlotState(page, slot, "front", name);
+ }
+ for (const { slot, name } of backs) {
+ await expectDisplaySheetSlotState(page, slot, "back", name);
+ }
+ // leave face state as fronts, matching expectCardGridSlotStates' own toggle-back convention
+ await ensureDisplayFace(page, "front");
+};
+
+// Every sheet position renders a `page-preview-slot` div regardless of whether it's a real
+// project member (PagePreview.tsx's own comment: "every cell gets a slot, only some get an
+//
"), so an unfilled/no-query "gap" slot (a real project member with nothing resolved for it
+// yet) is indistinguishable at a glance from a genuinely-past-the-end-of-the-deck empty grid cell
+// - neither renders an `
`. Clicking through to the rail disambiguates: only a real project
+// member selects it (`display-rail-header` shows "Slot N"); a past-the-end grid position ignores
+// the click (DisplayPage.tsx's own onSlotClick guard) and the rail stays idle.
+export const expectDisplaySheetSlotToExist = async (
+ page: Page,
+ slot: number
+) => {
+ await page
+ .getByTestId("page-preview-slot")
+ .nth(slot - 1)
+ .click();
+ await expect(page.getByTestId("display-rail-header")).toContainText(
+ `Slot ${slot}`
+ );
+};
+
+export const expectDisplaySheetSlotToNotExist = async (
+ page: Page,
+ slot: number
+) => {
+ await expect(
+ page
+ .getByTestId("page-preview-slot")
+ .nth(slot - 1)
+ .locator("img")
+ ).toHaveCount(0);
+};
+
+// The populated-project toolbar's compact search-bar row (ImportText's "inline" variant, no
+// Submit button of its own - a plain browser form submit fires on Enter, see ImportText.tsx's own
+// comment) - the unified page's equivalent of importText's "add more cards to a non-empty
+// project" step above.
+export const importTextInline = async (page: Page, text: string) => {
+ const field = page.getByRole("textbox", { name: "import-text-inline" });
+ await field.fill(text);
+ await field.press("Enter");
+ await expect(
+ page.locator('span:has-text("Loading your cards...")')
+ ).not.toBeVisible();
+};
+
export const importText = async (page: Page, text: string) => {
await openImportTextModal(page);
await page.getByRole("textbox", { name: "import-text" }).fill(text);
@@ -262,6 +398,107 @@ export const importXML = async (
).not.toBeAttached();
};
+// Import cluster parity port (2026-07-23, issue #272 wave 1). DisplayPage's EMPTY-project landing
+// (`display-empty-state`) mounts the bare `ImportCSV`/`ImportXML` components verbatim (DisplayPage
+// module comment: "the same plain ImportText/ImportURL/ImportXML/ImportCSV components
+// ProjectEditor.tsx's own AddCardsPanel mounts") directly inline inside a collapsed "Import a File
+// or URL" Accordion - not behind the classic "Add Cards" dropdown-triggered modal
+// openImportCSVModal/openImportXMLModal open (that dropdown only mounts once the project already
+// has a member - see openDisplayToolbarAddCardsDropdown below for that non-empty-project path).
+// The underlying `TextFileDropzone` `label`s ("import-csv"/"import-xml") are identical either way
+// - only how you REACH the form differs.
+export const importCSVOnEmptyLanding = async (
+ page: Page,
+ fileContents: string
+) => {
+ await page.getByRole("button", { name: "CSV", exact: false }).click();
+ const fileInput = page
+ .getByLabel("import-csv")
+ .locator('input[type="file"]')
+ .first();
+ const buffer = Buffer.from(fileContents);
+ await fileInput.setInputFiles({
+ name: "test.csv",
+ mimeType: "text/csv",
+ buffer: buffer,
+ });
+ await expect(
+ page.locator('span:has-text("Loading your cards...")')
+ ).not.toBeVisible();
+};
+
+export const importXMLOnEmptyLanding = async (
+ page: Page,
+ fileContents: string,
+ useXMLCardback: boolean = true
+) => {
+ await page.getByRole("button", { name: "XML", exact: false }).click();
+ if (!useXMLCardback) {
+ await page.getByText("Use XML Cardback").click();
+ }
+ const fileInput = page
+ .getByLabel("import-xml")
+ .locator('input[type="file"]')
+ .first();
+ const buffer = Buffer.from(fileContents);
+ await fileInput.setInputFiles({
+ name: "test.xml",
+ mimeType: "text/xml;charset=utf-8",
+ buffer: buffer,
+ });
+ await expect(
+ page.locator('span:has-text("Loading your cards...")')
+ ).not.toBeAttached();
+};
+
+// The non-empty-project counterpart of the two helpers above: once a project has at least one
+// member, DeckInputLanding (and its inline CSV/XML accordion) is no longer rendered at all -
+// DisplayPage's populated toolbar mounts `` instead, the SAME "Add Cards" dropdown
+// (Text/XML/CSV/URL, unforked) the classic grid's own right panel used (DisplayPage module
+// comment: "the existing Import.tsx dropdown ... mounted verbatim"). Only the surrounding
+// container differs (`display-toolbar` here vs. `right-panel` there) - openAddCardsDropdown/
+// getAddCardsMenu above stay untouched (their own callers, e.g. Toasts.spec.ts's still-skipped
+// assertions, target the classic container specifically) rather than generalized to cover both.
+export const openDisplayToolbarAddCardsDropdown = async (page: Page) => {
+ const textButton = page.getByRole("button", { name: " Text" });
+ if (await textButton.isVisible()) {
+ return;
+ }
+ await expect(async () => {
+ await page
+ .getByTestId("display-toolbar")
+ .getByText("Add Cards", { exact: false })
+ .click();
+ await expect(textButton).toBeVisible();
+ }).toPass({ timeout: 10_000 });
+};
+
+export const importXMLFromToolbar = async (
+ page: Page,
+ fileContents: string,
+ useXMLCardback: boolean = true
+) => {
+ await openDisplayToolbarAddCardsDropdown(page);
+ await page.getByRole("button", { name: "XML", exact: false }).click();
+ const modal = page.getByTestId("import-xml");
+
+ if (!useXMLCardback) {
+ await modal.getByText("Use XML Cardback").click();
+ }
+
+ const fileInput = modal.locator('input[type="file"]').first();
+ const buffer = Buffer.from(fileContents);
+ await fileInput.setInputFiles({
+ name: "test.xml",
+ mimeType: "text/xml;charset=utf-8",
+ buffer: buffer,
+ });
+
+ await expect(
+ page.locator('span:has-text("Loading your cards...")')
+ ).not.toBeAttached();
+};
+
export const downloadXML = async (page: Page): Promise<[string, string]> => {
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: " Download" }).click();
@@ -286,6 +523,39 @@ export const downloadDecklist = async (
return [content, download.suggestedFilename()];
};
+// Export content-correctness parity port (2026-07-23, issue #272 wave 1). The classic grid's
+// "Download" dropdown (downloadXML/downloadDecklist above) has no equivalent on the unified page -
+// DisplayExportMenu.tsx composes the exact same unchanged Dropdown.Items behind a differently-
+// named trigger instead (`display-export-menu-toggle`/`display-export-menu`, DisplayPage.tsx's own
+// toolbar - see DisplayPage.spec.ts's own "Export ▾ toolbar menu" precedent test). The download
+// functions/`export-xml-button`/`export-decklist-button` items themselves are unchanged either way
+// - only how the menu is opened differs.
+export const downloadXMLFromDisplayToolbar = async (
+ page: Page
+): Promise<[string, string]> => {
+ const downloadPromise = page.waitForEvent("download");
+ await page.getByTestId("display-export-menu-toggle").click();
+ await page.getByTestId("export-xml-button").click();
+ const download = await downloadPromise;
+ const path = await download.path();
+ if (!path) throw new Error("Download path is null");
+ const content = await readFile(path, "utf-8");
+ return [content, download.suggestedFilename()];
+};
+
+export const downloadDecklistFromDisplayToolbar = async (
+ page: Page
+): Promise<[string, string]> => {
+ const downloadPromise = page.waitForEvent("download");
+ await page.getByTestId("display-export-menu-toggle").click();
+ await page.getByTestId("export-decklist-button").click();
+ const download = await downloadPromise;
+ const path = await download.path();
+ if (!path) throw new Error("Download path is null");
+ const content = await readFile(path, "utf-8");
+ return [content, download.suggestedFilename()];
+};
+
export function normaliseString(text: string): string {
return text.replaceAll(" ", "").replaceAll("\n", "").replaceAll("\r", "");
}
diff --git a/frontend/tests/visual/CardDetailedViewModal.visual.spec.ts b/frontend/tests/visual/CardDetailedViewModal.visual.spec.ts
index 6bf7cd4d8..a027d3416 100644
--- a/frontend/tests/visual/CardDetailedViewModal.visual.spec.ts
+++ b/frontend/tests/visual/CardDetailedViewModal.visual.spec.ts
@@ -13,27 +13,17 @@ import {
import { test } from "../../playwright.setup";
import {
- expectCardGridSlotState,
importTextOnEditorLanding,
loadPageWithDefaultBackend,
+ openDetailedView,
} from "../test-utils";
-// Proposal H switchover (2026-07-23, issues #231/#272) - /editor now serves the unified
-// sheet+rail page (`DisplayPage.tsx`); the classic grid `ProjectEditor` this file's own setup
-// depends on (via testids/interaction patterns like `front-slot`/`back-slot`/`common-cardback`/
-// the "Add Cards" right-panel dropdown/the classic "Print!" tab, or a component with no rendered
-// equivalent on the new page yet - see issue #272's own tracked parity gaps) is fully unrouted,
-// not just delisted from the nav. Skipped here rather than deleted (component files themselves
-// are untouched, per this swap's own scope) or silently left red - porting this coverage to
-// DisplayPage's DOM is real, non-mechanical work tracked against #272, not done as part of the
-// route swap itself (the owner's directive was to proceed with the swap regardless of the
-// checklist's open items).
-test.beforeEach(async ({}, testInfo) => {
- testInfo.skip(
- true,
- "Proposal H switchover (2026-07-23): tests classic /editor-only UI, now unrouted - see issue #272"
- );
-});
+// Proposal H parity port (2026-07-23, issue #272 wave 1): ported onto the unified /editor page -
+// CardDetailedViewModal (the shared, unforked component this aria snapshot targets) is reached
+// via Browse mode - see openDetailedView's own module comment (test-utils.ts) for why that's the
+// one surface on this page that still opens it. The snapshot itself DID need re-baselining - see
+// the fix-round comment further down for why (real, unrelated content drift while this file sat
+// skipped, not a route-swap DOM difference).
test.describe("CardDetailedViewModal visual tests", () => {
test("card detailed view modal structure", async ({ page, network }) => {
@@ -51,13 +41,36 @@ test.describe("CardDetailedViewModal visual tests", () => {
page,
`my search query${SelectedImageSeparator}${cardDocument1.identifier}`
);
- await expectCardGridSlotState(page, 1, "front", cardDocument1.name, 1, 1);
+ await openDetailedView(page, "my search query", cardDocument1.identifier);
- await page.getByAltText(cardDocument1.name).click();
- await expect(page.getByText("Card Details")).toBeVisible();
await expect(page.getByText("English")).toBeVisible();
await expect(page.getByText("Not yet resolved")).toBeVisible();
+ // Fix round (2026-07-23, this port): toMatchAriaSnapshot asserts the container's full
+ // accessibility tree, not a partial/contains match - AttributeVotingPanel (VotePickers.spec.ts's
+ // own precedent notes this is gated behind a chain of fetches slower than a single round trip)
+ // mounts asynchronously once printing consensus resolves unresolved, same as "Not yet
+ // resolved" above; waiting for its own heading here avoids a race between that panel finishing
+ // its mount and the snapshot assertion running.
+ await expect(
+ page.getByRole("heading", { name: "Who's the artist?" })
+ ).toBeVisible({ timeout: 10000 });
+ // Fix round (2026-07-23, this port) - real baseline drift, unrelated to the route swap
+ // itself: this modal has grown three real features since this snapshot was last verified
+ // green (Add to Favorites/AddCardToFavorites.spec.ts, Report this card/ReportCard.spec.ts,
+ // What's That Card?/PrintingTagsBlock+AttributeVotingPanel - VotePickers.spec.ts), none of
+ // which this file's own snapshot had ever captured. Regenerated from the real, current, fully-
+ // settled DOM (`ariaSnapshot()` printed directly, once "Who's the artist?" above confirmed
+ // AttributeVotingPanel had finished mounting) rather than hand-edited. The full-res left-column
+ // image's own loading spinner (`status: Loading...`, `MemoizedCardImage`'s `showSpinner`) is
+ // the one node deliberately left out below - genuinely present in this sandbox (no real network
+ // egress to the CDN host these mock fixtures point `smallThumbnailUrl`-less cards at, so the
+ // image request never resolves) but not a meaningful assertion for this test, and liable to
+ // flip absent wherever the image genuinely does load in time (e.g. a real CI runner with
+ // internet egress) - `toMatchAriaSnapshot` tolerates a top-level node being skipped like this
+ // (confirmed empirically: a snapshot omitting both it and the `img` line straight after it
+ // still matched), unlike genuinely reordering/omitting something nested inside an otherwise-
+ // asserted subtree (e.g. the table's own rows), which it does not.
await expect(page.getByTestId("detailed-view")).toMatchAriaSnapshot(`
- text: Card Details
- button "Close"
@@ -84,21 +97,49 @@ test.describe("CardDetailedViewModal visual tests", () => {
- row "Tags Untagged":
- rowheader "Tags"
- cell "Untagged"
- - row /Resolution \\d+ DPI/:
+ - row "Resolution 1200 DPI":
- rowheader "Resolution"
- - cell /\\d+ DPI/
- - row /Date Created 1st January, \\d+/:
+ - cell "1200 DPI"
+ - row "Date Created 1st January, 2000":
- rowheader "Date Created"
- - cell /1st January, \\d+/
- - row /Date Modified 1st January, \\d+/:
+ - cell "1st January, 2000"
+ - row "Date Modified 1st January, 2000":
- rowheader "Date Modified"
- - cell /1st January, \\d+/
- - row /File Size \\d+ MB/:
+ - cell "1st January, 2000"
+ - row "File Size 10 MB":
- rowheader "File Size"
- - cell /\\d+ MB/
+ - cell "10 MB"
+ - row "Canonical Card Unknown":
+ - rowheader "Canonical Card"
+ - cell "Unknown"
+ - row "Canonical Aritst Unknown":
+ - rowheader "Canonical Aritst"
+ - cell "Unknown"
- button " Download Image"
+ - button " Add to Favorites"
- spinbutton: "1"
- button " Add to Project"
+ - button " Report this card"
+ - separator
+ - heading "What's That Card?" [level=5]
+ - paragraph: Help us figure out which real-world printing this card is!
+ - text: Not yet resolved
+ - textbox "Search for a different card..."
+ - button "None of these match No match":
+ - img "None of these match"
+ - text: No match
+ - button "abc 1 ABC 1 Some Artist":
+ - img "abc 1"
+ - text: ABC 1 Some Artist
+ - button "xyz 42 XYZ 42 Another Artist":
+ - img "xyz 42"
+ - text: XYZ 42 Another Artist
+ - separator
+ - heading "Who's the artist?" [level=6]
+ - text: Loading current consensus...
+ - textbox "Search for an artist..."
+ - button "Unknown artist"
+ - heading "Do any of these tags apply?" [level=6]
- button "Close"
`);
});
diff --git a/frontend/tests/visual/ImportText.visual.spec.ts b/frontend/tests/visual/ImportText.visual.spec.ts
index 54490b04e..f27c734c3 100644
--- a/frontend/tests/visual/ImportText.visual.spec.ts
+++ b/frontend/tests/visual/ImportText.visual.spec.ts
@@ -1,5 +1,7 @@
import { expect } from "@playwright/test";
+import { SelectedImageSeparator } from "@/common/constants";
+import { cardDocument1 } from "@/common/test-constants";
import {
cardDocumentsThreeResults,
defaultHandlers,
@@ -8,24 +10,20 @@ import {
} from "@/mocks/handlers";
import { test } from "../../playwright.setup";
-import { loadPageWithDefaultBackend, openImportTextModal } from "../test-utils";
+import {
+ importTextOnEditorLanding,
+ loadPageWithDefaultBackend,
+ openDisplayToolbarAddCardsDropdown,
+} from "../test-utils";
-// Proposal H switchover (2026-07-23, issues #231/#272) - /editor now serves the unified
-// sheet+rail page (`DisplayPage.tsx`); the classic grid `ProjectEditor` this file's own setup
-// depends on (via testids/interaction patterns like `front-slot`/`back-slot`/`common-cardback`/
-// the "Add Cards" right-panel dropdown/the classic "Print!" tab, or a component with no rendered
-// equivalent on the new page yet - see issue #272's own tracked parity gaps) is fully unrouted,
-// not just delisted from the nav. Skipped here rather than deleted (component files themselves
-// are untouched, per this swap's own scope) or silently left red - porting this coverage to
-// DisplayPage's DOM is real, non-mechanical work tracked against #272, not done as part of the
-// route swap itself (the owner's directive was to proceed with the swap regardless of the
-// checklist's open items).
-test.beforeEach(async ({}, testInfo) => {
- testInfo.skip(
- true,
- "Proposal H switchover (2026-07-23): tests classic /editor-only UI, now unrouted - see issue #272"
- );
-});
+// Proposal H parity port (2026-07-23, issue #272 wave 1): ported onto the unified /editor page.
+// `ImportTextButton` (the classic dropdown-triggered Modal this test's aria snapshot targets,
+// `data-testid="import-text"`) is unforked and only reachable once a project already has a
+// member - DisplayPage's own empty-project landing mounts the bare `ImportText` form directly
+// instead (no Modal chrome at all - see ImportText.spec.ts's own port). Seeding one card first via
+// importTextOnEditorLanding gets to the populated toolbar, whose "Add Cards" dropdown
+// (openDisplayToolbarAddCardsDropdown, test-utils.ts) opens the exact same, byte-for-byte
+// unmodified modal this snapshot was always asserting against.
test.describe("ImportText visual tests", () => {
test("import text modal structure", async ({ page, network }) => {
@@ -35,10 +33,15 @@ test.describe("ImportText visual tests", () => {
searchResultsThreeResults,
...defaultHandlers
);
- page.addInitScript({ content: "Math.random = () => 1;" });
+ await page.addInitScript({ content: "Math.random = () => 1;" });
await loadPageWithDefaultBackend(page);
+ await importTextOnEditorLanding(
+ page,
+ `my search query${SelectedImageSeparator}${cardDocument1.identifier}`
+ );
- await openImportTextModal(page);
+ await openDisplayToolbarAddCardsDropdown(page);
+ await page.getByRole("button", { name: " Text" }).click();
await expect(page.getByTestId("import-text")).toMatchAriaSnapshot(`
- text: Add Cards — Text