All three were measured before they were touched, and the diagnosis moved twice.
**Back from a Plex title played off Home landed on the Plex module's root.** One
click pushed three history entries: the page navigation, the sub-view opened by
the `jumpTo` hand-off, and — under StrictMode, whose effect body runs twice
before `setJumpTo(null)` re-renders — that same sub-view a second time. So the
first Back visibly did nothing and the second stranded the user on a Discover
page they had never visited.
A cross-module hand-off is the CONTINUATION of the navigation that brought you
there, not a second navigation, so it now REPLACES the page entry instead of
stacking on it (`useHistorySubview.open(view, {replace})`, asked for by the jump
target). Replacing is also idempotent, which is what disarms the StrictMode
double-run. The intent rides on `PlexJumpTarget` rather than being inferred,
because the header typeahead jumps from INSIDE the module, where the entry being
replaced would be a sub-view the user really was looking at.
**"Mást kérek" cycled nine cards and never left the resume tier.** Measured:
twelve presses, nine distinct titles, period nine, every one `tier: "resume"` —
the shelf and the surprise tier were unreachable by pressing the button. Two
causes multiplied: the tiers were tried in a fixed priority order over a 20-row
resume rail, so the button just walked down it; and the client's rolling
exclusion window of 8 guaranteed the wrap.
The press count now rotates which tier answers first (`start`), so each press
offers a different KIND of answer — the user's call, given the choice between
that, a wider window, and a surprise-only reroll. The fallthrough is kept, so a
press does not come back empty because the tier it led with had nothing. The
window is matched to the server's own cap rather than a smaller number of ours.
**A half-watched series on Home opened the show page.** A movie already went
straight to the player; only shows did not — and the comfort shelf and the hero
both resolved a show to its next episode while this rail alone did not, handing
back six seasons to choose between. The rail row now carries `next`, and the
client already played `next ?? card`, so this is a backend fix.
Affording it needed `queries.next_episode` to stop loading every episode of the
show into Python to pick one of them (236 rows for Friends, 227 for JAG) — three
ordered LIMIT 1 probes instead. The show→episode rule now lives in ONE function
(`rails.playable`), which is what the shelf, the hero and the rail all call; two
implementations are how they came to disagree.
Ten mutations run, all ten red for the right test — including a first pass where
the press count reached `api.discoverPick` and stopped there, invisible because
every Home test mocks that module. The slice now has its own URL test.
Verified in a clean browser tab against the live account: one Back returns to
Home, the rotation gives resume/comfort/surprise across nine presses with no
repeat, and Westworld opens at S1E1 rather than at its show page.
53 lines
1.9 KiB
TypeScript
53 lines
1.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The Home surface's slice builds its own query strings, and the Home DOM tests mock this module —
|
|
// so nothing else in the suite ever looks at the URL that actually goes out. That gap hid a live
|
|
// parameter once already: the reroll's `start` reached `api.discoverPick` and stopped there.
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { discoverApi } from "./discover";
|
|
|
|
const fetchMock = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
fetchMock.mockReset().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
headers: new Headers({ "content-type": "application/json" }),
|
|
json: async () => ({}),
|
|
text: async () => "{}",
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
});
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
// Indexed, not `.at(-1)`: the e2e tsconfig's lib target predates it, and the gate typechecks three
|
|
// projects.
|
|
const lastUrl = (): string => {
|
|
const calls = fetchMock.mock.calls;
|
|
return String(calls[calls.length - 1]?.[0]);
|
|
};
|
|
|
|
describe("discoverApi query strings", () => {
|
|
it("sends the press count as `start` — the tier rotation lives on the wire", async () => {
|
|
await discoverApi.discoverPick("medium", 42, [], 3);
|
|
expect(lastUrl()).toContain("start=3");
|
|
expect(lastUrl()).toContain("budget=medium");
|
|
expect(lastUrl()).toContain("seed=42");
|
|
});
|
|
|
|
it("sends the press count even on the first press, so `start` is never implied", async () => {
|
|
await discoverApi.discoverPick("any", 7);
|
|
expect(lastUrl()).toContain("start=0");
|
|
});
|
|
|
|
it("encodes the exclusion list, whose keys carry a colon", async () => {
|
|
await discoverApi.discoverPick("any", 1, ["youtube:abc", "plex:99"], 2);
|
|
expect(lastUrl()).toContain(`exclude=${encodeURIComponent("youtube:abc,plex:99")}`);
|
|
});
|
|
|
|
it("omits `exclude` entirely when there is nothing to exclude", async () => {
|
|
await discoverApi.discoverPick("any", 1, [], 0);
|
|
expect(lastUrl()).not.toContain("exclude=");
|
|
});
|
|
});
|