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.
151 lines
6.0 KiB
TypeScript
151 lines
6.0 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// jsdom (not node): importing ../api pulls in i18n, which reads navigator.language at module load.
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// Spy on the toast sink so we can assert exactly what each error maps to.
|
|
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
|
|
vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
|
|
|
|
import { HttpError, api } from "./api";
|
|
import { alreadyReported } from "./api/core";
|
|
import { notify } from "./notifications";
|
|
import { reportError } from "./errorDialog";
|
|
import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors";
|
|
import i18n from "../i18n";
|
|
|
|
const notifyMock = vi.mocked(notify);
|
|
beforeEach(() => {
|
|
notifyMock.mockClear();
|
|
vi.mocked(reportError).mockClear();
|
|
});
|
|
|
|
describe("a Plex collection name that is already taken", () => {
|
|
it("is reported once, in the reader's language", async () => {
|
|
// The 409 was a bare English string: the global error dialog showed it in English, and the
|
|
// editor's own toast said the same thing again, translated.
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ detail: { code: "collection", reason: "name_taken" } }), {
|
|
status: 409,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
const err = await api.plexCreateCollection("1", "Marvel", "m1").catch((e: unknown) => e);
|
|
notifyMock.mockClear();
|
|
notifyActionError(err, "EDIT_FAILED");
|
|
expect(notifyMock.mock.calls).toEqual([
|
|
[{ level: "error", title: "EDIT_FAILED", message: i18n.t("plex.collection.nameTaken") }],
|
|
]);
|
|
expect(reportError).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("notifyActionError", () => {
|
|
it("does not toast a fault the request layer already put on screen", async () => {
|
|
// A 500 opens the blocking error dialog inside `req`; the watched toggles, the saved views and a
|
|
// playlist delete then toasted the same failure as well.
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(JSON.stringify({ detail: "boom" }), {
|
|
status: 500,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
);
|
|
const err = await api.plexSetState("1", "watched").catch((e: unknown) => e);
|
|
notifyMock.mockClear();
|
|
notifyActionError(err, "FAILED");
|
|
expect(notifyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does toast one the caller was left to handle", () => {
|
|
notifyActionError(new HttpError(404, "gone"), "FAILED");
|
|
expect(notifyMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports one failure once, however many callers try", () => {
|
|
// What keeps the app-wide mutation reporter quiet for a mutation whose own onError already spoke.
|
|
const err = new HttpError(404, "gone");
|
|
notifyActionError(err, "FIRST");
|
|
notifyActionError(err, "SECOND");
|
|
expect(notifyMock).toHaveBeenCalledTimes(1);
|
|
expect(alreadyReported(err)).toBe(true);
|
|
});
|
|
|
|
it("says nothing about an upload the user cancelled", () => {
|
|
notifyActionError(new HttpError(0, "aborted"), "FAILED");
|
|
expect(notifyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("says a bare-English server reason in the reader's language, not the server's", () => {
|
|
// `reportFailure` leaves 403/404/429 to the caller, so for exactly those statuses whatever
|
|
// English a route hand-wrote went straight into the toast — "No user with that email." in an
|
|
// otherwise Hungarian UI, while the caller's translated fallback sat unused.
|
|
notifyActionError(new HttpError(404, "No user with that email."), "TRANSLATED");
|
|
expect(notifyMock).toHaveBeenCalledWith({
|
|
level: "error",
|
|
title: "TRANSLATED",
|
|
message: i18n.t("errors.notFound.generic"),
|
|
});
|
|
});
|
|
|
|
it("still prefers a STRUCTURED reason, which the request layer has already localized", () => {
|
|
// `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language
|
|
// AND more specific than anything a status can say — quota numbers, which row is gone.
|
|
const err = new HttpError(404, "Ez a csatorna már nincs a könyvtárban.", {
|
|
code: "not_found",
|
|
reason: "channel",
|
|
});
|
|
notifyActionError(err, "TRANSLATED FALLBACK");
|
|
expect(notifyMock).toHaveBeenCalledWith({
|
|
level: "error",
|
|
title: "TRANSLATED FALLBACK",
|
|
message: "Ez a csatorna már nincs a könyvtárban.",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("failureReason — why a write failed, when the server did not say", () => {
|
|
it.each([
|
|
[403, "errors.reason.forbidden"],
|
|
[404, "errors.notFound.generic"],
|
|
[429, "errors.reason.rateLimited"],
|
|
[502, "errors.reason.unreachable"],
|
|
[503, "errors.reason.unreachable"],
|
|
[504, "errors.reason.unreachable"],
|
|
[0, "errors.reason.unreachable"], // uploadReq's network failure
|
|
[500, "errors.generic"],
|
|
])("a %s reads %s", (status, key) => {
|
|
expect(failureReason(new HttpError(status, "hand-written English"))).toBe(i18n.t(key));
|
|
});
|
|
|
|
it("a request that never reached the server reads as unreachable", () => {
|
|
expect(failureReason(new TypeError("Failed to fetch"))).toBe(
|
|
i18n.t("errors.reason.unreachable"),
|
|
);
|
|
});
|
|
|
|
it("anything else is the app's own fault, not the server's", () => {
|
|
expect(failureReason(new Error("key import failed"))).toBe(i18n.t("errors.reason.unexpected"));
|
|
});
|
|
|
|
it("every reason exists in both languages", () => {
|
|
for (const lng of ["en", "hu"])
|
|
for (const key of [
|
|
"errors.reason.forbidden",
|
|
"errors.reason.rateLimited",
|
|
"errors.reason.unreachable",
|
|
"errors.reason.unexpected",
|
|
])
|
|
expect(i18n.exists(key, { lng }), `${lng}:${key}`).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("notifyKnownFailure", () => {
|
|
it("shows the caller's specific sentence and marks the failure reported", () => {
|
|
const err = new HttpError(409, "x", { code: "resend", reason: "email_off" });
|
|
notifyKnownFailure(err, "EMAIL IS OFF");
|
|
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EMAIL IS OFF" });
|
|
notifyActionError(err, "GENERIC");
|
|
expect(notifyMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|