commit 883ca8ed31054cdd3b468b0876a15abd46ef00f9
parent 5f15c6ad6d61a212ac52f6a204ca1d03a2017c65
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 13:23:08 -0400
tags S2.11: e2e for the chip row, and the Filters badge counts tags
export/e2e/tag-chips.spec.ts drives the SHARED fixture rather than a local
one, because the thing under test is that the row is built from what a site
publishes — so the fixture's /tags.json and the `curatedTags` on its
summaries have to be the same two facts the app sees. Seven cases: the
grouped row with this site's counts, the card's tags, narrowing + `tg` on
the URL, OR across two chips, a reload restoring the selection, a selected
chip clearing and dropping the param, and a `?tg=` link arriving filtered.
The eighth is the one that matters most: with /tags.json a 404 there is no
chip row at all, and the records still show the tags they carry, by their
raw ids. A site choosing not to publish a vocabulary does not erase a fact
about a video.
The Filters chip's badge counts a tag selection too — without it a phone
reader could apply a filter from a sheet and see no sign of it on the chip
that opens the sheet.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
Diffstat:
2 files changed, 197 insertions(+), 0 deletions(-)
diff --git a/common/components/SearchBar.tsx b/common/components/SearchBar.tsx
@@ -45,6 +45,7 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
draftStates,
draftDateFrom,
draftDateTo,
+ draftTags,
} = useSearchSession();
// From xl the filters are an inline block under the bar with their own
@@ -63,6 +64,7 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
if (draftNaa || draftNar) n += 1;
if (draftStates.size < MISSING_STATES.length + 1) n += 1;
if (draftDateFrom || draftDateTo) n += 1;
+ if (draftTags.size > 0) n += 1;
return n;
}, [
draftExcludedChannels,
@@ -74,6 +76,7 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
draftStates,
draftDateFrom,
draftDateTo,
+ draftTags,
]);
const submit = (
diff --git a/export/e2e/tag-chips.spec.ts b/export/e2e/tag-chips.spec.ts
@@ -0,0 +1,194 @@
+import { expect, test, type Page } from "@playwright/test";
+import { installRoutes, installTagRoutes } from "./helpers";
+import {
+ CHANNEL_SLUG,
+ TAG_COLLAB,
+ TAG_TOPIC,
+ VIDEO_CHAT_LARGE,
+ VIDEO_CHAT_SMALL,
+ VIDEO_TRANSCRIPT_ONLY,
+} from "./fixtures/data";
+
+// 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 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 tagged card shows its tags; an untagged one shows none", async ({
+ 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 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 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);
+ });
+ });
+
+ 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 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,
+ );
+ });
+ });
+});