Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/components/SearchBar.tsx | 3+++
Aexport/e2e/tag-chips.spec.ts | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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, + ); + }); + }); +});