fix(e24): review round 2 — a buried history entry, a stale hero, and a spinner that was not ours

Nine of the round's ten surviving findings. The tenth (a failed shelf reorder leaving an
order the server rejected on screen) is on ROADMAP.md: its obvious remedy reintroduces the
overlapping-move defect the code already documents, so it wants a design, not a review edit.

- `navigateOverOverlays` buried the live overlay entries before knowing whether the
  navigation pushed one. `setPage` no-ops when the page is already current and Home is the
  default landing page, so Shift+W over a player ON Home marked its entry buried under a
  push that never happened: the unmount consumed the mark instead of reclaiming the entry,
  and the next browser Back was a press that did nothing. `setPage` now reports whether it
  navigated, and one `buryUnder` rule serves both entry points — `closeThenNavigate` takes
  the same no-op navigation from a palette command for the page you are already on.
- The hero could answer a new time budget with the previous one's pick. The chip row resets
  seed/seen/press but cannot reset an answer that already resolved, and `keepPreviousData`
  chained through the disabled seed-0 query — so a reroll under "10 perc" showed the film
  picked for "Bármennyi" while it loaded, and kept it if the request failed. `lastPick` now
  carries the server's own budget echo, and the placeholder is scoped to the budget.
- The reroll button spun and swallowed clicks after mark-watched, bookmark, pin and shelf
  moves: the pick key sits under the invalidated `discover` prefix on purpose, so raw
  `isFetching` was true for background refetches. It now means "a press of mine is
  outstanding" — which still covers the first press, where there is no placeholder.
- Failed writes on Home toasted the page's LOAD-failure string over a fully loaded page;
  they get their own key (HU+EN). The empty-state reroll had no `loading`, so the press was
  unacknowledged and a second click skipped a tier. `danger` moved into the shared unpin row
  so the same command is not red on the shelf and neutral in the rail below it.
- Two comments claimed things the code does not do: the `exclude` cap guards no "unbounded
  scan" (it never reaches SQL), and the route test that named the cap sends three keys.

Tests: the burial rule gets its own file (it is the one assertion that lets a reconcile
issue a real programmatic back()); App's keymap wiring gets a source contract, because
dropping it left all 818 tests green; `add_pin`'s IntegrityError arm was reached by nothing.
Every new test mutation-checked red, both edges where the predicate has two.

Gate: ruff, ruff format, 3x tsc, eslint (0 errors), prettier, knip, vitest 822, pytest 1005.
This commit is contained in:
2026-09-09 00:12:53 +02:00
parent 3709b71a94
commit e4ffd59ca7
13 changed files with 317 additions and 32 deletions
+98
View File
@@ -295,6 +295,60 @@ describe("Home", () => {
expect(screen.queryByText("Hero v1")).toBeNull();
});
it("never answers a new budget with the previous budget's pick", async () => {
// Both halves of the hold outlive the question they answered: the chip row resets seed/seen/press,
// but it cannot reset an answer that already RESOLVED, and `keepPreviousData` chains straight
// 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).
discover.mockResolvedValue({
budget: "any",
hero: { tier: "resume", card: ytCard("v1", "Hero v1") },
continue_watching: [],
comfort: [],
});
discoverPick.mockResolvedValueOnce({
budget: "any",
hero: { tier: "surprise", card: ytCard("v9", "Long film") },
});
mount();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await screen.findByText("Long film");
// A different question — whose own answer is already loaded.
discover.mockResolvedValue({
budget: "short",
hero: { tier: "resume", card: ytCard("v2", "Short thing") },
continue_watching: [],
comfort: [],
});
fireEvent.click(screen.getByText("home.budget.short"));
await screen.findByText("Short thing");
// Rejected on OUR schedule, as the sibling test above does, so the assertion cannot land while
// the query is still pending — that is a different state.
let failPick!: (e: Error) => void;
discoverPick.mockReturnValueOnce(
new Promise((_resolve, reject) => {
failPick = reject;
}),
);
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
// In flight: the placeholder must not reach across the budget change.
expect(screen.queryByText("Long film")).toBeNull();
await act(async () => {
failPick(new Error("boom"));
await new Promise((r) => setTimeout(r, 0));
});
// Failed: this budget's own hero, never the film picked for "any".
expect(screen.getByText("Short thing")).toBeTruthy();
expect(screen.queryByText("Long film")).toBeNull();
});
it("a mutation's invalidation reaches the rerolled hero too", async () => {
// The reroll has its own query key so pressing the button does not refetch the rails — but under
// the SAME root, or the prefix every mutation invalidates misses it and a rerolled hero outlives
@@ -315,6 +369,50 @@ describe("Home", () => {
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
});
it("an unrelated mutation does not put the reroll button into a spinner", async () => {
// The other edge of the test above. Because the pick key sits under the `["discover"]` prefix,
// 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 `ui/Button` turns
// `loading` into `disabled`, swallow clicks 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.
const rerollBtn = () =>
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
let resolvePick!: (v: unknown) => void;
discoverPick.mockReturnValueOnce(
new Promise((resolve) => {
resolvePick = resolve;
}),
);
const { qc } = mountWithClient();
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(1));
// The positive control, and it is the half most easily broken while fixing the other: this is the
// FIRST press, so there is no previous pick for the placeholder to hold, and a predicate resting
// on `isPlaceholderData` alone would drop the spinner from exactly the press that needs it.
expect(rerollBtn().disabled).toBe(true);
expect(rerollBtn().getAttribute("aria-busy")).toBe("true");
await act(async () => {
resolvePick({ budget: "any", hero: { tier: "surprise", card: ytCard("v9", "Rerolled") } });
await new Promise((r) => setTimeout(r, 0));
});
await screen.findByText("Rerolled");
// Never settles, so the assertion lands squarely inside the refetch window rather than after it —
// and therefore the invalidation must NOT be awaited: its promise resolves only once every
// refetch it started has, which this one never will.
discoverPick.mockReturnValue(new Promise(() => {}));
act(() => {
void qc.invalidateQueries({ queryKey: qk.discover() });
});
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().disabled).toBe(false);
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
});
it("refreshes the payload the bookmark is read from", async () => {
mount();
await screen.findByText("Second thing");