fix(nav): a linked player, file and search survive what the app does by itself (review round 2)
- Playlists: a link over a playlist the account does not hold plays alone; the rail's own fallback no longer counts as a pick that gives the player up. - The linked opener opens or clears nothing once the address names another player, and stops looking (and fetching its row) once it settled. - Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a notice of its own (EN/HU). - History: an in-place view rewrite (search refine, count, channel to channel, sub-view) drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a search keeps the search; the Feed press on a search opens the plain feed. - Files/Playlists: an emptied search is written at once. - Client log: plf/cf (column text filters) are typed keys; the shell has none of its own. - Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark net reads page tags. - Feed defaults in one constant; one live-search queue helper; Scheduler writes through the Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button.
This commit is contained in:
@@ -4,9 +4,9 @@
|
||||
// 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, the keys a module declares are exactly
|
||||
// what its encoders write, a writer outside lib/ hands the address an encoder's result (or literal
|
||||
// keys, checked here) — never a key of its own making — and every key literal read from the live
|
||||
// query is declared.
|
||||
// what its encoders write, a writer outside lib/ hands the address a lib/ function's result (or
|
||||
// literal keys, checked here) — never a key of its own making — every key literal read from the live
|
||||
// query is declared, and every key is sorted as typed text or not (the client log's part).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
@@ -90,8 +90,96 @@ function sources(dir: string): string[] {
|
||||
const WRITER_OBJECTS = [
|
||||
/(?:urlWithParams|useUrlMirror)\(\{([^}]*)\}/g,
|
||||
/\burl=\{\{([^}]*)\}/g,
|
||||
/useBackToClose\([^,()]+,\s*\{([^}]*)\}/g,
|
||||
// Its first argument a name, a call or an arrow (`() => setOpen(false)`).
|
||||
/useBackToClose\((?:[^,()]|\([^()]*\))+,\s*\{([^}]*)\}/g,
|
||||
];
|
||||
// One of each form: a regex that matched nothing in the tree would pass every scan.
|
||||
const WRITER_FIXTURE = `
|
||||
useUrlMirror({ fxa: x });
|
||||
<Modal url={{ fxb: String(id) }} />
|
||||
useBackToClose(onClose, { fxc: "1" });
|
||||
useBackToClose(() => setOpen(false), { fxd: "1" });
|
||||
`;
|
||||
|
||||
// Where a writer outside lib/ is handed its value: the address writers' argument, and an
|
||||
// overlay's (Modal's `url`, `useBackToClose`'s second). An object literal (its keys are scanned), a
|
||||
// call of a lib/ function — the codecs, and the shell's own (pageRootParams, …) — or a choice
|
||||
// between them; anything else (a variable, a helper of the module's own) is a key no scan reads.
|
||||
const WRITER_CALLS = /\b(useUrlMirror|urlWithParams|useBackToClose)\(|\burl=\{/g;
|
||||
function closing(text: string, open: number): number {
|
||||
let depth = 0;
|
||||
for (let i = open; i < text.length; i++) {
|
||||
const c = text[i]!;
|
||||
if ("([{".includes(c)) depth++;
|
||||
else if (")]}".includes(c) && --depth === 0) return i;
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
/** The top-level pieces of `text` split at `sep` (outside any brackets). */
|
||||
function topLevel(text: string, sep: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let from = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i]!;
|
||||
if ("([{".includes(c)) depth++;
|
||||
else if (")]}".includes(c)) depth--;
|
||||
else if (depth === 0 && c === sep) {
|
||||
out.push(text.slice(from, i));
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(text.slice(from));
|
||||
return out.map((s) => s.trim());
|
||||
}
|
||||
const LIB_EXPORTS = new Set(
|
||||
sources(join(SRC, "lib")).flatMap((f) =>
|
||||
[...readFileSync(f, "utf8").matchAll(/export (?:function|const) (\w+)/g)].map((m) => m[1]!),
|
||||
),
|
||||
);
|
||||
function writerValueOk(value: string): boolean {
|
||||
const v = value.trim();
|
||||
if (v === "null" || v === "undefined") return true;
|
||||
const choice = topLevel(v, "?");
|
||||
if (choice.length === 2) return topLevel(choice[1]!, ":").every(writerValueOk);
|
||||
if (v.startsWith("{")) {
|
||||
// Its spreads are a lib/ function's result too.
|
||||
return topLevel(v.slice(1, closing(v, 0)), ",")
|
||||
.filter((piece) => piece.startsWith("..."))
|
||||
.every((piece) => writerValueOk(piece.slice(3)));
|
||||
}
|
||||
const call = /^(\w+)\(/.exec(v);
|
||||
return (
|
||||
call != null && LIB_EXPORTS.has(call[1]!) && closing(v, call[0].length - 1) === v.length - 1
|
||||
);
|
||||
}
|
||||
function writerValues(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const m of text.matchAll(WRITER_CALLS)) {
|
||||
const open = m.index + m[0].length - 1;
|
||||
const args = topLevel(text.slice(open + 1, closing(text, open)), ",");
|
||||
if (m[1] === "useBackToClose") {
|
||||
if (args.length > 1) out.push(args[1]!);
|
||||
} else if (args[0]) out.push(args[0]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function unreadWriterValues(): string[] {
|
||||
const out: string[] = [];
|
||||
for (const file of sources(SRC)) {
|
||||
const where = relative(SRC, file).split("\\").join("/");
|
||||
if (where.startsWith("lib/")) continue;
|
||||
for (const value of writerValues(readFileSync(file, "utf8"))) {
|
||||
// ui/Modal hands its own `url` prop on: its callers' values are the ones checked.
|
||||
if (where === "components/ui/Modal.tsx" && value === "url") continue;
|
||||
// The shell's page switch writes what the declarations name (moduleUrlKeys, foreignUrlKeys —
|
||||
// every module's keys cleared) and the shell's own: its helpers are that, held above.
|
||||
if (where === "features/navigation/NavigationProvider.tsx") continue;
|
||||
if (!writerValueOk(value)) out.push(`${where}: ${value.slice(0, 60)}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Key literals that reach the address bar's QUERY: the literal keys of a writer's object, and a
|
||||
// `.get("…")` / `.has("…")` in a file that reads the live query (any params object there: the
|
||||
@@ -132,6 +220,66 @@ function computedWriterKeys(inLib = false): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// The keys whose value is what the user typed (a search box, a column filter map with a text
|
||||
// filter in it), and every other one: chosen from what the page offers (a tab, a sort, a filter's
|
||||
// option, an id).
|
||||
const TYPED_KEYS = ["aq", "cf", "cq", "fq", "plf", "plq", "pq", "q", "yt"];
|
||||
const PICKED_KEYS = [
|
||||
"aact",
|
||||
"at",
|
||||
"au",
|
||||
"aw",
|
||||
"cfgtab",
|
||||
"channel",
|
||||
"csort",
|
||||
"ctab",
|
||||
"folder",
|
||||
"fplay",
|
||||
"from",
|
||||
"fsort",
|
||||
"fview",
|
||||
"hb",
|
||||
"item",
|
||||
"lev",
|
||||
"lgrp",
|
||||
"live",
|
||||
"ljob",
|
||||
"lreq",
|
||||
"lrun",
|
||||
"lsrc",
|
||||
"maxage",
|
||||
"maxdur",
|
||||
"mindur",
|
||||
"normal",
|
||||
"notag",
|
||||
"pf",
|
||||
"pl",
|
||||
"plgroup",
|
||||
"plsort",
|
||||
"pqf",
|
||||
"pscope",
|
||||
"pshow",
|
||||
"psort",
|
||||
"rjob",
|
||||
"rst",
|
||||
"scope",
|
||||
"shorts",
|
||||
"show",
|
||||
"sort",
|
||||
"source",
|
||||
"stab",
|
||||
"tagmode",
|
||||
"tagmode_language",
|
||||
"tagmode_other",
|
||||
"tagmode_topic",
|
||||
"tags",
|
||||
"to",
|
||||
"utab",
|
||||
"v",
|
||||
"year",
|
||||
"ytn",
|
||||
];
|
||||
|
||||
// A retired page's link keys: read once to open the page that replaced it, and cleared (lib/urlState
|
||||
// RETIRED) — no module writes them any more.
|
||||
const RETIRED_LINK_KEYS = ["lu", "lq", "lwin"];
|
||||
@@ -200,11 +348,36 @@ describe("module URL keys", () => {
|
||||
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));
|
||||
// A writer's literal, a one-line read, a two-line read, a retired link's.
|
||||
for (const key of ["page", "rjob", "link", "plq", "folder", "lq"]) expect(seen).toContain(key);
|
||||
for (const key of ["page", "link", "plq", "folder", "lq"]) expect(seen).toContain(key);
|
||||
});
|
||||
|
||||
it("are written outside lib/ only through the codecs that WRITES calls", () => {
|
||||
it("the writer scans see each form they are meant to", () => {
|
||||
const keys = WRITER_OBJECTS.flatMap((re) =>
|
||||
[...WRITER_FIXTURE.matchAll(re)].map((m) => /(\w+)\s*:/.exec(m[1]!)![1]),
|
||||
);
|
||||
expect(keys.sort()).toEqual(["fxa", "fxb", "fxc", "fxd"]);
|
||||
expect(writerValues(WRITER_FIXTURE)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("are written outside lib/ only as a literal or a lib/ function's result", () => {
|
||||
expect(computedWriterKeys()).toEqual([]);
|
||||
expect(unreadWriterValues()).toEqual([]);
|
||||
// …which the check tells apart (a check that passed everything would pass here too).
|
||||
expect(writerValueOk("{ page: p, ...activityParamUpdates(state) }")).toBe(true);
|
||||
expect(writerValueOk('shown ? playlistsParams(id, q) : playlistsParams(null, "")')).toBe(true);
|
||||
expect(writerValueOk("upd")).toBe(false);
|
||||
expect(writerValueOk("myOwnParams(v)")).toBe(false);
|
||||
expect(writerValueOk("{ ...st, zz: 1 }")).toBe(false);
|
||||
expect(writerValueOk("Object.fromEntries(rows)")).toBe(false);
|
||||
});
|
||||
|
||||
// A sub-view's address key is written by the history plumbing in lib/, from its definition: each
|
||||
// one is in a WRITES row (Plex's) or the shell's keys (the welcome card's), by name.
|
||||
it("name their sub-views' keys", () => {
|
||||
const defined = sources(SRC).flatMap((f) =>
|
||||
[...readFileSync(f, "utf8").matchAll(/const (\w+): SubviewUrl</g)].map((m) => m[1]!),
|
||||
);
|
||||
expect(defined.sort()).toEqual(["MODE_URL", "PLEX_SUB_URL"]);
|
||||
});
|
||||
|
||||
it("the computed-key scan sees what it is meant to (lib's own writers compute theirs)", () => {
|
||||
@@ -221,7 +394,11 @@ describe("module URL keys", () => {
|
||||
page,
|
||||
).toEqual([]);
|
||||
}
|
||||
expect([...typedUrlKeys()].sort()).toEqual(["aq", "cq", "fq", "plq", "pq", "q", "yt"]);
|
||||
// Every key sorted one way or the other, by name: a new one fails here until it is decided.
|
||||
const typed = new Set(typedUrlKeys());
|
||||
const all = REGISTERED_PAGES.flatMap((p) => moduleDef(p).urlKeys);
|
||||
expect([...typed].sort()).toEqual(TYPED_KEYS);
|
||||
expect(all.filter((k) => !typed.has(k)).sort()).toEqual(PICKED_KEYS);
|
||||
// …which the client log is handed as it installs.
|
||||
expect(readFileSync(join(SRC, "main.tsx"), "utf8")).toContain(
|
||||
"installClientLog(appCopyOf, typedUrlKeys());",
|
||||
@@ -233,10 +410,10 @@ describe("module URL keys", () => {
|
||||
// by its address — Plex excepted, by name there: its view is the instance's own library.
|
||||
describe("the bookmark net", () => {
|
||||
const spec = readFileSync(join(SRC, "..", "e2e", "bookmark.spec.ts"), "utf8");
|
||||
// A test tagged with the page that opens a bookmark of what it set (`bookmarked(`).
|
||||
const tests = spec.split(/\n\s*test\(/).slice(1);
|
||||
const opened = (page: Page) =>
|
||||
spec.includes(`page=${page}`) ||
|
||||
spec.includes(`gotoPage(page, "${page}")`) ||
|
||||
spec.includes(`gotoAdminPage(page, "${page}")`);
|
||||
tests.some((t) => t.includes(`tag: "@${page}"`) && t.includes("bookmarked("));
|
||||
|
||||
it.each(REGISTERED_PAGES.filter((p) => moduleDef(p).urlKeys.length > 0 && p !== "plex"))(
|
||||
"takes a round trip through %s",
|
||||
|
||||
Reference in New Issue
Block a user