import { expect, test, type Page } from "@playwright/test"; import { installRoutes, installTagRoutes, showAll } from "./helpers"; import { CHANNEL_SLUG, TAG_COLLAB, TAG_TOPIC, VIDEO_CHAT_LARGE, VIDEO_CHAT_SMALL, VIDEO_TRANSCRIPT_ONLY, } from "./fixtures/data"; // A composite query tree, seeded through `?qt=` exactly as posts-search.spec // does. Only the two leaf kinds this file needs. function qt( children: { q: string; s: "metadata" | "posts" }[], op: "AND" | "OR" = "OR", ): string { return encodeURIComponent( JSON.stringify({ k: "g", o: op, c: children.map((c) => ({ k: "l", q: c.q, s: c.s })), }), ); } // Curated per-video tags (common/lib/curatedTags.ts) in the export viewer: the // filter panel's chip row, the `tg` URL param, and the tags a result card // carries. Modelled on channel-group-chips.spec.ts, but driven from the shared // fixture, because the point of this feature is that the row is built from what // THIS SITE publishes — so the fixture's /tags.json and the fixture's // `curatedTags` have to be the same two facts the app sees. // // The fixture tags two of its three videos (see fixtures/data.ts): // vid-chat-small → eva-collab // vid-chat-large → eva-collab, eva-topic // vid-transcript-only → nothing at all (the "omitted when empty" control) const slugOf = (id: string) => `${CHANNEL_SLUG}/${id}`; async function waitForHydration(page: Page) { await page.getByTestId("query-builder").waitFor(); } function chipRow(page: Page) { return page.getByTestId("tag-chip-row"); } function chip(page: Page, id: string) { return page.locator(`[data-testid="tag-chip"][data-tag-id="${id}"]`); } function tagGroup(page: Page, groupId: string) { return page.locator( `[data-testid="tag-chip-group"][data-group-id="${groupId}"]`, ); } // Fold/unfold by clicking the group label in its summary — the same gesture // the channel group chips take (channel-group-chips.spec.ts). async function collapseTagGroup(page: Page, groupId: string, label: string) { await tagGroup(page, groupId) .locator("summary") .getByText(label, { exact: true }) .click(); } function card(page: Page, id: string) { return page.locator(`[data-result-slug="${slugOf(id)}"]`); } // Tag chips are an ordinary filter: the panel edits a draft, Search commits it. async function apply(page: Page) { await page.getByTestId("search-submit").click(); } test.describe("curated tag chips", () => { test.describe("on a site that publishes tags", () => { test.beforeEach(async ({ page }) => { await installRoutes(page); // AFTER installRoutes, which leaves /tags.json a 404 by default. await installTagRoutes(page); await page.goto("/"); await waitForHydration(page); }); test("the row is grouped and every chip carries this site's count", async ({ page, }) => { await expect(chipRow(page)).toBeVisible(); // The group label comes from the document, not from anything in the // code: "Eva: Collab · Discussed". const group = page.locator('[data-testid="tag-chip-group"][data-group-id="eva"]'); await expect(group).toContainText("Eva"); // Counts are the site's own, and they agree with the summaries: two // videos carry eva-collab, one carries eva-topic. await expect(chip(page, TAG_COLLAB)).toContainText("Collab"); await expect(chip(page, TAG_COLLAB)).toContainText("2"); await expect(chip(page, TAG_TOPIC)).toContainText("Discussed"); await expect(chip(page, TAG_TOPIC)).toContainText("1"); // Nothing is selected until someone selects it. await expect(chip(page, TAG_COLLAB)).toHaveAttribute("aria-pressed", "false"); }); test("a tag group collapses and its selection count survives on the summary", async ({ page, }) => { // A site can publish several groups of several chips each, and on a // phone that row is the whole viewport before a single result. Each // group is a
, open by default — folding one away must not // hide that it is still narrowing the list, so the count moves to the // summary. const group = tagGroup(page, "eva"); await expect(group).toHaveAttribute("open", ""); await expect(chip(page, TAG_COLLAB)).toBeVisible(); // Nothing selected: no count on the summary, not a zero. await expect(group.getByTestId("tag-group-selected")).toHaveCount(0); await chip(page, TAG_COLLAB).click(); await expect(group.getByTestId("tag-group-selected")).toHaveText( "1 selected", ); await collapseTagGroup(page, "eva", "Eva"); await expect(group).not.toHaveAttribute("open"); await expect(chip(page, TAG_COLLAB)).toBeHidden(); // Still visible, still saying what it is doing to the results. await expect(group.getByTestId("tag-group-selected")).toHaveText( "1 selected", ); // ...and the selection survives the fold, chip state included. await collapseTagGroup(page, "eva", "Eva"); await expect(group).toHaveAttribute("open", ""); await expect(chip(page, TAG_COLLAB)).toHaveAttribute( "aria-pressed", "true", ); await apply(page); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0); }); test("a tagged card shows its tags; an untagged one shows none", async ({ page, }) => { await showAll(page); await expect(card(page, VIDEO_CHAT_LARGE).getByTestId("card-tag")).toHaveCount(2); await expect( card(page, VIDEO_CHAT_LARGE).locator('[data-testid="card-tag"][data-tag-id="' + TAG_TOPIC + '"]'), ).toContainText("Discussed"); // The label comes from /tags.json, so the card and the chip agree. await expect(card(page, VIDEO_CHAT_SMALL).getByTestId("card-tag")).toHaveCount(1); await expect( card(page, VIDEO_TRANSCRIPT_ONLY).getByTestId("card-tag"), ).toHaveCount(0); }); test("selecting a chip narrows the list and puts tg on the URL", async ({ page, }) => { // All three videos before any tag filter. await showAll(page); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toBeVisible(); await chip(page, TAG_TOPIC).click(); await expect(chip(page, TAG_TOPIC)).toHaveAttribute("aria-pressed", "true"); await apply(page); // Only the one video carrying eva-topic survives. await expect(card(page, VIDEO_CHAT_LARGE)).toBeVisible(); await expect(card(page, VIDEO_CHAT_SMALL)).toHaveCount(0); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0); // …and the selection is on the URL, so the view is linkable. await expect .poll(() => new URL(page.url()).searchParams.getAll("tg")) .toEqual([TAG_TOPIC]); }); test("two chips are ORed, not ANDed", async ({ page }) => { // No fixture video carries eva-topic alone, and only one carries both — // an AND would leave a single card, which is the bug this pins. await chip(page, TAG_COLLAB).click(); await chip(page, TAG_TOPIC).click(); await apply(page); await expect(card(page, VIDEO_CHAT_SMALL)).toBeVisible(); await expect(card(page, VIDEO_CHAT_LARGE)).toBeVisible(); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0); await expect .poll(() => new URL(page.url()).searchParams.getAll("tg").sort()) .toEqual([TAG_COLLAB, TAG_TOPIC].sort()); }); test("a reload restores the selection from the URL", async ({ page }) => { await chip(page, TAG_COLLAB).click(); await apply(page); await expect .poll(() => new URL(page.url()).searchParams.getAll("tg")) .toEqual([TAG_COLLAB]); await page.reload(); await waitForHydration(page); await expect(chip(page, TAG_COLLAB)).toHaveAttribute("aria-pressed", "true"); await expect(card(page, VIDEO_CHAT_SMALL)).toBeVisible(); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0); }); test("clicking a selected chip clears it and drops the param", async ({ page, }) => { await chip(page, TAG_COLLAB).click(); await apply(page); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0); // The selected chip IS the clear affordance — there is no second control. await chip(page, TAG_COLLAB).click(); await expect(chip(page, TAG_COLLAB)).toHaveAttribute("aria-pressed", "false"); await apply(page); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toBeVisible(); await expect .poll(() => new URL(page.url()).searchParams.getAll("tg")) .toEqual([]); }); test("a tag filter takes an explicit posts-scope leaf out of the search", async ({ page, }) => { // A post carries no curated tags. The DEFAULT scope build already knew // that, but a scopes:['posts'] leaf is fed its own slug set, so the posts // corpus was still being searched under a tag filter: the rows were then // dropped by the display fold while `totalHits` — which sums the // pipeline's hits BEFORE that fold — still counted them. The header is // the assertion because the header is where it showed: N videos and a // hit count that no card on the page accounts for. // // "chat" matches all three video titles; "kappa" matches two posts. const tree = qt([ { q: "chat", s: "metadata" }, { q: "kappa", s: "posts" }, ]); await page.goto(`/?qt=${tree}&tg=${TAG_COLLAB}`); await waitForHydration(page); // Only the two tagged videos, and only their two hits. await expect(page.getByTestId("results-summary")).toHaveText( "Matching videos (2 videos, 2 hits)", ); await expect(card(page, VIDEO_CHAT_SMALL)).toBeVisible(); await expect(card(page, VIDEO_CHAT_LARGE)).toBeVisible(); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0); }); test("the same query without a tag filter does search the posts leaf", async ({ page, }) => { // The control for the case above: the posts leaf is only silenced BY the // tag filter, never by this change. const tree = qt([ { q: "chat", s: "metadata" }, { q: "kappa", s: "posts" }, ]); await page.goto(`/?qt=${tree}`); await waitForHydration(page); await expect(page.getByTestId("results-summary")).toHaveText( "Matching videos (5 videos, 5 hits)", ); }); test("a tg naming an id this site does not publish still gets a chip", async ({ page, }) => { // A valid id from another site in the family, or from this one before a // rebuild. Without a chip the Filters badge would say a filter is on // while nothing in the row was lit, and the reader would have no way to // clear the thing that emptied their results. await page.goto("/?tg=not-on-this-site"); await waitForHydration(page); const orphan = chip(page, "not-on-this-site"); await expect(orphan).toBeVisible(); await expect(orphan).toHaveAttribute("data-unpublished", "true"); await expect(orphan).toContainText("not on this site"); await expect(orphan).toHaveAttribute("aria-pressed", "true"); // It really is filtering: nothing carries it. await expect(page.getByTestId("results-summary")).toHaveText("All videos (0)"); // One click clears it, and the param goes with it. await orphan.click(); await apply(page); await expect(page.getByTestId("tag-chip")).toHaveCount(2); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toBeVisible(); await expect .poll(() => new URL(page.url()).searchParams.getAll("tg")) .toEqual([]); }); test("a malformed tg token is dropped at the URL, not carried", async ({ page, }) => { // The URL is an entry point like the MCP's parseSearchArgs and agrees // with it about what an id is. A token that can never match must not // become a filter that silently returns nothing. await page.goto("/?tg=Not%20An%20Id"); await waitForHydration(page); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toBeVisible(); await expect(page.locator("[data-unpublished]")).toHaveCount(0); }); test("a tg link arrives already filtered", async ({ page }) => { await page.goto(`/?tg=${TAG_TOPIC}`); await waitForHydration(page); await expect(chip(page, TAG_TOPIC)).toHaveAttribute("aria-pressed", "true"); await expect(card(page, VIDEO_CHAT_LARGE)).toBeVisible(); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0); }); }); // The Filters chip only exists below xl — from xl the panel is inline under // the bar and a chip would be a second copy of a control already on screen. test.describe("on a phone", () => { test.use({ viewport: { width: 390, height: 844 } }); test("the Filters badge counts a tag selection", async ({ page }) => { await installRoutes(page); await installTagRoutes(page); await page.goto("/"); await waitForHydration(page); const trigger = page.getByTestId("filters-trigger"); // Nothing narrowed yet: the badge is absent, not zero. await expect(trigger).toHaveText("Filters"); await trigger.click(); await chip(page, TAG_COLLAB).click(); await page.getByRole("button", { name: "Apply filters" }).click(); // Without this the reader could apply a filter from the sheet and see no // sign of it on the chip that opens the sheet. await expect(trigger).toContainText("1"); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0); }); test("a group folds away inside the sheet", async ({ page }) => { // 390px is where a multi-group row costs the most, so the fold has to // work in the sheet and not only in the inline panel. await installRoutes(page); await installTagRoutes(page); await page.goto("/"); await waitForHydration(page); await page.getByTestId("filters-trigger").click(); const group = tagGroup(page, "eva"); await expect(chip(page, TAG_COLLAB)).toBeVisible(); await collapseTagGroup(page, "eva", "Eva"); await expect(group).not.toHaveAttribute("open"); await expect(chip(page, TAG_COLLAB)).toBeHidden(); // The summary is still one line inside the sheet, not a torn row. const box = await group.locator("summary").boundingBox(); expect(box).not.toBeNull(); expect(box!.width).toBeLessThanOrEqual(390); }); }); test.describe("on a site that publishes none", () => { test("there is no chip row at all", async ({ page }) => { // installRoutes alone: /tags.json 404s, which is the real default for a // site with nothing shippable AND for every site built before corpus // spec 4. No chips, and no empty "Tags" header either — a row offering // nothing is worse than no row. await installRoutes(page); await page.goto("/"); await waitForHydration(page); await showAll(page); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toBeVisible(); await expect(chipRow(page)).toHaveCount(0); await expect(page.getByTestId("tag-chip")).toHaveCount(0); // The records still carry their tags — a site choosing not to publish a // vocabulary does not erase a fact about a video — so the card still // shows them, by their raw ids, because there are no labels to use. await expect(card(page, VIDEO_CHAT_LARGE).getByTestId("card-tag")).toHaveCount(2); await expect(card(page, VIDEO_CHAT_LARGE).getByTestId("card-tag").first()).toHaveText( TAG_COLLAB, ); }); }); });