Archilyzer · Source

archilyzer

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

commit de35c0490c045f21d0a1e1dad6dc60eaa708631d
parent 6c4467d10b58898acf7e853bde2701e70da7df9f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 21 Sep 2026 13:37:19 -0400

tags S3.7: tests for all three writers

editor/e2e/tags.spec.ts (6) — defs CRUD through the page, an invalid id refused
by name, a broken regex kept DISABLED WITH ITS REASON, a rule previewed against
the index and pinned from a preview row, the video panel turning a rule hit into
a suppression and back, the bulk bar tagging a selection, the chip filtering the
list with `tg` in the URL, and a site overlay that writes presentation and
nothing else.

The assertions are mostly about transcripts/tags.json rather than about the
controls, deliberately: the claim under test is that every surface ends at the
same write, so the file is where that is either true or not. Two places where
the spec had to say what the product does rather than what would be tidy: a
preview PERSISTS NOTHING (asserted as an empty assignments map after the rows
render), and a summary bulk clears the selection on success, which unmounts the
bar and its result line — so the chip's count is the visible confirmation, and
the spec polls the file.

editor/e2e/ops-api.spec.ts (+2) — define/pin/read/unpin/undefine over HTTP, the
`agent:ops` default provenance, re-pinning reporting changed: 0, `remove` leaving
no suppression behind, a definition removed while its assignments survive, and
the three refusals (traversing slug, unknown op, unknown key) writing no file.

umtool/e2e/report-tag-via-editor.spec.ts (2) — the route against the editor
stub, which gains /api/ops/tags and /api/ops/tag-videos. The fixture project
cites one recording twice in section A and carries a clip whose local name is a
URL slug while siteVideo/siteChannel say what the archive calls it, so the spec
fails if the manifest's ids are sent instead of the archive's. The stub answers
`changed` one FEWER than it was sent, so a UI echoing its own count fails too.

Co-Authored-By: Claude Opus <noreply@anthropic.com>

Diffstat:
Meditor/e2e/ops-api.spec.ts | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/tags.spec.ts | 257+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/fixtures/editor-stub.mjs | 41+++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/fixtures/make-fixture.mjs | 25+++++++++++++++++++++++++
Aumtool/e2e/report-tag-via-editor.spec.ts | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 604 insertions(+), 0 deletions(-)

diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts @@ -504,3 +504,150 @@ test("lane flips a hold, and /api/auto-queue/status agrees", async ({ expect(bogus.status).toBe(400); expect(bogus.body.error).toMatch(/transcription, download, digest, backfill/); }); + +// --- curated tags ----------------------------------------------------------- +// +// The same two claims as everywhere else in this file: the route is an adapter +// over the action the UI calls, and the thing to assert is the FILE plus the +// shared sentences — not the route's own shape. + +test("tags defines a vocabulary, and tag-videos writes pins with their source", async ({ + request, +}) => { + await resetData("one-youtube-channel-with-data"); + const slug = "test-youtube"; + const id = "20240101_test1234567"; + + const define = await ops(request, "tags", { + op: "define", + tag: { + id: "eva-collab", + label: "Collab", + group: "eva", + rules: [{ id: "meta", kind: "metadata", pattern: "synthetic" }], + }, + }); + expect(define.status, JSON.stringify(define.body)).toBe(200); + + const pin = await ops(request, "tag-videos", { + tag: "eva-collab", + op: "add", + videos: [{ slug, id }], + }); + expect(pin.status, JSON.stringify(pin.body)).toBe(200); + expect((pin.body as { changed?: number }).changed).toBe(1); + + const file = await readJson<{ + tags: { id: string; rules?: unknown[] }[]; + assignments: Record< + string, + { manual?: string[]; sources?: Record<string, { source: string }> } + >; + }>("test-transcripts/tags.json"); + expect(file.tags[0].id).toBe("eva-collab"); + expect(file.assignments[`${slug}/${id}`].manual).toEqual(["eva-collab"]); + // A CALL ARRIVING HERE IS NOT THE BROWSER, and the default provenance says so + // rather than claiming an operator pressed something. + expect(file.assignments[`${slug}/${id}`].sources?.["eva-collab"].source).toBe( + "agent:ops", + ); + + // Re-pinning what is pinned MOVES NOTHING, and says 0 rather than claiming + // work it did not do. + const again = await ops(request, "tag-videos", { + tag: "eva-collab", + op: "add", + videos: [{ slug, id }], + source: "agent:spec", + }); + expect((again.body as { changed?: number }).changed).toBe(0); + + // The read side: definitions with the counts somebody ASSERTED, then one + // tag's assignments with their provenance. + const all = await request.get(`${baseUrl}/api/ops/tags`, { headers: AUTH }); + const allBody = (await all.json()) as { + tags: { id: string; pinned: number; suppressed: number }[]; + }; + expect(allBody.tags[0]).toMatchObject({ + id: "eva-collab", + pinned: 1, + suppressed: 0, + }); + + const one = await request.get(`${baseUrl}/api/ops/tags?tag=eva-collab`, { + headers: AUTH, + }); + const oneBody = (await one.json()) as { + assignments: { channelSlug: string; id: string; state: string; source: string }[]; + }; + expect(oneBody.assignments).toEqual([ + { + channelSlug: slug, + id, + state: "pinned", + source: "agent:ops", + setAt: expect.any(String), + }, + ]); + + // UNPIN IS NOT SUPPRESS. `remove` clears the pin and leaves no rejection + // behind, so a rule that matches this video still puts the tag on it. + const unpin = await ops(request, "tag-videos", { + tag: "eva-collab", + op: "remove", + videos: [{ slug, id }], + }); + expect((unpin.body as { changed?: number }).changed).toBe(1); + const after = await readJson<{ assignments: Record<string, unknown> }>( + "test-transcripts/tags.json", + ); + expect(after.assignments).toEqual({}); + + // Removing a DEFINITION leaves assignments alone — it is a label, not a fact. + await ops(request, "tag-videos", { + tag: "eva-collab", + op: "suppress", + videos: [{ slug, id }], + }); + const undefine = await ops(request, "tags", { op: "remove", tag: "eva-collab" }); + expect(undefine.status).toBe(200); + const left = await readJson<{ + tags: unknown[]; + assignments: Record<string, { suppressed?: string[] }>; + }>("test-transcripts/tags.json"); + expect(left.tags).toEqual([]); + expect(left.assignments[`${slug}/${id}`].suppressed).toEqual(["eva-collab"]); +}); + +test("tag-videos refuses a traversing slug, a bad op and an unknown key", async ({ + request, +}) => { + await resetData("one-youtube-channel-with-data"); + const traversing = await ops(request, "tag-videos", { + tag: "x", + op: "add", + videos: [{ slug: "../escape", id: "v" }], + }); + expect(traversing.status).toBe(400); + expect(traversing.body.error).toContain("not a valid channel slug"); + + const badOp = await ops(request, "tag-videos", { + tag: "x", + op: "pin", + videos: [{ slug: "test-youtube", id: "v" }], + }); + expect(badOp.status).toBe(400); + expect(badOp.body.error).toContain("add, remove, suppress, unsuppress"); + + const unknown = await ops(request, "tag-videos", { + tag: "x", + op: "add", + videos: [{ slug: "test-youtube", id: "v" }], + provenance: "me", + }); + expect(unknown.status).toBe(400); + expect(unknown.body.error).toContain("unknown key(s): provenance"); + + // Nothing was written by any of the three. + expect(await pathExists("test-transcripts/tags.json")).toBe(false); +}); diff --git a/editor/e2e/tags.spec.ts b/editor/e2e/tags.spec.ts @@ -0,0 +1,257 @@ +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 corpus fixture is the one-video YouTube channel, and the index is built +// once per test that needs a rule: a rule is evaluated against the transcript +// index, so "preview" against a corpus that was never built is a legitimate but +// uninteresting empty state. + +const SLUG = "test-youtube"; +const VIDEO = "20240101_test1234567"; + +type TagsFile = { + version: number; + tags: { id: string; label: string; rules?: { id: string; kind: string }[] }[]; + assignments: Record< + string, + { + manual?: string[]; + suppressed?: string[]; + sources?: Record<string, { source: string; setAt: string }>; + } + >; +}; + +const tagsFile = () => readJson<TagsFile>("test-transcripts/tags.json"); + +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(); +} + +test("a tag is defined, edited and removed, and the file holds what the form said", async ({ + page, +}) => { + await resetData("one-youtube-channel-with-data"); + 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" }); + + 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("one-youtube-channel-with-data"); + 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("one-youtube-channel-with-data"); + await writeSite("testsite", { + channels: [{ slug: SLUG, groupId: "default" }], + }); + await buildIndex(page); + await addTag(page, "synthetic", "Synthetic", { pattern: "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("Synthetic Test 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 ${VIDEO}`, exact: true }) + .click(); + await expect(row).toContainText("pinned"); + const file = await tagsFile(); + expect(file.assignments[`${SLUG}/${VIDEO}`].manual).toEqual(["synthetic"]); + expect(file.assignments[`${SLUG}/${VIDEO}`].sources?.synthetic.source).toBe( + "operator", + ); +}); + +test("the video page shows provenance and turns a rule hit into a suppression", async ({ + page, +}) => { + await resetData("one-youtube-channel-with-data"); + await writeSite("testsite", { + channels: [{ slug: SLUG, groupId: "default" }], + }); + await buildIndex(page); + await addTag(page, "synthetic", "Synthetic", { pattern: "synthetic" }); + + await page.goto(`/channels/${SLUG}/videos/${VIDEO}`); + 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}/${VIDEO}`].suppressed).toEqual(["synthetic"]); + expect(file.assignments[`${SLUG}/${VIDEO}`].manual ?? []).toEqual([]); + expect(file.assignments[`${SLUG}/${VIDEO}`].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}/${VIDEO}`]) + .toBeUndefined(); +}); + +test("the bulk bar tags the selection in one write, and the chip filters the list", async ({ + page, +}) => { + await resetData("one-youtube-channel-with-data"); + await addTag(page, "eva-collab", "Collab"); + + await page.goto(`/channels/${SLUG}/videos`); + await page.getByLabel(`select ${VIDEO}`).check(); + await page.getByLabel("bulk action", { exact: true }).selectOption("tag"); + await page.getByLabel("bulk tag").selectOption("eva-collab"); + 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}/${VIDEO}`]?.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( + VIDEO, + ); + await expect(page).toHaveURL(/tg=eva-collab/); + + // Untag, and the chip has nothing left to offer. + await page.getByLabel(`select ${VIDEO}`).check(); + await page.getByLabel("bulk action", { exact: true }).selectOption("untag"); + await page.getByLabel("bulk tag").selectOption("eva-collab"); + await page.getByLabel("apply bulk action").click(); + await expect + .poll(async () => (await tagsFile()).assignments) + .toEqual({}); +}); + +test("a site overlays presentation and cannot touch rules or assignments", async ({ + page, +}) => { + await resetData("one-youtube-channel-with-data"); + 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(); + await overlay + .getByLabel("label for eva-collab", { exact: true }) + .fill("On mic"); + 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<TagsFile>( + "test-transcripts/sites/testsite/tags.json", + ); + expect(site.tags).toEqual([ + { id: "eva-collab", label: "On mic", 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(); +}); diff --git a/umtool/e2e/fixtures/editor-stub.mjs b/umtool/e2e/fixtures/editor-stub.mjs @@ -87,6 +87,47 @@ const server = http.createServer((req, res) => { return json(res, 401, { error: "missing bearer token" }); } + // THE OPS DOOR, stubbed alongside the media one. + // + // /api/report/tag forwards to the editor's /api/ops/tag-videos, and what a + // spec needs to prove is the part no file on disk shows: that the archive's + // ids (not the manifest's local ones), the op, the provenance and the bearer + // token crossed the wire. `changed` is deliberately NOT the same number as + // the videos sent — the real editor reports what actually moved, and the UI + // must print the editor's number rather than its own count. + if (req.method === "GET" && url.pathname === "/api/ops/tags") { + req.resume(); + return json(res, 200, { + ok: true, + tags: [ + { id: "eva-collab", label: "Collab", pinned: 0, suppressed: 0 }, + { id: "eva-topic", label: "Discussed", pinned: 0, suppressed: 0 }, + ], + }); + } + + if (req.method === "POST" && url.pathname === "/api/ops/tag-videos") { + let raw = ""; + req.on("data", (c) => (raw += c)); + req.on("end", () => { + let body; + try { + body = JSON.parse(raw); + } catch { + return json(res, 400, { ok: false, error: "malformed JSON body" }); + } + requests.push({ route: "tag-videos", ...body, authorization: auth }); + record(); + if (!body.tag || !Array.isArray(body.videos) || body.videos.length === 0) { + return json(res, 400, { ok: false, error: "tag and videos are required" }); + } + // One fewer than asked for, so a spec that echoed its own count instead + // of the editor's would fail. + return json(res, 200, { ok: true, changed: Math.max(0, body.videos.length - 1) }); + }); + return; + } + // GET /api/media/fetch-window/<jobId> const poll = /^\/api\/media\/fetch-window\/(.+)$/.exec(url.pathname); if (req.method === "GET" && poll) { diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -1020,6 +1020,31 @@ writeProject( ]), ); +// -- THE TAG FIXTURE ---------------------------------------------------------- +// +// Cited videos over TWO SECTIONS, for "tag cited videos as …". Nothing here is +// fetched, cut or judged: the action reads the manifest and asks the editor, so +// the fixture is the citation graph and nothing else. It shares sources with +// the read-only fixtures above deliberately — it writes no corpus file. +// +// a01, a02 two clips on vid1 -> section A is ONE video, which is the point: +// a report cites a recording many times and +// the tag lands on it once +// b01 vid2 -> section B +// b02 a clip whose LOCAL name is not the archive's: `video` is the URL +// slug a cue directory is named for, while siteVideo/siteChannel say +// what the published archive calls it. Rumble's two ids, and the +// editor must be asked with the archive's. +writeProject( + "editor-tag-fixture", + manifest("editor-tag-fixture", "The Editor Tag Fixture", { siteOrigin: "https://archive.example" }, [ + { type: "clip", id: "a01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, quote: "and because" }, + { type: "clip", id: "a02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, quote: "another whole sentence" }, + { type: "clip", id: "b01", video: "vid2", start: 1.0, end: 4.0, cite: 1, section: 0, quote: "no punctuation" }, + { type: "clip", id: "b02", video: "a-rumble-url-slug", siteVideo: "v2embedid", siteChannel: "testchan-rumble", start: 1.0, end: 4.0, cite: 1, section: 0, quote: "two ids" }, + ]), +); + // -- THE DELIVER FIXTURE ------------------------------------------------------ // // What a walked report owes its readers, in one project: the cut clips, the diff --git a/umtool/e2e/report-tag-via-editor.spec.ts b/umtool/e2e/report-tag-via-editor.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// --------------------------------------------------------------------------- +// "Tag cited videos as …" — handing a walked report's judgement to the archive. +// +// The editor is STUBBED (e2e/fixtures/editor-stub.mjs, a second webServer), for +// the same reason the fetch spec stubs it: a real one would arm its runners +// against whatever corpus it found, and here it would also WRITE that corpus. +// What the stub records is the part nothing on disk can prove — that the +// archive's ids, the op, the provenance and the bearer token crossed the wire. +// +// Two assertions carry the weight: +// * b02's LOCAL name is a URL slug and its archive name is an embed id; the +// editor must be asked with the archive's, or every Rumble clip silently +// tags a key nothing looks up. +// * the number shown is the EDITOR'S `changed`, not this tool's count of what +// it sent. The stub answers with one fewer on purpose. +// --------------------------------------------------------------------------- + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE = path.join(HERE, "..", ".e2e-song"); +const STUB_LOG = path.join(FIXTURE, "editor-stub.requests.json"); +const PROJECT = "reports/editor-tag-fixture"; + +type StubRequest = { + route?: string; + tag?: string; + op?: string; + source?: string; + videos?: { slug: string; id: string }[]; + authorization?: string; +}; + +const tagAsks = (): StubRequest[] => { + try { + return (JSON.parse(readFileSync(STUB_LOG, "utf8")) as StubRequest[]).filter( + (r) => r.route === "tag-videos", + ); + } catch { + return []; + } +}; + +test("the tag route asks the editor with the ARCHIVE's ids and the project's provenance", async ({ + request, + baseURL, +}) => { + const before = tagAsks().length; + + const read = await request.get( + `${baseURL}/api/report/tag?project=${encodeURIComponent(PROJECT)}`, + ); + expect(read.status(), await read.text()).toBe(200); + const state = (await read.json()) as { + configured: boolean; + reachable: boolean; + tags: { id: string }[]; + sections: { letter: string; clips: number; videos: number }[]; + videos: number; + }; + expect(state.configured).toBe(true); + expect(state.reachable).toBe(true); + expect(state.tags.map((t) => t.id)).toContain("eva-collab"); + // Four clips, three archive videos: section A cites one recording twice, and + // the tag lands on it once. + expect(state.videos).toBe(3); + expect(state.sections.map((s) => s.letter)).toEqual(["A", "B"]); + expect(state.sections.find((s) => s.letter === "A")).toMatchObject({ + clips: 2, + videos: 1, + }); + + const post = await request.post(`${baseURL}/api/report/tag`, { + data: { project: PROJECT, tag: "eva-collab" }, + }); + expect(post.status(), await post.text()).toBe(200); + const answer = (await post.json()) as { changed: number; videos: number }; + expect(answer.videos).toBe(3); + // The stub answers one fewer than it was sent; the route must pass the + // EDITOR's number through rather than counting its own request. + expect(answer.changed).toBe(2); + + const asks = tagAsks().slice(before); + expect(asks.length).toBe(1); + const ask = asks[0]; + expect(ask.authorization).toBe("Bearer umtool-e2e-token"); + expect(ask.tag).toBe("eva-collab"); + expect(ask.op).toBe("add"); + // WHO ASKED. A pin that came from a report can be traced back to it — by the + // project's own id, folder and all, which is the string that finds it again. + expect(ask.source).toBe(`umtool:${PROJECT}`); + const sent = (ask.videos ?? []).map((v) => `${v.slug}/${v.id}`).sort(); + expect(sent).toEqual([ + "testchan-rumble/v2embedid", + "testchan/vid1", + "testchan/vid2", + ]); +}); + +test("a section is its own scope, and the control on the project page uses it", async ({ + page, + request, + baseURL, +}) => { + const before = tagAsks().length; + + // The API first: section B is two videos, one of them the two-id clip. + const post = await request.post(`${baseURL}/api/report/tag`, { + data: { project: PROJECT, tag: "eva-topic", section: "B" }, + }); + expect(post.status(), await post.text()).toBe(200); + expect((await post.json()).videos).toBe(2); + expect(tagAsks().slice(before)[0].videos?.length).toBe(2); + + // And through the page, which is where an operator actually does it. + const asksBeforeUi = tagAsks().length; + await page.goto(`/browse/${PROJECT}`); + const control = page.locator("[data-tag-cited]"); + await expect(control).toBeVisible(); + await control.locator("[data-tag-cited-tag]").selectOption("eva-collab"); + await control.locator("[data-tag-cited-scope]").selectOption("A"); + const button = control.locator('[data-action="tag-cited"]'); + await expect(button).toContainText("tag 1 video"); + await button.click(); + await expect(control.locator("[data-tag-cited-msg]")).toContainText("tagged"); + + const ui = tagAsks().slice(asksBeforeUi); + expect(ui.length).toBe(1); + expect(ui[0].tag).toBe("eva-collab"); + expect(ui[0].videos).toEqual([{ slug: "testchan", id: "vid1" }]); +});