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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user