import { test, expect, type Page } from "@playwright/test"; import { buildIndex, readJson, resetData, writeSite } from "./helpers"; // Curated tags — the vocabulary, the rules, and the three writers. // // What this spec is really pinning is that ONE write path serves all of them: // the /tags preview, the video panel and the bulk bar each end at // applyTagAssignments, so what lands in transcripts/tags.json is the same shape // whoever pressed the button. Hence the assertions are mostly about that FILE — // its pins, its suppressions and the provenance beside each one — rather than // about the controls that produced it. // // THE FIXTURE IS THREE VIDEOS, AND EACH ONE IS THERE FOR A REASON: // // 20240101_small0000 ordinary, two short cues // 20240102_bigdesc000 a description AND a transcript both over lmdb-js's // ~1 KB compression threshold. A reader that opens the // index without `compression: true` throws on exactly // these values and on no others — which is how a preview // that "worked" against a three-line fixture took down // every video-detail page on the real corpus. // a-rumble-url-slug directory named for the URL slug, record keyed by the // embed id (v2embedid) — Rumble's two ids. Every // assignment must land under the RECORD id. const SLUG = "tagchan"; const SMALL = "20240101_small0000"; const BIG = "20240102_bigdesc000"; const RUMBLE_DIR = "a-rumble-url-slug"; const RUMBLE_ID = "v2embedid"; type TagsFile = { version: number; tags: { id: string; label: string; color?: string; hidden?: boolean; sites?: string[]; rules?: { id: string; kind: string; channelsExclude?: string[] }[]; }[]; assignments: Record< string, { manual?: string[]; suppressed?: string[]; sources?: Record; } >; }; const tagsFile = () => readJson("test-transcripts/tags.json"); async function withIndex(page: Page) { await writeSite("testsite", { channels: [{ slug: SLUG, groupId: "default" }], }); await buildIndex(page); } async function addTag( page: Page, id: string, label: string, rule?: { pattern: string; kind?: string }, ) { await page.goto("/tags"); await page.getByRole("button", { name: "add tag", exact: true }).click(); const card = page.getByTestId("tag-card").last(); await card.getByLabel("tag id").fill(id); await card.getByLabel("tag label").fill(label); if (rule) { await card.getByRole("button", { name: /add rule to/ }).click(); const ruleRow = card.getByTestId("tag-rule").last(); if (rule.kind) await ruleRow.getByLabel("rule kind").selectOption(rule.kind); await ruleRow.getByLabel("rule pattern").fill(rule.pattern); } await page.getByRole("button", { name: "save tags" }).click(); await expect(page.getByTestId("tags-saved")).toBeVisible(); } async function pinFromPanel(page: Page, tag: string) { const panel = page.getByTestId("video-tags-panel"); await panel.getByLabel("add tag").selectOption(tag); await panel.getByRole("button", { name: "pin selected tag" }).click(); } test("a tag is defined, edited and removed, and the file holds what the form said", async ({ page, }) => { await resetData("curated-tags-channel"); await addTag(page, "eva-collab", "Collab"); let file = await tagsFile(); expect(file.tags.map((t) => t.id)).toEqual(["eva-collab"]); expect(file.tags[0].label).toBe("Collab"); // Defining a tag asserts nothing about any video. expect(file.assignments).toEqual({}); // An edit round-trips through the page rather than through the file. await page.goto("/tags"); const card = page.getByTestId("tag-card").first(); await expect(card.getByLabel("tag id")).toHaveValue("eva-collab"); await card.getByLabel("tag label").fill("On mic"); await card.getByLabel("tag group", { exact: true }).fill("eva"); await page.getByRole("button", { name: "save tags" }).click(); await expect(page.getByTestId("tags-saved")).toBeVisible(); file = await tagsFile(); expect(file.tags[0]).toMatchObject({ label: "On mic", group: "eva" }); // Sites: the tag exists on these only (curated `sites`), saved sorted and // deduplicated, and read back into the box. await page.goto("/tags"); await page.getByTestId("tag-card").first().getByLabel("tag sites").fill("testsite, Testsite"); await page.getByRole("button", { name: "save tags" }).click(); await expect(page.getByTestId("tags-saved")).toBeVisible(); expect((await tagsFile()).tags[0]).toMatchObject({ sites: ["testsite"] }); await page.goto("/tags"); await expect(page.getByTestId("tag-card").first().getByLabel("tag sites")).toHaveValue("testsite"); await page.goto("/tags"); await page.getByRole("button", { name: "remove tag eva-collab" }).click(); await page.getByRole("button", { name: "save tags" }).click(); await expect(page.getByTestId("tags-saved")).toBeVisible(); expect((await tagsFile()).tags).toEqual([]); }); test("an invalid id is refused by name, and a broken regex is kept with its reason", async ({ page, }) => { await resetData("curated-tags-channel"); await page.goto("/tags"); await page.getByRole("button", { name: "add tag", exact: true }).click(); const card = page.getByTestId("tag-card").last(); await card.getByLabel("tag id").fill("Eva Collab"); await page.getByRole("button", { name: "save tags" }).click(); await expect(page.getByTestId("tags-error")).toContainText("not a valid tag id"); // A RULE WHOSE REGEX DOES NOT COMPILE IS NOT DROPPED. The typo has to stay // visible or the operator loses the rule and never learns why. await card.getByLabel("tag id").fill("eva-collab"); await card.getByRole("button", { name: /add rule to/ }).click(); const rule = card.getByTestId("tag-rule").last(); await rule.getByLabel("rule pattern").fill("elf(pire"); await expect(card.getByTestId("rule-disabled-reason")).toBeVisible(); await page.getByRole("button", { name: "save tags" }).click(); await expect(page.getByTestId("tags-saved")).toBeVisible(); const file = await tagsFile(); const saved = file.tags[0].rules?.[0] as | { pattern: string; enabled: boolean; disabledReason?: string } | undefined; expect(saved?.pattern).toBe("elf(pire"); expect(saved?.enabled).toBe(false); expect(saved?.disabledReason).toContain("does not compile"); }); test("a rule previews against the index, and Pin writes the operator's provenance", async ({ page, }) => { await resetData("curated-tags-channel"); await withIndex(page); await addTag(page, "synthetic", "Synthetic", { pattern: "small synthetic" }); await page.goto("/tags"); const card = page.getByTestId("tag-card").first(); await card.getByRole("button", { name: "preview synthetic" }).click(); const row = page.getByTestId("preview-row").first(); await expect(row).toContainText("A Small Synthetic Video"); await expect(row).toContainText(SLUG); // A preview PERSISTS NOTHING — rule hits are derived at build time. expect((await tagsFile()).assignments).toEqual({}); await row.getByRole("button", { name: `pin ${SMALL}`, exact: true }).click(); await expect(row).toContainText("pinned"); const file = await tagsFile(); expect(file.assignments[`${SLUG}/${SMALL}`].manual).toEqual(["synthetic"]); expect(file.assignments[`${SLUG}/${SMALL}`].sources?.synthetic.source).toBe( "operator", ); }); test("an excluded channel is saved on the rule and drops out of the preview", async ({ page, }) => { await resetData("curated-tags-channel"); await withIndex(page); await addTag(page, "synthetic", "Synthetic", { pattern: "small synthetic" }); await page.goto("/tags"); const card = page.getByTestId("tag-card").first(); await card.getByRole("button", { name: "preview synthetic" }).click(); await expect(page.getByTestId("preview-row")).toHaveCount(1); // Excluding the only channel in the fixture is the whole assertion: the // preview has to predict the build, and the build will not fire this rule // there any more. const rule = card.getByTestId("tag-rule").first(); await rule.getByLabel("rule exclude channels").fill(SLUG); await card.getByRole("button", { name: "preview synthetic" }).click(); await expect(page.getByTestId("preview-row")).toHaveCount(0); await expect(page.getByTestId("tag-preview")).toContainText("0 matches"); await page.getByRole("button", { name: "save tags" }).click(); await expect(page.getByTestId("tags-saved")).toBeVisible(); expect((await tagsFile()).tags[0].rules?.[0].channelsExclude).toEqual([SLUG]); // Cleared, it leaves no key behind — a rule nobody scoped round-trips as it // was written. await page.goto("/tags"); await page .getByTestId("tag-rule") .first() .getByLabel("rule exclude channels") .fill(""); await page.getByRole("button", { name: "save tags" }).click(); await expect(page.getByTestId("tags-saved")).toBeVisible(); expect((await tagsFile()).tags[0].rules?.[0]).not.toHaveProperty( "channelsExclude", ); }); // THE COMPRESSION REGRESSION, from both ends. // // buildIndex writes the index compressed; a reader that opens it without // `compression: true` throws "Data read, but end of buffer not reached" for // every value over ~1 KB. That is every real description and every transcript — // so the preview found nothing, and because the video page awaits the same // reader during its render, one rule was enough to 500 every video-detail page. test("a video whose description and transcript are large previews and renders", async ({ page, }) => { await resetData("curated-tags-channel"); await withIndex(page); // The caption side: the needle is in the last cue of a forty-one-cue // transcript, so a match proves the whole compressed cue array decoded. await addTag(page, "eva-topic", "Discussed", { pattern: "elfpire out loud", kind: "caption", }); await page.goto("/tags"); await page .getByTestId("tag-card") .first() .getByRole("button", { name: "preview eva-topic" }) .click(); const preview = page.getByTestId("tag-preview"); await expect(preview.getByTestId("preview-row")).toHaveCount(1); await expect(preview).toContainText("A Long Synthetic Video"); // A record that could not be read is COUNTED and said out loud. There is // nothing to say here, and if there were, this is where it would appear. await expect(preview).not.toContainText("could not be read"); // The metadata side: the same video's 1.8 KB description. await addTag(page, "eva-meta", "Metadata", { pattern: "guest tonight" }); await page.goto("/tags"); const metaCard = page.locator( '[data-testid="tag-card"][data-tag="eva-meta"]', ); await metaCard.getByRole("button", { name: "preview eva-meta" }).click(); await expect( metaCard.getByTestId("preview-row").filter({ hasText: "A Long Synthetic" }), ).toHaveCount(1); // And the page that awaits the same reader for one key. await page.goto(`/channels/${SLUG}/videos/${BIG}`); const panel = page.getByTestId("video-tags-panel"); await expect(panel).toBeVisible(); await expect( panel.getByTestId("video-tag").filter({ hasText: "Discussed" }), ).toHaveAttribute("data-state", "rule"); }); test("the video page shows provenance and turns a rule hit into a suppression", async ({ page, }) => { await resetData("curated-tags-channel"); await withIndex(page); await addTag(page, "synthetic", "Synthetic", { pattern: "small synthetic" }); await page.goto(`/channels/${SLUG}/videos/${SMALL}`); const panel = page.getByTestId("video-tags-panel"); const tag = panel.getByTestId("video-tag").filter({ hasText: "Synthetic" }); // No pin yet: it is here because the RULE fires, and the panel says so. await expect(tag).toHaveAttribute("data-state", "rule"); await expect(tag).toContainText("rule hit"); await tag.getByRole("button", { name: "suppress synthetic" }).click(); await expect( panel.getByTestId("video-tag").filter({ hasText: "Synthetic" }), ).toHaveAttribute("data-state", "suppressed"); const file = await tagsFile(); expect(file.assignments[`${SLUG}/${SMALL}`].suppressed).toEqual(["synthetic"]); expect(file.assignments[`${SLUG}/${SMALL}`].manual ?? []).toEqual([]); expect(file.assignments[`${SLUG}/${SMALL}`].sources?.synthetic.source).toBe( "operator", ); // Restore clears the rejection without pinning — the rule is left to decide. await page.reload(); await page .getByTestId("video-tags-panel") .getByRole("button", { name: "restore synthetic" }) .click(); await expect .poll(async () => (await tagsFile()).assignments[`${SLUG}/${SMALL}`]) .toBeUndefined(); }); // THE TWO-ID VIDEO, through all three surfaces. // // Its directory is `a-rumble-url-slug`; its record is `v2embedid`. Every // assignment must be keyed by the record id — a pin under the directory name is // a key the index, the export and every other reader will never look up — while // every URL and every list row still uses the directory name. test("a video whose record id is not its directory name is tagged by the record id", async ({ page, }) => { await resetData("curated-tags-channel"); await withIndex(page); await addTag(page, "eva-collab", "Collab"); // 1. the video page. await page.goto(`/channels/${SLUG}/videos/${RUMBLE_DIR}`); await expect(page.getByTestId("video-tags-panel")).toContainText(RUMBLE_ID); await pinFromPanel(page, "eva-collab"); await expect .poll(async () => Object.keys((await tagsFile()).assignments)) .toEqual([`${SLUG}/${RUMBLE_ID}`]); // 2. the list: the chip counts it, and the row it filters to is the DIRECTORY. await page.goto(`/channels/${SLUG}/videos`); const chip = page.getByRole("button", { name: "tag eva-collab", exact: true }); await expect(chip).toContainText("Collab 1"); await chip.click(); const list = page.getByRole("list", { name: "videos" }); await expect(list).toContainText(RUMBLE_DIR); await expect(list).not.toContainText(SMALL); // 3. the bulk bar, which is handed directory names and must send record ids. await page.getByLabel(`select ${RUMBLE_DIR}`).check(); await page.getByLabel("bulk action", { exact: true }).selectOption("untag"); await page.getByLabel("bulk tag", { exact: true }).selectOption("eva-collab"); await page.getByLabel("apply bulk action").click(); await expect.poll(async () => (await tagsFile()).assignments).toEqual({}); }); test("the bulk bar tags the selection in one write, and the chip filters the list", async ({ page, }) => { await resetData("curated-tags-channel"); await addTag(page, "eva-collab", "Collab"); await page.goto(`/channels/${SLUG}/videos`); await page.getByLabel(`select ${SMALL}`).check(); await page.getByLabel("bulk action", { exact: true }).selectOption("tag"); await page.getByLabel("bulk tag", { exact: true }).selectOption("eva-collab"); // UNTAG IS NOT THE OPPOSITE OF TAG, and the bar says so wherever a tag op is // selected: the chips show pins, and a rule's hit is not one. await expect(page.getByLabel("bulk tag hint")).toContainText( "Untag unpins; a rule that still matches keeps the tag", ); await page.getByLabel("apply bulk action").click(); // THE FILE IS THE ASSERTION, not the bar: a summary bulk clears the selection // on success, and the bar (with its result line) is rendered only while // something is selected. That is how every other bulk action here behaves. await expect .poll(async () => (await tagsFile()).assignments[`${SLUG}/${SMALL}`]?.manual) .toEqual(["eva-collab"]); await expect(page.getByLabel("bulk action bar")).toBeHidden(); // The chip is drawn from the rows' own tags and carries the count — which is // also the visible confirmation that the write landed. const chip = page.getByRole("button", { name: "tag eva-collab", exact: true }); await expect(chip).toContainText("Collab 1"); await chip.click(); await expect(page.getByRole("list", { name: "videos" })).toContainText(SMALL); await expect(page).toHaveURL(/tg=eva-collab/); // Suppress is its own bulk op, and it is not "untag twice": it REJECTS, so a // rule that matches this video stops putting the tag on it. await page.getByLabel(`select ${SMALL}`).check(); await page .getByLabel("bulk action", { exact: true }) .selectOption("suppress_tag"); await page.getByLabel("bulk tag", { exact: true }).selectOption("eva-collab"); await page.getByLabel("apply bulk action").click(); await expect .poll( async () => (await tagsFile()).assignments[`${SLUG}/${SMALL}`]?.suppressed, ) .toEqual(["eva-collab"]); expect( (await tagsFile()).assignments[`${SLUG}/${SMALL}`].manual ?? [], ).toEqual([]); }); test("Pin all skips the rows the operator already suppressed", async ({ page, }) => { await resetData("curated-tags-channel"); await withIndex(page); // Matches the long video and the rumble one — both descriptions say elfpire. await addTag(page, "eva-collab", "Collab", { pattern: "elfpire" }); await page.goto("/tags"); const card = page.getByTestId("tag-card").first(); await card.getByRole("button", { name: "preview eva-collab" }).click(); await expect(page.getByTestId("preview-row")).toHaveCount(2); // Reject one of them. await page .getByTestId("preview-row") .filter({ hasText: "Rumble" }) .getByRole("button", { name: `suppress ${RUMBLE_ID}`, exact: true }) .click(); await expect .poll( async () => (await tagsFile()).assignments[`${SLUG}/${RUMBLE_ID}`]?.suppressed, ) .toEqual(["eva-collab"]); // PIN ALL MUST NOT UNDO THAT. `add` clears a suppression of the same tag, so // pinning the whole page would silently reverse every judgement the operator // had just made — which is the work a preview exists to collect. const pinAll = card.getByRole("button", { name: "pin all eva-collab" }); await expect(pinAll).toContainText("skips 1 suppressed"); await pinAll.click(); await expect .poll(async () => (await tagsFile()).assignments[`${SLUG}/${BIG}`]?.manual) .toEqual(["eva-collab"]); expect( (await tagsFile()).assignments[`${SLUG}/${RUMBLE_ID}`].suppressed, ).toEqual(["eva-collab"]); expect( (await tagsFile()).assignments[`${SLUG}/${RUMBLE_ID}`].manual ?? [], ).toEqual([]); }); test("a definition deleted while it carries pins is confirmed, and its orphans are named", async ({ page, }) => { await resetData("curated-tags-channel"); await addTag(page, "eva-collab", "Collab"); await page.goto(`/channels/${SLUG}/videos/${SMALL}`); await pinFromPanel(page, "eva-collab"); await expect .poll(async () => (await tagsFile()).assignments[`${SLUG}/${SMALL}`]?.manual) .toEqual(["eva-collab"]); await page.goto("/tags"); page.once("dialog", (d) => { expect(d.message()).toContain("keeps them on those videos"); void d.accept(); }); await page.getByRole("button", { name: "remove tag eva-collab" }).click(); await page.getByRole("button", { name: "save tags" }).click(); await expect(page.getByTestId("tags-saved")).toBeVisible(); // The pin survives — an assignment is a fact about a video — and /tags is the // only surface that can still show it, so it does. expect((await tagsFile()).assignments[`${SLUG}/${SMALL}`].manual).toEqual([ "eva-collab", ]); await page.reload(); await expect(page.getByTestId("tags-orphans")).toContainText("eva-collab"); }); test("a site overlays presentation and cannot touch rules, assignments or a label it did not set", async ({ page, }) => { await resetData("curated-tags-channel"); await writeSite("testsite", { channels: [{ slug: SLUG, groupId: "default" }], }); await addTag(page, "eva-collab", "Collab", { pattern: "elfpire" }); await page.goto("/sites/testsite/tags"); const overlay = page.getByTestId("tag-overlay").first(); // COLOUR ONLY. Nothing here says anything about the label, and the tag must // not come out of the merge renamed to its id. await overlay.getByLabel("colour for eva-collab").fill("#b48ead"); await overlay.getByLabel("visibility for eva-collab").selectOption("true"); await page.getByRole("button", { name: "save site tags" }).click(); await expect(page.getByTestId("site-tags-saved")).toBeVisible(); const site = await readJson( "test-transcripts/sites/testsite/tags.json", ); // The row holds ONLY what this site said. No label — not even a copy of the // corpus one — because the site file is coerced at the site layer, where a // missing presentation field stays missing. That is what lets a later corpus // rename follow onto this site with no edit here. expect(site.tags).toEqual([ { id: "eva-collab", color: "#b48ead", hidden: true }, ]); // The corpus rule is untouched, and the site file carries no rules and no // assignments at all — a record is shared by every site that carries its // channel, so what a tag MEANS is decided once. expect(site.assignments).toEqual({}); expect((await tagsFile()).tags[0].rules?.length).toBe(1); await expect(page.getByText("Corpus rules:")).toBeVisible(); // And the corpus wording still reaches this site: rename the tag on /tags and // the overlay — which never claimed a label — follows it. await page.goto("/tags"); await page.getByTestId("tag-card").first().getByLabel("tag label").fill("On mic"); await page.getByRole("button", { name: "save tags" }).click(); await expect(page.getByTestId("tags-saved")).toBeVisible(); await page.goto("/sites/testsite/tags"); await expect( page .getByTestId("tag-overlay") .first() // exact, or this also matches "group label for eva-collab". .getByLabel("label for eva-collab", { exact: true }), ).toHaveAttribute("placeholder", "On mic"); expect( (await readJson("test-transcripts/sites/testsite/tags.json")) .tags[0].label, ).toBeUndefined(); });