Archilyzer · Source

archilyzer

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

commit ac6e6a0068ea1678f579b5cd94330ac60aa2ed48
parent 212f9175ecd5bafc3473237009643033a22db53f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 26 Aug 2026 00:46:35 -0400

channels: the speakers stage stops being called "backfill"

StageId "backfill" was a QUEUE KEY WEARING A STAGE'S NAME. Nobody can arm, pause
or run "a backfill" — BACKFILL_QUEUE is a scheduler key and correctly named as
one — while what the card actually holds is the speaker work: diarization and
the two attribution kinds. The card's own TITLE has been derived from the group
(operationsGroupLabel → "Speakers") for a while; only the id lagged.

StageId and FlowStationId rename together, because a station links to its card
as `?stage=${station.stage}` and a mismatch opens the wrong panel. tsc names
every site: the flow components hold Record<StageId, StageStatus>, GAP_LABEL and
the sidings maps are keyed by station id, and every in-app link is built from
the id.

?stage=backfill STILL WORKS. An unknown ?stage= resolves to the overview at 200,
which is right for a stage that no longer applies and wrong for one that was
renamed — a bookmark would open the overview with nothing saying the panel it
named is still there. STAGE_ALIASES resolves the old id first; new spec case.

TWO ASSERTIONS THAT WOULD HAVE PASSED VACUOUSLY after the rename, both updated
and both now carrying a note saying so:
  - channelFlow.test.ts's `notEqual(off.next?.stage, "backfill")` — a notEqual
    against a value the union can no longer hold is always true.
  - channel-stage-selection.spec.ts's `getByLabel("backfill section")` count-0 —
    an absent-count against a label nothing renders is always 0.

Renamed only the stage WRAPPER's aria-label ("backfill section" → "speakers
section", 4 spec lines). The POPULATION labels — "backfill kind …", "backfill
reachable/blocked/deferred/needs re-acquiring", "videos needing a backfill …" —
name the LANE's populations, appear on 16 spec lines, and go in the vocabulary
pass that follows this slice. Fewer hand-edited spec lines is less e2e risk for
the same result.

DELIBERATELY LEFT, each decided rather than missed:
  - snapshot.backfill and BACKFILL_QUEUE — keyed by KIND id / a scheduler key.
  - StationId (the /channels group buttons) — vocabulary pass.
  - the /actionable section id, and its `getByLabel("backfill", {exact:true})`
    at backfill.spec:767 and attribution.spec:363 — slice 4.
  - the widget's SectionId — persisted in user URLs. Never.

ALSO, step 5: the stage list's middle comes from the registry.

GROUP_STAGES is an exhaustive Record<OperationGroup, readonly StageId[]> spread
in OPERATION_GROUP_ORDER, so a new operation group does not compile until it
names its stage(s). NOT a cardinality change and not 1:1 — `media` owns both
download and transcode, which is why it is a Record of ARRAYS. Same array, same
order; every ?stage= consumer is unaffected.

Deliberately only the middle: configure/playlist and cleanup/diagnostics/danger
are channel CHORES, not operations, and stay hand-listed. `transcode` is
filtered rather than omitted because whether it applies is a per-CHANNEL fact
and the Record is a per-install one.

New stageOrder.test.ts pins the derived list against the literal that shipped,
for both transcodeApplies values — tsc checks membership but neither order nor
cardinality — plus that no group names zero stages and no stage is claimed twice.

tsc clean; editor unit tests 85/85 (69 + 3 new + the renamed channelFlow cases).
e2e runs once at the end of the slice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Diffstat:
Meditor/app/channels/[slug]/components/flow/NextAction.tsx | 2+-
Meditor/app/channels/[slug]/components/stages/BackfillStage.tsx | 7++++++-
Meditor/app/channels/[slug]/lib/channelFlow.test.ts | 40++++++++++++++++++++++------------------
Meditor/app/channels/[slug]/lib/channelFlow.ts | 33+++++++++++++++++++--------------
Aeditor/app/channels/[slug]/lib/stageOrder.test.ts | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/lib/stageStatus.ts | 49+++++++++++++++++++++++++++++++++++++++++--------
Meditor/app/channels/[slug]/page.tsx | 34+++++++++++++++++++++++++++-------
Meditor/e2e/attribution.spec.ts | 8++++----
Meditor/e2e/backfill.spec.ts | 24++++++++++++------------
Meditor/e2e/channel-line.spec.ts | 6+++---
Meditor/e2e/channel-stage-selection.spec.ts | 20+++++++++++++++++++-
Meditor/e2e/helpers.ts | 2+-
12 files changed, 245 insertions(+), 70 deletions(-)

diff --git a/editor/app/channels/[slug]/components/flow/NextAction.tsx b/editor/app/channels/[slug]/components/flow/NextAction.tsx @@ -23,7 +23,7 @@ const VARIANT_FOR: Partial< download: "downloadMissing", transcribe: "transcribeMissing", digest: "digestChannel", - backfill: "backfillChannel", + speakers: "backfillChannel", }; export function NextAction({ diff --git a/editor/app/channels/[slug]/components/stages/BackfillStage.tsx b/editor/app/channels/[slug]/components/stages/BackfillStage.tsx @@ -114,7 +114,12 @@ export function BackfillStage({ ].sort(); return ( - <div aria-label="backfill section" className="flex flex-col gap-3"> + // RENAMED WITH THE STAGE. The POPULATION labels below ("backfill reachable", + // "backfill kind …") are deliberately NOT renamed here: they name the LANE's + // populations, appear on 16 spec lines, and go in the vocabulary pass that + // follows this slice. Fewer hand-edited spec lines is less e2e risk for the + // same result. + <div aria-label="speakers section" className="flex flex-col gap-3"> <div> <h3 className="text-base font-semibold"> {heading} &middot; {kinds.length}{" "} diff --git a/editor/app/channels/[slug]/lib/channelFlow.test.ts b/editor/app/channels/[slug]/lib/channelFlow.test.ts @@ -93,8 +93,8 @@ test("a pre-`eligible` snapshot reports unknown coverage, not zero", () => { assert.equal(station(flow, "digest").through, null); assert.equal(station(flow, "digest").denominator, null); assert.equal(station(flow, "digest").coverage, null); - assert.equal(station(flow, "backfill").through, null); - assert.equal(station(flow, "backfill").coverage, null); + assert.equal(station(flow, "speakers").through, null); + assert.equal(station(flow, "speakers").coverage, null); }); test("the lane station does NOT sum its operations — it reads the lead one", () => { @@ -123,7 +123,7 @@ test("the lane station does NOT sum its operations — it reads the lead one", ( }, ); - const lane = station(flow, "backfill"); + const lane = station(flow, "speakers"); assert.equal(lane.through, 10); assert.equal(lane.denominator, 10); // The sum would be 1,010. Asserting the negative is the point. @@ -149,17 +149,17 @@ test("the lane station is named after its operations, not its queue key", () => kind({ id: "attribution-text" }), ], }); - assert.equal(station(speakers, "backfill").label, "Speakers"); + assert.equal(station(speakers, "speakers").label, "Speakers"); const mixed = flowOf(snapshotOf(), { backfillKinds: [kind({ id: "diarization" }), kind({ id: "digest" })], }); - assert.equal(station(mixed, "backfill").label, "Derived data"); + assert.equal(station(mixed, "speakers").label, "Derived data"); // Nothing enabled: the generic name, and no operations to state. const off = flowOf(snapshotOf(), { backfillKinds: [] }); - assert.equal(station(off, "backfill").label, "Derived data"); - assert.deepEqual(station(off, "backfill").operations, []); + assert.equal(station(off, "speakers").label, "Derived data"); + assert.deepEqual(station(off, "speakers").operations, []); }); test("an unknown `eligible` on the lead operation still renders unknown, not zero", () => { @@ -170,7 +170,7 @@ test("an unknown `eligible` on the lead operation still renders unknown, not zer snapshotOf({ backfill: { diarization: entry({ missing: 3 }) } }), { backfillKinds: [kind({ id: "diarization" })] }, ); - const lane = station(flow, "backfill"); + const lane = station(flow, "speakers"); assert.equal(lane.through, null); assert.equal(lane.denominator, null); assert.equal(lane.coverage, null); @@ -198,14 +198,18 @@ test("a lane that is switched off reads neutral, never ok and never amber", () = }); const off = flowOf(snapshot, { backfillKinds: [] }); - assert.equal(station(off, "backfill").tone, "neutral"); + assert.equal(station(off, "speakers").tone, "neutral"); // …and the work it recorded is not offered as something to press, because // nothing would run it. - assert.equal(off.gaps.find((g) => g.to === "backfill")?.reachable, 0); - assert.notEqual(off.next?.stage, "backfill"); + assert.equal(off.gaps.find((g) => g.to === "speakers")?.reachable, 0); + // NOT the pre-rename literal. This assertion passed vacuously the moment the + // stage id changed — a notEqual against a value the union can no longer hold + // is always true — so it is spelled with the live id and would fail if the + // disabled lane were ever offered as the next action again. + assert.notEqual(off.next?.stage, "speakers"); const on = flowOf(snapshot, { backfillKinds: [kind({ id: "diarization" })] }); - assert.equal(on.gaps.find((g) => g.to === "backfill")?.reachable, 7); + assert.equal(on.gaps.find((g) => g.to === "speakers")?.reachable, 7); }); test("deferred, blocked and missing-input never enter a gap's reachable count", () => { @@ -235,7 +239,7 @@ test("deferred, blocked and missing-input never enter a gap's reachable count", ); const toDigest = flow.gaps.find((g) => g.to === "digest"); - const toBackfill = flow.gaps.find((g) => g.to === "backfill"); + const toBackfill = flow.gaps.find((g) => g.to === "speakers"); // missing + stale + partial, and nothing else. assert.equal(toDigest?.reachable, 4); assert.equal(toBackfill?.reachable, 3); @@ -251,9 +255,9 @@ test("deferred, blocked and missing-input never enter a gap's reachable count", assert.equal(sidingCount("digest", "waiting on a transcript"), 1631); assert.equal(sidingCount("digest", "deferred"), 40); - assert.equal(sidingCount("backfill", "needs media re-acquired"), 500); - assert.equal(sidingCount("backfill", "deferred"), 11); - assert.equal(sidingCount("backfill", "waiting on an earlier backfill"), 70); + assert.equal(sidingCount("speakers", "needs media re-acquired"), 500); + assert.equal(sidingCount("speakers", "deferred"), 11); + assert.equal(sidingCount("speakers", "waiting on an earlier backfill"), 70); // The invariant stated as the sum nobody should be able to write: a gap's // reachable count is not the total of everything hanging under it. @@ -286,7 +290,7 @@ test("the transcode station appears only when the channel transcodes", () => { ); assert.deepEqual( withOut.gaps.map((g) => g.to), - ["download", "transcribe", "digest", "backfill"], + ["download", "transcribe", "digest", "speakers"], ); const withIt = flowOf( @@ -297,7 +301,7 @@ test("the transcode station appears only when the channel transcodes", () => { ); assert.deepEqual( withIt.gaps.map((g) => g.to), - ["download", "transcode", "transcribe", "digest", "backfill"], + ["download", "transcode", "transcribe", "digest", "speakers"], ); assert.equal(withIt.gaps.find((g) => g.to === "transcode")?.reachable, 2); assert.equal(station(withIt, "transcode").through, 58); diff --git a/editor/app/channels/[slug]/lib/channelFlow.ts b/editor/app/channels/[slug]/lib/channelFlow.ts @@ -66,7 +66,10 @@ export type FlowStationId = | "transcode" | "transcribe" | "digest" - | "backfill"; + // Renamed with StageId — see stageStatus.ts. The station and the stage card it + // links to must carry the same id or `?stage=${station.stage}` opens the wrong + // panel. + | "speakers"; // One pipeline drawn under a station. The band is the same instrument the // /channels strip and the /operations rail draw, at station scale — which is @@ -351,8 +354,8 @@ export function computeChannelFlow( stage: "digest", operations: opsFor("digest"), }, - backfill: { - id: "backfill", + speakers: { + id: "speakers", // NAMES THE WORK, NOT THE QUEUE. "Backfill" is a scheduler key — three // operations happen to share it — and an operator cannot control, arm or // pause "a backfill". They can pause speaker work. The name is derived @@ -368,12 +371,12 @@ export function computeChannelFlow( coverage: laneLead ? ratio(laneLead.band.present, laneLead.band.eligible) : null, - running: stages.backfill.running, + running: stages.speakers.running, // A station whose lane is DISABLED is neutral — never "ok" and never // amber. An empty work list because a feature is off is not the same as // being finished, and colouring it green claims a thing nobody checked. - tone: laneOff ? "neutral" : stages.backfill.tone, - stage: "backfill", + tone: laneOff ? "neutral" : stages.speakers.tone, + stage: "speakers", operations: laneOps, }, }; @@ -384,7 +387,7 @@ export function computeChannelFlow( ...(transcodeApplies ? (["transcode"] as const) : []), "transcribe", "digest", - "backfill", + "speakers", ]; const stations = order.map((id) => stationById[id]); @@ -473,25 +476,25 @@ export function computeChannelFlow( "The digest pass recorded something a human should look at.", ), ], - backfill: laneOff + speakers: laneOff ? [] : [ ...siding( "needs media re-acquired", laneMissingInput, - "backfill", + "speakers", "The source audio is gone; re-acquiring it is an opt-in re-download.", ), ...siding( "deferred", laneDeferred, - "backfill", + "speakers", deferredHintFor(backfillKinds), ), ...siding( "waiting on an earlier backfill", laneBlocked, - "backfill", + "speakers", dependsOnHint(backfillKinds), ), ], @@ -505,7 +508,7 @@ export function computeChannelFlow( transcode: transcodeApplies ? buckets.untranscoded.length : 0, transcribe: actionableDownloadedNoTranscript.length, digest: digestWork.reachable, - backfill: laneReachable, + speakers: laneReachable, }; const GAP_LABEL: Record<FlowStationId, string> = { @@ -514,7 +517,9 @@ export function computeChannelFlow( transcode: "to transcode", transcribe: "to transcribe", digest: "to digest", - backfill: "to backfill", + // A VALUE tsc keys but does not spell, so it is hand-checked: + // channel-line.spec.ts asserts this string. + speakers: "to speakers", }; const gaps: FlowGap[] = []; @@ -561,7 +566,7 @@ function pickNext( // A verb and its object, derived from the group — "Run speaker work". The // button used to read "Backfill", which is a queue key with no object and // nothing an operator recognises as a thing they wanted done. - backfill: `Run ${operationsActionLabel(laneKindIds)}`, + speakers: `Run ${operationsActionLabel(laneKindIds)}`, }; for (const gap of gaps) { if (gap.reachable <= 0) continue; diff --git a/editor/app/channels/[slug]/lib/stageOrder.test.ts b/editor/app/channels/[slug]/lib/stageOrder.test.ts @@ -0,0 +1,90 @@ +// Run with: +// node_modules/.bin/tsx --test "editor/app/channels/[slug]/lib/stageOrder.test.ts" +// +// page.tsx builds its stage list from the registry rather than from a literal: +// +// ["configure", "playlist", +// ...OPERATION_GROUP_ORDER.flatMap((g) => GROUP_STAGES[g]) +// .filter((id) => id !== "transcode" || transcodeApplies), +// "cleanup", "diagnostics", "danger"] +// +// That array is what `?stage=` is resolved against and what the switcher renders +// in order, so a change to it is a change to every stage link on the page. tsc +// checks MEMBERSHIP (GROUP_STAGES is an exhaustive Record<OperationGroup, …>) +// but not ORDER and not CARDINALITY — a group that gained a second stage, or an +// OPERATION_GROUP_ORDER someone resorted, would compile and silently reorder the +// page. This pins both against the literal list that shipped before the +// derivation, for each value of the one per-channel input. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { OPERATION_GROUP_ORDER } from "yt-dlp-transcript-common/lib/backfillKinds"; +import { GROUP_STAGES, type StageId } from "./stageStatus"; + +// The expression from page.tsx, verbatim. Duplicated rather than exported and +// imported because page.tsx is a server component that reads the filesystem at +// module scope; what is worth pinning is the SHAPE, and a copy that drifted from +// the page would fail this test by construction on the next edit to either. +function stageOrder(transcodeApplies: boolean): StageId[] { + return [ + "configure", + "playlist", + ...OPERATION_GROUP_ORDER.flatMap((g) => GROUP_STAGES[g]).filter( + (id) => id !== "transcode" || transcodeApplies, + ), + "cleanup", + "diagnostics", + "danger", + ]; +} + +test("the derived stage order is the list that shipped, transcode included", () => { + assert.deepEqual(stageOrder(true), [ + "configure", + "playlist", + "download", + "transcode", + "transcribe", + "digest", + "speakers", + "cleanup", + "diagnostics", + "danger", + ]); +}); + +test("…and the same list without transcode where it does not apply", () => { + // Per-CHANNEL, not per-install: whether a platform needs a transcode pass is a + // fact about the channel, which is why it is filtered here rather than left + // out of GROUP_STAGES. `media` owns both stages either way. + assert.deepEqual(stageOrder(false), [ + "configure", + "playlist", + "download", + "transcribe", + "digest", + "speakers", + "cleanup", + "diagnostics", + "danger", + ]); +}); + +test("every operation group names at least one stage, and none is orphaned", () => { + // The exhaustiveness tsc gives is on the KEYS. This is the other half: a group + // mapped to an empty array would compile and would mean an operation group + // with nowhere to appear on the channel page. + for (const group of OPERATION_GROUP_ORDER) { + assert.ok( + GROUP_STAGES[group].length > 0, + `${group} names no stage, so its operations have no card`, + ); + } + // And no stage is claimed by two groups, which would render it twice. + const claimed = OPERATION_GROUP_ORDER.flatMap((g) => GROUP_STAGES[g]); + assert.equal( + claimed.length, + new Set(claimed).size, + `a stage is claimed by more than one group: ${claimed.join(",")}`, + ); +}); diff --git a/editor/app/channels/[slug]/lib/stageStatus.ts b/editor/app/channels/[slug]/lib/stageStatus.ts @@ -9,6 +9,7 @@ import { laneEntriesOf, operationsGroupLabel, reachableBackfillWork, + type OperationGroup, } from "yt-dlp-transcript-common/lib/backfillKinds"; export type SnapshotBuckets = ChannelSnapshot["buckets"]; @@ -51,11 +52,43 @@ export type StageId = | "transcode" | "transcribe" | "digest" - | "backfill" + // THE OPERATIONS, NOT THE QUEUE. This card was called "backfill" — a queue key + // wearing a stage's name. Nobody can arm, pause or run "a backfill"; what the + // card actually holds is the speaker work (diarization and the two attribution + // kinds), which is a thing an operator recognises. The queue key BACKFILL_QUEUE + // is untouched: it is a scheduler key and correctly named as one. + // + // ?stage=backfill still resolves here — see STAGE_ALIASES in page.tsx. + | "speakers" | "cleanup" | "diagnostics" | "danger"; +// THE STAGES EACH OPERATION GROUP OWNS, in the group's own order. +// +// Record<> is EXHAUSTIVE, which is the whole point: a new OperationGroup does +// not compile until it names its stage(s). Before this, the channel page's stage +// list was a hand-written literal, so a registered operation in a new group got +// a card only if someone remembered to add one — and the failure mode was a +// silent absence, not an error. +// +// NOT 1:1 with the groups, and no honest derivation makes it so: `media` owns +// two stages (download and transcode). So this is a Record of ARRAYS, spread in +// OPERATION_GROUP_ORDER — which yields exactly today's order and today's +// cardinality. It is a compile-time membership check, not a re-shaping of the +// page. +// +// DELIBERATELY ONLY THE MIDDLE. configure/playlist and cleanup/diagnostics/ +// danger are channel CHORES, not operations — nothing registers them and no +// group owns them — so they stay hand-listed at the call site. Do not "finish" +// this derivation by inventing groups for them. +export const GROUP_STAGES: Record<OperationGroup, readonly StageId[]> = { + media: ["download", "transcode"], + transcript: ["transcribe"], + digest: ["digest"], + speakers: ["speakers"], +}; + export type StageTone = "neutral" | "attention" | "danger" | "running" | "ok"; export type StageStatus = { @@ -87,14 +120,14 @@ const JOB_KIND_TO_STAGE: Record<string, StageId> = { "digest-channel-local": "digest", "digest-channel-remote": "digest", "digest-share-cluster": "digest", - "backfill-channel": "backfill", + "backfill-channel": "speakers", // The sweep is corpus-wide and carries no channelSlug, so it will not normally // appear in a channel's running jobs — mapped anyway so that if one ever is // attributed here it lights the right card rather than none. - "backfill-sweep": "backfill", + "backfill-sweep": "speakers", // The pre-registry per-channel diarization button lands on the channel queue // but is the same work this card is about, so it lights this card too. - "diarize-channel": "backfill", + "diarize-channel": "speakers", "clean-audio-transcribed": "cleanup", "purge-superseded-auto-subs": "cleanup", "clean-extra-audio-formats": "cleanup", @@ -424,7 +457,7 @@ export function computeStageStatuses( // measured corpus, so counting it would hold every channel permanently amber // for work that cannot be done without an opt-in re-download — precisely the // trap /api/widget/actionable documents for `noDigest`. - const backfillRunning = runningByStage.has("backfill"); + const backfillRunning = runningByStage.has("speakers"); // laneEntriesOf, not Object.values. The snapshot's per-kind map carries every // operation in the catalog now, including digest — which runs on its own queue // key, has its own stage card directly above, and would otherwise add ~75,000 @@ -482,8 +515,8 @@ export function computeStageStatuses( `${backfillBlocked.toLocaleString()} waiting on an earlier backfill`, ); } - const backfill: StageStatus = { - id: "backfill", + const speakers: StageStatus = { + id: "speakers", // NAMED AFTER THE OPERATIONS, NOT THE QUEUE. "Backfill" is a scheduler key // that on this install stands for three different operations; nobody can // arm, pause or run "a backfill". Derived, so a lane that gains a kind from @@ -591,7 +624,7 @@ export function computeStageStatuses( transcode, transcribe, digest, - backfill, + speakers, cleanup, diagnostics, danger, diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -72,6 +72,7 @@ import { laneBackfillKinds, operationsActionLabel, operationsGroupLabel, + OPERATION_GROUP_ORDER, } from "yt-dlp-transcript-common/lib/backfillKinds"; import { ChannelLine } from "./components/flow/ChannelLine"; import { NextAction } from "./components/flow/NextAction"; @@ -83,6 +84,7 @@ import { readChannelConfigCached } from "./lib/channelConfigCache"; import { computeStageStatuses, normalizeBuckets, + GROUP_STAGES, type StageId, } from "./lib/stageStatus"; import { @@ -255,14 +257,24 @@ export default async function ChannelDetailPage({ backfillKindIds: backfillKinds.map((k) => k.id), }); + // THE MIDDLE COMES FROM THE REGISTRY, in OPERATION_GROUP_ORDER. Same array as + // the literal it replaces, in the same order — this is a compile-time + // membership check, not a re-shaping: GROUP_STAGES is an exhaustive + // Record<OperationGroup, StageId[]>, so a new operation group does not compile + // until it says which stage(s) hold it. + // + // The ends stay hand-listed. configure/playlist and cleanup/diagnostics/danger + // are channel chores, not operations — see GROUP_STAGES. + // + // `transcode` is filtered rather than omitted from the Record, because whether + // it applies is a per-CHANNEL fact (does this platform need a transcode pass) + // and the Record is a per-INSTALL one. const stageOrder: StageId[] = [ "configure", "playlist", - "download", - ...(transcodeApplies ? (["transcode"] as const) : []), - "transcribe", - "digest", - "backfill", + ...OPERATION_GROUP_ORDER.flatMap((g) => GROUP_STAGES[g]).filter( + (id) => id !== "transcode" || transcodeApplies, + ), "cleanup", "diagnostics", "danger", @@ -274,8 +286,16 @@ export default async function ChannelDetailPage({ // link to a stage that no longer applies (e.g. transcode) should still open // the channel. const rawStage = typeof sp.stage === "string" ? sp.stage : undefined; + // RETIRED STAGE IDS, so a bookmark does not silently land somewhere else. + // An unknown `?stage=` resolves to the OVERVIEW at 200 (pinned by + // channel-stage-selection.spec.ts), which is right for a stage that no longer + // applies — but wrong for one that was RENAMED: `?stage=backfill` would open + // the overview with no hint that the panel it named is still there under a new + // id. Resolve the alias first and the old link keeps working. + const STAGE_ALIASES: Record<string, StageId> = { backfill: "speakers" }; + const resolvedStage = rawStage ? (STAGE_ALIASES[rawStage] ?? rawStage) : undefined; const selectedStage: StageId | null = - (stageOrder.find((id) => id === rawStage) as StageId | undefined) ?? null; + (stageOrder.find((id) => id === resolvedStage) as StageId | undefined) ?? null; const flow = computeChannelFlow({ snapshot, @@ -415,7 +435,7 @@ export default async function ChannelDetailPage({ /> ); } - case "backfill": + case "speakers": return ( <BackfillStage slug={slug} diff --git a/editor/e2e/attribution.spec.ts b/editor/e2e/attribution.spec.ts @@ -148,7 +148,7 @@ async function seedChannel(opts: { diarization?: boolean } = {}) { } async function runBackfill(page: Page): Promise<void> { - await page.goto(channelStage(CHANNEL, "backfill")); + await page.goto(channelStage(CHANNEL, "speakers")); await page .getByRole("button", { name: "Run speaker work", exact: true }) .click(); @@ -227,7 +227,7 @@ test("the diarized lane upgrades a text-only record", async ({ page }) => { // diarized record genuinely was never made. One panel is open at a time now, // so the Backfill card has to be asked for — it used to be on screen because // every stage rendered expanded. - await page.goto(channelStage(CHANNEL, "backfill")); + await page.goto(channelStage(CHANNEL, "speakers")); await expect( page.getByLabel("video needing a backfill attrvid0001"), ).toBeVisible(); @@ -301,8 +301,8 @@ test("the stage card and /actionable show attribution beside diarization, with t }); await generateReport(page, CHANNEL); - await page.goto(channelStage(CHANNEL, "backfill")); - const section = page.getByLabel("backfill section"); + await page.goto(channelStage(CHANNEL, "speakers")); + const section = page.getByLabel("speakers section"); // Per-kind lines appear because there is now more than one kind — the same // component, unmodified, driven by a bigger registry. diff --git a/editor/e2e/backfill.spec.ts b/editor/e2e/backfill.spec.ts @@ -151,8 +151,8 @@ test("the stage card separates reachable work from what needs its media back", a }); await generateReport(page, SLUG); - await page.goto(channelStage(SLUG, "backfill")); - const section = page.getByLabel("backfill section"); + await page.goto(channelStage(SLUG, "speakers")); + const section = page.getByLabel("speakers section"); await expect(section.getByLabel("backfill reachable")).toContainText( "1 video can be worked on now", ); @@ -181,7 +181,7 @@ test("running the lane captures the reachable video and skips the one with no me await rm(resolvePath(dataRel("vidB", f)), { force: true }); } - await page.goto(channelStage(SLUG, "backfill")); + await page.goto(channelStage(SLUG, "speakers")); await page .getByRole("button", { name: "Run speaker work", exact: true }) .click(); @@ -225,7 +225,7 @@ test("a sidecar from a different threshold is regenerated", async ({ page }) => }) + "\n", ); - await page.goto(channelStage(SLUG, "backfill")); + await page.goto(channelStage(SLUG, "speakers")); await page .getByRole("button", { name: "Run speaker work", exact: true }) .click(); @@ -258,7 +258,7 @@ test("re-acquired media is deleted after a successful backfill", async ({ } expect(await audioFiles("vidB")).toEqual([]); - await page.goto(channelStage(SLUG, "backfill")); + await page.goto(channelStage(SLUG, "speakers")); await page .getByRole("button", { name: "Run speaker work", exact: true }) .click(); @@ -292,7 +292,7 @@ test("re-acquired media is deleted even when the backfill fails", async ({ await rm(resolvePath(dataRel("vidC", f)), { force: true }); } - await page.goto(channelStage(SLUG, "backfill")); + await page.goto(channelStage(SLUG, "speakers")); await page .getByRole("button", { name: "Run speaker work", exact: true }) .click(); @@ -328,7 +328,7 @@ test("re-acquired media is KEPT when the video is marked do-not-clean", async ({ JSON.stringify({ setAt: new Date().toISOString(), note: "e2e" }) + "\n", ); - await page.goto(channelStage(SLUG, "backfill")); + await page.goto(channelStage(SLUG, "speakers")); await page .getByRole("button", { name: "Run speaker work", exact: true }) .click(); @@ -364,7 +364,7 @@ test("the disk floor refuses to re-acquire anything", async ({ page }) => { await rm(resolvePath(dataRel("vidB", f)), { force: true }); } - await page.goto(channelStage(SLUG, "backfill")); + await page.goto(channelStage(SLUG, "speakers")); await page .getByRole("button", { name: "Run speaker work", exact: true }) .click(); @@ -607,7 +607,7 @@ test("a backfill runs concurrently with a transcription", async ({ ), { timeout: 30_000 }) .toBe(true); - await page.goto(channelStage(SLUG, "backfill")); + await page.goto(channelStage(SLUG, "speakers")); await page .getByRole("button", { name: "Run speaker work", exact: true }) .click(); @@ -691,7 +691,7 @@ test("a digest runs concurrently with a backfill, not behind it", async ({ await writeDigestVideo({ channelSlug: SLUG, videoId: "digestme" }); await generateReport(page, SLUG); - await page.goto(channelStage(SLUG, "backfill")); + await page.goto(channelStage(SLUG, "speakers")); await page .getByRole("button", { name: "Run speaker work", exact: true }) .click(); @@ -879,8 +879,8 @@ test("a digest entry in the snapshot does not move the backfill instrument", asy expect(snapshot.backfill?.diarization?.missing).toBe(1); // HALF TWO: every surface that sums the lane ignores it. - await page.goto(channelStage(SLUG, "backfill")); - const backfillSection = page.getByLabel("backfill section"); + await page.goto(channelStage(SLUG, "speakers")); + const backfillSection = page.getByLabel("speakers section"); // 1 — vidA's diarization. NOT 3, which is what folding the digest entry in // would produce here. await expect(backfillSection.getByLabel("backfill reachable")).toContainText( diff --git a/editor/e2e/channel-line.spec.ts b/editor/e2e/channel-line.spec.ts @@ -231,7 +231,7 @@ test("a lane that is switched off reads neutral, not finished", async ({ await writeSettings(laneSettings(true)); await page.goto(`/channels/${SLUG}`); await expect( - page.getByLabel("channel line").getByLabel("to backfill shortfall"), + page.getByLabel("channel line").getByLabel("to speakers shortfall"), ).toContainText("7"); await expect( page.getByLabel("channel line").getByLabel("Speakers through"), @@ -247,9 +247,9 @@ test("a lane that is switched off reads neutral, not finished", async ({ // operationsGroupLabel over an empty set. It cannot claim "Speakers" when no // speaker operation is on. await expect(line.getByLabel("Derived data through")).toHaveText("—"); - await expect(line.getByLabel("to backfill shortfall")).toContainText("0"); + await expect(line.getByLabel("to speakers shortfall")).toContainText("0"); // …and the stage says why rather than claiming completion. - await page.goto(channelStage(SLUG, "backfill")); + await page.goto(channelStage(SLUG, "speakers")); await expect(page.getByLabel("Derived data stage summary")).toContainText( "Nothing here is enabled", ); diff --git a/editor/e2e/channel-stage-selection.spec.ts b/editor/e2e/channel-stage-selection.spec.ts @@ -27,7 +27,11 @@ test("?stage=download renders the Download panel and NOT the Transcribe one", as ).toHaveCount(0); // Nor is any other panel along for the ride. await expect(page.getByRole("button", { name: "Verify" })).toHaveCount(0); - await expect(page.getByLabel("backfill section")).toHaveCount(0); + // THE LIVE LABEL, not the pre-rename one. This assertion passed vacuously the + // moment the stage card's aria-label changed from "backfill section" — an + // absent-count check against a label nothing renders is always 0 — so it is + // spelled with the label the card actually carries. + await expect(page.getByLabel("speakers section")).toHaveCount(0); // The selected tab says so. await expect(page.getByLabel("Download stage tab")).toHaveAttribute( @@ -92,6 +96,20 @@ test("an unknown ?stage= falls back to the overview rather than 404ing", async ( await expect(page.getByLabel("overview panel")).toBeVisible(); }); +test("?stage=backfill still opens the speakers panel", async ({ page }) => { + // A RENAMED stage is not an unknown one. The test above pins that an unknown + // ?stage= lands on the overview at 200, which is right for a stage that no + // longer applies — and would be wrong here: a bookmarked ?stage=backfill would + // open the overview with nothing saying the panel it named is still there. The + // alias in page.tsx is what makes the old link keep working. + await resetData("one-transcribe-channel-with-audio"); + await generateReport(page, SLUG); + const resp = await page.goto(`/channels/${SLUG}?stage=backfill`); + expect(resp?.status()).toBe(200); + await expect(page.getByLabel("speakers section")).toBeVisible(); + await expect(page.getByLabel("overview panel")).toHaveCount(0); +}); + test("the Videos tab is a separate route, and the stage panels are not on it", async ({ page, }) => { diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts @@ -340,7 +340,7 @@ export type ChannelStage = | "transcode" | "transcribe" | "digest" - | "backfill" + | "speakers" | "cleanup" | "diagnostics" | "danger";