The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
// @vitest-environment jsdom
|
||
//
|
||
// A subscribed channel's ONE action list: what the manager row's "…" (and its right-click,
|
||
// long-press and Shift+F10) offers — and the row buttons it replaced, now rows here: full history,
|
||
// hide (an undo toast instead of a confirm), unsubscribe (danger, confirmed) — and the Tags and
|
||
// Priority submenus.
|
||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||
import { renderHook, act, cleanup, waitFor } from "@testing-library/react";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import type { ReactNode } from "react";
|
||
|
||
const h = vi.hoisted(() => ({
|
||
notify: vi.fn(),
|
||
confirm: vi.fn(async () => true),
|
||
role: "user",
|
||
canWrite: true,
|
||
}));
|
||
|
||
vi.mock("react-i18next", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||
return {
|
||
...actual,
|
||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||
};
|
||
});
|
||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||
notify: (...a: unknown[]) => h.notify(...a),
|
||
}));
|
||
vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
|
||
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: h.role, can_write: h.canWrite }) }));
|
||
|
||
import { useChannelActions } from "./useChannelActions";
|
||
import { api, type ManagedChannel, type Tag } from "../lib/api";
|
||
import type { ActionMenuItem } from "./ui/ActionMenu";
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.restoreAllMocks();
|
||
h.notify.mockReset();
|
||
h.confirm.mockReset();
|
||
h.confirm.mockImplementation(async () => true);
|
||
h.role = "user";
|
||
h.canWrite = true;
|
||
tags = [];
|
||
tagsState = "ready";
|
||
onToggleTag.mockReset();
|
||
onPickPriority.mockReset();
|
||
});
|
||
|
||
const ch = (over: Partial<ManagedChannel> = {}): ManagedChannel => ({
|
||
id: "UC1",
|
||
title: "Chan",
|
||
handle: "chan",
|
||
thumbnail_url: null,
|
||
subscriber_count: null,
|
||
video_count: null,
|
||
description: null,
|
||
stored_videos: 0,
|
||
last_video_at: null,
|
||
total_duration_seconds: 0,
|
||
priority: 0,
|
||
hidden: false,
|
||
deep_requested: false,
|
||
deep_in_queue: false,
|
||
tag_ids: [],
|
||
details_synced: false,
|
||
recent_synced: false,
|
||
backfill_done: false,
|
||
...over,
|
||
});
|
||
|
||
const onView = vi.fn();
|
||
const onToggleTag = vi.fn();
|
||
const onPickPriority = vi.fn();
|
||
let tags: Tag[] = [];
|
||
let tagsState: "loading" | "error" | "ready" = "ready";
|
||
const tag = (id: number, name: string) => ({ id, name }) as Tag;
|
||
function mount() {
|
||
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||
);
|
||
return renderHook(
|
||
() =>
|
||
useChannelActions({
|
||
onView,
|
||
onOpenWizard: () => {},
|
||
tags,
|
||
tagsState,
|
||
onToggleTag,
|
||
onPickPriority,
|
||
}),
|
||
{ wrapper },
|
||
);
|
||
}
|
||
const pin = [{ id: "pin", label: "pin", onSelect: () => {} }];
|
||
const ids = (items: { id: string }[]) => items.map((i) => i.id);
|
||
const rowsFor = (c: ManagedChannel) => mount().result.current.items(c, { pin });
|
||
const row = (c: ManagedChannel, id: string) => rowsFor(c).find((i) => i.id === id)!;
|
||
|
||
describe("useChannelActions — the menu", () => {
|
||
it("lists open, pin, tags + priority, the YouTube + copy group, full history, hide and unsubscribe", () => {
|
||
const items = rowsFor(ch());
|
||
expect(ids(items)).toEqual([
|
||
"open",
|
||
"pin",
|
||
"tags",
|
||
"priority",
|
||
"open-youtube",
|
||
"copy-text",
|
||
"copy-link",
|
||
"backfill",
|
||
"hide",
|
||
"unsubscribe",
|
||
]);
|
||
// Groups: tuning, YouTube/copy, the channel's own settings, then the destructive row alone.
|
||
expect(items.filter((i) => i.separatorBefore).map((i) => i.id)).toEqual([
|
||
"tags",
|
||
"open-youtube",
|
||
"backfill",
|
||
"unsubscribe",
|
||
]);
|
||
expect(items.find((i) => i.id === "unsubscribe")).toMatchObject({ danger: true });
|
||
expect(items.find((i) => i.id === "copy-text")!.label).toBe("common.copy.name");
|
||
});
|
||
|
||
it("has no unsubscribe without the YouTube write scope", () => {
|
||
h.canWrite = false;
|
||
expect(ids(rowsFor(ch()))).not.toContain("unsubscribe");
|
||
});
|
||
|
||
it("opens the channel page, and YouTube in a new tab inside the click", () => {
|
||
const open = vi.spyOn(window, "open").mockReturnValue(null);
|
||
row(ch(), "open").onSelect!();
|
||
expect(onView).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }));
|
||
const yt = row(ch(), "open-youtube");
|
||
expect("inGesture" in yt && yt.inGesture).toBe(true);
|
||
yt.onSelect!();
|
||
expect(open).toHaveBeenCalledWith(
|
||
"https://www.youtube.com/@chan",
|
||
"_blank",
|
||
"noopener,noreferrer",
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("useChannelActions — full history", () => {
|
||
it("asks for it, and takes a pending ask back", async () => {
|
||
const upd = vi.spyOn(api, "updateChannel").mockResolvedValue({} as never);
|
||
const ask = row(ch(), "backfill");
|
||
expect(ask.label).toBe("channels.row.backfillThis");
|
||
act(() => ask.onSelect!());
|
||
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { deep_requested: true }));
|
||
const cancel = row(ch({ deep_requested: true }), "backfill");
|
||
expect(cancel.label).toBe("channels.row.cancelBackfill");
|
||
act(() => cancel.onSelect!());
|
||
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { deep_requested: false }));
|
||
});
|
||
|
||
it("stays in place, off, with the reason, once it is in or on its way for everyone", () => {
|
||
expect(row(ch({ backfill_done: true }), "backfill")).toMatchObject({
|
||
disabled: true,
|
||
note: "channels.row.fullHint",
|
||
});
|
||
expect(row(ch({ deep_in_queue: true }), "backfill")).toMatchObject({
|
||
disabled: true,
|
||
note: "channels.row.queuedByOtherHint",
|
||
});
|
||
});
|
||
|
||
it("gives an admin the reset, behind a confirm that names the reset", async () => {
|
||
h.role = "admin";
|
||
const reset = vi.spyOn(api, "resetChannelBackfill").mockResolvedValue({} as never);
|
||
const it_ = row(ch({ backfill_done: true }), "backfill");
|
||
expect(it_).toMatchObject({ label: "channels.row.resetBackfill" });
|
||
expect(it_.disabled).toBeFalsy();
|
||
await act(async () => it_.onSelect!());
|
||
expect(h.confirm).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
title: "channels.row.resetBackfill",
|
||
confirmLabel: "channels.row.resetBackfill",
|
||
}),
|
||
);
|
||
await waitFor(() => expect(reset).toHaveBeenCalledWith("UC1"));
|
||
});
|
||
});
|
||
|
||
describe("useChannelActions — hide and unsubscribe", () => {
|
||
it("hides without a confirm, and the toast's Undo shows the channel again", async () => {
|
||
const upd = vi.spyOn(api, "updateChannel").mockResolvedValue({} as never);
|
||
const { result } = mount();
|
||
act(() => result.current.items(ch(), { pin }).find((i) => i.id === "hide")!.onSelect!());
|
||
await waitFor(() => expect(h.notify).toHaveBeenCalled());
|
||
expect(h.confirm).not.toHaveBeenCalled();
|
||
expect(upd).toHaveBeenCalledWith("UC1", { hidden: true });
|
||
const toast = h.notify.mock.calls[0]![0] as {
|
||
message: string;
|
||
action: { onClick: () => void };
|
||
};
|
||
expect(toast.message).toBe("channels.notify.hidden");
|
||
act(() => toast.action.onClick());
|
||
await waitFor(() => expect(upd).toHaveBeenCalledWith("UC1", { hidden: false }));
|
||
});
|
||
|
||
it("unsubscribes only after the danger confirm says yes", async () => {
|
||
const unsub = vi.spyOn(api, "unsubscribeChannel").mockResolvedValue({} as never);
|
||
const { result } = mount();
|
||
const unsubRow = () => result.current.items(ch(), { pin }).find((i) => i.id === "unsubscribe")!;
|
||
h.confirm.mockImplementation(async () => false);
|
||
await act(async () => unsubRow().onSelect!());
|
||
expect(h.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
|
||
expect(unsub).not.toHaveBeenCalled();
|
||
h.confirm.mockImplementation(async () => true);
|
||
await act(async () => unsubRow().onSelect!());
|
||
await waitFor(() => expect(unsub).toHaveBeenCalledWith("UC1"));
|
||
});
|
||
});
|
||
|
||
const sub = (it: ActionMenuItem) => (it.items ?? []) as ActionMenuItem[];
|
||
|
||
describe("useChannelActions — the Tags submenu", () => {
|
||
it("lists the reader's tags A–Z, the channel's own ticked, each a toggle that stays open", () => {
|
||
tags = [tag(3, "rust"), tag(1, "Go"), tag(2, "music")];
|
||
const items = sub(row(ch({ tag_ids: [2] }), "tags"));
|
||
expect(items.map((i) => i.label)).toEqual(["Go", "music", "rust"]);
|
||
expect(items.map((i) => ("check" in i ? i.check : null))).toEqual([
|
||
{ kind: "many", checked: false },
|
||
{ kind: "many", checked: true },
|
||
{ kind: "many", checked: false },
|
||
]);
|
||
items[1]!.onSelect!();
|
||
expect(onToggleTag).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 2);
|
||
});
|
||
|
||
// A failed /tags read said "no tags yet" — an error dressed as an empty list.
|
||
it("says the tags are loading, or failed to load, instead of 'no tags yet'", () => {
|
||
tagsState = "loading";
|
||
expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "common.loading" });
|
||
tagsState = "error";
|
||
expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "common.loadError" });
|
||
});
|
||
|
||
it("stays in place, off, saying where tags are made, with no tag yet", () => {
|
||
expect(row(ch(), "tags")).toMatchObject({ disabled: true, note: "channels.row.noTagsYet" });
|
||
expect(row(ch(), "tags").items).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe("useChannelActions — the Priority submenu", () => {
|
||
it("offers the fixed steps with the current one ticked, and picks a new one", () => {
|
||
const items = sub(row(ch({ priority: 3 }), "priority"));
|
||
expect(items.map((i) => i.label)).toEqual(["10", "5", "3", "2", "1", "0"]);
|
||
expect(items.filter((i) => "check" in i && i.check?.checked).map((i) => i.label)).toEqual([
|
||
"3",
|
||
]);
|
||
expect(items.every((i) => "check" in i && i.check?.kind === "one")).toBe(true);
|
||
items[0]!.onSelect!();
|
||
expect(onPickPriority).toHaveBeenCalledWith(expect.objectContaining({ id: "UC1" }), 10);
|
||
});
|
||
|
||
it("shows an off-scale priority ticked in its place, and re-picking the current one is a no-op", () => {
|
||
const items = sub(row(ch({ priority: 7 }), "priority"));
|
||
expect(items.map((i) => i.label)).toEqual(["10", "7", "5", "3", "2", "1", "0"]);
|
||
items[1]!.onSelect!();
|
||
expect(onPickPriority).not.toHaveBeenCalled();
|
||
});
|
||
});
|