Owner answers to the E11 S5 raises (2026-09-13): 1 → A, 2 → "Feed" in every language, 3 → translate the
proxy label and fix every config key missing in BOTH languages ("maintenance", "tmdb" — old bugs).
Focus across a view change (1-A). Opening a Plex title from Home's rail, a channel from the Channels
table or a title from a Plex grid switches the page or the sub-view, which unmounts the list; Back used
to hand focus to <body>, so the next `j` started over at the top. The YouTube player (an overlay)
already restored onto the card.
- `lib/listNav`: `navPosition()` (list's place in reading order, the page's list count, item index)
and `restoreNavPosition()`: focuses the item once the page has as many lists on screen as when it was
left, polls on a timer (a hidden tab runs no animation frames), gives up after 2 s or at once when
something already holds focus, focuses without scrolling (the scroll restore owns the position).
- `lib/history`: `rememberNavPosition()` stamps `sfNav` onto the entry being left and returns the state
without it (a new entry must not inherit it); one popstate listener restores and spends the stamp,
so a later landing on the same entry (an overlay closing over it) cannot pull focus to an old item.
- Called before every view push: `setPage`, `openChannel`, `enterYtSearch`, `useHistorySubview.open`,
the Channels tab push. `navPosition.wiring.contract.test` fails on any `history.pushState(` without
it (the overlay stack's own push is the one exemption).
"Feed" (2). The module is "Feed" in both languages: every "hírfolyam" in the Hungarian UI (≈45 strings,
suffixed: Feedből, Feeded, Feedben…) and the prose "feed" in English that means the module ("your
Feed", "Hide from Feed"); "RSS feed" keeps its meaning. The welcome email says it too.
Backend ids without a name (3). The Configuration page showed `maintenance`, `tmdb`,
`maintenance_revalidate_batch`, `download_upload_max_bytes`, `audit_retention_days`, `plex_overlay_map`,
`tmdb_api_key` and `tmdb_language` as raw identifiers — the lookup falls back to the id, and the i18n
parity test stayed green because both languages missed the same keys. Added labels + hints (EN/HU),
the two groups (and their tab order), the missing `scheduler.jobDesc.demo_reset`, removed the stale
`plex_sync_interval_min` field, and "YouTube API kimenő proxy". `i18n/backendIds.contract.test` reads
`sysconfig.SPECS`, `scheduler.JOB_INTERVALS` and the audit/quota action enums from the backend sources
and fails on an id without its name in either language, or a name left for an id the backend dropped.
55 lines
2.3 KiB
TypeScript
55 lines
2.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
// Every push that opens a new view over the current one remembers the list item it was opened from
|
|
// (`lib/history.rememberNavPosition`), so Back hands focus back to that item (E11 S5). A push that
|
|
// forgets strands keyboard focus on <body> again with no test noticing — the flow only shows in a real
|
|
// browser, after a round trip. So the source is the check: each `history.pushState(` in the app has a
|
|
// `rememberNavPosition(` right before it or inside its arguments. The overlay stack's own push is the
|
|
// one exception — an overlay entry opens no view, and its list stays mounted underneath.
|
|
//
|
|
// The assertion pins the SET of offending sites and checks the scan found pushes at all: a guard whose
|
|
// glob stops matching is green over nothing (E10 review round 4).
|
|
|
|
const files = import.meta.glob(["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"], {
|
|
query: "?raw",
|
|
import: "default",
|
|
eager: true,
|
|
}) as Record<string, string>;
|
|
|
|
const EXEMPT: Record<string, string> = {
|
|
"lib/history.ts#_ov": "the overlay stack: an overlay entry opens no view over the page",
|
|
};
|
|
|
|
// Lines before a push, and lines of the push call itself, that may carry the remember call.
|
|
const BEFORE = 4;
|
|
const WITHIN = 4;
|
|
|
|
function offenders(): { hits: string[]; pushes: number } {
|
|
const hits: string[] = [];
|
|
let pushes = 0;
|
|
for (const [path, code] of Object.entries(files)) {
|
|
// Glob keys are relative to this file: `./x.ts` is lib, `../components/x.tsx` is a sibling folder.
|
|
const rel = path.startsWith("./") ? `lib/${path.slice(2)}` : path.replace(/^\.\.\//, "");
|
|
const lines = code.split("\n");
|
|
lines.forEach((line, i) => {
|
|
if (!line.includes("history.pushState(")) return;
|
|
pushes++;
|
|
const around = lines.slice(Math.max(0, i - BEFORE), i + WITHIN).join("\n");
|
|
if (around.includes("rememberNavPosition(")) return;
|
|
const exempt = Object.keys(EXEMPT).find(
|
|
(id) => id.startsWith(`${rel}#`) && around.includes(id.split("#")[1]!),
|
|
);
|
|
if (!exempt) hits.push(`${rel}:${i + 1}`);
|
|
});
|
|
}
|
|
return { hits: hits.sort(), pushes };
|
|
}
|
|
|
|
describe("every view push remembers the item it was opened from", () => {
|
|
it("finds no pushState without rememberNavPosition", () => {
|
|
const { hits, pushes } = offenders();
|
|
expect(pushes).toBeGreaterThan(4);
|
|
expect(hits).toEqual([]);
|
|
});
|
|
});
|