chore(comments): drop epic, sprint and review-round ids from the code
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.
This commit is contained in:
@@ -30,7 +30,7 @@ test.describe("channel view", () => {
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
});
|
||||
|
||||
// Bug 1 (Platform S4): opening a channel and coming Back restores the feed's scroll position
|
||||
// Opening a channel and coming Back restores the feed's scroll position
|
||||
// rather than dumping you at the top. The shell owns the scroller and remembers each view's offset.
|
||||
test("Back restores the feed scroll position", async ({ page }) => {
|
||||
await openApp(page);
|
||||
@@ -60,7 +60,7 @@ test.describe("channel view", () => {
|
||||
.toBeGreaterThan(saved - 500);
|
||||
});
|
||||
|
||||
// Bug 2 (Platform S5): opening a channel no longer FORCES Live/Upcoming on — the channel view
|
||||
// Opening a channel no longer FORCES Live/Upcoming on — the channel view
|
||||
// seeds its content-type prefs (normal / shorts / live) from the global feed. Locked both ways so
|
||||
// a future refactor can't silently re-hardcode it.
|
||||
test("the channel view inherits the feed's content filters (no forced Live/Upcoming)", async ({
|
||||
@@ -86,7 +86,7 @@ test.describe("channel view", () => {
|
||||
await expectContentType(page, "includeLive", true);
|
||||
});
|
||||
|
||||
// Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there
|
||||
// The channel view is its OWN filter scope — a content toggle made there
|
||||
// must NOT leak back into the global feed. (FeedFiltersProvider owns the feed's filters; the
|
||||
// channel page keeps a separate local cell.)
|
||||
test("a channel's content toggle does not leak into the feed", async ({ page }) => {
|
||||
@@ -106,7 +106,7 @@ test.describe("channel view", () => {
|
||||
await expectContentType(page, "includeLive", false);
|
||||
});
|
||||
|
||||
// Platform S4b + E31 S4c: the channel chrome sits outside the scroller. Scrolling into the videos
|
||||
// The channel chrome sits outside the scroller. Scrolling into the videos
|
||||
// folds the banner and identity once (the name moves into the tab row); from then on the folded
|
||||
// chrome stays put while only the video grid scrolls.
|
||||
test("the channel chrome folds once, then stays fixed while the grid scrolls", async ({
|
||||
|
||||
@@ -51,7 +51,7 @@ test.describe("feed", () => {
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
// Bug 4 (Platform S4): the toolbar (show/content chips, count, sort, view) is fixed chrome — it
|
||||
// The toolbar (show/content chips, count, sort, view) is fixed chrome — it
|
||||
// pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll.
|
||||
test("the feed toolbar stays pinned while cards scroll", async ({ page }) => {
|
||||
await openApp(page);
|
||||
@@ -66,7 +66,7 @@ test.describe("feed", () => {
|
||||
expect(Math.abs(topAfter - topBefore)).toBeLessThan(4);
|
||||
});
|
||||
|
||||
// Bug 3 (Platform S4): changing a filter is a fresh result set, so the scroll jumps back to the
|
||||
// Changing a filter is a fresh result set, so the scroll jumps back to the
|
||||
// top rather than stranding you deep in the old list's offset.
|
||||
test("a new filter resets the scroll to the top", async ({ page }) => {
|
||||
await openApp(page);
|
||||
|
||||
@@ -76,7 +76,7 @@ setup("seed + authenticate", async ({ page }) => {
|
||||
|
||||
// 4. Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay
|
||||
// that would intercept every click — the test account has no YouTube connection, so it
|
||||
// auto-opens), and opt the account out of starting on Home. Since E24 a bare URL lands on
|
||||
// auto-opens), and opt the account out of starting on Home. A bare URL lands on
|
||||
// Home, and the feed, channel and player flows this net locks all open "/" expecting the
|
||||
// feed; the opt-out is the one Settings → Appearance → Startup writes. Both flags are
|
||||
// per-account: `siftlode.onboarding.dismissed.<id>`, `siftlode.startOnHome.<id>`.
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function gotoPage(page: Page, id: string): Promise<void> {
|
||||
* Playwright would scroll it into view to click it, moving the scroll position — fatal to any test
|
||||
* that then asserts on where the feed was. Being inside the viewport is not enough either: a card
|
||||
* can sit under the page chrome above the scroller (the update banner, the toolbar band), and the
|
||||
* covered click makes Playwright scroll and retry — the same move (E32 S2: webkit failed 3 in 24).
|
||||
* covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24).
|
||||
* So the link must be the element hit at its own centre. Returns false if none found.
|
||||
*/
|
||||
export async function clickVisibleChannelLink(page: Page): Promise<boolean> {
|
||||
|
||||
@@ -6,9 +6,9 @@ import { openApp } from "./helpers";
|
||||
// this exercises is precisely what the module-registry refactor replaces.
|
||||
test.describe("page switching", () => {
|
||||
// Core + admin modules (the E2E account is an admin, matching the real primary user), so this
|
||||
// also exercises the admin pages. Since E31 those sit behind ONE rail row ("Administration",
|
||||
// also exercises the admin pages. They sit behind ONE rail row ("Administration",
|
||||
// `nav-admin`) that opens a panel of them — the spec still listed them as rail rows of their own,
|
||||
// and it failed on every run from E31 to E32 without anyone seeing it, because e2e was in no
|
||||
// and it failed on every run for a while without anyone seeing it, because e2e was in no
|
||||
// required gate (it is now part of `release`).
|
||||
const RAIL = ["channels", "playlists", "stats", "settings", "feed"];
|
||||
const ADMIN = ["scheduler", "config", "users", "audit"];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test";
|
||||
import { openApp, openFirstVideo } from "./helpers";
|
||||
|
||||
// Locks the in-app player's open/close contract. The player is a portaled overlay whose
|
||||
// inconsistent mounting caused an E3 regression — exactly the kind of thing the refactor's
|
||||
// inconsistent mounting caused a regression — exactly the kind of thing the refactor's
|
||||
// overlay-root convention must not break. We assert the modal shell, not YouTube playback.
|
||||
test.describe("player modal", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
// E32: an index read is `T | undefined` here, as it really is (0 errors when switched on).
|
||||
// An index read is `T | undefined` here, as it really is (0 errors when switched on).
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
|
||||
+42
-42
@@ -10,21 +10,21 @@ import playwright from "eslint-plugin-playwright";
|
||||
|
||||
// Flat config (ESLint 9). Deliberately NARROW: the high-value rules here are react-hooks' (the
|
||||
// hook rules and the React Compiler's diagnostics). The 33 inline `exhaustive-deps` suppressions this
|
||||
// config first met are all gone (0 left, E32 review); what is still suppressed is suppressed inline,
|
||||
// config first met are all gone (0 left); what is still suppressed is suppressed inline,
|
||||
// one site at a time, each naming its invariant. tsc already owns unused vars, undefined names
|
||||
// and types (noUnusedLocals/Parameters + strict), so typescript-eslint's non-type-checked
|
||||
// `recommended` is layered on but its overlap with tsc is turned off below to avoid double-reporting
|
||||
// and a churny diff. `eslint-config-prettier` is LAST so formatting rules never fight Prettier.
|
||||
//
|
||||
// reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself
|
||||
// an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate).
|
||||
// an error — that is what keeps inert disable comments from re-accumulating.
|
||||
//
|
||||
// E32 (gate integrity): every switched-off or narrowed rule here is listed, with its reason and its
|
||||
// Gate integrity: every switched-off or narrowed rule here is listed, with its reason and its
|
||||
// measured count, in docs/gates.md, and lib/gates.contract.test.ts fails when one is not. A rule
|
||||
// with a single real hit is never switched off — its sites are fixed, or each suppressed inline with
|
||||
// a `-- <the invariant>` description (eslint-comments/require-description makes that mandatory).
|
||||
|
||||
// E22 S4: every way out of the browser goes through the one module that LOGS it — a request line
|
||||
// Every way out of the browser goes through the one module that LOGS it — a request line
|
||||
// with a request id (lib/http.ts), the upload XHR (lib/api/core.ts). A bare `fetch` elsewhere is a
|
||||
// request the client log never sees, which is exactly how the baseline survey found four. Each owner
|
||||
// is exempt from its OWN global only; a global with no owner (null) has no exempt file at all.
|
||||
@@ -40,16 +40,16 @@ const networkGlobals = (exempt) =>
|
||||
.map(([name, owner]) => ({
|
||||
name,
|
||||
message: owner
|
||||
? `Use the logged request layer: \`${name}\` belongs to ${owner} only (E22 S4 — a request outside it never reaches the client log).`
|
||||
: `No \`${name}\` without a logged owner module (E22 S4).`,
|
||||
? `Use the logged request layer: \`${name}\` belongs to ${owner} only — a request outside it never reaches the client log.`
|
||||
: `No \`${name}\` without a logged owner module.`,
|
||||
}));
|
||||
const networkProperties = [
|
||||
{ object: "window", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
|
||||
{ object: "globalThis", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
|
||||
{ object: "window", property: "fetch", message: "Use lib/http.ts `send`." },
|
||||
{ object: "globalThis", property: "fetch", message: "Use lib/http.ts `send`." },
|
||||
{
|
||||
object: "navigator",
|
||||
property: "sendBeacon",
|
||||
message: "Use lib/api/core.ts `beacon` (E22 S4).",
|
||||
message: "Use lib/api/core.ts `beacon`.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -70,7 +70,7 @@ const jsxCommentText = {
|
||||
};
|
||||
},
|
||||
};
|
||||
// E28 S3g + E30 S3 (docs/DESIGN.md "Copy"): a date, a number and an alphabetical order are all in the
|
||||
// docs/DESIGN.md "Copy": a date, a number and an alphabetical order are all in the
|
||||
// UI language — dates and numbers built in lib/format.ts, text compared in lib/collate.ts. A bare
|
||||
// `toLocaleString()` formats in the BROWSER's locale (seven date sites had drifted into five shapes), a
|
||||
// bare `toFixed` writes the English decimal point, a bare `localeCompare` sorts in the browser's
|
||||
@@ -134,7 +134,7 @@ const localeFormat = {
|
||||
};
|
||||
},
|
||||
};
|
||||
// E28 S3b: one primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had
|
||||
// One primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had
|
||||
// drifted into as many looks, heights and hover styles; they were swept onto ui/Button, IconButton,
|
||||
// Pressable, Chip and DragHandle, and this keeps the next one out. The raw element lives only in the
|
||||
// files that ARE a control primitive — not in the rest of components/ui/, whose composites (a pager, a
|
||||
@@ -144,7 +144,7 @@ const localeFormat = {
|
||||
// native disabled drops a focused keyboard user to <body> the moment the button turns disabled (a
|
||||
// mutation pending, a list at its end, a form gone clean) — measured in Chrome, and ~60 buttons in this
|
||||
// app are disabled by their own click. ui/FocusSafeButton (and ui/Button on top of it) keeps the
|
||||
// focus. (It was its own rule, `focus-safe-disabled`, until E35.)
|
||||
// focus. (It used to be its own rule, `focus-safe-disabled`.)
|
||||
const BUTTON_PRIMITIVES = new Set([
|
||||
"FocusSafeButton.tsx", // the focus-keeping <button> everything stands on
|
||||
"Button.tsx",
|
||||
@@ -196,8 +196,8 @@ const buttonPrimitive = {
|
||||
else if (exempt) return;
|
||||
else if (name === "button" || name === "FocusSafeButton")
|
||||
context.report({ node, messageId: "raw", data: { name } });
|
||||
// S3g: the same button spelled as a div with a role slipped past the rule above (my own
|
||||
// S3h Plex picture). A component (capitalised) may take a role for its own reasons.
|
||||
// The same button spelled as a div with a role slipped past the rule above (one Plex
|
||||
// picture did it). A component (capitalised) may take a role for its own reasons.
|
||||
else if (
|
||||
/^[a-z]/.test(name) &&
|
||||
node.attributes.some(
|
||||
@@ -213,8 +213,8 @@ const buttonPrimitive = {
|
||||
};
|
||||
},
|
||||
};
|
||||
// The same for a text field and the native checkbox / slider / colour well (docs/DESIGN.md A4, A6;
|
||||
// E28 S3c): ~100 hand-styled boxes carried six edges, four radii and two focus looks between them. A
|
||||
// The same for a text field and the native checkbox / slider / colour well (docs/DESIGN.md A4, A6):
|
||||
// ~100 hand-styled boxes carried six edges, four radii and two focus looks between them. A
|
||||
// file picker has no look (it is hidden behind a Button), so it passes.
|
||||
const INPUT_PRIMITIVES = new Set([
|
||||
"Input.tsx",
|
||||
@@ -247,7 +247,7 @@ const inputPrimitive = {
|
||||
};
|
||||
},
|
||||
};
|
||||
// B5 (docs/DESIGN.md; E28 S3d): a glass surface is a ROLE from ui/surfaces.ts, which brings its radius.
|
||||
// B5 (docs/DESIGN.md): a glass surface is a ROLE from ui/surfaces.ts, which brings its radius.
|
||||
// ~140 sites had paired the three glass tiers with five radii and three menu paddings. A class string
|
||||
// (two or more tokens, so `variant="glass"` passes) holding a bare glass tier is refused outside the
|
||||
// role table and the control primitives that paint a glass BUTTON face.
|
||||
@@ -389,7 +389,7 @@ const sortableA11y = {
|
||||
},
|
||||
};
|
||||
// An <a> whose content is only a glyph is a link that looks like a button (DESIGN.md): `IconLink`, with
|
||||
// a name, a 24+ box and the icon button's layer. The E28 audit found five bare 14px ones. Text or an
|
||||
// a name, a 24+ box and the icon button's layer. An audit found five bare 14px ones. Text or an
|
||||
// expression anywhere in its CHILDREN (not its attributes: every link has an `href={…}`) is a text
|
||||
// link or a card, and passes; a self-closing <a> is a `Trans` slot that i18n fills with text.
|
||||
const glyphLink = {
|
||||
@@ -434,7 +434,7 @@ const localRules = {
|
||||
// A px-frozen BOX (the no-restricted-syntax block below): an esquery regex, as a string. A box utility
|
||||
// (`w-[150px]`) or a grid's auto-fill column MINIMUM (`minmax(150px,1fr)`): the text inside grows with
|
||||
// the slider while the box or the column floor stays, so it re-wraps, clips or cramps at a large text
|
||||
// size (E30 review for the grid). `[\d.]+`, not `\d+`: a fractional `text-[9.5px]` slipped past the
|
||||
// size (seen in the grid). `[\d.]+`, not `\d+`: a fractional `text-[9.5px]` slipped past the
|
||||
// integer-only pattern. A fixed track (`grid-cols-[1fr_240px]`) is left alone, as is VirtualGrid's
|
||||
// measured `minCol` (a number).
|
||||
const BOX_PX =
|
||||
@@ -459,14 +459,14 @@ const PALETTE_MESSAGE =
|
||||
"a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.";
|
||||
|
||||
// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`),
|
||||
// or the interpolation IS the variant and the text goes on with its class (`${NARROW}:w-24`, E28 S3f3).
|
||||
// or the interpolation IS the variant and the text goes on with its class (`${NARROW}:w-24`).
|
||||
// A clock (`${m}:00`) and a URL (`${u}://`) are not classes.
|
||||
const SPLIT_CLASS = "/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/";
|
||||
const SPLIT_CLASS_MESSAGE =
|
||||
"a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant).";
|
||||
|
||||
// Applies to EVERY linted file: a suppression says which rule and why. A bare
|
||||
// `eslint-disable-next-line rule` named nothing in four places the E32 audit found, against the
|
||||
// `eslint-disable-next-line rule` named nothing in four places an audit found, against the
|
||||
// CLAUDE.md rule that each one names its invariant; a blanket `eslint-disable` names no rule at all.
|
||||
const suppressionRules = {
|
||||
plugins: { "@eslint-community/eslint-comments": eslintComments },
|
||||
@@ -477,7 +477,7 @@ const suppressionRules = {
|
||||
},
|
||||
};
|
||||
|
||||
// The two type-aware rules (E32 D3: a curated pair, not strictTypeChecked — ~2000 hits there, none
|
||||
// The two type-aware rules (a curated pair, not strictTypeChecked — ~2000 hits there, none
|
||||
// real in the sample). A floating promise is a rejection nobody handles: the audit found two real
|
||||
// ones (the chat dock's device key store, since removed, and VideoEditor's play()). `void` marks a deliberate fire-and-forget
|
||||
// whose callee reports its own failure. React Query's invalidate/refetch family never rejects
|
||||
@@ -524,12 +524,12 @@ export default tseslint.config(
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
...typedRules,
|
||||
// The React Compiler's own diagnostics that `recommended` leaves out (E32). Each is a function
|
||||
// The React Compiler's own diagnostics that `recommended` leaves out. Each is a function
|
||||
// the compiler SKIPS, silently — `panicThreshold` is "none" and there is no logger, so a lint
|
||||
// error is the only place the skip is said out loud. `todo` is the compiler's "cannot lower
|
||||
// this shape yet" (E32 S4 rewrote all 31 try statements it found); the rest had 0 hits when
|
||||
// this shape yet" (all 31 try statements it found were rewritten); the rest had 0 hits when
|
||||
// switched on, and are tripwires. Three more exist but cannot report anything under
|
||||
// eslint-plugin-react-hooks 7.1.1 (E32 review, read in its source and probed), so they are not
|
||||
// eslint-plugin-react-hooks 7.1.1 (read in its source and probed), so they are not
|
||||
// listed: `exhaustive-effect-dependencies` (its validation is compiled off — the classic
|
||||
// `exhaustive-deps` reports a missing effect dependency), `memoized-effect-dependencies` (nothing
|
||||
// emits its category) and `rule-suppression` (lint mode hands it no suppression rules). A
|
||||
@@ -547,14 +547,14 @@ export default tseslint.config(
|
||||
"react-hooks/hooks": "error",
|
||||
"react-hooks/no-deriving-state-in-effects": "error",
|
||||
// eslint-plugin-react-hooks 7's `recommended` is no longer two rules — it carries the whole
|
||||
// React Compiler rule set (E27 S1). On this codebase that landed 283 findings. 66 were fixed or
|
||||
// React Compiler rule set. On this codebase that landed 283 findings. 66 were fixed or
|
||||
// suppressed inline, each naming its invariant; there is DELIBERATELY no suppressions file — a
|
||||
// bulk `--suppress-all` would hide the next violation instead of answering it.
|
||||
//
|
||||
// `refs` (the other 217) was switched OFF in E27 as mostly false positives, and that triage was
|
||||
// `refs` (the other 217) was once switched OFF as mostly false positives, and that triage was
|
||||
// wrong: the rule and the React Compiler share one analysis, so every hit was a function the
|
||||
// compiler SKIPS — 21 popover components among them, and Channels' 0.9 s row menu was the bill
|
||||
// (E32). E32 S3–S4 fixed every site (245 measured) instead of narrowing the rule: the popover
|
||||
// compiler SKIPS — 21 popover components among them, and Channels' 0.9 s row menu was the bill.
|
||||
// Every site was fixed (245 measured) instead of narrowing the rule: the popover
|
||||
// engine is read through destructuring, render-phase ref writes became `useEffectEvent` /
|
||||
// `useLatest`. It is on, at error, with no exception.
|
||||
"react-hooks/refs": "error",
|
||||
@@ -571,12 +571,12 @@ export default tseslint.config(
|
||||
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"no-undef": "off",
|
||||
// R7 S3a eliminated every explicit `any` (api.ts prefs/data bags → Record<string, unknown>;
|
||||
// Every explicit `any` was eliminated (api.ts prefs/data bags → Record<string, unknown>;
|
||||
// catch clauses → unknown + HttpError narrowing; the YouTube IFrame boundary → lib/youtube.ts
|
||||
// types; i18n `t` props → TFunction), so the rule is now enforced to keep new ones from
|
||||
// creeping back in. Prefer `unknown` + narrowing, or a typed boundary, over `any`.
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
// E22 S4: the client log (lib/log.ts) is the one place diagnostics go — redacted, levelled,
|
||||
// The client log (lib/log.ts) is the one place diagnostics go — redacted, levelled,
|
||||
// bounded. A `console.*` call is none of those, and it is invisible to the user's own log.
|
||||
"no-console": "error",
|
||||
"no-restricted-globals": ["error", ...networkGlobals(null)],
|
||||
@@ -592,7 +592,7 @@ export default tseslint.config(
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
// A next-page fetch called bare CANCELS the one in flight and sends the same cursor again;
|
||||
// our requests ignore the abort, so the server ran both (E31 S4e: every feed page 2–3×).
|
||||
// our requests ignore the abort, so the server ran both (every feed page 2–3×).
|
||||
// lib/nextPage.ts NEXT_PAGE makes the second call join the first.
|
||||
{
|
||||
selector:
|
||||
@@ -614,7 +614,7 @@ export default tseslint.config(
|
||||
},
|
||||
// Tailwind emits CSS only for a class it finds WHOLE in the source. A variant glued onto an
|
||||
// interpolated prefix (`${ACT}hover:text-fg`) is never found, so it ships with no rule: every
|
||||
// hover colour of the button and chip primitives was dead that way (E28 audit).
|
||||
// hover colour of the button and chip primitives was dead that way (a design audit).
|
||||
{
|
||||
selector: `TemplateLiteral > TemplateElement:not(:first-child)[value.raw=${SPLIT_CLASS}]`,
|
||||
message: SPLIT_CLASS_MESSAGE,
|
||||
@@ -627,19 +627,19 @@ export default tseslint.config(
|
||||
message:
|
||||
"No native <select>: use ui/SegmentedToggle for a short fixed set, ui/FilterSelect for a longer one or a user's own list.",
|
||||
},
|
||||
// R10 S2: keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT
|
||||
// Keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT
|
||||
// scale with the Settings --font-scale slider while its rem neighbours do; use
|
||||
// `text-2xs`/`text-3xs` or a `text-[Nrem]` arbitrary instead. Matches both plain strings and
|
||||
// template-literal chunks; rem arbitraries (text-[0.75rem]) don't match (no "px").
|
||||
{
|
||||
selector: "Literal[value=/text-\\[[\\d.]+px\\]/]",
|
||||
message:
|
||||
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
||||
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx].",
|
||||
},
|
||||
{
|
||||
selector: "TemplateElement[value.raw=/text-\\[[\\d.]+px\\]/]",
|
||||
message:
|
||||
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).",
|
||||
"px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx].",
|
||||
},
|
||||
// …and the same for a BOX: a `w-[150px]` stays 150px while the text inside it grows with the
|
||||
// slider, so the label re-wraps or clips at large sizes. Use the spacing scale (`w-37.5` is
|
||||
@@ -654,7 +654,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
// E10 S1: `components/ui/` is the PLATFORM layer, and the rule that makes it a layer rather than
|
||||
// `components/ui/` is the PLATFORM layer, and the rule that makes it a layer rather than
|
||||
// a folder is enforced here, not in prose (see components/ui/README.md). A platform component is
|
||||
// domain-free: it may import sibling primitives and generic `lib/` helpers, never a feature
|
||||
// component (`../Feed`) and never a domain API slice. Without this rule the boundary erodes on
|
||||
@@ -666,7 +666,7 @@ export default tseslint.config(
|
||||
{
|
||||
// NOTE the String.raw: these are REGEX sources, and in a plain "..." literal `\.` is an
|
||||
// unknown escape that collapses to a bare `.` — the any-char wildcard. The first cut of
|
||||
// this rule shipped that way and matched far less than it read like (E10 review round 1).
|
||||
// this rule shipped that way and matched far less than it read like.
|
||||
patterns: [
|
||||
{
|
||||
// `../X` (exactly one level up = a feature component). A `regex` rather than a glob:
|
||||
@@ -696,7 +696,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
// E11 S3: the same one-way rule one layer down. `lib/` is what components are built ON (hooks,
|
||||
// The same one-way rule one layer down. `lib/` is what components are built ON (hooks,
|
||||
// the API slices, pure helpers), so it may not reach back UP into `components/` — not even for a
|
||||
// type. That is how the `Column` contract came to be owned by a ui component that three lib
|
||||
// helpers had to import from. A piece both layers need moves down into `lib/`.
|
||||
@@ -725,7 +725,7 @@ export default tseslint.config(
|
||||
rules: { "no-restricted-globals": ["error", ...networkGlobals(name)] },
|
||||
})),
|
||||
{
|
||||
// The design lab (src/design-lab, E28) is a dev-only page that never reaches the production
|
||||
// The design lab (src/design-lab) is a dev-only page that never reaches the production
|
||||
// bundle (devOnlyGuard fails the build if it does). Its copy is the developer's and the product
|
||||
// owner's working text, not the product's, so the translate-every-label rule does not apply.
|
||||
files: ["src/design-lab/**/*.{ts,tsx}"],
|
||||
@@ -738,7 +738,7 @@ export default tseslint.config(
|
||||
plugins: { vitest },
|
||||
rules: {
|
||||
// A focused test silently skips the rest of the suite; a skipped one is a test nobody runs
|
||||
// (E32 — vitest's allowOnly is on outside CI, so nothing else catches a stray `.only` locally).
|
||||
// (vitest's allowOnly is on outside CI, so nothing else catches a stray `.only` locally).
|
||||
"vitest/no-focused-tests": "error",
|
||||
"vitest/no-disabled-tests": "error",
|
||||
"no-restricted-globals": "off",
|
||||
@@ -750,7 +750,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
// Outside src (E32): the e2e suite and the TypeScript build-tool configs went unlinted — the
|
||||
// Outside src: the e2e suite and the TypeScript build-tool configs went unlinted — the
|
||||
// flat config named only src/**, so `eslint .` skipped them without a word. The same base and
|
||||
// typed rules as the app; Node globals, since none of it runs in the browser.
|
||||
files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts"],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import reactCompiler from "babel-plugin-react-compiler";
|
||||
|
||||
// ONE React transform, shared by vite.config.ts and vitest.config.ts (E27 S1).
|
||||
// ONE React transform, shared by vite.config.ts and vitest.config.ts.
|
||||
//
|
||||
// It is a shared factory rather than two `react()` calls because the two configs must not drift:
|
||||
// if the compiler ran only in the production build, every vitest run would be exercising code the
|
||||
@@ -14,7 +14,7 @@ import reactCompiler from "babel-plugin-react-compiler";
|
||||
// bails out on its own for anything it cannot prove safe — SILENTLY: `panicThreshold` is "none" and
|
||||
// there is no logger here, so a skipped component still works and simply re-renders everything under
|
||||
// it. This comment used to say "its rules are also linted"; it was false while `react-hooks/refs` was
|
||||
// off, and 95 functions sat uncompiled behind it (E32). What actually holds the line now is
|
||||
// off, and 95 functions sat uncompiled behind it. What actually holds the line now is
|
||||
// src/components/compilerCoverage.contract.test.ts, which runs THIS compiler over the shipped source
|
||||
// and fails on any skip not on its list; the react-hooks lint rules are the early warning. None of
|
||||
// them is off (three run at the preset's warn, which `--max-warnings 0` fails — docs/gates.md), but
|
||||
|
||||
@@ -146,9 +146,9 @@ export default function App() {
|
||||
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
|
||||
useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []);
|
||||
|
||||
// The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is
|
||||
// The account's consented diagnostics level, applied to this tab's log: raised, the log is
|
||||
// kept in more detail and streamed to the server until the level lapses. A debug session that is
|
||||
// recording (E16) raises it for its own run and tags what it keeps. A lapse (its own timer, or the
|
||||
// recording raises it for its own run and tags what it keeps. A lapse (its own timer, or the
|
||||
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
|
||||
// Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones:
|
||||
// applyDiagnostics computes an effective-level key and returns early when it has not moved
|
||||
@@ -395,7 +395,7 @@ export default function App() {
|
||||
)}
|
||||
{/* `@container/appmain`: the floating header + its search box adapt to THIS column's width
|
||||
(which shrinks when the filter sidebar is open), not the viewport — a viewport breakpoint
|
||||
can't see the sidebar-driven narrowing (E5 S7). NB: `container-type: inline-size` implies
|
||||
can't see the sidebar-driven narrowing. NB: `container-type: inline-size` implies
|
||||
`contain: layout`, making this column a containing block for `position: fixed` descendants
|
||||
too — safe today because floating-ui menus render via FloatingPortal (to document.body) and
|
||||
the toasts below are absolute; keep any new fixed overlay inside here body-portalled. */}
|
||||
@@ -456,7 +456,7 @@ export default function App() {
|
||||
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
|
||||
<KeyboardHelp />
|
||||
<AppCommandPalette />
|
||||
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
|
||||
{/* The debug recorder: its start dialog, and its pill docked under the toasts. */}
|
||||
{me && <DebugRecorder />}
|
||||
<Suspense fallback={null}>
|
||||
{aboutOpen && (
|
||||
|
||||
@@ -17,7 +17,7 @@ import { SURFACE } from "./ui/surfaces";
|
||||
* tables.
|
||||
*
|
||||
* The card keeps the platform's default hover role (`tooltip`); making this rich, interactive panel
|
||||
* properly keyboard-reachable belongs to R9 (focus/a11y), not to the mechanical migration.
|
||||
* properly keyboard-reachable belongs to the focus/a11y work, not to the mechanical migration.
|
||||
*/
|
||||
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
|
||||
const preview = (text ?? "").trim();
|
||||
|
||||
@@ -18,7 +18,7 @@ import { SURFACE } from "./ui/surfaces";
|
||||
import { SectionLabel } from "./ui/Headings";
|
||||
|
||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||
// one account lives (E12 · S5, design option C).
|
||||
// one account lives.
|
||||
//
|
||||
// It exists because nothing in the admin UI showed an account's GATE STATE: an admin could see a
|
||||
// "Pending approval" chip but not whether the address was confirmed, whether the password had been
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
||||
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
|
||||
// "new playlist" creation. Same shape as the channel tag editor (the popover pilot): radio-style
|
||||
// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and
|
||||
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
|
||||
export default function AddToPlaylist({
|
||||
@@ -55,7 +55,7 @@ export default function AddToPlaylist({
|
||||
);
|
||||
}
|
||||
|
||||
/** The same panel, opened from an object's menu ("Add to playlist…", E28 C1) rather than its own
|
||||
/** The same panel, opened from an object's menu ("Add to playlist…") rather than its own
|
||||
* button: anchored to the element the menu came from, and gone when it closes. */
|
||||
export function AddToPlaylistAt({
|
||||
videoId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E22.6: the admin log console. A request id narrows the list to that request (and says so, in the
|
||||
// The admin log console. A request id narrows the list to that request (and says so, in the
|
||||
// filter row and the URL); a filter value the facets no longer count stays pickable; "Live" watches
|
||||
// the newest page only, and stops on a failed read instead of re-raising it; a failed read is an
|
||||
// error, not an empty log; Copy takes every loaded line, oldest first.
|
||||
|
||||
@@ -42,7 +42,7 @@ import { DismissibleChip } from "./ui/Chip";
|
||||
import { InnerInput } from "./ui/Input";
|
||||
import { fieldClass } from "./ui/fieldStyles";
|
||||
|
||||
// Admin → Szervernapló: the server's stored log (`log_events`, E22.6). One list, newest first; a line
|
||||
// Admin → Szervernapló: the server's stored log (`log_events`). One list, newest first; a line
|
||||
// opens in place to its full record, and its request id / job id narrow the list to that request or
|
||||
// job. Filters live in the URL, so a filtered view is a link. "Élő" re-reads the newest page every
|
||||
// few seconds while the tab is visible — the newest page only: live is watching the top, so paging
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A failed user action is said once, and a user who is gone is not left listed (deep-review round 4).
|
||||
// A failed user action is said once, and a user who is gone is not left listed.
|
||||
// setRole / suspend / delete had no onError at all — "the global error dialog says it" — but a 404
|
||||
// (the user was deleted in another tab) gets no dialog, so the click did nothing and the row stayed.
|
||||
// And the invite approve/deny toasted unconditionally on top of the dialog `req` opens for a 409.
|
||||
@@ -76,7 +76,7 @@ function mount() {
|
||||
return users;
|
||||
}
|
||||
|
||||
describe("the last sign-in column (E22 S1)", () => {
|
||||
describe("the last sign-in column", () => {
|
||||
it("says how long ago each account signed in, and a dash where nothing is recorded", async () => {
|
||||
const at = new Date(Date.now() - 3 * 86_400_000).toISOString();
|
||||
vi.spyOn(api, "adminUsers").mockResolvedValue([
|
||||
@@ -96,7 +96,7 @@ describe("the last sign-in column (E22 S1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E28 (owner): the hover ended wherever the row's first badge or button began, because the layer was
|
||||
// The hover ended wherever the row's first badge or button began, because the layer was
|
||||
// the name button's own. The card is the row: it paints the layer under its content, and the name
|
||||
// button's hit layer spans the card, under the actions that sit above it.
|
||||
describe("a user row", () => {
|
||||
@@ -117,7 +117,7 @@ describe("a user row", () => {
|
||||
/** The open menu's rows (a closed popover's are hidden from the accessibility tree in jsdom). */
|
||||
const menuRows = () => screen.queryAllByRole("menuitem", { hidden: true });
|
||||
|
||||
/** The row's actions live in its "…" menu (E31): open it, then pick the row. */
|
||||
/** The row's actions live in its "…" menu: open it, then pick the row. */
|
||||
async function chooseInRowMenu(label: string) {
|
||||
fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" }));
|
||||
const item = await waitFor(() => {
|
||||
@@ -128,7 +128,7 @@ async function chooseInRowMenu(label: string) {
|
||||
fireEvent.click(item);
|
||||
}
|
||||
|
||||
// E31 (DESIGN § Action placement): the role, the suspension and the delete moved off the row into
|
||||
// DESIGN § Action placement: the role, the suspension and the delete moved off the row into
|
||||
// ONE menu — the row's "…", which a right-click on the row opens too. A locked one stays listed with
|
||||
// its reason.
|
||||
describe("a user row's menu", () => {
|
||||
|
||||
@@ -113,7 +113,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
// Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since
|
||||
// with per-row disabling the admin can now start actions on several rows concurrently.
|
||||
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
|
||||
// The account panel (E12 S5): which user it is showing, or null. Held here rather than per row so
|
||||
// The account panel: which user it is showing, or null. Held here rather than per row so
|
||||
// only one is ever mounted.
|
||||
const [panelFor, setPanelFor] = useState<AdminUserRow | null>(null);
|
||||
const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id));
|
||||
@@ -208,7 +208,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
if (ok) del.mutate({ id: u.id, email: u.email });
|
||||
};
|
||||
|
||||
// The row's actions are ONE menu (DESIGN § Action placement, E31): the role, the suspension and
|
||||
// The row's actions are ONE menu (DESIGN § Action placement): the role, the suspension and
|
||||
// the delete — rare and two of them destructive — open from the row's "…", a right-click on the row,
|
||||
// a long-press or Shift+F10. The row keeps what the admin reads: the name, the role and state
|
||||
// badges, the last sign-in. A locked action (on yourself) stays listed with its reason.
|
||||
@@ -273,7 +273,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
{rows.map((u) => {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
// The whole card is the row, so the whole card answers (owner, E28): the name's button
|
||||
// The whole card is the row, so the whole card answers (owner's call): the name's button
|
||||
// stretches an invisible layer over the card (`after:inset-0`), so a click on a badge or
|
||||
// a gap opens the account too, and the B3 hover layer is the card's own `::before`, under
|
||||
// the badges and buttons (`isolate` + `z-under`). The actions are `relative`, above the
|
||||
|
||||
@@ -129,7 +129,7 @@ export default function AppCommandPalette() {
|
||||
id: "actions",
|
||||
label: t("palette.groups.actions"),
|
||||
items: [
|
||||
// "Meglepetés" — Home's reroll, reachable from anywhere (E24). There is deliberately no
|
||||
// "Meglepetés" — Home's reroll, reachable from anywhere. There is deliberately no
|
||||
// second "Mit nézzek?" row beside it: the Home module row above ALREADY matches that phrase,
|
||||
// because the palette searches a module's descriptive `titleKey` too and Home's is literally
|
||||
// "Mit nézzek most?". A row that navigates where an existing row already navigates would be
|
||||
@@ -151,7 +151,7 @@ export default function AppCommandPalette() {
|
||||
icon: <Keyboard className="w-4 h-4" />,
|
||||
run: toggleKeyboardHelp,
|
||||
},
|
||||
// The debug recorder (E16). While it runs, Stop and a step marker take Start's place: the palette is reachable inside a
|
||||
// The debug recorder. While it runs, Stop and a step marker take Start's place: the palette is reachable inside a
|
||||
// player or a dialog where the pill's buttons are a long Tab away.
|
||||
...(recordingSession
|
||||
? [
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { AuditRow } from "../lib/api";
|
||||
import { Button } from "./ui/Button";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
|
||||
/** A folded run of one job's successful runs (lib/auditGroups, E31): the row shows the newest, this
|
||||
/** A folded run of one job's successful runs (lib/auditGroups): the row shows the newest, this
|
||||
* opens the earlier ones, each with its moment and its result (`detailOf`, the row's own wording). */
|
||||
export default function AuditEarlierRuns({
|
||||
runs,
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function AuditLog() {
|
||||
const confirm = useConfirm();
|
||||
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
|
||||
const data = q.data;
|
||||
// Back-to-back successful runs of one job fold into one row (lib/auditGroups, E31).
|
||||
// Back-to-back successful runs of one job fold into one row (lib/auditGroups).
|
||||
const rows = useMemo(() => foldJobRuns(data?.items ?? []), [data]);
|
||||
|
||||
const actionOptions = useMemo(() => {
|
||||
|
||||
@@ -11,7 +11,7 @@ function parseKeywords(s: string): string[] {
|
||||
return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean);
|
||||
}
|
||||
// topicCategories are Wikipedia URLs (…/wiki/Music) → the page slug ("Pop_music"), de-duplicated. The
|
||||
// slug is the vocabulary word (E30): named in the reader's language, else shown with spaces.
|
||||
// slug is the vocabulary word: named in the reader's language, else shown with spaces.
|
||||
function topicSlugs(urls: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const url of urls) {
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function ChannelDiscovery({
|
||||
|
||||
const columns: Column<DiscoveredChannel>[] = [
|
||||
// Subscribe first: it is this table's primary action, the one thing a discovery row is for (the
|
||||
// Subscriptions table has no such button — its row ends in the channel's "…", E28 C1).
|
||||
// Subscriptions table has no such button — its row ends in the channel's "…").
|
||||
{
|
||||
key: "actions",
|
||||
header: t("channels.cols.actions"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel manager's card layout as a keyboard list (E11 S4c): each card is an item, and Enter opens
|
||||
// The channel manager's card layout as a keyboard list: each card is an item, and Enter opens
|
||||
// the channel through the name ChannelLink renders. VirtualGrid is replaced by a stand-in that mounts
|
||||
// every item — jsdom has no layout for the virtualizer to measure — and registers with listNav exactly
|
||||
// when the real one would: when it is asked to (`keyboardNav`).
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
|
||||
// The channel manager's `cards` layout, built by reusing the SAME `Column<T>` render closures
|
||||
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
|
||||
// the card view for free, and a cell's behaviour (priority bump, tag toggle, sync, actions) is defined
|
||||
// once. The `table` layout keeps DataTable; this renders only when the manager's layout is `cards`.
|
||||
@@ -28,7 +28,7 @@ interface ChannelLayoutGridProps<T> {
|
||||
aboutKey?: string;
|
||||
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
|
||||
actionsKey?: string;
|
||||
/** Column key docked at the top-right corner after the lead: the card's "…" (E28 C1), which
|
||||
/** Column key docked at the top-right corner after the lead: the card's "…", which
|
||||
* belongs beside the name it acts on, not under the card's last line. */
|
||||
moreKey?: string;
|
||||
rowClassName?: (row: T) => string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel page's Block and Hide toggles (deep-review E11 round 2) and its Subscribe / Unsubscribe
|
||||
// button (round 3: the same row, missed) while their request runs. A
|
||||
// The channel page's Block and Hide toggles and its Subscribe / Unsubscribe button (the same row,
|
||||
// missed by the first fix) while their request runs. A
|
||||
// focused button that turns natively `disabled` drops the keyboard to <body> in Chrome (measured for
|
||||
// ui/Button in E11 S2), so a busy toggle blocks the click with aria-disabled instead — focusable, and
|
||||
// ui/Button), so a busy toggle blocks the click with aria-disabled instead — focusable, and
|
||||
// still pressed only once.
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach, onTestFinished } from "vitest";
|
||||
@@ -99,7 +99,7 @@ describe("ChannelPage — a busy toggle keeps the keyboard", () => {
|
||||
});
|
||||
|
||||
// The banner is retried on error (googleusercontent drops a burst); while an attempt fails and the
|
||||
// next waits, a visible <img> shows the browser's broken-image glyph (seen live, E28). It stays
|
||||
// next waits, a visible <img> shows the browser's broken-image glyph (seen live). It stays
|
||||
// invisible until a source has actually loaded.
|
||||
describe("ChannelPage — the banner", () => {
|
||||
it("is invisible until it loads, and again while a failed attempt is retried", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E31 (the owner's pick): the channel page's chrome is fixed, and its banner + identity block kept ~40% of a
|
||||
// The owner's pick: the channel page's chrome is fixed, and its banner + identity block kept ~40% of a
|
||||
// 133% screen before the first video for as long as the page was open. Once the grid scrolls they fold
|
||||
// away and the name rides in the tab row; back at the top they return. The Back button is one element in
|
||||
// both states, so a focused Back keeps its focus through the fold.
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function ChannelPage({
|
||||
// where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so
|
||||
// the toolbar lands under the tabs inside the fixed band rather than in the scroller.
|
||||
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null);
|
||||
// The banner and the identity block fold away once the grid scrolls (E31, the owner's pick): the chrome
|
||||
// The banner and the identity block fold away once the grid scrolls (owner's pick): the chrome
|
||||
// is fixed, so unfolded it kept ~40% of a 133% screen for good. Folded, the name rides in the tab row.
|
||||
const foldable = useRef<HTMLDivElement>(null);
|
||||
const folded = useScrollFold(foldable);
|
||||
@@ -102,7 +102,7 @@ export default function ChannelPage({
|
||||
const MAX_BANNER_RETRIES = 3;
|
||||
const [bannerAttempt, setBannerAttempt] = useState(0);
|
||||
// The banner source that has actually loaded. Until then the <img> is invisible: an attempt that
|
||||
// fails shows the browser's broken-image glyph for as long as the retry waits (seen live, E28).
|
||||
// fails shows the browser's broken-image glyph for as long as the retry waits (seen live).
|
||||
const [bannerLoaded, setBannerLoaded] = useState<string | null>(null);
|
||||
|
||||
// A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and
|
||||
@@ -144,7 +144,7 @@ export default function ChannelPage({
|
||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||
})
|
||||
// Both the automatic first-visit explore and "load more" used to fail in silence: an empty
|
||||
// channel page that looked like a channel with no videos (E11 S2b).
|
||||
// channel page that looked like a channel with no videos.
|
||||
.catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel")))
|
||||
.finally(() => setExploring(false))
|
||||
);
|
||||
@@ -267,7 +267,7 @@ export default function ChannelPage({
|
||||
|
||||
// Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so
|
||||
// the explored, per-user-private videos show — the backend gates them to this explorer).
|
||||
// The watch state is the one filter a channel page remembers (the owner, E31): a fresh page shows
|
||||
// The watch state is the one filter a channel page remembers (owner's call): a fresh page shows
|
||||
// what is still unwatched, and a change sticks — across channels, reloads and devices.
|
||||
const [show, setShow] = usePref<string>("channelShow", "unwatched", {
|
||||
localKey: LS.channelShow,
|
||||
@@ -313,7 +313,7 @@ export default function ChannelPage({
|
||||
ch?.total_view_count != null
|
||||
? t("channel.totalViews", { formatted: formatCompact(ch.total_view_count, lang) })
|
||||
: null,
|
||||
// Per-type breakdown (R14 moved this off the manager table's N/S/L column). Only meaningful
|
||||
// Per-type breakdown (moved here off the manager table's old N/S/L column). Only meaningful
|
||||
// once we actually hold some of the channel's videos.
|
||||
ch && ch.stored_videos > 0
|
||||
? t("channel.videoTypes", {
|
||||
@@ -439,7 +439,7 @@ export default function ChannelPage({
|
||||
corner: there are only ever two tabs, so there's room, and it keeps the things you click
|
||||
within a few pixels of each other instead of across the page. */}
|
||||
{/* Wraps rather than overflows: at a tablet width (1024) the actions + tags ran past the edge and
|
||||
the whole page scrolled sideways (E31 S1). The tag lane takes what is left and folds into "+N". */}
|
||||
the whole page scrolled sideways. The tag lane takes what is left and folds into "+N". */}
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-2 border-b border-border pb-2 ${folded ? "pt-3" : "mt-3"}`}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E31 (the owner): a freshly opened channel page shows what is still unwatched, and a watch state the
|
||||
// The owner's call: a freshly opened channel page shows what is still unwatched, and a watch state the
|
||||
// viewer picks there sticks — a reload (a fresh mount, seeded from the stored preference) keeps it.
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the catalog-stats hover chip: the compact trigger, the rows it reveals on open (value
|
||||
// formatting + the conditional "left" row), and — the point of the R13-S3 review fix — that each row
|
||||
// formatting + the conditional "left" row), and — the point of a review fix — that each row
|
||||
// still carries its per-stat explanation as a `title`, so the guidance the old inline stats showed is
|
||||
// not lost. i18n is mocked to a key-passthrough so assertions read the stable keys, not translations.
|
||||
// The AnchoredPopover platform is floating-ui based (hover mode), so we stub the observers autoUpdate
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the suggested-tag ghost chips (E8·S4): on an UNTAGGED channel, ChannelTagsControl renders
|
||||
// Contract of the suggested-tag ghost chips: on an UNTAGGED channel, ChannelTagsControl renders
|
||||
// one dashed "ghost" chip per suggestion; clicking the chip body accepts it (onAcceptSuggestion with the
|
||||
// tag id), the trailing × dismisses it (onDismissSuggestion), and a suggestion whose tag is already
|
||||
// attached is never shown. i18n is key-passthrough; the control rides the floating-ui popover platform
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||
// page. Built on the popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||
// 1. Stable edit anchor — the "+" edit trigger LEADS the row (fixed left slot), so toggling tags
|
||||
// (which grows the chip row to its right) never moves it, and the editor popover doesn't drift.
|
||||
// 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and
|
||||
@@ -42,7 +42,7 @@ function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// A suggested user tag (E8·S4): a dashed "ghost" chip the user hasn't applied yet, learned from
|
||||
// A suggested user tag: a dashed "ghost" chip the user hasn't applied yet, learned from
|
||||
// their own tagging pattern. The chip body accepts it (one tap → attaches the real tag); the trailing
|
||||
// × dismisses it for the session. Rendered only on untagged channels, so it never competes with the
|
||||
// measured attached-chip row. (Two sibling buttons, not a button-in-button — invalid nesting.)
|
||||
@@ -296,7 +296,7 @@ export function ChannelTagsControl({
|
||||
onCreate: (name: string) => void;
|
||||
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
|
||||
onChipClick?: (tagId: number, name: string) => void;
|
||||
/** Manager only (E8·S4): suggested user tags for an UNTAGGED channel, shown as accept/dismiss ghost
|
||||
/** Manager only: suggested user tags for an UNTAGGED channel, shown as accept/dismiss ghost
|
||||
* chips after the (empty) attached row. Omit on the detail page. */
|
||||
suggestions?: SuggestedTag[];
|
||||
onAcceptSuggestion?: (tagId: number) => void;
|
||||
|
||||
@@ -211,7 +211,7 @@ export default function Channels() {
|
||||
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
|
||||
});
|
||||
|
||||
// A priority step picked from the row's menu (E28 S3e3b, owner): the one thing a pick is FOR is
|
||||
// A priority step picked from the row's menu (the owner's call): the one thing a pick is FOR is
|
||||
// to move the channel up (or down) the list, so the list re-sorts at once — only that row moves
|
||||
// (`placeByPriority`, the server's order) — and the page and the focus follow it (`follow`): the
|
||||
// table/card pager turns to its page, and the effect below hands the focus to its "…" (its node
|
||||
@@ -248,7 +248,7 @@ export default function Channels() {
|
||||
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
|
||||
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also
|
||||
// recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
|
||||
// the learned pattern (E8·S4).
|
||||
// the learned pattern.
|
||||
onSuccess: (id) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(id) });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
@@ -267,7 +267,7 @@ export default function Channels() {
|
||||
// The options are a memo so the hook's toggleTag/createAndAttachTag stay the same functions: the
|
||||
// column memo below names them (see `columns`).
|
||||
const { toggleTag, createAndAttachTag } = useChannelTagActions(tagActionOpts);
|
||||
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
|
||||
// --- Suggested tags: learned from the user's own manual-tag pattern, shown as accept/dismiss
|
||||
// ghost chips on untagged channels. Dismissal is session-only (an accepted suggestion self-clears —
|
||||
// the channel becomes tagged and the server drops it; a dismissed one just hides until reload). The
|
||||
// /tags/suggestions query, the session dismiss set, and the optimistic accept/dismiss/bulk logic all
|
||||
@@ -311,7 +311,7 @@ export default function Channels() {
|
||||
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
|
||||
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
|
||||
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
|
||||
// B4 (E28 review L7): on a short window the band's filters — the tag chip lane and the status /
|
||||
// B4: on a short window the band's filters — the tag chip lane and the status /
|
||||
// pager row — fold behind one "Filters · n"; the tabs and the TAGS editor stay. n = each picked tag,
|
||||
// plus every other active column filter (the status chips are the Sync column's).
|
||||
const fold = useFilterFold();
|
||||
@@ -369,7 +369,7 @@ export default function Channels() {
|
||||
(c: ManagedChannel) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")),
|
||||
[onViewChannel, t],
|
||||
);
|
||||
// The channel's ONE action list (E28 C1) — ONE menu for the list, shared by the table and the card
|
||||
// The channel's ONE action list — ONE menu for the list, shared by the table and the card
|
||||
// layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…"
|
||||
// must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery
|
||||
// result is not a `Channel` row yet, so pinning or hiding one could only 404.
|
||||
@@ -405,7 +405,7 @@ export default function Channels() {
|
||||
|
||||
// Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top
|
||||
// chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a
|
||||
// column — R14 retired the Sync column and folded its status into a ✓ charm on Stored — so it's
|
||||
// column — the retired Sync column became a ✓ charm on Stored — so it's
|
||||
// applied as a pre-filter (`applySyncFilter`) over the rows fed to BOTH the table and the cards,
|
||||
// still driven by the same top chips. Hidden channels have their own tab (excluded here). Only the
|
||||
// name search filters at this step; the remaining column funnels run in DataTable / filterRows below.
|
||||
@@ -698,7 +698,7 @@ export default function Channels() {
|
||||
{/* One row: the TAGS editor trigger (left) + the tag-filter chip lane (folds overflow into
|
||||
"+N") + Clear, then the catalog "…" actions pinned right, so the "…" menu always stays on the
|
||||
first line. Only the suggestions block wraps: below 40rem it takes a line of its own under
|
||||
the row (E28 S3f3, the owner's pick A) — at 375 it had squeezed the chip lane to 0 px and
|
||||
the row (the owner's pick) — at 375 it had squeezed the chip lane to 0 px and
|
||||
pushed the "…" off the screen. Channel-name search lives in the shared top SearchBar. */}
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
||||
@@ -725,7 +725,7 @@ export default function Channels() {
|
||||
{t("datatable.clear")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a
|
||||
{/* Suggested-tags affordance: a sparkle count of the visible suggestions, with a
|
||||
one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
|
||||
rest. Hidden when there's nothing to suggest. */}
|
||||
{visibleSuggestions.length > 0 && (
|
||||
@@ -865,7 +865,7 @@ export default function Channels() {
|
||||
rowClassName={() => "group"}
|
||||
/>
|
||||
{/* Folded, the status / pager row leaves the band, as the table's does — and the
|
||||
list's own end keeps it here too (round 2, F2: the cards had lost their pager). */}
|
||||
list's own end keeps it here too (the cards had once lost their pager). */}
|
||||
{fold.folded && (
|
||||
<div className="pt-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
{statusChips}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// ChannelsProvider's tab-history behaviour: setView pushes a `_chTab` history entry + persists the tab,
|
||||
// and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was
|
||||
// re-routed through useAccountPersistedState in R13 S2b, so it's exercised here rather than only by hand E2E.
|
||||
// re-routed through useAccountPersistedState, so it's exercised here rather than only by hand E2E.
|
||||
//
|
||||
// Uses REAL storage (jsdom localStorage/sessionStorage) with the tab's account pinned to 1, so
|
||||
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
|
||||
@@ -29,7 +29,7 @@ beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account
|
||||
// Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
|
||||
// and the S8 URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
|
||||
// and the URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
|
||||
// this provider's mount-time URL seed and change the starting tab.
|
||||
window.history.replaceState({ sfPage: "channels" }, "", "/");
|
||||
});
|
||||
@@ -78,7 +78,7 @@ describe("ChannelsProvider tab history", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChannelsProvider — S8 shareable URL", () => {
|
||||
describe("ChannelsProvider — shareable URL", () => {
|
||||
it("seeds tab + sort + filters from a shared manager link at mount (URL wins over stored)", () => {
|
||||
// Stored view says "blocked"; the shared link says "hidden" + a sort + a filter → the URL wins.
|
||||
localStorage.setItem(VIEW_KEY, "blocked");
|
||||
|
||||
@@ -76,7 +76,7 @@ function readStoredFilters(): FilterMap {
|
||||
return migrateSyncChip() ?? {};
|
||||
}
|
||||
|
||||
// S8: a shared manager link carries the content state in namespaced URL keys (ctab/csort/cf). When
|
||||
// A shared manager link carries the content state in namespaced URL keys (ctab/csort/cf). When
|
||||
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
|
||||
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
|
||||
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
|
||||
@@ -114,7 +114,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
const [focusNameRaw, setFocusName] = useState<string | null>(null);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
|
||||
// S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
|
||||
// Shareable manager view. Captured once at mount (or null when no manager URL keys are present).
|
||||
const [urlSeed] = useState(readChannelsUrlSeed);
|
||||
const seededRef = useRef(false);
|
||||
|
||||
@@ -239,7 +239,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
],
|
||||
);
|
||||
|
||||
// S8: mirror the content state (tab + sort + column filters) into the address bar under the namespaced
|
||||
// Mirror the content state (tab + sort + column filters) into the address bar under the namespaced
|
||||
// keys so a manager view is shareable. Gated on page===channels: off the manager the keys are cleared
|
||||
// (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a
|
||||
// deliberately transient rail-search, like the feed's and plex's.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E22 round-2 review: locks the stacking that makes an admin config row usable on a phone. The
|
||||
// Locks the stacking that makes an admin config row usable on a phone. The
|
||||
// control column cannot shrink, so beside a wide control it left the label and its hint a sliver of
|
||||
// the ~250 px a phone leaves next to the navigation rail. The sibling fix — a long-labelled choice
|
||||
// becoming a dropdown — is covered in ConfigPanel.choice.dom.test.tsx, but that one does nothing for
|
||||
@@ -61,7 +61,7 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConfigPanel field row (E22 round 2)", () => {
|
||||
describe("ConfigPanel field row", () => {
|
||||
it("stacks the label above the control below `sm`, and lets the control fit the screen", async () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E31 (the owner's pick): Configuration's sixteen group tabs wrapped to three rows at 133% zoom, so they
|
||||
// The owner's pick: Configuration's sixteen group tabs wrapped to three rows at 133% zoom, so they
|
||||
// are a side list under four headings, as Settings has. The registry owns the order: the groups follow
|
||||
// its sections, not the order the groups object happens to arrive in.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -64,7 +64,7 @@ const renderPanel = () =>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
describe("Configuration sections (E31)", () => {
|
||||
describe("Configuration sections", () => {
|
||||
it("lists the groups in the registry's sections, each heading once above its run", async () => {
|
||||
renderPanel();
|
||||
const list = await screen.findByRole("tablist");
|
||||
|
||||
@@ -159,7 +159,7 @@ export default function ConfigPanel() {
|
||||
notifyActionError(e, t("config.plexTestFailed"));
|
||||
},
|
||||
});
|
||||
// Bug reports' "Test connection" (E16): the repository answers, takes issues, is private.
|
||||
// Bug reports' "Test connection": the repository answers, takes issues, is private.
|
||||
const testIssues = useMutation({
|
||||
mutationFn: () => api.testIssues(),
|
||||
onSuccess: (r) =>
|
||||
@@ -192,7 +192,7 @@ export default function ConfigPanel() {
|
||||
return <LoadingState label={t("config.loading")} />;
|
||||
}
|
||||
|
||||
// The groups in the registry's sections, in order (E31: sixteen tabs wrapped to three rows at 133%
|
||||
// The groups in the registry's sections, in order (sixteen tabs wrapped to three rows at 133%
|
||||
// zoom, so they are a side list under four headings, as Settings has).
|
||||
const groupKeys = data.sections.flatMap((s) => s.groups);
|
||||
// Clamp the persisted tab to a real group (the registry can change between sessions). The
|
||||
@@ -214,7 +214,7 @@ export default function ConfigPanel() {
|
||||
<div className="px-4 pb-24 pt-3 max-w-4xl w-full mx-auto">
|
||||
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
|
||||
{/* The groups as a side list under their headings, the fields beside it: Settings' own
|
||||
layout, one pattern for both settings pages (E31). */}
|
||||
layout, one pattern for both settings pages. */}
|
||||
<div className={`${SURFACE.panel} mb-4 flex flex-col overflow-hidden sm:flex-row`}>
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 (owner): a native date field reports a complete, valid date on every keystroke of its year, so
|
||||
// A native date field reports a complete, valid date on every keystroke of its year, so
|
||||
// typing 2026 applied 0002, 0020, 0202 and 2026 — four feed refetches for one year.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E16 S2: the debug recorder. Its pill rides the toast hosts (lib/toastDock) and stays out of their
|
||||
// The debug recorder. Its pill rides the toast hosts (lib/toastDock) and stays out of their
|
||||
// live region; Start is consent-first; Stop sends what is waiting BEFORE the server closes the
|
||||
// session; a step is one tagged event; Escape in the step prompt closes only the prompt, never the
|
||||
// dialog the pill sits in. i18n is a key-passthrough with its params; the API is stubbed and `me`
|
||||
|
||||
@@ -31,7 +31,7 @@ import { useDebugRecording } from "./useDebugRecording";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The debug recorder (E16): Start → reproduce → Stop.
|
||||
// The debug recorder: Start → reproduce → Stop.
|
||||
//
|
||||
// While a session records, its pill sits under the toasts (lib/toastDock), so it rides every toast
|
||||
// host — the page, each dialog, both players, the fullscreen player — and is reachable wherever the
|
||||
@@ -296,7 +296,7 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
|
||||
// It does NOT take the Tab, though: it is a field inside whatever dialog the pill is docked in,
|
||||
// not a dialog of its own, and the trap belongs to that one — it spans the prompt as it spans the
|
||||
// rest of the toast host. Claiming it here switched the dialog's off and left every control
|
||||
// OUTSIDE this little box untrapped (deep-review round 3).
|
||||
// OUTSIDE this little box untrapped.
|
||||
useLayer(true, () => onDone(null), { trapsTab: false });
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -332,7 +332,7 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
|
||||
|
||||
/** The five words a past session can read as. A union, not `string`, because `chipTone` below
|
||||
* matches on these same keys: untyped, a rename in one of them left the other matching nothing
|
||||
* and every chip untoned, with the gate green (deep-review round 4). */
|
||||
* and every chip untoned, with the gate green. */
|
||||
type HistoryState =
|
||||
| "recorder.history.sending"
|
||||
| "recorder.history.draft"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E22 S5: Settings → Diagnosztika. Raising the level is a consent, so choosing Részletes or Teljes
|
||||
// Settings → Diagnosztika. Raising the level is a consent, so choosing Részletes or Teljes
|
||||
// must change NOTHING until the consent box's own button is pressed; going back to Alap needs no
|
||||
// consent. i18n is a key-passthrough (with the count, so the retention days are visible); the level
|
||||
// endpoint is stubbed, and `me` lives in a real query cache so a saved level re-renders like in the app.
|
||||
|
||||
@@ -32,7 +32,7 @@ function useRemaining(until: string | null): { h: number; m: number } | null {
|
||||
return { h: Math.floor(minutes / 60), m: minutes % 60 };
|
||||
}
|
||||
|
||||
// Settings → Diagnosztika (E22 S5): the consented diagnostics level and the tab's own log.
|
||||
// Settings → Diagnosztika: the consented diagnostics level and the tab's own log.
|
||||
//
|
||||
// There is no separate on/off switch: Alap IS off. Choosing Részletes or Teljes does not change
|
||||
// anything yet — it opens the consent box under the picker, which says what will be recorded, that
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Download Center's confirm dialogs name their ACTION on the confirm button (E11.5): the shared
|
||||
// The Download Center's confirm dialogs name their ACTION on the confirm button: the shared
|
||||
// `confirmThen` once labelled it with the dialog's question — "Re-download this file?" on the button,
|
||||
// and the cancel dialog's own "Cancel" beside "Cancel". i18n is key-passthrough, so labels read as keys.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -107,7 +107,7 @@ describe("DownloadCenter confirm dialogs", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// E28 C1: the destructive and rare row buttons live in the row's "…".
|
||||
// The destructive and rare row buttons live in the row's "…".
|
||||
await pick("Clip 1", "downloads.actions.cancel");
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||
// The tab's label shares its button with a count badge.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 C1 on the Download Center: a download row keeps its primary action (play; pause / resume /
|
||||
// The Download Center: a download row keeps its primary action (play; pause / resume /
|
||||
// retry in the queue) and the save link in sight, and everything else — the editors, sharing,
|
||||
// re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it
|
||||
// shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function DownloadCenter() {
|
||||
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
|
||||
const library = jobs.filter((j) => j.status === "done");
|
||||
|
||||
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
|
||||
// --- The rows' ONE menu -------------------------------------------------------------
|
||||
// Keyed by the job's id: the queue polls every 2 s while a job runs, replacing the object under an
|
||||
// open menu, and its "…" must still close it. The tab is the one on screen.
|
||||
const rowMenu = useContextMenu<DownloadJob>({ keyOf: jobKey });
|
||||
@@ -203,7 +203,7 @@ export default function DownloadCenter() {
|
||||
>
|
||||
{t("downloads.page.add")}
|
||||
</Button>
|
||||
{/* Local upload is a peer to "add a URL": a general file → the same library (E7 S5c). */}
|
||||
{/* Local upload is a peer to "add a URL": a general file → the same library. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -307,7 +307,7 @@ export default function DownloadCenter() {
|
||||
{tab === "shared" && (
|
||||
<NavGrid className="space-y-2">
|
||||
{(sharedQ.data ?? []).map((job, i) => {
|
||||
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
||||
// Same media_kind + can_download gate as the library, via the shared helper.
|
||||
const a = sharedRowActions(job);
|
||||
return (
|
||||
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function DownloadDialog({
|
||||
} catch (e) {
|
||||
// A quota or validation refusal (422/409) is already in the global error dialog, which the
|
||||
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
|
||||
// open with nothing said (E11 S2b).
|
||||
// open with nothing said.
|
||||
notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E30: a render crash's notice speaks the reader's language. It showed `error.message` — the JS
|
||||
// A render crash's notice speaks the reader's language. It showed `error.message` — the JS
|
||||
// engine's English ("Cannot read properties of undefined (reading 'x')") — in the toast and the inbox,
|
||||
// where it outlives the reload. The technical text stays in the `ui.crash` log.
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -33,7 +33,7 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
level: "fatal",
|
||||
title: i18n.t("errors.boundary.notifTitle"),
|
||||
// Never the raw `error.message`: a JS engine's English ("Cannot read properties of undefined…")
|
||||
// is no message for the reader (E30) — the `ui.crash` log above carries it for diagnosis.
|
||||
// is no message for the reader — the `ui.crash` log above carries it for diagnosis.
|
||||
message: i18n.t("errors.boundary.notifMessage"),
|
||||
requiresInteraction: true,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Hidden / Blocked channel cards as a keyboard list (E11 S4c): `j`/`k` walk the cards, Enter opens
|
||||
// The Hidden / Blocked channel cards as a keyboard list: `j`/`k` walk the cards, Enter opens
|
||||
// the channel — the ChannelLink name is the card's open element — and the card's own action button
|
||||
// keeps its Enter.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The count pill while a new search's count loads. keepPreviousData holds the last filter's count, and a
|
||||
// broad word's count takes seconds ("a": ~3 s on the prod-sized library, E31): the pill showed the old
|
||||
// broad word's count takes seconds ("a": ~3 s on the prod-sized library): the pill showed the old
|
||||
// number as if it were the new search's. It now dims and is aria-busy until the new count lands.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// A next page that fails. Retry is off and the read-ahead re-runs whenever a fetch settles, so a failed
|
||||
// page was requested again once a round-trip, forever, while the end of the list was in view, and
|
||||
// nothing said it had failed (E31 review 2). The read-ahead now stops at a failed page, and a row says
|
||||
// nothing said it had failed. The read-ahead now stops at a failed page, and a row says
|
||||
// so and retries on a click. VirtualFeed is replaced by what Feed hands it: the read-ahead's gate and a
|
||||
// button standing in for the read-ahead itself.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// "Reset — clear watch progress/status" on a half-watched video. The optimistic layer overrode only
|
||||
// the STATUS, so the card kept its resume position — its progress bar and its Continue button — until
|
||||
// the next refetch (found in the E28 S3e1 UAT). The grid is replaced by a list that shows what Feed
|
||||
// the next refetch (found in a UAT). The grid is replaced by a list that shows what Feed
|
||||
// hands it: each video's position, and a button that fires the reset.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
@@ -879,7 +879,7 @@ export default function Feed({
|
||||
onResetState={onResetState}
|
||||
onOpen={openVideo}
|
||||
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
|
||||
// re-request it once a round-trip for as long as the end is in view (E31 review 2). The row
|
||||
// re-request it once a round-trip for as long as the end is in view. The row
|
||||
// below says so and retries on a click.
|
||||
hasNextPage={!!hasNextPage && !query.isFetchNextPageError}
|
||||
isFetching={query.isFetching}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// The live-YouTube results' Sort control, wired end to end: the pref it writes, the key and
|
||||
// direction Feed hands to sortYtResults, and the order that reaches the grid. sortYtResults itself
|
||||
// is pinned in feedSort.yt.test.ts; a deep-review of the 2026-09 bug hunt found that the CALL SITE
|
||||
// is pinned in feedSort.yt.test.ts; a review during the 2026-09 bug hunt found that the CALL SITE
|
||||
// could ignore the chosen direction (or skip the sort entirely) with every lane green.
|
||||
//
|
||||
// The virtualised grid lays out nothing in jsdom, so it is replaced by a plain list that shows the
|
||||
|
||||
@@ -18,7 +18,7 @@ import { PaletteLauncher } from "./PaletteLauncher";
|
||||
// has one, and the per-user sync chip at the right end.
|
||||
// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly
|
||||
// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which
|
||||
// owns both the header slot and the padded content wrapper (E5 S7).
|
||||
// owns both the header slot and the padded content wrapper.
|
||||
const Header = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
@@ -107,7 +107,7 @@ const Header = forwardRef<
|
||||
so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
|
||||
{page === "feed" && <FeedScopeToggle />}
|
||||
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
|
||||
the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. The
|
||||
the filter sidebar so the primary "what am I browsing" control is always in reach. The
|
||||
Discover ⟷ Browse toggle sits right beside it (driven via the PlexViewBridge). */}
|
||||
{page === "plex" && me.plex_enabled && <PlexScopeToggle />}
|
||||
{page === "plex" && me.plex_enabled && <PlexViewToggle />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E24 S2: the Home surface. Covers the four behaviours the browser round actually caught, each of
|
||||
// The Home surface. Covers the four behaviours the browser round actually caught, each of
|
||||
// which is invisible to tsc and to a render-only check:
|
||||
// • the hero's kicker comes from the SERVER's tier, never from the card's shape;
|
||||
// • the hero is not printed twice (once as the hero, once as the first resume tile);
|
||||
@@ -308,7 +308,7 @@ describe("Home", () => {
|
||||
// through the disabled seed-0 query the chip click leaves behind. So the first reroll under a new
|
||||
// budget offered the old budget's card — while it loaded, and permanently once it failed, since
|
||||
// `retry: false` makes a failed pick terminal. Under a chip that promises ten minutes, that makes
|
||||
// the page's one control meaningless (round 2).
|
||||
// the page's one control meaningless.
|
||||
discover.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
|
||||
@@ -381,7 +381,7 @@ describe("Home", () => {
|
||||
// every mark-watched, bookmark, pin and shelf move on this page refetches it in the BACKGROUND —
|
||||
// so binding the button's `loading` to raw `isFetching` made it spin and, since a `loading`
|
||||
// `ui/Button` swallows its clicks, ignore presses after actions the user never associated with a
|
||||
// reroll (round 2). A press is outstanding only while THIS key has no answer of its own.
|
||||
// reroll. A press is outstanding only while THIS key has no answer of its own.
|
||||
const rerollBtn = () =>
|
||||
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
|
||||
let resolvePick!: (v: unknown) => void;
|
||||
@@ -419,7 +419,7 @@ describe("Home", () => {
|
||||
});
|
||||
|
||||
it("a press that FAILED does not leave the button hostage to the next mutation", async () => {
|
||||
// Round 3's correction to the case above. A key whose request failed never gets data of its own
|
||||
// A later correction to the case above. A key whose request failed never gets data of its own
|
||||
// (`retry: false` makes it terminal), so a predicate reading `data === undefined` stayed true
|
||||
// forever and every later background refetch re-disabled the button — the same defect, one
|
||||
// branch over. `isFetched` remembers that the key settled, an error counting as settled.
|
||||
@@ -437,7 +437,7 @@ describe("Home", () => {
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(1));
|
||||
// `waitFor`, not a fixed tick: react-query hands the rejection to the observer through
|
||||
// `notifyManager`'s own `setTimeout(0)`, registered AFTER any timer this test schedules, so
|
||||
// "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red.
|
||||
// "await one macrotask then assert" races it. That race was measured as an intermittent red.
|
||||
failPick(new Error("boom"));
|
||||
await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull()); // the failure releases the button
|
||||
|
||||
@@ -454,7 +454,7 @@ describe("Home", () => {
|
||||
// The tagged slot is authoritative for its own budget, including when what it holds is "no hero
|
||||
// at all". Under a `??` chain that read as "nothing remembered" and fell through to the loaded
|
||||
// payload, so a failed press resurrected the card the page had already excluded as seen and just
|
||||
// said it had nothing beyond (round 3).
|
||||
// said it had nothing beyond.
|
||||
discover.mockResolvedValue({
|
||||
budget: "short",
|
||||
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
|
||||
@@ -543,7 +543,7 @@ describe("Home", () => {
|
||||
expect(screen.getByText("Hero v1")).toBeTruthy();
|
||||
});
|
||||
|
||||
// E24 S4. The palette's "Meglepetés" is pressed on ANOTHER page, so Home's lazy chunk is not
|
||||
// The palette's "Meglepetés" is pressed on ANOTHER page, so Home's lazy chunk is not
|
||||
// mounted yet — the request has to WAIT as a token rather than fire into an empty room. And it
|
||||
// must be spent: a token that survived would make a later visit reroll on its own, which reads as
|
||||
// the page overruling the answer you came back for.
|
||||
@@ -703,7 +703,7 @@ describe("Home", () => {
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
// Since E28 C1 the tile's ⓘ is its menu's "Media info" row, opened from the tile's "…".
|
||||
// The tile's ⓘ is now its menu's "Media info" row, opened from the tile's "…".
|
||||
fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!);
|
||||
const info = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
@@ -782,7 +782,7 @@ describe("Home", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E11 S4b, owner decision D4: the rails are one keyboard page — `j` walks a rail's cards and goes on
|
||||
// The owner's decision: the rails are one keyboard page — `j` walks a rail's cards and goes on
|
||||
// into the next rail, whichever order the page stacks them in.
|
||||
describe("Home · keyboard", () => {
|
||||
it("walks the rails with j, from one rail on into the next", async () => {
|
||||
@@ -828,7 +828,7 @@ describe("Home · keyboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E28 S3h (the owner, 2026-09-26/27): the hero stands in three quarters of a wide page with its text
|
||||
// The owner's call (2026-09-26/27): the hero stands in three quarters of a wide page with its text
|
||||
// centred beside the picture; one fact line, no repeated year, and prose only where there is some,
|
||||
// at a readable measure.
|
||||
describe("Home's hero text", () => {
|
||||
@@ -883,7 +883,7 @@ describe("Home's hero text", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Home · the reader's language (E30)", () => {
|
||||
describe("Home · the reader's language", () => {
|
||||
it("asks for the page in Hungarian, so a Plex card comes back with its Hungarian title", async () => {
|
||||
setLanguage("hu");
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E24 S3: the comfort shelf's editor — the right-click menu on the Home rails. Its own file, not an
|
||||
// The comfort shelf's editor — the right-click menu on the Home rails. Its own file, not an
|
||||
// addition to Home.dom.test.tsx, because the shelf query is module-level react-query state that a
|
||||
// sibling test's mocks would leak into.
|
||||
//
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
mediaTitleClass,
|
||||
} from "./ui/mediaCardStyles";
|
||||
|
||||
// The Home surface (E24) — "Mit nézzek most?".
|
||||
// The Home surface — "Mit nézzek most?".
|
||||
//
|
||||
// Layout C+, chosen from mockups: the time-budget chips sit ABOVE a single large hero, so the
|
||||
// question comes first and the hero is its answer; the shelves follow, compact. The page's whole
|
||||
@@ -144,7 +144,7 @@ function useHomeCardMenu({
|
||||
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
|
||||
const menu = useContextMenu<DiscoverCard>();
|
||||
const pin = useComfortPin(menu.open);
|
||||
// A video tile's own action list (E28 C1), the same one the feed's menu shows; the rail keeps its
|
||||
// A video tile's own action list, the same one the feed's menu shows; the rail keeps its
|
||||
// Play row (it resolves `next`) and its shelf rows around it.
|
||||
const videoActions = useVideoActions({ onState, onToggleSave }, menu.open);
|
||||
// A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does).
|
||||
@@ -723,7 +723,7 @@ export default function Home() {
|
||||
}, [budget, seed, press, seen]);
|
||||
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null);
|
||||
|
||||
// A Plex card's title is in the reader's language (E30), so the answer is cached per language.
|
||||
// A Plex card's title is in the reader's language, so the answer is cached per language.
|
||||
const lang = plexLang(i18n.language);
|
||||
const q = useQuery({
|
||||
queryKey: qk.discover(budget, lang),
|
||||
@@ -745,7 +745,7 @@ export default function Home() {
|
||||
//
|
||||
// But only WITHIN a budget. Plain `keepPreviousData` chains through the disabled seed-0 query a
|
||||
// chip click leaves behind, so the first reroll under a new budget held up the card the PREVIOUS
|
||||
// question was answered with — under a chip promising something else (round 2). Returning
|
||||
// question was answered with — under a chip promising something else. Returning
|
||||
// undefined instead falls the hero back to this budget's own loaded answer, which is already in
|
||||
// hand; the spinner still covers the wait.
|
||||
placeholderData: (prev) => (prev?.budget === budget ? prev : undefined),
|
||||
@@ -754,11 +754,11 @@ export default function Home() {
|
||||
// `["discover"]` prefix so a mutation's invalidation reaches the rerolled hero — which also means
|
||||
// every mark-watched, bookmark, pin/unpin and shelf move on this page refetches it in the
|
||||
// background, and `ui/Button` turns `loading` into `disabled`. So the reroll spun and swallowed
|
||||
// clicks after actions the user never associated with it (round 2). A press is outstanding exactly
|
||||
// clicks after actions the user never associated with it. A press is outstanding exactly
|
||||
// while THIS key has never settled — which is what `isFetched` means (react-query counts both a
|
||||
// success and an error as settled), and every press mints a fresh seed, so a fresh key.
|
||||
//
|
||||
// Round 3 corrected this: `isPlaceholderData || data === undefined` looked equivalent and was not.
|
||||
// Since corrected: `isPlaceholderData || data === undefined` looked equivalent and was not.
|
||||
// A pick that FAILED leaves its key data-less forever (`retry: false`), so the second disjunct
|
||||
// stayed true and the next unrelated mutation re-disabled the button — the same defect, surviving
|
||||
// in the error branch. Guarding on `!isError` does NOT help either: query-core resets a data-less
|
||||
@@ -775,7 +775,7 @@ export default function Home() {
|
||||
// reset an answer that already resolved, so an untagged slot outlived the question it answered —
|
||||
// switch to "10 perc", press reroll, let that request fail (`retry: false`, so it is terminal),
|
||||
// and the two-hour film picked under "Bármennyi" was still the hero, under a chip promising ten
|
||||
// minutes (round 2). Taken from the SERVER's `budget` echo rather than this render's state, so the
|
||||
// minutes. Taken from the SERVER's `budget` echo rather than this render's state, so the
|
||||
// tag cannot be minted from a budget the pick never answered.
|
||||
const [lastPick, setLastPick] = useState<{
|
||||
budget: DiscoverBudget;
|
||||
@@ -802,7 +802,7 @@ export default function Home() {
|
||||
// pick for this budget resolved to no hero" is a real answer, not a missing one. Under
|
||||
// `??` the two collapsed, so a failed press after an empty pick resurrected the card the
|
||||
// page loaded with — which it had already excluded as seen and just said it had nothing
|
||||
// beyond (round 3). A mismatched or absent tag still falls through to the loaded hero,
|
||||
// beyond. A mismatched or absent tag still falls through to the loaded hero,
|
||||
// which is what the FIRST press of a budget needs.
|
||||
lastPick?.budget === budget
|
||||
? lastPick.hero
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E16 S3: the issue composer. It drafts the steps from the session, saves as it goes (the rendered
|
||||
// The issue composer. It drafts the steps from the session, saves as it goes (the rendered
|
||||
// text included — the export is built from what it SAVED), reopens as it was left, takes a log line
|
||||
// out of the evidence in the review, adds a pasted image, and a sent session is read-only. i18n is a
|
||||
// key-passthrough with its params; the API is stubbed.
|
||||
@@ -495,7 +495,7 @@ describe("the issue composer", () => {
|
||||
|
||||
it("opens on the title, not on the Edit / Preview switch above it", async () => {
|
||||
// The form arrives after the dialog opens; its first control is the switch, and the focus
|
||||
// landed there — typing went nowhere (E32 review round 3).
|
||||
// landed there — typing went nowhere.
|
||||
layOut();
|
||||
mount();
|
||||
await waitFor(() => expect(document.activeElement).toBe(title()));
|
||||
|
||||
@@ -30,12 +30,12 @@ import { Textarea, Input } from "./ui/Input";
|
||||
import { Checkbox } from "./ui/NativeControls";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the
|
||||
// The issue composer: one dialog — the draft on the left (title, description, the
|
||||
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
|
||||
// each reviewable line by line, and the screenshots, removable one by one, with paste / drop / pick
|
||||
// to add more — an image pasted anywhere in it is taken). It saves as it goes, and what is still
|
||||
// waiting to be saved when it closes is saved then, so closing it loses nothing: Settings →
|
||||
// Diagnosztika reopens any past session. The export and (S4) the issue are built from what it SAVED.
|
||||
// Diagnosztika reopens any past session. The export and the issue are built from what it SAVED.
|
||||
|
||||
const SAVE_DELAY_MS = 800;
|
||||
type Source = "web" | "server";
|
||||
@@ -200,7 +200,7 @@ function ComposerBody({
|
||||
setForm(next);
|
||||
};
|
||||
|
||||
// The issue (S4), like the export, is built from the SAVED draft: save what is on screen first.
|
||||
// The issue, like the export, is built from the SAVED draft: save what is on screen first.
|
||||
// A send that stopped half-way is finished by the same call, which does only what is missing.
|
||||
// While it runs the draft is held as it was saved: nothing typed then would reach the issue.
|
||||
const target = useMe()?.issues ?? null;
|
||||
@@ -274,7 +274,7 @@ function ComposerBody({
|
||||
// draft is already frozen (`locked`), where the save is both pointless and refused. While a send
|
||||
// holds the claim the server 409s `issue_sending`, and that refusal would take the zip with it:
|
||||
// `GET /export` has no send guard and would have served exactly what the send is uploading, so
|
||||
// the one tracker-free way out of a stuck send used to fail at the last step (round 4).
|
||||
// the one tracker-free way out of a stuck send used to fail at the last step.
|
||||
const exportZip = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!locked) await save.mutateAsync();
|
||||
|
||||
@@ -11,7 +11,7 @@ import { SURFACE } from "./ui/surfaces";
|
||||
// Compact language picker (globe + current code). Presentational: the parent decides what
|
||||
// changing the language does (set it; persist server-side when signed in).
|
||||
//
|
||||
// Two variants, now one implementation on the R8 popover platform: "header" opens below the pill
|
||||
// Two variants, now one implementation on the popover platform: "header" opens below the pill
|
||||
// (used on the landing page and the setup wizard) and "rail" is an icon-only button in the left
|
||||
// nav's bottom cluster whose menu opens to the RIGHT, bottom-aligned. Both used to hand-roll their
|
||||
// own anchoring, and the header one also opened on hover — dropped on purpose: the platform focuses
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E7·S4b — the in-app library player streams the completed item's LOCAL file into a native media
|
||||
// The in-app library player streams the completed item's LOCAL file into a native media
|
||||
// element. video/audio is chosen from the stored media_kind (the single source), NOT a container
|
||||
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
|
||||
// is key-passthrough here.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { jobTitle } from "./downloadTitle";
|
||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||
import { useMediaLogRef } from "../lib/mediaLog";
|
||||
|
||||
// E7 S4b — in-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
|
||||
// In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
|
||||
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
|
||||
// APIs and can't point at a local file. Instead we reuse the shared a11y shell (`Modal`: focus-trap,
|
||||
// ESC/back-to-close, scroll-lock, layer-stack, portal) and stream the local file into a NATIVE
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E22 S5: "Saját napló" shows this tab's own log live, newest first, filters it by level group and
|
||||
// "Saját napló" shows this tab's own log live, newest first, filters it by level group and
|
||||
// text, and copies exactly what is filtered (oldest first, one JSON event per line).
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function NavSidebar({
|
||||
{systemItems.length > 0 && (
|
||||
<>
|
||||
<NavRailDivider />
|
||||
{/* The admin pages, one row opening a panel of them (E31): five rows of their own sat
|
||||
{/* The admin pages, one row opening a panel of them: five rows of their own sat
|
||||
below the fold at 133% zoom, behind a fade alone. */}
|
||||
<NavRailGroup
|
||||
testId="nav-admin"
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("NavigationProvider · the tab title", () => {
|
||||
expect(document.title.endsWith(" · Siftlode")).toBe(true);
|
||||
});
|
||||
|
||||
// E31 S1: the Feed's tab read a bare "Siftlode" — the one page that named no place.
|
||||
// The Feed's tab read a bare "Siftlode" — the one page that named no place.
|
||||
it("names the Feed like every other page", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
|
||||
render();
|
||||
@@ -100,7 +100,7 @@ describe("NavigationProvider · the tab title", () => {
|
||||
expect(document.title).toBe("header.account.feed · Siftlode");
|
||||
});
|
||||
|
||||
// E31 S1: a channel opened from a link was "Channels · Siftlode", the same channel opened in the
|
||||
// A channel opened from a link was "Channels · Siftlode", the same channel opened in the
|
||||
// app was named; a Plex title, person or collection only "Plex". The object is the view's heading.
|
||||
it("puts the open view's own heading before the place, and drops it when the view closes", async () => {
|
||||
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
|
||||
@@ -161,8 +161,8 @@ describe("NavigationProvider · setPage's same-page guard", () => {
|
||||
// the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
|
||||
const { result } = render();
|
||||
// The page under test must be the one that is ALREADY current, or the guard's first conjunct is
|
||||
// false and this never reaches the clause it is named for — the mistake round 4 caught here: a
|
||||
// bare URL with cleared storage lands on "home" (navigationInit's E24 default), not "feed", so
|
||||
// false and this never reaches the clause it is named for — the mistake a review caught here: a
|
||||
// bare URL with cleared storage lands on "home" (navigationInit's default), not "feed", so
|
||||
// asking for "feed" was just an ordinary page change and deleting the clause left it green.
|
||||
const current = result.current.page;
|
||||
expect(current).toBe("home");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 C1 on the notification center: a row keeps its link and its unread mark in sight, its dismiss
|
||||
// The notification center: a row keeps its link and its unread mark in sight, its dismiss
|
||||
// shows on hover (on touch it is in the "…"), and everything it can do is its ONE menu — opened by
|
||||
// the row's "…" or a right-click; a hidden / watched notice's Find in feed and Undo live only there.
|
||||
// The band keeps Mark all read; Clear everything moved into the header's "…" and asks first.
|
||||
@@ -46,7 +46,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: h.setPage }) }));
|
||||
vi.mock("./channelsContext", () => ({ useChannelsActions: () => ({ focusChannel: vi.fn() }) }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
// Every narrowing a feed can carry (E28 review C2: the year, the custom range, the length, the
|
||||
// Every narrowing a feed can carry (the year, the custom range, the length, the
|
||||
// content types and the scope survived "Find in feed" and could still hide the video).
|
||||
useFeedFilters: () => ({
|
||||
tags: [54],
|
||||
@@ -331,7 +331,7 @@ describe("NotificationsPanel · the page's background menu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a download-lifecycle notice (E30)", () => {
|
||||
describe("a download-lifecycle notice", () => {
|
||||
it("is named from its data in the reader's language, never the server's English title", () => {
|
||||
h.server = [
|
||||
server(1, {
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function NotificationsPanel() {
|
||||
else refresh();
|
||||
}
|
||||
// It empties both lists for good (the server's are deleted), so it asks first — it moved from the
|
||||
// band into the header's "…" (E28 C1), where a destructive row keeps its confirm.
|
||||
// band into the header's "…", where a destructive row keeps its confirm.
|
||||
async function clearEverything() {
|
||||
const ok = await confirm({
|
||||
title: t("inbox.clearConfirmTitle"),
|
||||
@@ -145,8 +145,8 @@ export default function NotificationsPanel() {
|
||||
// notices — jump to the feed filtered to that state and the video's channel.
|
||||
// Built from nothing, not spread over the reader's filters: EVERY narrowing that could leave the
|
||||
// video out goes — a tag, "no tag", a search, the age cap, the year, the custom range, the length,
|
||||
// a content type (a hidden Short under "no Shorts"), the scope and the library source (E28 review
|
||||
// C2: the spread kept whatever this list forgot). Only how the reader orders and matches stays.
|
||||
// a content type (a hidden Short under "no Shorts"), the scope and the library source (the
|
||||
// spread kept whatever this list forgot). Only how the reader orders and matches stays.
|
||||
function locate(meta: VideoHiddenMeta | VideoWatchedMeta) {
|
||||
setFilters({
|
||||
tags: [],
|
||||
@@ -176,7 +176,7 @@ export default function NotificationsPanel() {
|
||||
.catch((e) => notifyActionError(e, t("inbox.revertFailed")));
|
||||
}
|
||||
|
||||
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
|
||||
// --- The rows' ONE menu -------------------------------------------------------------
|
||||
// A row keeps its link and its unread mark in sight; its dismiss shows on hover. Everything it can
|
||||
// do — the link again, find / undo on an activity row, mark read, dismiss — is its menu.
|
||||
// One menu per list, keyed by the notification's id: the server list polls, and the "…" must still
|
||||
@@ -329,7 +329,7 @@ export default function NotificationsPanel() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The inbox band (B4): Mark all read in sight, Clear everything in its "…" (E28 C1), fixed
|
||||
{/* The inbox band (B4): Mark all read in sight, Clear everything in its "…", fixed
|
||||
while the list scrolls. No title card: the nav rail and the tab title name the page, and the
|
||||
shell's hidden h1 names it to a screen reader. */}
|
||||
{/* The page's background menu (DESIGN "Background menu"): the band's own non-destructive
|
||||
@@ -518,7 +518,7 @@ function NotificationRow({
|
||||
const isScheduler = serverKind(n) === "scheduler";
|
||||
const isDownloadDone = serverKind(n) === "download";
|
||||
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
|
||||
// A download's life after it is done (E30): shared with you, about to expire, expired, evicted.
|
||||
// A download's life after it is done: shared with you, about to expire, expired, evicted.
|
||||
const lifecycle = DOWNLOAD_LIFECYCLE[n.type];
|
||||
let title = n.title;
|
||||
let body = n.body;
|
||||
@@ -529,7 +529,7 @@ function NotificationRow({
|
||||
body = t("inbox.maintenance.body", { count: d.count });
|
||||
} else if (isScheduler) {
|
||||
// "<Job label> finished/failed", with the result as the body — named in the reader's language
|
||||
// when it rode along as data (E30), else the English summary of an older notice.
|
||||
// when it rode along as data, else the English summary of an older notice.
|
||||
const run = jobRunText(t, n.data as { job_id: string }, lang);
|
||||
title = run.title;
|
||||
body = run.body || n.body;
|
||||
@@ -636,7 +636,7 @@ function ClientActivityRow({
|
||||
}) {
|
||||
const { icon: Icon, color } = LEVEL_STYLE[n.level];
|
||||
const needsAction = n.requiresInteraction && !n.dismissed;
|
||||
// A hidden / watched notice's Find in feed and Undo are in its menu (E28 C1): the row reads.
|
||||
// A hidden / watched notice's Find in feed and Undo are in its menu: the row reads.
|
||||
const quiet = n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched";
|
||||
const subscribed: ChannelSubscribedMeta | null =
|
||||
n.meta?.kind === "channel-subscribed" ? n.meta : null;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// The wizard is a modal overlay in every way that matters to a user, but it renders its own shell
|
||||
// instead of going through `ui/Modal` — and for a keyboard or screen-reader user it had nothing of
|
||||
// the dialog contract: no role, no label, no initial focus, no trap, no restore. Tab past its last
|
||||
// control walked into the app behind the scrim, where the focus ring is invisible (round 5).
|
||||
// control walked into the app behind the scrim, where the focus ring is invisible.
|
||||
//
|
||||
// `focusablesIn` filters on `el.offsetParent !== null` and jsdom reports null for everything, so the
|
||||
// trap is untestable as written without the same stub `ui/Modal.dom.test.tsx` uses.
|
||||
@@ -90,7 +90,7 @@ describe("the onboarding wizard as a dialog", () => {
|
||||
// `Node.contains(self)` is true, so focus sitting ON the tabIndex=-1 panel — where a click on
|
||||
// its dead space puts it — matched no branch of `trapTab` and the browser's native Shift+Tab
|
||||
// ran, into the app behind the scrim. That is what the third argument exists for, and this
|
||||
// caller was written without it on the very commit that extracted the helper (round 6).
|
||||
// caller was written without it on the very commit that extracted the helper.
|
||||
mount();
|
||||
const panel = screen.getByRole("dialog");
|
||||
panel.focus();
|
||||
@@ -104,7 +104,7 @@ describe("the onboarding wizard as a dialog", () => {
|
||||
// Ownership has to be measured over the same scope the Tab trap uses. The wizard's toast host
|
||||
// sits beside the panel and INSIDE the dialog, so tabbing to a toast's ✕ is legal — but read
|
||||
// against the panel alone it said "the user left", and since removing the toast fires no
|
||||
// focusin to re-arm, closing afterwards stranded the focus on <body> (round 6). Unlike
|
||||
// focusin to re-arm, closing afterwards stranded the focus on <body>. Unlike
|
||||
// `ui/Modal`, whose root carries the aria-modal attribute, nothing else can rescue this one.
|
||||
const outside = document.createElement("button");
|
||||
document.body.appendChild(outside);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Escape closes the topmost layer only — the wizard included (deep-review round 4). Its own Escape
|
||||
// Escape closes the topmost layer only — the wizard included. Its own Escape
|
||||
// was a raw `document` listener with no layer check, and document listeners run before the window
|
||||
// listener ui/Modal uses: a sync that failed inside the wizard opens the global error dialog, and one
|
||||
// Esc dismissed the error AND ended onboarding, skipping the write-scope step.
|
||||
|
||||
@@ -117,13 +117,13 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
|
||||
// A layer in the shared stack, so Escape closes the wizard only when it is on top: a raw document
|
||||
// listener ran BEFORE the error dialog's (a sync that failed inside the wizard opens one), and one
|
||||
// Esc closed the error AND ended onboarding, skipping the write-scope step (deep-review round 4).
|
||||
// Esc closed the error AND ended onboarding, skipping the write-scope step.
|
||||
const layer = useLayer(true, close);
|
||||
|
||||
// The rest of the dialog contract. This is a modal overlay in every way that matters to a user, but
|
||||
// it renders its own shell (it is not a titled dialog with a header row), so Escape ordering was all
|
||||
// it had: focus started on <body>, nothing announced a dialog, and Tab past the last control walked
|
||||
// into the app behind the scrim — where the focus ring is invisible (deep-review round 5). The trap
|
||||
// into the app behind the scrim — where the focus ring is invisible. The trap
|
||||
// and the roles come from the same place `ui/Modal` gets them, not from a second hand-written copy.
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -136,7 +136,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
if (e.key !== "Tab" || layer.blockingAbove()) return;
|
||||
const panel = panelRef.current;
|
||||
// The SAME three arguments `ui/Modal` passes, for the same two reasons — and this call had
|
||||
// neither of them (round 6, on the commit that extracted the helper so the two shells could
|
||||
// neither of them (on the commit that extracted the helper so the two shells could
|
||||
// not disagree). Scope is the whole dialog, so the toast host beside the panel stays in the
|
||||
// Tab cycle (a sticky connection notice's ✕ was unreachable from the keyboard); `container`
|
||||
// is the tabIndex=-1 panel, because `panel.contains(panel)` is true, so focus sitting ON the
|
||||
@@ -151,7 +151,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
// Are we currently importing (or about to)? Show a progress screen instead of the
|
||||
// "you're connected" step until the first import settles. Once triggered, the mutation itself says
|
||||
// which (pending / success / error) — the trigger ref is the effect's once-only latch, never read
|
||||
// here (a ref read in render made the React Compiler skip the wizard, E32).
|
||||
// here (a ref read in render made the React Compiler skip the wizard).
|
||||
const importing =
|
||||
me.can_read &&
|
||||
!importSubs.isError &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S7 — the Personal-views manager gains an EDITABLE detail pane: captured filters are now removable
|
||||
// The Personal-views manager gains an EDITABLE detail pane: captured filters are now removable
|
||||
// chips that write the pruned set back to the stored view (undoable), an explicit Save-current button
|
||||
// stores the live filters into the view, and rename has a visible commit affordance. This drives the
|
||||
// real modal against a mocked savedViewsContext so we can assert exactly which mutation fires; the
|
||||
@@ -108,7 +108,7 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PersonalViewsModal — S7 editable detail pane", () => {
|
||||
describe("PersonalViewsModal — editable detail pane", () => {
|
||||
it("removing a captured-filter chip writes the pruned filters back to the stored view", async () => {
|
||||
mount();
|
||||
const removeBtn = await screen.findByLabelText("feed.chips.remove");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The YouTube player modal, rendered for real. Its pure helpers (afterRemoval, the progress cache)
|
||||
// had tests; what the modal DOES with them had none — a deep-review of the 2026-09 bug hunt found
|
||||
// had tests; what the modal DOES with them had none — a review during the 2026-09 bug hunt found
|
||||
// that deleting the lines that apply them left every lane green.
|
||||
//
|
||||
// `window.YT` is a fake installed before the module's API loader first runs (that loader memoises
|
||||
@@ -293,9 +293,9 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
||||
});
|
||||
|
||||
// --- the dialog contract it now shares (ui/dialogFocus) -----------------------------------------
|
||||
// Until E11 this was the one `aria-modal` shell with no accessible name, no Tab trap and no focus
|
||||
// This was once the one `aria-modal` shell with no accessible name, no Tab trap and no focus
|
||||
// restore: Tab walked out of an open player into the page behind the scrim, and closing it with
|
||||
// Escape left a keyboard user on <body>, their place in a long feed lost (deep-review round 7).
|
||||
// Escape left a keyboard user on <body>, their place in a long feed lost.
|
||||
describe("PlayerModal — the dialog contract", () => {
|
||||
const open = (onClose = () => {}) => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
@@ -444,7 +444,7 @@ describe("PlayerModal — the side arrows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E31 S1: at 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel
|
||||
// At 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel
|
||||
// row. The card's width is capped from the window height less what sits under the video, measured.
|
||||
describe("PlayerModal — fits the window's height", () => {
|
||||
it("caps the card's width by the chrome measured under the video", () => {
|
||||
@@ -469,7 +469,7 @@ describe("PlayerModal — fits the window's height", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E31 (the owner's pick "A"): on a wide window the video was capped at ~1008 px of a 1920 screen, with
|
||||
// The owner's pick: on a wide window the video was capped at ~1008 px of a 1920 screen, with
|
||||
// empty space all round. From 1280 px the title, channel, actions, description and queue sit in a side
|
||||
// panel beside the video, and the video takes the window's height.
|
||||
describe("PlayerModal — the side panel on a wide window", () => {
|
||||
@@ -542,7 +542,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
|
||||
});
|
||||
|
||||
// E31 S5: a failed load read "No description." in the always-open panel — an error drawn as empty.
|
||||
// A failed load read "No description." in the always-open panel — an error drawn as empty.
|
||||
it("says the description failed to load, with a retry, instead of 'No description'", async () => {
|
||||
wideWindow(true);
|
||||
const detail = vi
|
||||
@@ -563,7 +563,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
expect(detail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// E31 review 2: the popover below 1280 px said it the same way only by a copy nobody tested.
|
||||
// The popover below 1280 px said it the same way only by a copy nobody tested.
|
||||
it("says it in the description popover below 1280 px too", async () => {
|
||||
wideWindow(false);
|
||||
vi.spyOn(api, "videoDetail").mockRejectedValue(new Error("boom"));
|
||||
|
||||
@@ -100,7 +100,7 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// From this window width the player's details sit in a side panel beside the video (E31). The panel is
|
||||
// From this window width the player's details sit in a side panel beside the video. The panel is
|
||||
// a rem width, so it grows with the text size; the video takes what is left.
|
||||
const WIDE_PLAYER_PX = 1280;
|
||||
const SIDE_PANEL = "20rem";
|
||||
@@ -206,10 +206,10 @@ export default function PlayerModal({
|
||||
const focusModal = useCallback(() => cardRef.current?.focus(), []);
|
||||
// The card fits the window, video AND what sits under it (the queue strip, title, channel row): the
|
||||
// video is 16:9 at the card's width, so on a short window (133% zoom on a 1080p screen: 685px) a
|
||||
// full-width card ran past the bottom and cut its channel row (E31 S1). `below` is that chrome's
|
||||
// full-width card ran past the bottom and cut its channel row. `below` is that chrome's
|
||||
// height, measured (it wraps with the title and the text size), and caps the card's width below.
|
||||
const [below, setBelow] = useState(0);
|
||||
// A wide window (E31, the owner's pick "A"): the title, channel, actions, description and the queue
|
||||
// A wide window (the owner's pick): the title, channel, actions, description and the queue
|
||||
// move into a side panel beside the video, so the video takes the window's whole height — at
|
||||
// 1920×911 it was capped at ~1008 px wide with empty space all round. Narrower, they stay under it.
|
||||
const wide = useMediaQuery(`(min-width: ${WIDE_PLAYER_PX}px)`, false);
|
||||
@@ -229,8 +229,8 @@ export default function PlayerModal({
|
||||
}, []);
|
||||
// The dialog contract every other `aria-modal` shell shares (ui/dialogFocus): the focus goes back
|
||||
// to whatever opened the player, and the page lock is the one counted lock. It opens focused on the
|
||||
// CARD, not its first button, so Space/F/arrows reach the shortcuts. Until E11 this was the one
|
||||
// hand-rolled shell — no accessible name, no Tab trap, no restore (deep-review round 7).
|
||||
// CARD, not its first button, so Space/F/arrows reach the shortcuts. This was once the one
|
||||
// hand-rolled shell — no accessible name, no Tab trap, no restore.
|
||||
useDialogChrome(cardRef, dialogRef, { focusScope: true });
|
||||
const titleId = useId();
|
||||
const togglePlay = () => {
|
||||
@@ -477,7 +477,7 @@ export default function PlayerModal({
|
||||
// over the player by doing so.
|
||||
const overlayOrder = useOverlayOrder();
|
||||
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the
|
||||
// download dialog, or — after S4 — an anchored menu) takes Escape first and this player defers.
|
||||
// download dialog, or an anchored menu) takes Escape first and this player defers.
|
||||
const layerIdRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = pushLayer();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A playlist or tag edit that fails is said, and what the screen shows goes back to what is true
|
||||
// (deep-review round 4). The Plex playlist page, the add-to-playlist popover and the tag manager had
|
||||
// A playlist or tag edit that fails is said, and what the screen shows goes back to what is true.
|
||||
// The Plex playlist page, the add-to-playlist popover and the tag manager had
|
||||
// no failure handling at all: a 404 for a list or tag deleted elsewhere — now a structured
|
||||
// `not_found`, which the request layer leaves to the caller — failed in silence, and an optimistic
|
||||
// removal stayed removed.
|
||||
@@ -92,7 +92,7 @@ describe("the Plex playlist page", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Movie 1");
|
||||
// E28 C1: the row's X went into its menu (right-click here, the "…" beside it on screen).
|
||||
// The row's X went into its menu (right-click here, the "…" beside it on screen).
|
||||
await pickFromMenu(
|
||||
() => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!),
|
||||
"plex.playlist.removeFromPlaylist",
|
||||
@@ -160,8 +160,8 @@ describe("the add-to-playlist popover", () => {
|
||||
it("says so on the PLEX side too, where the same sweep had missed the dialog", async () => {
|
||||
// Same shape, same 404, same silence — `req` leaves 403/404/429 to the caller, and this one had
|
||||
// no catch: the tick did not flip, nothing was said, the gone row stayed, and the floating
|
||||
// promise became an unhandled rejection. Round 5 fixed the collection editor and the YouTube
|
||||
// twin above; this dialog was left behind (round 6).
|
||||
// promise became an unhandled rejection. A sweep fixed the collection editor and the YouTube
|
||||
// twin above; this dialog was left behind.
|
||||
const lists = vi
|
||||
.spyOn(api, "plexPlaylists")
|
||||
.mockResolvedValue({ playlists: [{ id: 9, title: "Gone", has_item: false }] } as never);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The playlists module shows fewer lists than youtube.com does, and without a word about it that
|
||||
// reads as Siftlode being broken (the owner's ask, 2026-09-03: say it is YouTube's API, up front).
|
||||
// Three facts, behind one quiet line at the foot of the rail — the owner's pick from the S7 mockups
|
||||
// Three facts, behind one quiet line at the foot of the rail — the owner's pick from the mockups
|
||||
// (2026-09-19): always there to find again, never in the way.
|
||||
export default function PlaylistLimitsHint() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -392,7 +392,7 @@ describe("Playlists — a change the server refused is taken back on screen", ()
|
||||
// refetch that follows a successful reorder did. So a second reorder rejected while that refetch
|
||||
// was still in flight rolled the rows back past the FIRST one, which the server had accepted.
|
||||
// The membership guard cannot correct an order (its key is the sorted id set), so the wrong
|
||||
// order stayed on screen for good and the next drag persisted it (deep-review round 5).
|
||||
// order stayed on screen for good and the next drag persisted it.
|
||||
const reorder = vi
|
||||
.spyOn(api, "reorderPlaylist")
|
||||
.mockResolvedValueOnce(undefined as never)
|
||||
@@ -429,7 +429,7 @@ describe("Playlists — a change the server refused is taken back on screen", ()
|
||||
});
|
||||
});
|
||||
|
||||
// E28 C1: a row's X column went. Remove is in the row's menu and on the Delete key, with no confirm —
|
||||
// A row's X column went. Remove is in the row's menu and on the Delete key, with no confirm —
|
||||
// the toast's Undo puts the row back where it stood, in ONE request.
|
||||
describe("Playlists — removing a row, and undoing it", () => {
|
||||
// The server's side of a removal, so the refetch after it agrees with it.
|
||||
@@ -489,7 +489,7 @@ describe("Playlists — removing a row, and undoing it", () => {
|
||||
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "A", 0));
|
||||
});
|
||||
|
||||
// E28 review C4: the stored index drifts when the list changes before the Undo (a row added at
|
||||
// The stored index drifts when the list changes before the Undo (a row added at
|
||||
// the top in another tab) — the row goes back after its old neighbour instead.
|
||||
it("puts it back after its neighbour when the list changed before the Undo", async () => {
|
||||
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
|
||||
@@ -566,7 +566,7 @@ describe("Playlists — removing a row, and undoing it", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E28 C1: the header keeps Play all; rename, YouTube and delete are the list's menu, behind a "…".
|
||||
// The header keeps Play all; rename, YouTube and delete are the list's menu, behind a "…".
|
||||
describe("Playlists · the header's '…'", () => {
|
||||
it("replaces the pencil, the YouTube buttons and the trash, and renames from its menu", async () => {
|
||||
mount({ 1: [vid("A", 0)] });
|
||||
@@ -615,7 +615,7 @@ describe("Playlists · a narrow table", () => {
|
||||
el.classList.contains("hidden") && el.classList.contains("@2xl:table-cell");
|
||||
const heads = [...document.querySelectorAll("thead th")];
|
||||
const cells = [...document.querySelectorAll("tbody tr:first-child td")];
|
||||
// Columns: grip, #, title, channel, length, "…". Length went too (E28 S3f3, the owner's pick A).
|
||||
// Columns: grip, #, title, channel, length, "…". Length went too (the owner's pick).
|
||||
expect(heads.map(narrowHidden)).toEqual([false, true, false, true, true, false]);
|
||||
expect(cells.map(narrowHidden)).toEqual([false, true, false, true, true, false]);
|
||||
});
|
||||
@@ -661,7 +661,7 @@ describe("Playlists — the rows are a keyboard list", () => {
|
||||
});
|
||||
|
||||
// The header's meta line and its view-controls gate were typechecked and linted but never rendered,
|
||||
// so a regression in either shipped green (E9 review). The count and the runtime sum the WHOLE
|
||||
// so a regression in either shipped green. The count and the runtime sum the WHOLE
|
||||
// list; an unknown duration counts as nothing rather than poisoning the sum; and a list of one has
|
||||
// no view to change.
|
||||
describe("Playlists · the header", () => {
|
||||
|
||||
@@ -68,11 +68,11 @@ import { HiddenFilterCue } from "./ui/HiddenFilterCue";
|
||||
import { PageHeading } from "./ui/Headings";
|
||||
import { useCopyItems } from "./copyItems";
|
||||
|
||||
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
||||
// The playlist detail is a HYBRID table: real sortable/filterable column headers over a
|
||||
// body that is still a drag-reorderable @dnd-kit list.
|
||||
//
|
||||
// The distinction that shapes the whole module: **the column sort and the filters are a VIEW**,
|
||||
// while the playlist's own order is stored data. Before S4 the sort control wrote straight through
|
||||
// while the playlist's own order is stored data. At first the sort control wrote straight through
|
||||
// to the server, so picking "sort by title" silently rewrote the playlist and marked a
|
||||
// YouTube-linked mirror dirty. Now nothing is written until the user presses "Save this order",
|
||||
// which still goes through the undoable path — the capability is intact, it just stopped being a
|
||||
@@ -186,7 +186,7 @@ export default function Playlists() {
|
||||
// `restoreFromCache` rolls back to, and until this refetch landed it still held the order
|
||||
// from before this reorder — so a SECOND reorder failing in that window rolled the rows
|
||||
// back to an order the server does not have, permanently (the membership guard cannot
|
||||
// correct an order). One accepted write, one cache entry that matches it (round 5).
|
||||
// correct an order). One accepted write, one cache entry that matches it.
|
||||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
||||
old ? { ...old, items: next } : old,
|
||||
);
|
||||
@@ -220,8 +220,8 @@ export default function Playlists() {
|
||||
// Put the rows back to what the CACHED detail says — the server's list as last fetched. A failed
|
||||
// reorder or move must not wait for the resync to do it: the server's list is unchanged, so the
|
||||
// refetch comes back deep-equal, react-query keeps the same `data` object, and the effect below
|
||||
// (keyed on it) never runs. The moved row stayed gone and the rejected order stayed on screen
|
||||
// (deep-review round 4). Only for the list on screen: the selection may have moved meanwhile.
|
||||
// (keyed on it) never runs. The moved row stayed gone and the rejected order stayed on screen.
|
||||
// Only for the list on screen: the selection may have moved meanwhile.
|
||||
function restoreFromCache(listId: number) {
|
||||
if (listId !== selectedRef.current) return;
|
||||
const cur = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
||||
@@ -257,7 +257,7 @@ export default function Playlists() {
|
||||
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
|
||||
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
|
||||
const rowMenu = useContextMenu<Video>();
|
||||
// The comfort-shelf pin composes into the row's existing menu (E24 S3) rather than mounting a
|
||||
// The comfort-shelf pin composes into the row's existing menu rather than mounting a
|
||||
// second one on the same <tr>.
|
||||
const comfortPin = useComfortPin(rowMenu.open);
|
||||
const copy = useCopyItems();
|
||||
@@ -370,7 +370,7 @@ export default function Playlists() {
|
||||
[items],
|
||||
);
|
||||
|
||||
// The row's "…" opens the row menu under itself (E28 C1). Which row's menu is open is part of the
|
||||
// The row's "…" opens the row menu under itself. Which row's menu is open is part of the
|
||||
// cell (aria-expanded), so the columns rebuild when it changes — a cheap array of closures.
|
||||
const { buttonProps: rowMenuButton, open: rowMenuOpen, target: rowMenuTarget } = rowMenu;
|
||||
const columns = useMemo(
|
||||
@@ -451,8 +451,8 @@ export default function Playlists() {
|
||||
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
|
||||
}
|
||||
|
||||
/** Takes a row out at once and says so in a toast whose Undo puts it back where it was (E28 C1:
|
||||
* the X column became the Delete key + this toast; no confirm, because nothing is lost).
|
||||
/** Takes a row out at once and says so in a toast whose Undo puts it back where it was (the
|
||||
* X column became the Delete key + this toast; no confirm, because nothing is lost).
|
||||
* @returns whether the item was actually removed — the player needs to know, because it must
|
||||
* step to the next item (or close) only when the removal really happened. */
|
||||
async function removeItem(video: Video, listIdArg?: number | null): Promise<boolean> {
|
||||
@@ -558,9 +558,9 @@ export default function Playlists() {
|
||||
// NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and
|
||||
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already
|
||||
// impossible — the submenu never offers the current list). So the row used to snap back with no
|
||||
// explanation and the shipped moveErrors strings were unreachable (E10 review round 1).
|
||||
// explanation and the shipped moveErrors strings were unreachable.
|
||||
// A fault `req` already put in its error dialog is not toasted again — `notifyActionError`
|
||||
// checks (E10 review round 2 found it reported twice).
|
||||
// checks (a review found it reported twice).
|
||||
notifyActionError(e, t("playlists.moveFailed"));
|
||||
restoreFromCache(selectedId); // the row is still in this list
|
||||
}
|
||||
@@ -600,7 +600,7 @@ export default function Playlists() {
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
})
|
||||
// No catch used to mean an unhandled rejection and nothing said (E11 S2b).
|
||||
// No catch used to mean an unhandled rejection and nothing said.
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
|
||||
}
|
||||
|
||||
@@ -896,7 +896,7 @@ export default function Playlists() {
|
||||
{t("playlists.playAll")}
|
||||
</Button>
|
||||
{/* Rename, the pin, YouTube (open / push / revert) and Delete: the list's ONE
|
||||
menu (E28 C1), the same the rail row opens — the header keeps Play all. */}
|
||||
menu, the same the rail row opens — the header keeps Play all. */}
|
||||
<MoreButton
|
||||
ref={renameTarget}
|
||||
variant="outline"
|
||||
@@ -1016,7 +1016,7 @@ export default function Playlists() {
|
||||
dnd-kit has handled). */}
|
||||
<NavGrid
|
||||
as="tbody"
|
||||
// Delete on a walked row = the menu's Remove (E28 C1), with the same undo toast.
|
||||
// Delete on a walked row = the menu's Remove, with the same undo toast.
|
||||
onDelete={
|
||||
canEdit
|
||||
? (i) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// The rail's in-row rename, from the keyboard. Enter and Escape unmount the focused field and render
|
||||
// the row as a NEW button; without a refocus the keyboard user was dropped on <body> and lost their
|
||||
// place (E10 review round 1, measured again in Chrome in E11 S2). Everything around the rail that is
|
||||
// place (measured in Chrome). Everything around the rail that is
|
||||
// not the rename path is stubbed.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
@@ -107,7 +107,7 @@ describe("PlaylistsRail · rename from the keyboard", () => {
|
||||
it("Escape cancels and puts the focus back on the row", async () => {
|
||||
const field = await openRename();
|
||||
fireEvent.change(field, { target: { value: "alpha2" } });
|
||||
// Cancelled: the closing key must not go on to press the row the focus lands on (E28 audit).
|
||||
// Cancelled: the closing key must not go on to press the row the focus lands on.
|
||||
expect(fireEvent.keyDown(field, { key: "Escape" })).toBe(false);
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
|
||||
expect(rename).not.toHaveBeenCalled();
|
||||
@@ -122,7 +122,7 @@ describe("PlaylistsRail · rename from the keyboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E28 C1: the row's "…" opens the same menu as its right-click — the list's one action list.
|
||||
// The row's "…" opens the same menu as its right-click — the list's one action list.
|
||||
describe("PlaylistsRail · the row's '…'", () => {
|
||||
it("opens the playlist's menu under the row, and says so", async () => {
|
||||
vi.spyOn(api, "playlists").mockResolvedValue([alpha]);
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function PlaylistsRail({
|
||||
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
|
||||
{ key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" },
|
||||
{ key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" },
|
||||
// E10 S2 — "the one I just touched, first". Reads `updated_at`, which the API bumps for item
|
||||
// "the one I just touched, first". Reads `updated_at`, which the API bumps for item
|
||||
// adds/removes/moves/reorders too, not just renames (see the backend's _touch). NOT the
|
||||
// default: a rail that reshuffles itself while you look at it costs more than it saves.
|
||||
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
|
||||
@@ -178,9 +178,9 @@ export default function PlaylistsRail({
|
||||
});
|
||||
|
||||
// Enter and Escape unmount the focused <input> and re-render the row as a NEW <button>, so without
|
||||
// this a keyboard user was dropped on <body> and lost their place in the rail (E10 review round 1).
|
||||
// The row is focused from its ref callback, i.e. as that button attaches: measured in Chrome (E11
|
||||
// S2), a focus issued after the commit sticks and nothing takes it back — the round-1 attempt that
|
||||
// this a keyboard user was dropped on <body> and lost their place in the rail.
|
||||
// The row is focused from its ref callback, i.e. as that button attaches: measured in Chrome, a
|
||||
// focus issued after the commit sticks and nothing takes it back — the first attempt that
|
||||
// "never worked" focused before the replacement button existed. Set by Enter/Escape only: a
|
||||
// blur-commit means the user put the focus somewhere else on purpose.
|
||||
const refocusRowRef = useRef<number | null>(null);
|
||||
@@ -189,7 +189,7 @@ export default function PlaylistsRail({
|
||||
// Opening this field from the context menu used to race the menu's own focus return (floating-ui
|
||||
// hands the focus back to the trigger from a microtask queued as the popover unmounts, and the field
|
||||
// mounted in that same commit). `MenuItemList` now runs the item's action on a fresh task AFTER that
|
||||
// return, so the field simply mounts and is focused here; measured in Chrome (E11 S2), the field is
|
||||
// return, so the field simply mounts and is focused here; measured in Chrome, the field is
|
||||
// focused and survives.
|
||||
useEffect(() => {
|
||||
if (renamingId !== null) renameInputRef.current?.focus();
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function PlexBrowse() {
|
||||
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang);
|
||||
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
|
||||
const fieldState = healPlexField(field, scope);
|
||||
// Discover is the landing (E5 S4); the flat grid is the "Browse all" secondary view. A header search
|
||||
// Discover is the landing; the flat grid is the "Browse all" secondary view. A header search
|
||||
// or an added filter is a browse intent → flip to the grid (below); an explicit toggle returns.
|
||||
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function PlexCollectionEditor({
|
||||
} catch (e) {
|
||||
// 403/404 are left to the caller by `req` (see reportFailure), so without this the tick
|
||||
// simply never appeared and the user clicked again — a collection deleted on the Plex server
|
||||
// or flipped read-only failed in silence (deep-review round 5).
|
||||
// or flipped read-only failed in silence.
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(null);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S4 Play-all — buildQueue's show→episode expansion and playAll's decision branch, the honest-states
|
||||
// behaviour the deep-review flagged as untested. A leaf member queues as itself; a show member is
|
||||
// Play-all — buildQueue's show→episode expansion and playAll's decision branch, the honest-states
|
||||
// behaviour a review flagged as untested. A leaf member queues as itself; a show member is
|
||||
// expanded (lazily, on click) into its episodes in season→episode order; a show that expands to zero
|
||||
// episodes must NOT silently no-op but surface the `playEmpty` info notice. api.plexCollection /
|
||||
// api.plexShow are stubbed so the queries resolve synchronously; notify is spied to assert the notice.
|
||||
@@ -142,7 +142,7 @@ describe("PlexCollectionView — Play-all", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Bug-hunt deep-review: the Sort control put the sort into the detail query's key, and two things
|
||||
// Bug hunt: the Sort control put the sort into the detail query's key, and two things
|
||||
// on this page did not follow it.
|
||||
describe("PlexCollectionView — after the sort joined the query key", () => {
|
||||
it("flips a poster's watched state on the page it was clicked on", async () => {
|
||||
@@ -212,7 +212,7 @@ describe("PlexCollectionView — after the sort joined the query key", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexCollectionView — the reader's language (E30)", () => {
|
||||
describe("PlexCollectionView — the reader's language", () => {
|
||||
it("asks for the members, and a show it expands, in Hungarian", async () => {
|
||||
setLanguage("hu");
|
||||
try {
|
||||
@@ -228,7 +228,7 @@ describe("PlexCollectionView — the reader's language (E30)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexCollectionView — the summary (E30 review)", () => {
|
||||
describe("PlexCollectionView — the summary", () => {
|
||||
it("offers no More under a summary that fits", async () => {
|
||||
vi.spyOn(api, "plexCollection").mockResolvedValue({
|
||||
...detail([card("m1", "movie")]),
|
||||
|
||||
@@ -282,7 +282,7 @@ export default function PlexCollectionView({
|
||||
posterDisabled={!canPlay || queueBusy}
|
||||
showPlayOverlay={canPlay}
|
||||
onPosterClick={() => canPlay && !queueBusy && playAll(false)}
|
||||
// Sized to the text beside it (E31, the owner's pick): a title, one meta line and a short
|
||||
// Sized to the text beside it (the owner's pick): a title, one meta line and a short
|
||||
// description sat beside a show-sized poster, two thirds of the hero empty under them.
|
||||
posterSizeClass="w-20 sm:w-24"
|
||||
customizeMenu={null}
|
||||
@@ -423,7 +423,7 @@ export default function PlexCollectionView({
|
||||
</div>
|
||||
</div>
|
||||
{/* Two columns on a phone, like every other poster grid (≥140px a card): three left
|
||||
an ~85px card whose title shared its line with the touch "…" (E28 C1). */}
|
||||
an ~85px card whose title shared its line with the touch "…". */}
|
||||
<NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
||||
{items.map((c, i) => (
|
||||
<PlexPosterCard
|
||||
@@ -451,7 +451,7 @@ export default function PlexCollectionView({
|
||||
canEdit
|
||||
? [
|
||||
{
|
||||
// A write to the shared Plex library: a menu row since E28 C1 (the card's hover
|
||||
// A write to the shared Plex library: a menu row (the card's hover
|
||||
// row owns its corner now), still behind the confirmation in `doRemove`.
|
||||
id: "collection-remove",
|
||||
label: t("plex.collection.removeItem"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E5 S4: the Plex Discover landing. Covers the rail surface served from the mocked /discover +
|
||||
// The Plex Discover landing. Covers the rail surface served from the mocked /discover +
|
||||
// /surprise calls — that each non-empty rail renders, the genre tile fires onGenre, the recently-added
|
||||
// "see all" fires onSeeAllRecent, the ↻ reshuffle refetches the surprise rail alone, and an all-empty
|
||||
// payload shows the empty message. i18n is key-passthrough; the api is mocked so queries resolve
|
||||
@@ -272,7 +272,7 @@ describe("PlexDiscover", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E11 S4b: every rail and the genre grid are lists the keyboard walks (lib/listNav), so `j` goes on
|
||||
// Every rail and the genre grid are lists the keyboard walks (lib/listNav), so `j` goes on
|
||||
// from one to the next in page order (owner decision D4).
|
||||
describe("PlexDiscover · keyboard", () => {
|
||||
it("registers every rail and the genre grid, and j walks on from rail to rail", async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
|
||||
// The Plex "Discover" landing: a stack of horizontal Carousel rails served from ONE
|
||||
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
|
||||
// watched X — plus a separately-fetched, reshuffling Surprise-me rail. The flat browse grid stays its
|
||||
// own view (PlexBrowse's grid branch); a header search or filter flips the user there. Card click /
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E5 S5: the enriched Plex detail page. Covers the TMDB-fed pieces added on the page variant — the
|
||||
// The enriched Plex detail page. Covers the TMDB-fed pieces added on the page variant — the
|
||||
// multi-source ratings row (IMDb / TMDb / audience), the facts panel (budget / revenue / studios /
|
||||
// countries), the keyword chips (→ onFilter), and the full crew (→ onOpenPerson). i18n is
|
||||
// key-passthrough; PlexInfo is prop-driven (no queries) so it just needs a QueryClient for its cache
|
||||
@@ -104,7 +104,7 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
expect(screen.getByText("$160M")).toBeTruthy();
|
||||
expect(screen.getByText("$830M")).toBeTruthy();
|
||||
expect(screen.getByText("Studio A")).toBeTruthy(); // tmdb.studios preferred over the fallback
|
||||
// E30: a coded country is named through the vocab (key-echo i18n here), an uncoded one as stored.
|
||||
// A coded country is named through the vocab (key-echo i18n here), an uncoded one as stored.
|
||||
expect(screen.getByText("vocab.region.HU, United States")).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
expect(onFilter).toHaveBeenCalledWith({ keywords: ["heist"] });
|
||||
});
|
||||
|
||||
// E28: every value that filters says so on hover — the genre, certificate and studio had no hint
|
||||
// Every value that filters says so on hover — the genre, certificate and studio had no hint
|
||||
// while the keyword and the year did — and a value filters in the facts panel as in the header.
|
||||
it("every filtering value carries a filter hint", () => {
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={() => {}} />);
|
||||
@@ -139,7 +139,7 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
const onOpenPerson = vi.fn();
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onOpenPerson={onOpenPerson} />);
|
||||
expect(screen.getByText("plex.info.crew")).toBeTruthy();
|
||||
// E30: the job is TMDB's English word, named through the vocab (key-echo i18n here).
|
||||
// The job is TMDB's English word, named through the vocab (key-echo i18n here).
|
||||
expect(screen.getByText("vocab.crewJob.Director")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Jane Director"));
|
||||
expect(onOpenPerson).toHaveBeenCalledWith("Jane Director");
|
||||
@@ -173,9 +173,9 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E28 S3h, the owner's layout B: a main column (collection strips → crew → cast) beside a sidebar of
|
||||
// The owner's layout: a main column (collection strips → crew → cast) beside a sidebar of
|
||||
// facts (details, keywords); a column with nothing in it is not rendered and the other takes the width.
|
||||
// E31 (the owner's pick "A"): the hero's text stopped at ~55% of the panel. The facts and the key crew are
|
||||
// The owner's pick: the hero's text stopped at ~55% of the panel. The facts and the key crew are
|
||||
// its second column now (a grid that splits in a wide hero), the keywords sit under the text, and under
|
||||
// the hero only the collection strips and the cast remain, stacked.
|
||||
describe("PlexInfo — the hero's second column (page)", () => {
|
||||
@@ -252,7 +252,7 @@ describe("PlexInfo — the hero's second column (page)", () => {
|
||||
expect(follows(heading("Test Movie"), kw)).toBe(true);
|
||||
});
|
||||
|
||||
// E31: the hero's grid row is as tall as its taller column — the crew, beside the text — and the poster,
|
||||
// The hero's grid row is as tall as its taller column — the crew, beside the text — and the poster,
|
||||
// a flex child, stretched to it: a 2:3 picture drawn ~175×590 and cropped. It keeps its own ratio.
|
||||
it("keeps the poster at its own height, not stretched to the row", () => {
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
|
||||
@@ -261,7 +261,7 @@ describe("PlexInfo — the hero's second column (page)", () => {
|
||||
expect(poster.className.split(/\s+/)).toContain("self-start");
|
||||
});
|
||||
|
||||
// E31 (the owner's call): twelve names ran the column ~170px past the text. The first CREW_SHOWN show,
|
||||
// The owner's call: twelve names ran the column ~170px past the text. The first CREW_SHOWN show,
|
||||
// the rest behind "Show N more"; a list barely longer shows whole.
|
||||
it("shows the first crew names and opens the rest on demand", () => {
|
||||
const crew = Array.from({ length: 12 }, (_, i) => ({ name: `Person ${i}`, job: "Producer" }));
|
||||
@@ -301,7 +301,7 @@ describe("PlexInfo — the hero's second column (page)", () => {
|
||||
expect(screen.queryByRole("button", { name: "plex.info.crewMore" })).toBeNull();
|
||||
});
|
||||
|
||||
// E32 S2: the cast row is the shared CastRow now; the page and the player's overlay hand it their
|
||||
// The cast row is the shared CastRow now; the page and the player's overlay hand it their
|
||||
// own palette. Only the overlay sits on the dark art, so only it may carry the white text.
|
||||
it.each([
|
||||
["page", false],
|
||||
@@ -313,7 +313,7 @@ describe("PlexInfo — the hero's second column (page)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E28 review D1 + D2: a collection sibling is a Plex card like any other — its "…" menu, its watched
|
||||
// A collection sibling is a Plex card like any other — its "…" menu, its watched
|
||||
// toggle and its playlist add, as on the related strip — where it used to be a Play and nothing else.
|
||||
describe("a collection strip's card", () => {
|
||||
beforeAll(() => {
|
||||
|
||||
@@ -38,7 +38,7 @@ import { vocab } from "../lib/vocab";
|
||||
// a card's "i" button (variant="page", with a big Play/Resume + watch controls), and a lean overlay
|
||||
// over the running player (variant="overlay", opened with "i"). Two GUI elements are user-toggleable
|
||||
// personal preferences, persisted in users.preferences: the faint art backdrop and the cast row.
|
||||
// The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew — E5 S5) render
|
||||
// The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew) render
|
||||
// on the page variant only; the overlay stays lean.
|
||||
|
||||
// One rating chip's number: a filter button when this is the score the backend's ratingMin column
|
||||
@@ -74,7 +74,7 @@ function Score({
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-source ratings cluster (E5 S5): IMDb (links out) · TMDb (with vote count) · Plex audience —
|
||||
// Multi-source ratings cluster: IMDb (links out) · TMDb (with vote count) · Plex audience —
|
||||
// whichever are present. The "primary" score (IMDb, else audience) mirrors the backend rating column
|
||||
// (coalesce(imdb, audience)), so only it carries the ratingMin filter; the others are informational.
|
||||
function RatingsRow({
|
||||
@@ -181,8 +181,7 @@ type Props = {
|
||||
// When provided, a cast member's photo/name opens their filmography page instead of filtering.
|
||||
onOpenPerson?: (name: string) => void;
|
||||
// Play a sibling title from a collection strip: the page opens its player, the overlay plays it
|
||||
// in the running one. Required, so no variant draws a collection card whose Play does nothing
|
||||
// (E28 review D1).
|
||||
// in the running one. Required, so no variant draws a collection card whose Play does nothing.
|
||||
onPlayItem: (id: string, fromStart?: boolean) => void;
|
||||
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
|
||||
library?: string;
|
||||
@@ -229,7 +228,7 @@ export default function PlexInfo({
|
||||
const [playlistOpen, setPlaylistOpen] = useState(false);
|
||||
|
||||
// Faint art backdrop, HTPC-style (page variant only) — cross-dissolves through the item's own art,
|
||||
// its TMDB backdrops (E5 S2), and its collection siblings' art (E5 S8). Cleared in the overlay
|
||||
// its TMDB backdrops, and its collection siblings' art. Cleared in the overlay
|
||||
// variant / when disabled / on unmount.
|
||||
const backdropPool = [
|
||||
detail.art,
|
||||
@@ -251,7 +250,7 @@ export default function PlexInfo({
|
||||
onStateChange?.();
|
||||
};
|
||||
|
||||
// A collection sibling is a Plex card like any other (E28 review D2): the related strip's menu, its
|
||||
// A collection sibling is a Plex card like any other: the related strip's menu, its
|
||||
// watched toggle and its playlist add. The flip lands on this item's cached detail, whose strip it
|
||||
// is; the resync corrects it to the truth either way.
|
||||
const colMenu = useContextMenu<PlexCard>();
|
||||
@@ -416,7 +415,7 @@ export default function PlexInfo({
|
||||
<PlaybackChip detail={detail} overlay={overlay} />
|
||||
</div>
|
||||
|
||||
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
|
||||
{/* Multi-source ratings row: IMDb / TMDb / audience, whichever are present. */}
|
||||
<div className="mt-2">
|
||||
<RatingsRow
|
||||
imdbRating={detail.imdb_rating}
|
||||
@@ -539,7 +538,7 @@ export default function PlexInfo({
|
||||
|
||||
{/* Below the hero: the collection strips first (the owner's most used), then the cast, each
|
||||
per-user toggleable, stacked so a hidden one leaves no hole. The facts / keywords / crew that
|
||||
sat beside them (E28 S3h) moved up into the hero's second column (E31). The overlay (over
|
||||
sat beside them moved up into the hero's second column. The overlay (over
|
||||
the running player) is the same stack, lean. */}
|
||||
{(() => {
|
||||
const collections = (detail.collections ?? []).filter(
|
||||
@@ -568,7 +567,7 @@ export default function PlexInfo({
|
||||
</div>
|
||||
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav>
|
||||
{col.items.map((m, i) => (
|
||||
// The one media card, as on every other strip (E28 S3h). A grid cell, so the
|
||||
// The one media card, as on every other strip. A grid cell, so the
|
||||
// card fills the slot the carousel stretches to its tallest card.
|
||||
<div key={m.id} className="grid w-37.5 shrink-0 grid-cols-1">
|
||||
<PlexPosterCard
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 C1: a Plex title's ONE action list. The poster's right-click and its "…" open the same menu,
|
||||
// A Plex title's ONE action list. The poster's right-click and its "…" open the same menu,
|
||||
// which holds what the card no longer shows (details, copy, the pin) beside the everyday toggles;
|
||||
// the card's hover "add" and the menu's "Add to playlist" open the same dialog.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E5 S4: the actor detail page. Covers the two pieces the /code-review flagged as untested — the
|
||||
// The actor detail page. Covers the two pieces the /code-review flagged as untested — the
|
||||
// cast-row rewiring (a cast click now OPENS the person page via onOpenPerson, falling back to the old
|
||||
// filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero,
|
||||
// the Movies/Shows sections, and the "filter library by this person" action). i18n is key-passthrough
|
||||
@@ -117,11 +117,11 @@ describe("PlexPersonView", () => {
|
||||
expect(screen.getByText("plex.person.movies")).toBeTruthy(); // section headings (key passthrough)
|
||||
expect(screen.getByText("plex.person.shows")).toBeTruthy();
|
||||
|
||||
// E5 S6 bio hero: department, biography, birth facts, aka.
|
||||
// E30: TMDB's English department, named through the vocab (key-echo i18n here).
|
||||
// The bio hero: department, biography, birth facts, aka.
|
||||
// TMDB's English department, named through the vocab (key-echo i18n here).
|
||||
expect(screen.getByText("vocab.department.Acting")).toBeTruthy();
|
||||
expect(screen.getByText("A celebrated actor.")).toBeTruthy();
|
||||
// E30 S3: the birth date as a date in the UI language, not the raw ISO string
|
||||
// The birth date as a date in the UI language, not the raw ISO string
|
||||
expect(screen.getByText("Jan 1, 1970 · Springfield")).toBeTruthy();
|
||||
expect(screen.getByText(/plex\.person\.aka/)).toBeTruthy();
|
||||
expect(screen.getByText(/Janie D/)).toBeTruthy();
|
||||
@@ -157,7 +157,7 @@ describe("PlexPersonView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexPersonView — the reader's language (E30)", () => {
|
||||
describe("PlexPersonView — the reader's language", () => {
|
||||
it("asks for the filmography in Hungarian", async () => {
|
||||
setLanguage("hu");
|
||||
try {
|
||||
@@ -189,7 +189,7 @@ describe("PlexPersonView — the reader's language (E30)", () => {
|
||||
|
||||
const BIO_POLL = 1600; // just past plexViews' BIO_POLL_MS
|
||||
|
||||
describe("PlexPersonView — a bio on its way (E30 S6)", () => {
|
||||
describe("PlexPersonView — a bio on its way", () => {
|
||||
const view = (
|
||||
<PlexPersonView
|
||||
name="Jane Doe"
|
||||
@@ -320,7 +320,7 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexPersonView — the hero's columns (E30 S6)", () => {
|
||||
describe("PlexPersonView — the hero's columns", () => {
|
||||
const heroGrid = async (knownFor: number) => {
|
||||
plexPerson.mockReset();
|
||||
plexPerson.mockResolvedValue({
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// player's OWN menus push layers, so ⌫ stopped closing them), then naming just the sheet and the
|
||||
// palette (the app-wide ErrorDialog is neither, so ⌫ tore the player down under it). The rule that
|
||||
// holds: the player's own panels answer first; anything else layered above it takes the key. It had
|
||||
// no test (E10 review coverage debt).
|
||||
// no test.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player's controls on touch and the C5 control set (DESIGN "Players", E28 S3f2): a tap on
|
||||
// The Plex player's controls on touch and the C5 control set (DESIGN "Players"): a tap on
|
||||
// the picture only shows or hides the controls, a double-tap on a side third skips 10 s, Back goes by
|
||||
// position, Next exists only with a next item, the rare tools sit behind a fixed ⋯, and the card's
|
||||
// Restart opens the player at 0. jsdom has no media pipeline: the <video> is stubbed as in
|
||||
@@ -226,7 +226,7 @@ describe("a tap on the picture (C3)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E28 review E6: the volume slider counted as a text field, so its arrows never woke the bands — the
|
||||
// The volume slider counted as a text field, so its arrows never woke the bands — the
|
||||
// bar faded from under a keyboard user adjusting it — and nothing held the bands while a key-focused
|
||||
// control sat in them.
|
||||
describe("keyboard focus in the bands", () => {
|
||||
@@ -436,7 +436,7 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
|
||||
expect(session.mock.calls.length).toBe(calls);
|
||||
});
|
||||
|
||||
// E28 review E5: the tap that brings the bands back must not hand the SECOND tap to them — a double-tap
|
||||
// The tap that brings the bands back must not hand the SECOND tap to them — a double-tap
|
||||
// at the bottom would land on the seek bar (a seek to wherever the finger was) instead of the skip.
|
||||
it("a tap that reveals the bands keeps them out of the finger's way for the double-tap window", async () => {
|
||||
mount({ details: { A: item("A") } });
|
||||
@@ -464,7 +464,7 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E28 review D1: the info overlay drew its collection siblings with a Play that did nothing — the
|
||||
// The info overlay drew its collection siblings with a Play that did nothing — the
|
||||
// overlay had no way to play one. It plays it in this player, at its resume point or from the start.
|
||||
describe("a collection sibling in the info overlay", () => {
|
||||
const withSibling = (id: string): PlexItemDetail => ({
|
||||
@@ -671,7 +671,7 @@ describe("the C5 row", () => {
|
||||
expect(screen.getByLabelText("plex.player.more").getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
// E28 review D3: the ⋯ was a hand-built <div> of buttons — no menu role, no arrow keys, and Escape
|
||||
// The ⋯ was a hand-built <div> of buttons — no menu role, no arrow keys, and Escape
|
||||
// closed it with the focus left on <body>. It is a menu on the popover engine now.
|
||||
it("↑/↓ on the closed ⋯ and tracks buttons are the volume, not an open menu", async () => {
|
||||
const two = item("A");
|
||||
@@ -742,7 +742,7 @@ describe("the C5 row", () => {
|
||||
await waitFor(() => expect(document.activeElement).toBe(more));
|
||||
});
|
||||
|
||||
// E28 review E4: an episode's Next comes from its detail, which is unknown while the next episode
|
||||
// An episode's Next comes from its detail, which is unknown while the next episode
|
||||
// loads — the button unmounted from under the focus that had just pressed it (focus on <body>).
|
||||
it("Next keeps the focus while the next item's detail loads", async () => {
|
||||
let arrive: (d: PlexItemDetail) => void = () => {};
|
||||
|
||||
@@ -158,7 +158,7 @@ describe("an HLS session the server no longer has", () => {
|
||||
// so a session that parses its manifest and then 404s re-arms the guard every cycle. The server
|
||||
// also answers 404 for "that segment is not there YET" (a transcode falling behind realtime),
|
||||
// which is indistinguishable on the wire — so the player respawned ffmpeg forever behind a
|
||||
// spinner that never became an error (round 5).
|
||||
// spinner that never became an error.
|
||||
const session = mount();
|
||||
const v = () => document.querySelector("video") as Media;
|
||||
// The first session plus the two it is allowed to reclaim; the third 404 has nothing left.
|
||||
@@ -178,7 +178,7 @@ describe("an HLS session the server no longer has", () => {
|
||||
// The budget used to be cleared by the first FRAG_LOADED of any session — and a reclaimed
|
||||
// session essentially always delivers one, because `start_session` does not return until its
|
||||
// first segment exists. So it bounded NEITHER case its own comment named: measured at 40 loss
|
||||
// cycles, the player opened 41 sessions and never showed an error (deep-review round 6). A
|
||||
// cycles, the player opened 41 sessions and never showed an error. A
|
||||
// fragment is not proof the session can be served; surviving the window is.
|
||||
const session = mount();
|
||||
const v = () => document.querySelector("video") as Media;
|
||||
@@ -227,7 +227,7 @@ describe("an HLS session the server no longer has", () => {
|
||||
// loss and the bound never bit: a transcode falling ~70s behind realtime — the case the ERROR
|
||||
// handler itself names — reclaimed forever (measured: 200 losses 70s apart → 200 reclaims,
|
||||
// never an error). The two cases above space their losses 0s and 10min apart, i.e. both sit
|
||||
// outside the 60–120s band where the defect lived (deep-review round 7).
|
||||
// outside the 60–120s band where the defect lived.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ["Date"] });
|
||||
try {
|
||||
const session = mount();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player's session lifecycle, rendered for real (deep-review round 3 of bug hunt 2026-09).
|
||||
// The Plex player's session lifecycle, rendered for real.
|
||||
//
|
||||
// jsdom has no media pipeline, so the <video> is a small stand-in that behaves like Chrome where it
|
||||
// matters here: `pause()` fires its `timeupdate` in a LATER task (measured in Chrome — which is how the
|
||||
@@ -279,7 +279,7 @@ it("shows a toast raised while it is open inside itself, not under its scrim", a
|
||||
expect(host).not.toBe(document.body); // the player's own host, beside its <video>
|
||||
});
|
||||
|
||||
// --- deep-review round 4 ------------------------------------------------------------------------
|
||||
// --- leaving a played-through item, restarts, failed requests -----------------------------------
|
||||
|
||||
/** A session answer that FAILS when the test says so. */
|
||||
function deferredFailure() {
|
||||
@@ -300,8 +300,7 @@ describe("leaving an item that was played through", () => {
|
||||
|
||||
// The end of the media, in the order the HTML spec mandates: the browser sets `paused`, fires
|
||||
// 'pause', and only THEN fires 'ended'. Dispatching a bare 'ended' models a browser that does not
|
||||
// exist, and hid the fact that the pause checkpoint goes out before the finish latch is up
|
||||
// (deep-review round 5).
|
||||
// exist, and hid the fact that the pause checkpoint goes out before the finish latch is up.
|
||||
const endMedia = () => {
|
||||
const el = video();
|
||||
Object.defineProperty(el, "ended", { configurable: true, get: () => true });
|
||||
@@ -361,7 +360,7 @@ describe("leaving an item that was played through", () => {
|
||||
// The mark was fired and forgotten (`.catch(() => {})`) while the cache was patched to
|
||||
// watched / position 0 regardless, and the once-per-id start guard meant the corrected detail
|
||||
// arriving later was never used: the episode reopened at 0:00 with a Watched badge the server
|
||||
// disagreed with (round 5).
|
||||
// disagreed with.
|
||||
prefs({ upNext: false });
|
||||
const { qc, progress } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
@@ -379,19 +378,19 @@ describe("leaving an item that was played through", () => {
|
||||
// And the rollback does NOT write the position back. 2995 of 3000 is inside the backend's
|
||||
// finish margin, so that checkpoint would have set watched / position 0 and scrobbled to Plex
|
||||
// — performing, through a second route, the very write that was just refused, while the error
|
||||
// toast said it had failed (round 6). There is no resume point at the end of the media to
|
||||
// toast said it had failed. There is no resume point at the end of the media to
|
||||
// restore; the cache drop is the whole of the rollback here.
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
||||
});
|
||||
|
||||
it("performs the refused mark through no OTHER route while it stays on the finished item", async () => {
|
||||
// Round 6 guarded the explicit rollback write, but the same catch lowered the play-through
|
||||
// A fix guarded the explicit rollback write, but the same catch lowered the play-through
|
||||
// latch — and that latch is the only gate on the 10s interval, the page-hide beacon and
|
||||
// `settle`. All three post `absRef`, which on this path IS the end of the media, and the
|
||||
// backend reads a position within `_FINISH_MARGIN_S` of the duration as a finish: within ten
|
||||
// seconds of the toast saying the mark had failed, the item was watched, its position cleared
|
||||
// and a scrobble sent to Plex (deep-review round 7). `upNext` is ON here — the default — so the
|
||||
// player STAYS on the finished item and those routes are live; the round-6 test above turns it
|
||||
// and a scrobble sent to Plex. `upNext` is ON here — the default — so the
|
||||
// player STAYS on the finished item and those routes are live; the test above turns it
|
||||
// off, which tears the effect down before any of them can fire.
|
||||
prefs({ upNext: true });
|
||||
const beacon = vi.spyOn(api, "plexProgressBeacon").mockImplementation(() => {});
|
||||
@@ -467,7 +466,7 @@ describe("leaving an item that was played through", () => {
|
||||
it("records a second viewing when the viewer plays on from the finished frame", async () => {
|
||||
// `finished` is a latch that makes every checkpoint a no-op; the only thing that lowers it is
|
||||
// the video's own 'play' event. Nothing tested that, so dropping the listener would have
|
||||
// discarded a whole rewatch with the gate green (round 5).
|
||||
// discarded a whole rewatch with the gate green.
|
||||
prefs({ upNext: true });
|
||||
const { progress } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player paints an opaque, full-viewport layer over the whole app — and until deep-review
|
||||
// round 4 it was the only overlay in the tree with no Tab trap at all. Tab from its transport bar,
|
||||
// The Plex player paints an opaque, full-viewport layer over the whole app — and for a long
|
||||
// time it was the only overlay in the tree with no Tab trap at all. Tab from its transport bar,
|
||||
// from its menus, or from the debug recorder's pill (which docks in THIS player's toast host) walked
|
||||
// straight out into the page behind it, where the focus ring is invisible and the next Enter fires
|
||||
// something the user cannot see. `ui/Modal`, `PlayerModal`, `OnboardingWizard` and `Welcome` all trap;
|
||||
// the round-3 comment that handed the step prompt's trap to "the dialog below" assumed this one did
|
||||
// an earlier comment that handed the step prompt's trap to "the dialog below" assumed this one did
|
||||
// too. Its own file because `layerStack` is module state and these tests push into it.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, act, screen } from "@testing-library/react";
|
||||
|
||||
@@ -114,7 +114,7 @@ const PLAYER_MENU =
|
||||
"rounded-xl border border-white/15 bg-media-chrome/95 text-sm text-white shadow-2xl";
|
||||
const PLAYER_ROW = "block w-full rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
|
||||
// A track choice: the chosen one wears the tick in the leading slot (DESIGN "one of several"), which
|
||||
// the highlight's white text cannot wash out the way it did the accent-coloured label (round 2, P6).
|
||||
// the highlight's white text cannot wash out the way it did the accent-coloured label.
|
||||
const TRACK_ROW =
|
||||
"flex w-full min-h-9 items-center gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-white/10";
|
||||
const TAP_SKIP_S = 10;
|
||||
@@ -139,12 +139,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume
|
||||
// position (losing live progress). See the detail effect's deps.
|
||||
const tRef = useLatest(t);
|
||||
// E30: a track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)").
|
||||
// A track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)").
|
||||
const audioName = (a: PlexTrackNaming) => trackLabel(t, a, "audio", i18n.language);
|
||||
const subtitleName = (x: PlexTrackNaming) => trackLabel(t, x, "subtitle", i18n.language);
|
||||
const qc = useQueryClient();
|
||||
const [id, setId] = useState(itemId);
|
||||
// Serve the player's title/summary/rich detail in the active language (E5 S6), matching the grid.
|
||||
// Serve the player's title/summary/rich detail in the active language, matching the grid.
|
||||
const lang = plexLang(i18n.language);
|
||||
const detailQ = useQuery({
|
||||
queryKey: qk.plexItem(id, lang),
|
||||
@@ -160,7 +160,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
const aliveRef = useRef(true);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
// A control that goes while it holds the focus — Next onto the last item or a series finale —
|
||||
// hands it to its neighbour instead of <body> (round 2, P5). The player is portaled outside the
|
||||
// hands it to its neighbour instead of <body>. The player is portaled outside the
|
||||
// page scroller, whose keeper does not watch it.
|
||||
useFocusKeeper(wrapRef);
|
||||
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
|
||||
@@ -185,7 +185,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// Whether the session being asked for plays once it can — null while none is out. Set from
|
||||
// loadSession's `resumePlay` and then the viewer's to change: until the answer attaches, the media
|
||||
// is the OUTGOING one, and a play() there sounded the old stream while the new one still attached
|
||||
// paused (E28 review round 4). `said`: the viewer changed it, so it is what they now see.
|
||||
// paused. `said`: the viewer changed it, so it is what they now see.
|
||||
const pendingPlayRef = useRef<{ play: boolean; said?: boolean } | null>(null);
|
||||
// The key of the last press that landed on faded bands, while it is held (see onKey).
|
||||
const blindKeyRef = useRef<string | null>(null);
|
||||
@@ -279,7 +279,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
videoRef,
|
||||
});
|
||||
// The pointer resting on either band holds the controls up (F22), and so does a keyboard focus in
|
||||
// one (E6) — not the focus a click leaves on a button, which would pin the bar until the next click;
|
||||
// one — not the focus a click leaves on a button, which would pin the bar until the next click;
|
||||
// `:focus-visible` is the browser's own "this focus came from the keyboard". Both bands wear these.
|
||||
const holdWhileHovered = {
|
||||
"data-player-band": "", // what a key on a faded-out control is told apart by (onKey, below)
|
||||
@@ -294,7 +294,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// It renders inside the player, whose Tab trap spans it; the ⋯ counts as inside, since the ⋯ is
|
||||
// what closes it (below).
|
||||
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
|
||||
// The ⋯ and the tracks list are real menus on the popover engine (E28 review D3): roving arrows,
|
||||
// The ⋯ and the tracks list are real menus on the popover engine: roving arrows,
|
||||
// Home/End, Escape as a layer, and the focus back on the trigger however they close. They were
|
||||
// hand-built <div>s — no roles, no arrows, and Escape left the focus on <body>. The engine portals
|
||||
// them into the fullscreen element when there is one, so they show in fullscreen too.
|
||||
@@ -335,8 +335,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
const helpOpen = useKeyboardHelpOpen();
|
||||
const helpOpenRef = useLatest(helpOpen);
|
||||
|
||||
// On-screen confirmation for a KEYBOARD track switch. A/S have cycled audio and subtitles since
|
||||
// E5, but silently — you changed the track and the only way to see what you landed on was to open
|
||||
// On-screen confirmation for a KEYBOARD track switch. A/S have long cycled audio and
|
||||
// subtitles, but silently — you changed the track and the only way to see what you landed on was to open
|
||||
// the tracks menu, which is exactly the modal round-trip the shortcut exists to avoid. The menu
|
||||
// itself needs no OSD: it shows the selection where you clicked.
|
||||
const [osd, setOsd] = useState<string | null>(null);
|
||||
@@ -358,7 +358,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// "D" toggles the stats-for-nerds debug overlay (active mode, codec, dropped frames, buffer…).
|
||||
const [statsOpen, setStatsOpen] = useState(false);
|
||||
const statsOpenRef = useLatest(statsOpen);
|
||||
// Up-next end-card: shown at episode end (with a next item) instead of an instant jump (S4).
|
||||
// Up-next end-card: shown at episode end (with a next item) instead of an instant jump.
|
||||
const [upNextOpen, setUpNextOpen] = useState(false);
|
||||
const upNextOpenRef = useLatest(upNextOpen);
|
||||
const upNextPrefRef = useLatest(prefs.upNext);
|
||||
@@ -540,7 +540,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// server also answers 404 for "that segment is not there YET" — indistinguishable from "the
|
||||
// session is gone" on the wire — so a transcode that falls behind realtime, or two viewers of
|
||||
// one item taking turns reclaiming its session, restarted ffmpeg forever behind a spinner
|
||||
// that never became an error (deep-review round 5). A couple of reclaims is what a genuine
|
||||
// that never became an error. A couple of reclaims is what a genuine
|
||||
// reap needs; past that the honest answer is that playback failed.
|
||||
hls.on(Hls.Events.ERROR, (_e, data) => {
|
||||
// Fatal ones are what the user saw fail; the recoverable ones hls.js retries by itself
|
||||
@@ -568,8 +568,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// longer than half the window satisfied the reset on every second loss, so the budget
|
||||
// never reached its bound and the reclaim loop it exists to end ran forever (measured:
|
||||
// 200 losses 70s apart → 200 reclaims, never an error; 5s apart → 2 and an error, which
|
||||
// is why the committed test, spacing its cases 0s and 10min apart, could not see it —
|
||||
// deep-review round 7).
|
||||
// is why the committed test, spacing its cases 0s and 10min apart, could not see it).
|
||||
if (now - restartsRef.current.at > RECLAIM_WINDOW_MS) restartsRef.current.n = 0;
|
||||
restartsRef.current.at = now;
|
||||
if (restartsRef.current.n < MAX_SESSION_RECLAIMS) {
|
||||
@@ -652,7 +651,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// reconnect (Wi-Fi blips, the query is stale after 30 s and every checkpoint moved the position),
|
||||
// the in-player info panel invalidates it after a watched toggle, a language switch fetches it
|
||||
// again. Keyed on its identity, each of those restarted the film at the last saved checkpoint — or
|
||||
// at 0 after marking it watched (deep-review round 4). The trade-off: a detail that was stale in
|
||||
// at 0 after marking it watched. The trade-off: a detail that was stale in
|
||||
// the cache when the player opened is not corrected by the refetch that follows; the player's own
|
||||
// leave-patch (usePlexProgress) keeps the cache honest for everything played here.
|
||||
const startedForRef = useRef<string | null>(null);
|
||||
@@ -878,7 +877,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
if (otherId && otherId === id) {
|
||||
// The item already playing (the info overlay's collection strip lists the film itself):
|
||||
// nothing to switch to. The switch below would pause it and stop trusting its clock, and
|
||||
// `setId` to the same id loads nothing — a frozen 0:00 (E28 review round 2, P1). The play
|
||||
// `setId` to the same id loads nothing — a frozen 0:00. The play
|
||||
// intent goes INTO the seek: a paused film whose session starts past 0 restarts the session,
|
||||
// and a play() beside that resumed the outgoing stream while the new one stayed paused.
|
||||
if (fromZero) seekTo(0, true);
|
||||
@@ -927,7 +926,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
: detail?.next_id;
|
||||
// Whether the row has a Next. An episode's next is in its detail, unknown while the next episode
|
||||
// loads; the slot keeps its last answer meanwhile (a disabled Next), because the button unmounting
|
||||
// took the focus that had just pressed it to <body> (E28 review E4). Adjusted during render.
|
||||
// took the focus that had just pressed it to <body>. Adjusted during render.
|
||||
const nextKnown = queue || !detailQ.isPending ? !!nextId : undefined;
|
||||
const [nextSlot, setNextSlot] = useState(false);
|
||||
if (nextKnown !== undefined && nextKnown !== nextSlot) setNextSlot(nextKnown);
|
||||
@@ -952,7 +951,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
null,
|
||||
);
|
||||
// A tap that brings the bands back keeps them out of the pointer's way until a double-tap can no
|
||||
// longer follow (E28 review E5): the second tap of a skip at the bottom would otherwise land on the
|
||||
// longer follow: the second tap of a skip at the bottom would otherwise land on the
|
||||
// band the first one just revealed — the seek bar, a seek to wherever the finger was.
|
||||
const [tapShield, setTapShield] = useState(false);
|
||||
const tapShieldTimer = useRef<number | undefined>(undefined);
|
||||
@@ -1082,7 +1081,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// the server refuses the mark, in which case `finish` takes itself back (see usePlexProgress).
|
||||
finishProgress(watchedWrite);
|
||||
// With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel)
|
||||
// and let IT advance — binge stays in the viewer's control (S4/D6-A). The card only appears here,
|
||||
// and let IT advance — binge stays in the viewer's control. The card only appears here,
|
||||
// on play-to-the-end; credits-auto-skip advances earlier and this handler never fires for it.
|
||||
if (nextId && upNextPrefRef.current) setUpNextOpen(true);
|
||||
else if (nextId) go(nextId);
|
||||
@@ -1145,7 +1144,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// Its toast host's rank among open overlays (see ui/Toaster).
|
||||
const overlayOrder = useOverlayOrder();
|
||||
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
|
||||
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
|
||||
// settings/tracks menus included) takes Escape first and this player defers.
|
||||
const layerIdRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = pushLayer();
|
||||
@@ -1185,7 +1184,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// Keyboard use also reveals the controls — but typing in a player field is not "using the
|
||||
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
|
||||
// opts in), which is why this is a guard around `wake` rather than an early return.
|
||||
// A slider is not a text field: its arrows are the remote too (E28 review E6).
|
||||
// A slider is not a text field: its arrows are the remote too.
|
||||
if (!isTypingTarget(e) || (e.target as Element | null)?.matches?.('input[type="range"]'))
|
||||
wake();
|
||||
if (swallow) {
|
||||
@@ -1196,7 +1195,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// The shared dialog Tab trap. This player paints an opaque full-viewport layer over the app,
|
||||
// so Tab leaving it lands on a control nobody can see, where the ring is invisible and the
|
||||
// next Enter fires something blind. Every other overlay in the tree traps (ui/Modal,
|
||||
// PlayerModal, OnboardingWizard, Welcome); this one never did, and deep-review round 4 caught
|
||||
// PlayerModal, OnboardingWizard, Welcome); this one never did, and a review caught
|
||||
// it when the recorder's step prompt — which docks in this player's toast host — handed its
|
||||
// own trap to "the dialog below" and found none. Only while nothing that owns the keyboard is
|
||||
// layered above: a Modal opened over the player traps for itself. `wake()` above has already
|
||||
@@ -1257,10 +1256,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
// Back button (shared cascade).
|
||||
// Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY
|
||||
// and its focused close button is not a typing target, so the key reaches us regardless.
|
||||
// Two wrong answers so far: deferring to nothing at all (round 1's bug — the cheat sheet
|
||||
// Two wrong answers so far: deferring to nothing at all (the first bug — the cheat sheet
|
||||
// took ⌫ and the player stopped underneath it), then deferring to `anyLayerAbove` outright
|
||||
// (round 1's fix, round 2's bug — the player's OWN gear/tracks popovers push layers too, so
|
||||
// ⌫ stopped closing them), then naming just the sheet and palette (round 2's fix, round 3's
|
||||
// (the first fix, the second bug — the player's OWN gear/tracks popovers push layers too, so
|
||||
// ⌫ stopped closing them), then naming just the sheet and palette (the second fix, the third
|
||||
// bug — the app-wide ErrorDialog is neither, so ⌫ tore the player down under it).
|
||||
// The rule that holds: the player's own panels answer first, because `handleBack`'s cascade
|
||||
// is what closes them; anything else layered above us takes the key.
|
||||
@@ -1465,7 +1464,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
leaving the viewer to conclude that switching subtitles "does nothing" (bug hunt 2026-09,
|
||||
item 8). A failure is said even over a mounted window (see `subNotice`). It sits one rung
|
||||
above the track-switch OSD so the two never overlap. Both notices are `z-base`, under the
|
||||
settings and tracks popovers (`z-menu`): an open menu is never drawn over (E28 audit). */}
|
||||
settings and tracks popovers (`z-menu`): an open menu is never drawn over. */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-36 z-base flex justify-center px-4"
|
||||
aria-live="polite"
|
||||
@@ -1490,8 +1489,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
The REGION is always mounted and only the bubble is conditional: a polite region has to
|
||||
pre-exist in the accessibility tree for a later insertion to be announced, so one created
|
||||
together with its own first message is unreliably read — and since `osd` clears after 2.2s,
|
||||
conditioning the wrapper re-created it for every switch, not just the first (E10 review
|
||||
round 1). Toaster.tsx carries the same rule and the same shape. */}
|
||||
conditioning the wrapper re-created it for every switch, not just the first.
|
||||
Toaster.tsx carries the same rule and the same shape. */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-24 z-base flex justify-center px-4"
|
||||
aria-live="polite"
|
||||
@@ -1953,7 +1952,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
detail={detail}
|
||||
variant="overlay"
|
||||
onClose={() => setInfoOpen(false)}
|
||||
// A collection sibling plays right here (E28 review D1), as Next would.
|
||||
// A collection sibling plays right here, as Next would.
|
||||
onPlayItem={(other, fromZero) => {
|
||||
setInfoOpen(false);
|
||||
go(other, undefined, fromZero);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 review D5 + L5 + D6: the Plex player's settings panel. Its section switch was a row of
|
||||
// The Plex player's settings panel. Its section switch was a row of
|
||||
// `aria-pressed` buttons (now an ARIA tab list on `ui/Tabs`), its rows sat under the 44 touch target,
|
||||
// its on/off rows were a hand-laid label (now `CheckRow`), and its sliders and colour wells had no
|
||||
// accessible name — the visible label beside them was not tied to them.
|
||||
|
||||
@@ -21,7 +21,7 @@ export function PlexPlayerSettings(props: {
|
||||
// about that and 18's types let it.
|
||||
menuRef: RefObject<HTMLDivElement | null>;
|
||||
/** The ⋯ that opened the panel: the focus goes back to it when the panel closes with the focus
|
||||
* inside — by Escape, Back or the ⋯ itself — instead of falling to <body> (E28 review D3). */
|
||||
* inside — by Escape, Back or the ⋯ itself — instead of falling to <body>. */
|
||||
returnFocusTo: RefObject<HTMLElement | null>;
|
||||
settingsTabs: { key: SettingsTab; label: string }[];
|
||||
activeTab: SettingsTab;
|
||||
|
||||
@@ -62,8 +62,8 @@ export default function PlexPlaylistAdd({
|
||||
// A failure is said, and the list refreshed either way. With no catch, a playlist deleted on
|
||||
// another device (a 404 `not_found/playlist` — still offered here, from a cache up to 30 s old)
|
||||
// was an unhandled rejection: `req` leaves 403/404/429 to the caller, so nothing was shown, the
|
||||
// tick never flipped and the gone row stayed. Round 5 swept the collection editor and its own
|
||||
// YouTube twin (`AddToPlaylist`) and missed this one (round 6).
|
||||
// tick never flipped and the gone row stayed. A sweep fixed the collection editor and its own
|
||||
// YouTube twin (`AddToPlaylist`) and missed this one.
|
||||
async function toggle(p: PlexPlaylist) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//
|
||||
// One accepted write, one cache entry that matches it — the Plex half.
|
||||
//
|
||||
// `Playlists.tsx` (the YouTube list) got this rule in round 5: the detail cache is what a rollback
|
||||
// `Playlists.tsx` (the YouTube list) got this rule first: the detail cache is what a rollback
|
||||
// reads and what a re-entry inside the 30 s staleTime renders without refetching, so an accepted
|
||||
// reorder that is not written there leaves the cache disagreeing with the server. The Plex list got
|
||||
// the rollback half of that round's fix and not this half (round 6).
|
||||
// the rollback half of that fix and not this half.
|
||||
//
|
||||
// Its own file: `@dnd-kit/core` is mocked module-wide so the test can fire `onDragEnd` directly —
|
||||
// dnd-kit's pointer sensor does not activate under jsdom (no layout, no pointer capture).
|
||||
@@ -64,10 +64,10 @@ const movie = (id: string) =>
|
||||
({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard;
|
||||
|
||||
it("writes the order the server accepted into the detail cache, without waiting for a refetch", async () => {
|
||||
// The refetch that same handler schedules (round 7) must not be what makes the cache right: the
|
||||
// The refetch that same handler schedules must not be what makes the cache right: the
|
||||
// point of the write is that a re-entry inside the 30 s staleTime renders the accepted order
|
||||
// immediately, with no request at all. So the refetch is HELD OPEN here — if this assertion
|
||||
// needed it to land, deleting the write would still pass and round 6's fix would be unguarded.
|
||||
// needed it to land, deleting the write would still pass and that fix would be unguarded.
|
||||
let calls = 0;
|
||||
vi.spyOn(api, "plexPlaylist").mockImplementation(() =>
|
||||
++calls === 1
|
||||
@@ -101,7 +101,7 @@ it("refetches the detail too, so a membership change that landed meanwhile is no
|
||||
// surface, an item added elsewhere), and with only the LIST key invalidated nothing corrected it:
|
||||
// staleTime is 30 s and refetchOnWindowFocus is off, so the phantom row stayed on screen for as
|
||||
// long as the view was mounted and the next drag persisted an order built from it. The YouTube
|
||||
// sibling invalidates the detail key right after writing it; this half did not (round 7).
|
||||
// sibling invalidates the detail key right after writing it; this half did not.
|
||||
let calls = 0;
|
||||
vi.spyOn(api, "plexPlaylist").mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user