fix(e24): review round 4 — a test that pinned nothing, and prose still ahead of the code

Round 4 reviewed round 3's commit. Nothing in production behaviour was wrong; what was wrong
was the round's own test and its comments, which is the failure class this loop keeps
finding.

- The new NavigationProvider case named after the `&& !channelViewRef.current` clause never
  reached it. With storage cleared and a bare URL the provider lands on "home" (E24's own
  default), not "feed", so asking for "feed" was an ordinary page change and the guard's
  first conjunct short-circuited. Deleting the clause left all 828 tests green — a dead nav
  control (the rail's own module stops closing an open channel page) with no red anywhere.
  The test now asserts the precondition and calls `setPage` with the page that IS current.
- Both new Home cases rejected a promise and then asserted after one fixed macrotask.
  React-query notifies through `notifyManager`'s own `setTimeout(0)`, registered after any
  timer the test schedules, so the assertion raced it; two reviewers measured intermittent
  reds. They poll for a signal that the rejection LANDED instead — the reroll button
  releasing, which is also the honest one for the empty-answer case, where the empty state is
  already on screen while the press is in flight and asserting it early would pass either way.
- `buryUnder`'s comment still opened with "one rule for both entry points" ten lines above the
  paragraph saying the rule is complete only for one of them, listed the non-reporting palette
  rows without `openChannel` — which has its own `replaceState` no-push branch and is reached
  from PlayerModal too — and pointed at a ROADMAP.md this repository does not contain.
- `history.dom.test.ts`'s header claimed its layers close by genuine Backs only, which two of
  its own tests contradict; and `CommandPalette.ordering.dom.test.tsx` still claimed to be
  "the only assertion in the suite" doing what the file corrected in round 3 also does. Round
  3's message said that pair was handled and only one side had been.

The provider test was re-mutation-checked (dropping the clause now goes red), and both
hardened Home tests still go red against their own reverts. Seven consecutive full runs green.
Gate: ruff, ruff format, 3x tsc, eslint (0 errors), prettier, knip, vitest 828, pytest 1005.
This commit is contained in:
2026-09-09 01:02:22 +02:00
parent 59efc8f2e8
commit d832bb19ce
6 changed files with 63 additions and 35 deletions
+13 -7
View File
@@ -430,11 +430,11 @@ describe("Home", () => {
await screen.findByText("Hero v1");
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(1));
await act(async () => {
// `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.
failPick(new Error("boom"));
await new Promise((r) => setTimeout(r, 0));
});
expect(rerollBtn().disabled).toBe(false); // the failure itself releases the button
await waitFor(() => expect(rerollBtn().disabled).toBe(false)); // the failure releases the button
// Now an unrelated action refetches that errored key in the background.
discoverPick.mockReturnValue(new Promise(() => {}));
@@ -475,12 +475,18 @@ describe("Home", () => {
failPick = reject;
}),
);
const rerollBtn = () =>
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
await act(async () => {
expect(rerollBtn().disabled).toBe(true); // the press is outstanding
failPick(new Error("boom"));
await new Promise((r) => setTimeout(r, 0));
});
// Wait for a signal that the REJECTION has landed, not for a fixed tick: react-query notifies
// the observer through its own `setTimeout(0)`, and the empty state is already on screen while
// the press is in flight — so asserting it straight away would pass with or without the fix.
// The button releasing is that signal (`isFetched` flips once the key settles, error included).
await waitFor(() => expect(rerollBtn().disabled).toBe(false));
expect(screen.getByText("home.hero.emptyBudget")).toBeTruthy();
expect(screen.queryByText("Hero v1")).toBeNull();
});
@@ -11,12 +11,13 @@
// `return` fails tsc — but round 3 measured that swapping the two values (early return `true`, tail
// `false`) passes typecheck and all 822 tests. Hence assertions on the value AND on the effects the
// early return skips, which is what the boolean actually stands for.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react";
import type { ReactNode } from "react";
// Partial: the provider only wants `t` for the document title, but the i18n module this pulls in
// initialises against the real `initReactI18next`.
// Partial: the provider reads `t` AND `i18n.language` (both feed the document-title effect), and the
// import graph pulls in `src/i18n`, which initialises against the real `initReactI18next` — so a
// wholesale mock throws before any test runs.
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
@@ -38,6 +39,9 @@ beforeEach(() => {
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory
window.history.replaceState({ sfPage: "feed" }, "", "/");
});
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
// provider (and its popstate listener) mounted for the next case. Same reason Home.dom.test.tsx does.
afterEach(cleanup);
describe("NavigationProvider · setPage reports whether it navigated", () => {
it("returns true and pushes an entry for a real page change", () => {
@@ -77,9 +81,15 @@ describe("NavigationProvider · setPage reports whether it navigated", () => {
});
it("returns true even for the current page when a channel page is over it", () => {
// The nav rail's "Feed" while a channel opened from the feed is a real navigation: it closes the
// channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
// The nav rail's own module while a channel page is open over it is a real navigation: it closes
// 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
// 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");
act(() => {
result.current.openChannel("UC1", "A channel");
});
@@ -89,7 +99,7 @@ describe("NavigationProvider · setPage reports whether it navigated", () => {
try {
let reported: boolean | undefined;
act(() => {
reported = result.current.setPage("feed");
reported = result.current.setPage(current);
});
expect(reported).toBe(true);
expect(push).toHaveBeenCalledTimes(1);
@@ -1,8 +1,10 @@
// @vitest-environment jsdom
//
// ONE test, ONE file, on purpose. This is the close→reclaim→run ordering contract of the palette
// (see closeThenNavigate), and it is the only assertion in the suite that drives a real
// useBackToClose entry through a real programmatic history.back().
// (see closeThenNavigate), and it ASSERTS on a real useBackToClose entry driven through a real
// programmatic history.back() — which is why it cannot share module state with anything. (It is not
// the only place in the suite where an unstubbed back() fires; history.burial.dom.test.ts is the
// other file that asserts on one, and any test rendering a real Modal can let one go.)
//
// It cannot live in CommandPalette.dom.test.tsx. jsdom gives a test FILE a single session history,
// and history.ts's overlay bookkeeping is module-global, so the sibling tests there leave an
+5 -5
View File
@@ -1,10 +1,10 @@
// @vitest-environment jsdom
//
// ONE test, ONE file, for the same reason CommandPalette.ordering.dom.test.tsx has its own — it is
// the other assertion in the suite that lets a reconcile issue a REAL programmatic history.back()
// (unstubbed), and history.ts's overlay bookkeeping is module-global. history.dom.test.ts states its
// own invariant — every layer there is closed by a genuine Back or a stubbed back(), so nothing real
// fires and the shared state stays clean between its tests — and this test would break it.
// ONE test, ONE file, for the same reason CommandPalette.ordering.dom.test.tsx has its own: it
// ASSERTS on a reconcile issuing a REAL, unstubbed programmatic history.back(), and history.ts's
// overlay bookkeeping is module-global. history.dom.test.ts keeps its shared state clean by never
// leaving a real back() in flight — its layers close by a genuine Back, a stubbed back(), or a
// consumed burial mark — and this test would break that.
import { describe, it, expect, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { navigateOverOverlays, useBackToClose } from "./history";
+6 -2
View File
@@ -7,8 +7,12 @@
// A GENUINE user Back is simulated by dispatching `popstate` directly: the real-Back branch in
// history.ts does NOT call history.back() (it only decrements its own historyDepth to match the
// entry the browser already dropped), so a raw popstate reproduces it deterministically without
// depending on jsdom's async back() semantics. Each test closes its layers via genuine Backs, so
// no reconcile-driven history.back() runs and the shared module state stays clean between tests.
// depending on jsdom's async back() semantics. The invariant that keeps the module-global
// bookkeeping clean between tests here is NOT "only genuine Backs" — two closeThenNavigate cases
// deliberately drive a reconcile into history.back() — it is that no REAL back() is ever left in
// flight: a layer is closed by a genuine Back, by a STUBBED back() that lands its own popstate, or
// by an unmount whose burial mark is consumed. A test that needs an unstubbed one belongs in its own
// file (see history.burial.dom.test.ts).
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { closeThenNavigate, navigateOverOverlays, useBackToClose, useBackToExit } from "./history";
+16 -10
View File
@@ -171,17 +171,23 @@ let listenerInstalled = false;
* open" navigated nowhere yet still buried the player's entry. The player's unmount then consumed
* the mark instead of issuing history.back(), orphaning its browser entry — and the next Back
* popped onto an identical `sfPage` with an empty overlay stack, i.e. a press that did nothing
* (round 2). One rule for both entry points below, because both take the same no-op navigation.
* (round 2). One rule for both entry points below — but read the next paragraph before trusting it
* at the `closeThenNavigate` one, where most callers cannot report at all.
*
* It fails OPEN, and that is a deliberate limit rather than an oversight: only a literal `false`
* skips the mark, so a `navigate` that reports nothing is assumed to have pushed — which is what
* the code did unconditionally before, so nothing regressed by adding this. The gap it leaves is
* `closeThenNavigate`'s palette callers: `CommandItem.run` is typed `() => void`, so only the rows
* written as a bare `() => setPage(page)` forward the boolean at all, while the block-bodied and
* non-navigating rows (surprise, playlists, theme, the cheat sheet) still mark a live sibling
* overlay's entry buried under a push that never happened. Closing that overlay by its own control
* then costs one dead browser-Back press. Deferred, with the fix (widen `CommandItem.run` and have
* each row report) on ROADMAP.md — this rule is complete only for `navigateOverOverlays`.
* It fails OPEN: only a literal `false` skips the mark, so a `navigate` that reports nothing is
* assumed to have pushed. That is what the code did unconditionally before, so nothing regressed by
* adding the rule — it just does not yet reach everywhere. Today only `navigateOverOverlays`
* (App's Shift+W → `setPage`) is fully covered. Of `closeThenNavigate`'s callers, only a palette
* row written as a bare `() => setPage(page)` forwards the boolean: `CommandItem.run` is typed
* `() => void`, so the block-bodied and non-navigating rows report nothing — and neither does
* `openChannel`, which has its OWN no-push branch (it `replaceState`s when the entry already
* carries `_chan`) and is reached both from a palette row and from PlayerModal's channel-name jump.
* Each of those still marks a live sibling overlay's entry buried under a push that never happened,
* costing one dead browser-Back press when that overlay is closed by its own control.
*
* Closing it is three changes, not one — widen `CommandItem.run` to `() => boolean | void`, return
* the verdict from the block-bodied rows, and give `openChannel` the boolean `setPage` now has — so
* it is deferred to the project backlog rather than bolted on here.
*
* Marking AFTER the call is safe: `navigate()` is synchronous and only `reconcileHistory` (a
* microtask) and the popstate listener ever move `historyDepth`. */