Public: anyone can read this; the crew can change it.

/next narrows to one pillar or a word: server-rendered filters that keep the owner's order #11887

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11867-next-narrows-to-one-pillar-or-a-word-ser → main 7 commits fork 3 threads · 3 unresolved

  • Merged. scout/11867-next-narrows-to-one-pillar-or-a-word-ser was merged into main as 06bacc2.

Review

Sign in to review this change.
7 files changed +319 −30
docs/features/next-up.md +2
@@ -13,6 +13,8 @@
1313
1414 **Terminal:** the palette's "Next up in a repository" opens the same list; `t`, `u`, `d` and `n` are Top, Up, Down and Not now on the row under the cursor, through the same action as the page and MCP. A reader sees the order and the keys do nothing; a move made anywhere reorders an open screen live. At most 200 issues are ranked; a move past that is refused and says so.
1515
16 **Filter (#11867):** a plain GET form above the list narrows `/next` to one pillar (`?pillar=P7` or `Later`, typed in a box) and/or a word (`?q=cache`, matched without regard to case, shown as typed, at most 80 characters). A filter only hides rows: each keeps its real place in the whole ranking, the cut line stays where the whole list puts it (drawn only between shown rows), and a move made under a filter moves in the whole order and returns to the same filter. A line says "N of M shown", and that the week's capacity bar is left out while filtered. An unknown or malformed pillar shows an empty list that says so. **Terminal:** `/` asks for a pillar and/or a word (`P7 cache`); an empty answer clears it; ranks, `t` `u` `d` `n` and the header count behave as on the page.
17
1618 ## The week is the sprint (#8704)
1719
1820 The subscription's weekly reset is the sprint boundary; what the crew may still spend before it is the capacity (`flow/week.ts`, from `core/standing.ts`). No ceremony and no locked scope: rerank any time and the line moves on the next paint.
src/tui/screens/next.ts +58 −13
@@ -14,11 +14,13 @@
1414 import { rankedWords, rankIssue, stepTo, type Place, placeOf } from "../../actions/next.ts";
1515 import { nextUp, type NextRow } from "../../core/next_up.ts";
1616 import { canRead, canWrite, type Repo } from "../../core/repos.ts";
17 import { pillarsOf, servedBy } from "../../core/pillars.ts";
1718 import { sealedScope } from "../../core/sealed.ts";
1819 import { notStartable } from "../../crew/not_startable.ts";
1920 import { rangeWords, weekView, type WeekView } from "../../flow/week.ts";
2021 import { openPullStages } from "../../flow/read.ts";
2122 import type { Stage } from "../../flow/stage.ts";
23 import { countWords, filtered, matches, readFilter, MAX_Q } from "../../web/pages/next_filter.ts";
2224 import { stateWord } from "../../web/pages/next_state.ts";
2325 import type { Ctx, Screen } from "../app/app.ts";
2426 import { binding } from "../app/keys.ts";
@@ -28,7 +30,7 @@ import { truncate } from "../term/width.ts";
2830 import type { Session } from "../session.ts";
2931 import { issueScreen } from "./issue.ts";
3032 import { local } from "./where.ts";
31 import { ListState, drawList, hitList, live, type ListRow } from "./common.ts";
33 import { ListState, drawList, hitList, live, promptScreen, type ListRow } from "./common.ts";
3234
3335 /** What reorders or reshapes the list: a rank, or an issue opening, closing or changing what it waits for. */
3436 const LIVE_KINDS = ["issue.ranked", "issue.opened", "issue.closed", "issue.reopened", "issue.blocked", "issue.unblocked"];
@@ -40,6 +42,10 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4042 let week: WeekView | undefined;
4143 let stages = new Map<number, Stage>();
4244 let loaded = false;
45 // The filter (#11867): a pillar (`P7`, `Later`) and/or words, as on the page. It hides rows
46 // only; ranks and moves stay the whole list's.
47 let asked = "";
48 let shown: number[] = [];
4349 // Read at use, not at open: access can change under a session.
4450 const may = (): boolean =>
4551 f.user !== undefined && f.user.kind === "user" && canWrite(f.db, repo, f.user);
@@ -58,15 +64,49 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5864 stages = openPullStages(f.db, repo, scope, new Date(), new Set(rows.map((r) => r.issue.number)));
5965 }
6066 loaded = true;
67 narrow();
6168 if (follow !== undefined) {
62 const at = rows.findIndex((r) => r.issue.number === follow);
69 const at = shown.findIndex((i) => rows[i]!.issue.number === follow);
6370 if (at >= 0) state.index = at;
6471 }
65 state.clamp(rows.length);
72 state.clamp(shown.length);
6673 ctx.invalidate();
6774 };
6875
69 const current = (): NextRow | undefined => rows[state.index];
76 const filterOf = () => {
77 const params = new URLSearchParams();
78 const words = asked.split(/\s+/).filter((w) => w !== "");
79 const pillar = words.find((w) => /^(P[0-9]{1,6}|Later)$/.test(w));
80 if (pillar !== undefined) params.set("pillar", pillar);
81 params.set("q", words.filter((w) => w !== pillar).join(" "));
82 return readFilter(params);
83 };
84 const narrow = (): void => {
85 const filter = filterOf();
86 const on = filtered(filter);
87 const served = filter.pillar !== null ? servedBy(f.db, pillarsOf(f.db, repo)) : new Map<string, string>();
88 shown = rows.map((_, at) => at).filter((at) => !on || matches(filter, rows[at]!.issue.title, served.get(rows[at]!.issue.id)));
89 };
90 const ask = (): void => {
91 ctx.push(
92 promptScreen(ctx, screen, {
93 title: "Filter",
94 label: "A pillar (P7, Later) and/or a word in the title; empty clears",
95 value: asked,
96 onSubmit: (raw) => {
97 if (raw.trim().length > MAX_Q) return "too long";
98 asked = raw.trim();
99 narrow();
100 state.index = 0;
101 state.clamp(shown.length);
102 ctx.invalidate();
103 return undefined;
104 },
105 }),
106 );
107 };
108
109 const current = (): NextRow | undefined => rows[shown[state.index] ?? -1];
70110
71111 const rank = (to: Place | undefined): void => {
72112 const row = current();
@@ -80,7 +120,9 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
80120 ctx.toast(rankedWords(row.issue.number, to), "success");
81121 };
82122 const step = (by: -1 | 1): void => {
83 const to = stepTo(rows.map((r) => r.issue.number), state.index, by);
123 const row = current();
124 if (row === undefined) return;
125 const to = stepTo(rows.map((r) => r.issue.number), rows.indexOf(row), by);
84126 rank(to === undefined ? undefined : placeOf(to));
85127 };
86128
@@ -91,10 +133,11 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
91133
92134 // The cut line is a muted line under the last row that fits, as on the page.
93135 const listRows = (): ListRow[] =>
94 rows.map((r, at) => {
136 shown.map((at, i) => {
137 const r = rows[at]!;
95138 const estimate = week?.priced.find((p) => p.number === r.issue.number)?.estimate;
96139 const cut = week?.cut;
97 const ends = cut !== undefined && cut.unsure === null && cut.fits.length > 0 && at === cut.fits.length - 1;
140 const ends = cut !== undefined && cut.unsure === null && cut.fits.length > 0 && at < cut.fits.length && (shown[i + 1] ?? cut.fits.length) >= cut.fits.length;
98141 return {
99142 title: `${at + 1}. #${r.issue.number} ${r.issue.title}`,
100143 secondary: `${r.byOwner ? "Owner's pick · " : ""}${r.role === "blocker" ? "Blocker" : "Enabler"}`,
@@ -112,10 +155,11 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
112155 return [
113156 binding(["j", "k"], "move cursor"),
114157 binding(["enter"], "open"),
115 binding(["t"], "top", { disabled: !on || state.index === 0 }),
116 binding(["u"], "up", { disabled: !on || state.index === 0 }),
117 binding(["d"], "down", { disabled: !on || state.index >= rows.length - 1 }),
158 binding(["t"], "top", { disabled: !on || rows.indexOf(current()!) === 0 }),
159 binding(["u"], "up", { disabled: !on || rows.indexOf(current()!) === 0 }),
160 binding(["d"], "down", { disabled: !on || rows.indexOf(current()!) >= rows.length - 1 }),
118161 binding(["n"], "not now", { disabled: !on }),
162 binding(["/"], "filter"),
119163 ];
120164 },
121165 load(signal) {
@@ -130,18 +174,19 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
130174 ? "Loading…"
131175 : rows.length === 0
132176 ? "Nothing is ready: everything open waits for something."
133 : `${week?.sentence ?? ""}${may() ? "" : " · you can read this order, not change it"}${away > 0 ? ` · ${away} not now` : ""}`;
177 : `${asked !== "" ? `${countWords(shown.length, rows.length)} shown for "${asked}" · ` : ""}${week?.sentence ?? ""}${may() ? "" : " · you can read this order, not change it"}${away > 0 ? ` · ${away} not now` : ""}`;
134178 s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
135179 drawList(s, list!, c, state, listRows());
136 hitList(ctx, list!, state, rows.length, { open });
180 hitList(ctx, list!, state, shown.length, { open });
137181 },
138182 key(e) {
139 if (state.key(e, rows.length)) return true;
183 if (state.key(e, shown.length)) return true;
140184 if (e.ctrl || e.alt) return false;
141185 if (e.key === "enter") open();
142186 else if (e.key === "t") rank({ to: "top" });
143187 else if (e.key === "u") step(-1);
144188 else if (e.key === "d") step(1);
189 else if (e.key === "/") ask();
145190 else if (e.key === "n") rank({ to: "not_now" });
146191 else return false;
147192 return true;
src/web/pages/next.ts +43 −16
@@ -12,6 +12,7 @@ import { openPullStages } from "../../flow/read.ts";
1212 import { stateWord, type StateFacts } from "./next_state.ts";
1313 import { canWrite, type Repo } from "../../core/repos.ts";
1414 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
15 import { pillarsOf, servedBy } from "../../core/pillars.ts";
1516 import { sealedScope } from "../../core/sealed.ts";
1617 import { notStartable } from "../../crew/not_startable.ts";
1718 import { CUT_TOP_WORDS, cutWords, FIT_COUNT_JS, rangeWords, rowCosts, weekView, type Cut, type Priced } from "../../flow/week.ts";
@@ -21,6 +22,7 @@ import { backTo, field, readForm } from "../form.ts";
2122 import { html, raw, type Html } from "../html.ts";
2223 import { flashOrJson, page } from "../layout.ts";
2324 import { btn, kbd, row } from "../ui.ts";
25 import { countWords, filterPath, markBefore, filtered, matches, readFilter } from "./next_filter.ts";
2426
2527 /**
2628 * The owner's issue titles are their own words, so inside `[data-next]` a long
@@ -38,7 +40,9 @@ export const NEXT_CSS = `[data-next] { overflow-wrap: anywhere; }
3840 [data-next] .row-after { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; padding: 0 14px 8px; }
3941 [data-next] summary h2 { display: inline; margin: 0; }
4042 [data-next] .mine { font-weight: 600; }
41 [data-next] .cut-line { overflow-wrap: anywhere; }`;
43 [data-next] .cut-line { overflow-wrap: anywhere; }
44 [data-next] .form-actions > .input { flex: 1 1 10rem; min-width: 0; min-height: 44px; }
45 [data-next] .form-actions > .btn, [data-next] .form-actions > a { min-height: 44px; display: inline-flex; align-items: center; }`;
4246
4347 export const KEYS_JS = `
4448 (() => {
@@ -194,37 +198,60 @@ export function nextPage(ctx: Ctx): Response {
194198 const scope = sealedScope(ctx.app.db, repo, ctx.auth.user);
195199 const next = nextUp(ctx.app.db, repo, scope, (ready) => notStartable(ctx.app, repo, ready, scope));
196200 const rows = next.rows;
197 const back = `${base}/next`;
201 const filter = readFilter(ctx.url.searchParams);
202 const isFiltered = filtered(filter);
203 const back = filterPath(`${base}/next`, filter, ctx.url.searchParams.get("pillar") ?? "");
204 // Pillars are read only when a pillar is asked for: the unfiltered page stays at its budget.
205 const pillars = filter.pillar !== null ? pillarsOf(ctx.app.db, repo) : undefined;
206 const served = pillars !== undefined ? servedBy(ctx.app.db, pillars) : new Map<string, string>();
207 const shows = (r: NextRow): boolean => !isFiltered || matches(filter, r.issue.title, served.get(r.issue.id));
198208 const rendering = performance.now();
199209 const { priced, cut, capacity, sentence } = weekView(ctx.app.db, repo.id, rows, new Date());
200210 const costs = rowCosts(priced);
201211 const stages = openPullStages(ctx.app.db, repo, scope, new Date(), new Set(rows.map((r) => r.issue.number)));
202212 const facts = (r: NextRow): StateFacts => ({ stage: stages.get(r.issue.number) ?? "issue", waits: r.waits, held: r.state === "held", onIt: r.onIt !== null });
203213 // One numbered order, folded: the first TOP rows open, what comes after, then Later.
204 const first = rows.slice(0, TOP);
205 const after = rows.slice(TOP);
206 const section = (title: string, list: readonly NextRow[], start: number, open: boolean, id?: string): Html =>
207 html`<details class="mt"${open ? raw(" open") : null}><summary><h2>${title} · ${String(list.length)}</h2></summary>
208 <ul class="list setting-list"${id === undefined ? null : html` id="${id}"`}${id !== undefined && costs !== null && capacity !== null && cut.unsure === null ? html` data-capacity="${String(capacity.usd)}"` : null}>
209 ${list.flatMap((_, i) => {
210 const at = start + i;
211 const mark = at === cut.fits.length && cut.unsure === null && id !== undefined && i > 0 ? [cutMark(cut, false)] : [];
212 return [...mark, line(base, rows, at, writer, back, false, priced, costs?.[at], at + 1, stateWord(rows[at]!, facts(rows[at]!)), rows[at]!.byOwner)];
213 })}
214 // A filter hides rows but keeps each one's place in the whole ranking.
215 const ats = rows.map((_, at) => at).filter((at) => shows(rows[at]!));
216 const cutAt = cut.fits.length;
217 const section = (title: string, list: readonly number[], open: boolean, id?: string): Html => {
218 const mark = markBefore(list, cutAt);
219 return html`<details class="mt"${open ? raw(" open") : null}><summary><h2>${title} · ${String(list.length)}</h2></summary>
220 <ul class="list setting-list"${id === undefined ? null : html` id="${id}"`}${id !== undefined && !isFiltered && costs !== null && capacity !== null && cut.unsure === null ? html` data-capacity="${String(capacity.usd)}"` : null}>
221 ${list.flatMap((at, i) => [
222 ...(i === mark && cut.unsure === null && id !== undefined ? [cutMark(cut, false)] : []),
223 line(base, rows, at, writer, back, false, priced, costs?.[at], at + 1, stateWord(rows[at]!, facts(rows[at]!)), rows[at]!.byOwner),
224 ])}
214225 </ul></details>`;
226 };
227 const first = isFiltered ? ats : ats.filter((at) => at < TOP);
228 const rest = isFiltered ? [] : ats.filter((at) => at >= TOP);
229 const parked = next.notNow.map((_, at) => at).filter((at) => shows(next.notNow[at]!));
230 const nothingWords = filter.badPillar
231 ? "That is not a pillar: a pillar is P and digits, or Later."
232 : filter.pillar !== null && filter.pillar !== "Later" && !pillars!.pillars.some((p) => p.short === filter.pillar)
233 ? `This repository has no pillar ${filter.pillar}.`
234 : "Nothing here matches.";
235 const bar = html`<form method="get" action="${base}/next" class="form-actions" role="search">
236 <input class="input" name="pillar" type="text" maxlength="8" value="${filter.pillar ?? ""}" placeholder="Pillar: P7 or Later" aria-label="Pillar" autocomplete="off" />
237 <input class="input" name="q" type="search" maxlength="80" value="${filter.q}" placeholder="A word in the title" aria-label="Filter by word" autocomplete="off" />
238 ${btn("Filter", { type: "submit", size: "sm" })}
239 ${isFiltered ? html`<a href="${base}/next">Clear</a>` : null}
240 </form>`;
215241 const body = html`${repoHeader(ctx, repo, "issues")}
216242 <div data-next>
217243 ${pageHead("Next up", { sub: "The crew builds strictly from the top." })}
218244 <p class="error" id="next-flash" role="alert" hidden></p>
219245 ${rows.length === 0
220246 ? html`<p class="muted">Nothing is ready: everything open waits for something. <a href="${base}/map">See the map</a>.</p>`
221 : html`${section("Up next", first, 0, true, "next-list")}${after.length > 0 ? section("After", after, TOP, false) : null}`}
247 : html`${bar}${isFiltered ? html`<p class="muted" id="next-count">${countWords(ats.length, rows.length)} shown${ats.length + parked.length === 0 ? html`. ${nothingWords}` : null}. Numbers and the cut line are the whole list's; the week's capacity bar is left out while filtered.</p>` : null}
248 ${first.length > 0 ? section(isFiltered ? "Matching" : "Up next", first, true, "next-list") : null}${rest.length > 0 ? section("After", rest, false) : null}`}
222249 <p class="muted small mt" id="next-week">${sentence} <a href="${base}/week">The week</a>${writer
223250 ? " · Drag a row, or press the key on its button; j and k move between rows."
224251 : null}</p>
225 ${next.notNow.length > 0
226 ? html`<details class="mt"><summary><h2>Later · ${String(next.notNowTotal)}</h2></summary><ul class="list setting-list">
227 ${next.notNow.map((_, at) => line(base, next.notNow, at, writer, back, true))}
252 ${parked.length > 0
253 ? html`<details class="mt"><summary><h2>Later · ${String(isFiltered ? parked.length : next.notNowTotal)}</h2></summary><ul class="list setting-list">
254 ${parked.map((at) => line(base, next.notNow, at, writer, back, true))}
228255 </ul></details>`
229256 : null}
230257 ${next.unlisted > 0
src/web/pages/next_filter.ts added +60
@@ -0,0 +1,60 @@
1 /**
2 * The /next filter (#11867): `?pillar=P7|Later` and `?q=word`, both read at
3 * the boundary into plain data. A filter only hides rows; ranks, the cut line
4 * and every move stay those of the whole ranking.
5 */
6 export interface NextFilter {
7 /** `P7` or `Later`, as asked; null when none was asked. */
8 readonly pillar: string | null;
9 /** What was typed, trimmed and capped; matching ignores its case, the input shows it as typed. */
10 readonly q: string;
11 /** A pillar value that is not `P` and digits or `Later`: it matches nothing. */
12 readonly badPillar: boolean;
13 }
14
15 export const MAX_Q = 80;
16 const PILLAR_SHAPE = /^(P[0-9]{1,6}|Later)$/;
17
18 export function readFilter(params: URLSearchParams): NextFilter {
19 const asked = (params.get("pillar") ?? "").trim();

q is lowercased on read, and the page echoes it back into the input as lowercase. Keep the typed text for display; lowercase in matches().

20 const q = (params.get("q") ?? "").trim().slice(0, MAX_Q);
21 if (asked === "") return { pillar: null, q, badPillar: false };
22 if (!PILLAR_SHAPE.test(asked)) return { pillar: null, q, badPillar: true };
23 return { pillar: asked, q, badPillar: false };
24 }
25
26 export const filtered = (f: NextFilter): boolean => f.pillar !== null || f.q !== "" || f.badPillar;
27
28 /**
29 * Whether a row with this title, served by pillar name `served` (`P7 Trust`, `Later`, or none),
30 * shows.
31 */
32 export function matches(f: NextFilter, title: string, served: string | undefined): boolean {
33 if (f.badPillar) return false;
34 if (f.pillar !== null && (served === undefined || served.split(" ")[0] !== f.pillar)) return false;
35 return f.q === "" || title.toLowerCase().includes(f.q.toLowerCase());
36 }
37
38 /** The path back to this page with the filter, for a move form to return to. */
39 export function filterPath(base: string, f: NextFilter, rawPillar: string): string {
40 const parts: string[] = [];
41 if (f.pillar !== null || f.badPillar) parts.push(`pillar=${encodeURIComponent(rawPillar.trim().slice(0, 40))}`);
42 if (f.q !== "") parts.push(`q=${encodeURIComponent(f.q)}`);
43 return parts.length === 0 ? base : `${base}?${parts.join("&")}`;
44 }
45
46 /** The page's own wording for how many rows show. */
47 export function countWords(shown: number, total: number): string {
48 return `${shown} of ${total}`;
49 }
50
51 /**
52 * Where the cut line falls among the shown rows (their places in the whole
53 * ranking): before the first one at or past `cutAt`, as an index, or -1 when
54 * every shown row is on one side of it. A filter never moves the line, it only
55 * hides it when no shown row is under it.
56 */
57 export function markBefore(shown: readonly number[], cutAt: number): number {
58 const i = shown.findIndex((at) => at >= cutAt);
59 return i > 0 ? i : -1;
60 }
test/next-filter.test.ts added +130
@@ -0,0 +1,130 @@
1 /** /next narrows to one pillar or a word, keeping the owner's order and real numbers (#11867). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { openIssue } from "../src/actions/issues.ts";
8 import { rankIssue } from "../src/actions/next.ts";
9 import { createRepo } from "../src/actions/repos.ts";
10 import { createSession } from "../src/core/sessions.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { filterPath, markBefore, matches, readFilter } from "../src/web/pages/next_filter.ts";
14
15 let dir: string;
16 let app: App;
17 let servers: Servers;
18 let cookie: string;
19 const nums: Record<string, number> = {};
20 let alice: Parameters<typeof rankIssue>[1];
21 let repo: Parameters<typeof rankIssue>[2];
22
23 beforeAll(async () => {
24 dir = await mkdtemp(join(tmpdir(), "forge-next-filter-"));
25 app = createApp({ dataDir: dir, port: 0 });
26 servers = await serve(app);
27 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
28 if (!made.ok) throw new Error(made.error);
29 alice = made.value;
30 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
31 const r = await createRepo(app, alice, { name: "filt", visibility: "public" });
32 if (!r.ok) throw new Error(JSON.stringify(r.error));
33 repo = r.value;
34 const open = (title: string): number => {
35 const o = openIssue(app, alice, repo, { title });
36 if (!o.ok) throw new Error(String(o.error));
37 nums[title] = o.value.number;
38 return o.value.number;
39 };
40 const dep = (a: number, b: number) =>
41 app.db.run("INSERT INTO issue_dependency (issue_id, blocked_by_id) VALUES ((SELECT id FROM issue WHERE repo_id=?1 AND number=?2),(SELECT id FROM issue WHERE repo_id=?1 AND number=?3))", [repo.id, a, b]);
42 const u7 = open("P7 Speed: be quick");
43 const u1 = open("P1 Trust: be safe");
44 const titles = ["alpha cache", "beta cache", "gamma safe", "delta cache", "epsilon"];
45 const made5 = titles.map(open);
46 dep(u7, made5[0]!);
47 dep(u7, made5[1]!);
48 dep(u1, made5[2]!);
49 dep(u7, made5[3]!);
50 dep(u1, made5[4]!);
51 });
52
53 afterAll(async () => {
54 await servers.stop();
55 app.close();
56 await rm(dir, { recursive: true, force: true });
57 });
58
59 const get = async (qs: string) => {
60 const res = await fetch(`${servers.url}/alice/filt/next${qs}`, { headers: { cookie } });
61 return { status: res.status, text: await res.text() };
62 };
63 const numbersOf = (page: string) => [...page.matchAll(/<li[^>]*data-number="(\d+)"/g)].map((m) => Number(m[1]));
64
65 test("a word keeps the owner's order and the real numbers, and counts in words", async () => {
66 const all = numbersOf((await get("")).text);
67 const page = (await get("?q=CACHE")).text;
68 const shown = numbersOf(page);
69 const want = [nums["alpha cache"]!, nums["beta cache"]!, nums["delta cache"]!];
70 expect(shown).toEqual(all.filter((n) => want.includes(n)));
71 expect(shown.length).toBe(3);
72 expect(page).toContain(`3 of ${all.length} shown`);
73 // What was typed is kept for the input; only matching ignores case.
74 expect(page).toContain('value="CACHE"');
75 expect(page).toContain("capacity bar is left out while filtered");
76 // Rank numbers are the full ranking's, not 1, 2, 3.
77 for (const n of shown) expect(page).toContain(`>${all.indexOf(n) + 1}<`);
78 });
79
80 test("a pillar shows only what serves it", async () => {
81 const page = (await get("?pillar=P1")).text;
82 expect(numbersOf(page).sort()).toEqual([nums["gamma safe"]!, nums["epsilon"]!].sort());
83 expect(page).toContain("2 of");
84 });
85
86 test("a move under a filter carries the filter back and moves in the full ranking", async () => {
87 const page = (await get("?q=cache&pillar=P7")).text;
88 const forms = [...page.matchAll(/name="back" value="([^"]*)"/g)].map((m) => m[1]);
89 expect(forms.length).toBeGreaterThan(0);
90 for (const f of forms) expect(f).toBe("/alice/filt/next?pillar=P7&amp;q=cache");
91 const before = numbersOf((await get("")).text);
92 const target = nums["delta cache"]!;
93 const res = await fetch(`${servers.url}/alice/filt/next`, {
94 method: "POST",
95 redirect: "manual",
96 headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" },
97 body: new URLSearchParams({ number: String(target), to: "top", back: "/alice/filt/next?pillar=P7&q=cache" }),
98 });
99 expect(res.status).toBe(303);
100 expect(res.headers.get("location")).toContain("/alice/filt/next?pillar=P7&q=cache");
101 const after = numbersOf((await get("")).text);
102 expect(after[0]).toBe(target);
103 expect(after.length).toBe(before.length);
104 });
105
106 test("bad input is bounded and escaped; an unknown pillar is an empty list, not an error", async () => {
107 const unknown = await get("?pillar=P99");
108 expect(unknown.status).toBe(200);
109 expect(numbersOf(unknown.text)).toEqual([]);
110 expect(unknown.text).toContain("no pillar P99");
111 const bad = await get("?pillar=%3Cscript%3E");
112 expect(bad.status).toBe(200);
113 expect(bad.text).not.toContain("<script>alert");
114 expect(numbersOf(bad.text)).toEqual([]);
115 const xss = await get(`?q=${encodeURIComponent('"><img src=x onerror=alert(1)>')}`);
116 expect(xss.status).toBe(200);
117 expect(xss.text).not.toContain("<img src=x");
118 expect(readFilter(new URLSearchParams({ q: "x".repeat(200) })).q.length).toBe(80);
119 expect(readFilter(new URLSearchParams({ q: "CaChe" })).q).toBe("CaChe");
120 expect(matches(readFilter(new URLSearchParams({ q: "CaChe" })), "a cache", undefined)).toBe(true);
121 expect(matches(readFilter(new URLSearchParams("pillar=Later")), "t", "P1 Trust")).toBe(false);
122 expect(filterPath("/b", readFilter(new URLSearchParams("")), "")).toBe("/b");
123 });
124
125 test("the cut line stays at the whole ranking's place: before the first shown row past it, never at an edge", () => {
126 expect(markBefore([0, 3, 7, 9], 5)).toBe(2);
127 expect(markBefore([6, 7], 5)).toBe(-1); // every shown row is under it: no line to draw above them
128 expect(markBefore([0, 1], 5)).toBe(-1); // every shown row fits: no line below them
129 expect(markBefore([], 5)).toBe(-1);
130 });
test/next-up.test.ts +1 −1
@@ -368,7 +368,7 @@ describe("Next up page shape (#9764)", () => {
368368 for (const label of ["Top", "Up", "Down", "Not now"]) {
369369 expect(body).toMatch(new RegExp(`<form method="post" action="[^"]*/next"[^>]*>(?:(?!</form>).)*${label} <kbd>`));
370370 }
371 expect(body).not.toMatch(/<form(?![^>]*method="post")[^>]*action="[^"]*\/next"/);
371 expect(body).not.toMatch(/<form(?![^>]*method="(?:post|get)")[^>]*action="[^"]*\/next"/); // the filter is the one GET form
372372
373373 // Not now (?all=1) is rows under its own heading too, and Top is the one move it offers.
374374 const gate = open("Later gate: not now");
test/tui-next-up.test.ts +25
@@ -190,4 +190,29 @@ describe("next up in the terminal", () => {
190190 expect(h.text()).not.toContain("Secret work");
191191 h.stop();
192192 });
193
194 test("/ narrows to a word, ranks stay the whole list's, a move still moves in the whole order, and an empty filter clears it (#11867)", async () => {
195 const h = boot(alice);
196 await h.toNext("alice/plan");
197 const before = order();
198 expect(h.text()).toContain("Work a");
199 await h.press("/");
200 for (const ch of "WORK B") await h.press(ch);
201 await h.press("enter");
202 const text = h.text();
203 expect(text).toMatch(/1 of \d+ shown/);
204 expect(text).toContain("Work b");
205 expect(text).not.toContain("Work a");
206 expect(text).toContain(`${before.indexOf(n.b!) + 1}. #` + n.b); // its place in the whole ranking
207 // Top moves the row under the cursor in the whole order, not in the shown one.
208 await h.press("t");
209 expect(order()[0]).toBe(n.b!);
210 expect(order()).toHaveLength(before.length);
211 // Clear: empty submit.
212 await h.press("/");
213 for (let i = 0; i < 6; i++) await h.press("backspace");
214 await h.press("enter");
215 expect(h.text()).toContain("Work a");
216 h.stop();
217 });
193218 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs