feat(nav): each module declares its URL keys; a page switch clears them and a page opens without another's
The shell cleared a hand-kept key list on a page switch (it missed the Files and feed keys) and cleared nothing when a page opened from a link carrying another page's keys. ModuleDef.urlKeys now names every key a page's view writes, the shell derives both from the registry, and moduleUrl.contract.test holds the declarations true: one owner per key, no shell key, exactly what each codec writes, and no undeclared key literal in the app.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The URL is the view: each module declares the address-bar keys its view writes (`urlKeys`), and the
|
||||
// shell clears them on a page switch and opens a page without another page's keys (NavigationProvider,
|
||||
// derived from these declarations). That only holds while the declarations are true, so this pins
|
||||
// them: each key belongs to one module and is no shell key, every codec writes only its module's keys,
|
||||
// and no key literal anywhere in the app reads or writes the address bar undeclared.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { moduleDef, REGISTERED_PAGES } from "./features/navigation/moduleRegistry";
|
||||
import { DEFAULT_TAG_MODES, EMPTY_PLEX_FILTERS, type FeedFilters } from "./lib/api";
|
||||
import { filterParamUpdates, SHELL_KEYS, type Page } from "./lib/urlState";
|
||||
import { channelsParamUpdates } from "./lib/channelsUrlState";
|
||||
import { plexParamUpdates } from "./lib/plexUrlState";
|
||||
import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState";
|
||||
import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState";
|
||||
import { FILES_KEYS } from "./lib/filesUrlState";
|
||||
import { PLEX_SUB_URL } from "./modules/plex/plexSubUrl";
|
||||
import { MODE_URL } from "./shell/welcomeMode";
|
||||
|
||||
const SRC = __dirname;
|
||||
|
||||
const FEED: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
// What each module's own encoders write, from the encoders themselves. Every module that declares a
|
||||
// key has a row, so a new one cannot declare keys its codec does not write, or the other way round.
|
||||
const WRITES: Partial<Record<Page, () => string[]>> = {
|
||||
feed: () => Object.keys(filterParamUpdates(FEED)),
|
||||
channels: () =>
|
||||
Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {} })),
|
||||
plex: () => [
|
||||
...Object.keys(
|
||||
plexParamUpdates({ scope: "both", sort: null, show: "all", filters: EMPTY_PLEX_FILTERS }),
|
||||
),
|
||||
PLEX_SUB_URL.key,
|
||||
],
|
||||
files: () => [...FILES_KEYS],
|
||||
playlists: () => ["pl"],
|
||||
activity: () => [
|
||||
...Object.keys(activityParamUpdates(DEFAULT_ACTIVITY_STATE)),
|
||||
...Object.keys(logsParamUpdates(DEFAULT_LOGS_STATE)),
|
||||
],
|
||||
};
|
||||
|
||||
function sources(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) return sources(path);
|
||||
return /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
// Key literals that reach the address bar's QUERY: the keys of an object literal handed to
|
||||
// `urlWithParams` / `useUrlMirror`, and a `.get("…")` on the live query.
|
||||
function queryKeyLiterals(): { key: string; where: string }[] {
|
||||
const out: { key: string; where: string }[] = [];
|
||||
for (const file of sources(SRC)) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const where = relative(SRC, file).split("\\").join("/");
|
||||
for (const m of text.matchAll(/(?:urlWithParams|useUrlMirror)\(\{([^}]*)\}/g)) {
|
||||
for (const k of `{${m[1]!}`.matchAll(/[{,]\s*(\w+)\s*:/g)) out.push({ key: k[1]!, where });
|
||||
}
|
||||
for (const m of text.matchAll(
|
||||
/new URLSearchParams\(window\.location\.search\)\.get\("(\w+)"\)/g,
|
||||
)) {
|
||||
out.push({ key: m[1]!, where });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("module URL keys", () => {
|
||||
const owner = new Map<string, Page>();
|
||||
for (const page of REGISTERED_PAGES) {
|
||||
for (const key of moduleDef(page).urlKeys) owner.set(key, owner.get(key) ?? page);
|
||||
}
|
||||
|
||||
it("belong to one module each", () => {
|
||||
const all = REGISTERED_PAGES.flatMap((p) => moduleDef(p).urlKeys);
|
||||
expect(all.filter((k, i) => all.indexOf(k) !== i)).toEqual([]);
|
||||
});
|
||||
|
||||
it("are never a shell key", () => {
|
||||
const shell: readonly string[] = SHELL_KEYS;
|
||||
expect([...owner.keys()].filter((k) => shell.includes(k))).toEqual([]);
|
||||
// The welcome card's sub-view key is the shell's own.
|
||||
expect(shell).toContain(MODE_URL.key);
|
||||
});
|
||||
|
||||
it.each([...REGISTERED_PAGES])("are exactly what %s's encoders write", (page) => {
|
||||
const declared = [...moduleDef(page).urlKeys].sort();
|
||||
const writes = WRITES[page];
|
||||
if (!declared.length) {
|
||||
expect(writes).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
expect(writes, `${page} declares keys but has no WRITES row`).toBeDefined();
|
||||
expect([...new Set(writes!())].sort()).toEqual(declared);
|
||||
});
|
||||
|
||||
it("cover every key literal the app reads or writes in the address bar", () => {
|
||||
const shell: readonly string[] = SHELL_KEYS;
|
||||
const undeclared = queryKeyLiterals().filter(
|
||||
({ key }) => !owner.has(key) && !shell.includes(key),
|
||||
);
|
||||
expect(undeclared).toEqual([]);
|
||||
});
|
||||
|
||||
it("the literal scan sees the call sites it is meant to (a regex that matched nothing would pass)", () => {
|
||||
const seen = new Set(queryKeyLiterals().map(({ key }) => key));
|
||||
for (const key of ["chan", "pl", "page", "rjob", "link"]) expect(seen).toContain(key);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user