Public: anyone can read this; the crew can change it.
A wave on /changelog says in plain numbers how many pulls it gathered and how many apps they touched #11351
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11334-a-wave-on-changelog-says-in-plain-number → main 6 commits fork 3 threads · 3 unresolved
10 files
changed +111
−36
docs/features/wave-walk.md +4
| @@ -22,3 +22,7 @@ When a wave's pulls closed issues under a milestone, the wave opens as its headl | ||
| 22 | 22 | 3. Esc (or `q`) on the pull returns to the wave, and again to the list. |
| 23 | 23 | |
| 24 | 24 | Only pulls the viewer may read are listed, and Enter re-checks that before opening one. The walk is tested in `test/story-walk.test.ts` and `test/tui-waves.test.ts`. |
| 25 | ||
| 26 | ## A wave's size | |
| 27 | ||
| 28 | Each wave says how big it is in plain numbers, such as `7 changes in 3 apps`: on the list row, under the headline on the wave's page, on the terminal's waves list and at the top of a wave there, and as `pulls` and `apps` on each item of `GET /api/v1/repos/<owner>/<repo>/waves`. The numbers count only the pulls you can read, once each, so a guest is not told how many private pulls a wave holds. |
src/http/api.ts +12 −2
| @@ -291,7 +291,7 @@ import { blobForRender, commitLog, listRefs, listTree, refTip } from "../git/rea | ||
| 291 | 291 | import { repoDir } from "../git/repos.ts"; |
| 292 | 292 | import { holds, weakEtag } from "./etag.ts"; |
| 293 | 293 | import { carriedPulls, pullPath } from "../story/wave_pulls.ts"; |
| 294 | import { pullRefs, readWave, readWaves } from "../story/waves.ts"; | |
| 294 | import { pullRefs, readWave, readWaves, waveSize } from "../story/waves.ts"; | |
| 295 | 295 | import { originForRepo, type OriginRepoRow } from "../core/origins.ts"; |
| 296 | 296 | import { subscribe as subscribeJob, type MirrorJob } from "../origins/jobs.ts"; |
| 297 | 297 | import type { Origin } from "../origins/types.ts"; |
| @@ -2240,10 +2240,20 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2240 | 2240 | const entries = await timePhaseAsync(ctx.request, "waves", () => |
| 2241 | 2241 | readWaves(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), repo.defaultBranch), |
| 2242 | 2242 | ); |
| 2243 | // Sized over the pulls this viewer can read, as the page does. | |
| 2243 | 2244 | return timePhase(ctx.request, "render", () => |
| 2244 | 2245 | json({ |
| 2245 | 2246 | items: entries.flatMap((e) => |
| 2246 | e.ok ? [{ version: e.wave.version, date: e.wave.date, headline: e.wave.headline }] : [], | |
| 2247 | e.ok | |
| 2248 | ? [ | |
| 2249 | { | |
| 2250 | version: e.wave.version, | |
| 2251 | date: e.wave.date, | |
| 2252 | headline: e.wave.headline, | |
| 2253 | ...waveSize(carriedPulls(ctx.app.db, pullRefs(e.wave), ctx.auth.user)), | |
| 2254 | }, | |
| 2255 | ] | |
| 2256 | : [], | |
| 2247 | 2257 | ), |
| 2248 | 2258 | next_cursor: null, |
| 2249 | 2259 | }), |
src/story/waves.ts +19
| @@ -164,6 +164,25 @@ export function pullRefs(wave: Wave): PullRef[] { | ||
| 164 | 164 | return out; |
| 165 | 165 | } |
| 166 | 166 | |
| 167 | /** | |
| 168 | * How big a wave is for one viewer: the pulls they can see, once each, and the | |
| 169 | * distinct repositories (apps) those sit in. Takes the visible pulls (what | |
| 170 | * `carriedPulls` returns), never the whole wave, so a hidden pull is not counted. | |
| 171 | */ | |
| 172 | export function waveSize(refs: readonly { owner: string; name: string }[]): { pulls: number; apps: number } { | |
| 173 | return { pulls: refs.length, apps: new Set(refs.map((r) => `${r.owner}/${r.name}`)).size }; | |
| 174 | } | |
| 175 | ||
| 176 | /** `7 changes in 3 apps`; `1 change in 1 app`. Numbers only, so nothing here needs escaping. */ | |
| 177 | export function sizeWords(size: { pulls: number; apps: number }): string { | |
| 178 | return `${size.pulls} ${size.pulls === 1 ? "change" : "changes"} in ${size.apps} ${size.apps === 1 ? "app" : "apps"}`; | |
| 179 | } | |
| 180 | ||
| 181 | /** A wave's one-line title, on the terminal's list: `<version> · <date> · <headline> · N changes in M apps`, counted over what this viewer can see. */ | |
| 182 | export function waveLine(w: Wave, size: { pulls: number; apps: number }): string { | |
| 183 | return `${w.version} · ${w.date} · ${w.headline} · ${sizeWords(size)}`; | |
| 184 | } | |
| 185 | ||
| 167 | 186 | /** Unreadable files tried before giving up on finding the newest good wave. */ |
| 168 | 187 | const LATEST_TRIES = 5; |
| 169 | 188 |
src/tui/screens/waves.ts +12 −6
| @@ -12,7 +12,7 @@ import { movedWithPulls, waveProgress, type MovedMilestone } from "../../story/w | ||
| 12 | 12 | import { milestonePath, planNow } from "../../story/wave_plan.ts"; |
| 13 | 13 | import { repoBase } from "../../web/chrome.ts"; |
| 14 | 14 | import { carriedPulls, pullPath, type CarriedPull } from "../../story/wave_pulls.ts"; |
| 15 | import { pullRefs, readWave, readWaves, type WaveEntry } from "../../story/waves.ts"; | |
| 15 | import { pullRefs, readWave, readWaves, sizeWords, waveLine, waveSize, type WaveEntry } from "../../story/waves.ts"; | |
| 16 | 16 | import { NO_WAVES } from "../../story/words.ts"; |
| 17 | 17 | import type { RepoSource } from "../../view/source.ts"; |
| 18 | 18 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -26,12 +26,12 @@ import { ListState, Scroll, confirmScreen, drawList, drawLines, hitList, plural, | ||
| 26 | 26 | import { pullScreen } from "./pull.ts"; |
| 27 | 27 | import { local, localRepo, sourceName, whereRows, whereStrip } from "./where.ts"; |
| 28 | 28 | |
| 29 | /** One line per wave, as the screen draws it. */ | |
| 30 | export function waveRows(entries: WaveEntry[]) { | |
| 29 | /** One line per wave, as the screen draws it, sized as the page sizes it: `sizeOf` counts the pulls this viewer can see. */ | |
| 30 | export function waveRows(entries: WaveEntry[], sizeOf: (e: WaveEntry & { ok: true }) => { pulls: number; apps: number }) { | |
| 31 | 31 | return entries.map((e) => |
| 32 | 32 | e.ok |
| 33 | 33 | ? { |
| 34 | title: `${e.wave.version} · ${e.wave.date} · ${e.wave.headline}`, | |
| 34 | title: plain(waveLine(e.wave, sizeOf(e))), | |
| 35 | 35 | secondary: "", |
| 36 | 36 | meta: "", |
| 37 | 37 | } |
| @@ -59,7 +59,8 @@ export function waveLines( | ||
| 59 | 59 | const w = entry.wave; |
| 60 | 60 | return [ |
| 61 | 61 | plain(w.headline), |
| 62 | `${plain(w.version)} ${plain(w.date)}`, | |
| 62 | // Sized over `pulls`, the ones this viewer may see, as the page's header is. | |
| 63 | `${plain(w.version)} ${plain(w.date)} ${sizeWords(waveSize(pulls))}`, | |
| 63 | 64 | ...(moved.length === 0 ? [] : [plain(waveProgress(moved))]), |
| 64 | 65 | "", |
| 65 | 66 | "Highlights", |
| @@ -157,6 +158,8 @@ function waveScreen( | ||
| 157 | 158 | export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { |
| 158 | 159 | const repo = localRepo(src); |
| 159 | 160 | let entries: WaveEntry[] = []; |
| 161 | /** Each wave's size by file, counted once per load with the page's gate on each pull. */ | |
| 162 | let sizes = new Map<string, { pulls: number; apps: number }>(); | |
| 160 | 163 | let loaded = false; |
| 161 | 164 | const state = new ListState(); |
| 162 | 165 | |
| @@ -194,6 +197,9 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { | ||
| 194 | 197 | repo !== undefined && canRead(f.db, repo, f.user) |
| 195 | 198 | ? await readWaves(f.dir(repo), repo.defaultBranch) |
| 196 | 199 | : []; |
| 200 | sizes = new Map( | |
| 201 | entries.flatMap((e) => (e.ok ? [[e.file, waveSize(carriedPulls(f.db, pullRefs(e.wave), f.user))] as const] : [])), | |
| 202 | ); | |
| 197 | 203 | loaded = true; |
| 198 | 204 | ctx.invalidate(); |
| 199 | 205 | }; |
| @@ -279,7 +285,7 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { | ||
| 279 | 285 | }); |
| 280 | 286 | return; |
| 281 | 287 | } |
| 282 | const rows = waveRows(entries); | |
| 288 | const rows = waveRows(entries, (e) => sizes.get(e.file) ?? { pulls: 0, apps: 0 }); | |
| 283 | 289 | drawList(s, body!, c, state, rows); |
| 284 | 290 | hitList(ctx, body!, state, rows.length, { open: openAt }); |
| 285 | 291 | }, |
src/web/pages/waves.ts +5 −10
| @@ -16,7 +16,7 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 16 | 16 | import { VERSION, type Wave } from "../../story/wave.ts"; |
| 17 | 17 | import { movedWithPulls, waveProgress } from "../../story/wave_moved.ts"; |
| 18 | 18 | import { carriedPulls, pullPath } from "../../story/wave_pulls.ts"; |
| 19 | import { fromWave, pullRefs, readWave, readWaves } from "../../story/waves.ts"; | |
| 19 | import { fromWave, pullRefs, readWave, readWaves, sizeWords, waveSize } from "../../story/waves.ts"; | |
| 20 | 20 | import { NO_WAVES } from "../../story/words.ts"; |
| 21 | 21 | import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts"; |
| 22 | 22 | import { escapeHtml, html } from "../html.ts"; |
| @@ -37,20 +37,15 @@ export const WAVES_CSS = `[data-waves] { overflow-wrap: anywhere; } | ||
| 37 | 37 | const WHAT_A_WAVE = |
| 38 | 38 | "A wave is a release note in waves/<version>.md that names its highlights and the pulls merged since the last one."; |
| 39 | 39 | |
| 40 | /** A wave's row title: `<version> · <date> · <headline> · N pulls`, N being the pulls this viewer can see. */ | |
| 41 | export function waveLine(w: Wave, visible: number): string { | |
| 42 | return `${w.version} · ${w.date} · ${w.headline} · ${visible} ${visible === 1 ? "pull" : "pulls"}`; | |
| 43 | } | |
| 44 | ||
| 45 | 40 | /** One wave on the list: its headline, the first highlight's why as the one sentence under it, and version, date and pull count as a muted trail. */ |
| 46 | export function waveRow(w: Wave, href: string, visible: number) { | |
| 41 | export function waveRow(w: Wave, href: string, size: { pulls: number; apps: number }) { | |
| 47 | 42 | const why = w.highlights[0]?.why; |
| 48 | 43 | return row({ |
| 49 | 44 | href, |
| 50 | 45 | nav: true, |
| 51 | 46 | title: w.headline, |
| 52 | 47 | ...(why === undefined || why === "" ? {} : { meta: html`${why}` }), |
| 53 | trail: html`<span class="muted small">${w.version} · ${w.date} · ${visible} ${visible === 1 ? "pull" : "pulls"}</span>`, | |
| 48 | trail: html`<span class="muted small">${w.version} · ${w.date} · <span data-wave-size>${sizeWords(size)}</span></span>`, | |
| 54 | 49 | }); |
| 55 | 50 | } |
| 56 | 51 | |
| @@ -76,7 +71,7 @@ export async function wavesPage(ctx: Ctx): Promise<Response> { | ||
| 76 | 71 | e.wave, |
| 77 | 72 | fileHref(e.file), |
| 78 | 73 | // The wave's own page lists these same pulls, so the two agree. |
| 79 | carriedPulls(ctx.app.db, pullRefs(e.wave), ctx.auth.user).length, | |
| 74 | waveSize(carriedPulls(ctx.app.db, pullRefs(e.wave), ctx.auth.user)), | |
| 80 | 75 | ) |
| 81 | 76 | : row({ |
| 82 | 77 | href: fileHref(e.file), |
| @@ -232,7 +227,7 @@ export async function wavePage(ctx: Ctx): Promise<Response> { | ||
| 232 | 227 | |
| 233 | 228 | const content = entry.ok |
| 234 | 229 | ? html`<h1>${entry.wave.headline}</h1> |
| 235 | <p class="muted">${entry.wave.version} · ${entry.wave.date}</p> | |
| 230 | <p class="muted">${entry.wave.version} · ${entry.wave.date} · <span data-wave-size>${sizeWords(waveSize(carried))}</span></p> | |
| 236 | 231 | ${line === "" |
| 237 | 232 | ? "" |
| 238 | 233 | : html`<p class="muted" data-wave-progress>${line}</p>`} |
test/api-waves.test.ts +9 −2
| @@ -123,8 +123,8 @@ test("the list is newest first by version, not by name", async () => { | ||
| 123 | 123 | expect(got.status).toBe(200); |
| 124 | 124 | expect(JSON.parse(got.body)).toEqual({ |
| 125 | 125 | items: [ |
| 126 | { version: "0.10.0", date: "2026-02-01", headline: "Tenth" }, | |
| 127 | { version: "0.9.0", date: "2026-01-01", headline: "Ninth" }, | |
| 126 | { version: "0.10.0", date: "2026-02-01", headline: "Tenth", pulls: 0, apps: 0 }, | |
| 127 | { version: "0.9.0", date: "2026-01-01", headline: "Ninth", pulls: 0, apps: 0 }, | |
| 128 | 128 | ], |
| 129 | 129 | next_cursor: null, |
| 130 | 130 | }); |
| @@ -151,6 +151,13 @@ test("a wave's pulls are the page's: the owner sees the private one, an outsider | ||
| 151 | 151 | expect(outsider.body).not.toContain("/alice/hid/pulls/"); |
| 152 | 152 | }); |
| 153 | 153 | |
| 154 | test("the list sizes a wave over the pulls the viewer can see, so hidden ones and apps are not counted", async () => { | |
| 155 | const sizeOf = async (cookie?: string) => | |
| 156 | JSON.parse((await get("/api/v1/repos/alice/mix/waves", cookie)).body).items.find((i: { version: string }) => i.version === "0.1.0"); | |
| 157 | expect(await sizeOf()).toMatchObject({ pulls: 2, apps: 2 }); | |
| 158 | expect(await sizeOf(bobCookie)).toMatchObject({ pulls: 1, apps: 1 }); | |
| 159 | }); | |
| 160 | ||
| 154 | 161 | test("an unknown or malformed version is 404", async () => { |
| 155 | 162 | for (const v of ["9.9.9", "nope", "1.2", "..%2Fx"]) |
| 156 | 163 | expect((await get(`/api/v1/repos/alice/pub/waves/${v}`)).status).toBe(404); |
test/looks-owner.test.ts +1 −1
| @@ -341,7 +341,7 @@ describe("the forge's own owner looks, over the preview seed", () => { | ||
| 341 | 341 | const waves = await asOwner(`${repoHome}/waves`); |
| 342 | 342 | expect(waves).toContain(PREVIEW_WAVE); |
| 343 | 343 | expect(waves).toContain(PREVIEW_WAVE_HEADLINE); |
| 344 | expect(waves).toContain("1 pull"); | |
| 344 | expect(waves).toContain("1 change in 1 app"); | |
| 345 | 345 | const milestones = await asOwner(`${repoHome}/milestones`); |
| 346 | 346 | expect(milestones).toContain("The home page says hello"); |
| 347 | 347 | expect(milestones).toContain("2 of 3 done"); |
test/tui-waves.test.ts +18 −1
| @@ -170,7 +170,7 @@ describe("waves in the terminal", () => { | ||
| 170 | 170 | const h = await open(alice, pub, ["enter"]); |
| 171 | 171 | h.stop(); |
| 172 | 172 | expect(h.text).toContain("Tenth"); |
| 173 | expect(h.text).toContain("0.10.0 2026-02-01"); | |
| 173 | expect(h.text).toContain("0.10.0 2026-02-01 0 changes in 0 apps"); | |
| 174 | 174 | expect(h.text).toContain("Thing 1 - because"); |
| 175 | 175 | expect(h.text).toContain("No pulls to show here."); |
| 176 | 176 | }); |
| @@ -188,6 +188,23 @@ describe("waves in the terminal", () => { | ||
| 188 | 188 | expect(outsider.text).not.toContain("/alice/priv/pulls/"); |
| 189 | 189 | }); |
| 190 | 190 | |
| 191 | test("a wave's size counts only the pulls the viewer can see, on the list row and the wave's header", async () => { | |
| 192 | const ownerList = await open(alice, mix); | |
| 193 | ownerList.stop(); | |
| 194 | expect(ownerList.text).toContain("0.1.0 · 2026-03-05 · Mixed · 2 changes in 2 apps"); | |
| 195 | const owner = await open(alice, mix, ["enter"]); | |
| 196 | owner.stop(); | |
| 197 | expect(owner.text).toContain("0.1.0 2026-03-05 2 changes in 2 apps"); | |
| 198 | const outsider = await open(bob, mix, ["enter"]); | |
| 199 | outsider.stop(); | |
| 200 | expect(outsider.text).toContain("1 change in 1 app"); | |
| 201 | expect(outsider.text).not.toContain("2 changes"); | |
| 202 | // The list row, before Enter, says the same. | |
| 203 | const list = await open(bob, mix); | |
| 204 | list.stop(); | |
| 205 | expect(list.text).toContain("0.1.0 · 2026-03-05 · Mixed · 1 change in 1 app"); | |
| 206 | }); | |
| 207 | ||
| 191 | 208 | test("the wave leads to its pulls and back: pick, open the pull, back returns to the wave", async () => { |
| 192 | 209 | const h = await open(alice, mix, ["enter", "n", "enter", "q"]); |
| 193 | 210 | h.stop(); |
test/waves-line-date.test.ts +8 −7
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { waveLine } from "../src/web/pages/waves.ts"; | |
| 2 | import { waveLine } from "../src/story/waves.ts"; | |
| 3 | 3 | import { waveRows } from "../src/tui/screens/waves.ts"; |
| 4 | 4 | import type { Wave } from "../src/story/wave.ts"; |
| 5 | 5 | |
| @@ -12,13 +12,14 @@ const wave = (version: string, date: string, headline: string): Wave => ({ | ||
| 12 | 12 | }); |
| 13 | 13 | |
| 14 | 14 | test("waveLine puts the date right after the version", () => { |
| 15 | expect(waveLine(wave("0.7.12", "2026-10-20", "Headline"), 4)).toBe( | |
| 16 | "0.7.12 · 2026-10-20 · Headline · 4 pulls", | |
| 15 | expect(waveLine(wave("0.7.12", "2026-10-20", "Headline"), { pulls: 4, apps: 2 })).toBe( | |
| 16 | "0.7.12 · 2026-10-20 · Headline · 4 changes in 2 apps", | |
| 17 | 17 | ); |
| 18 | expect(waveLine(wave("0.7.12", "2026-10-20", "Headline"), 1)).toContain("1 pull"); | |
| 18 | expect(waveLine(wave("0.7.12", "2026-10-20", "Headline"), { pulls: 1, apps: 1 })).toContain("1 change in 1 app"); | |
| 19 | 19 | }); |
| 20 | 20 | |
| 21 | test("the terminal's row says the same line", () => { | |
| 22 | const rows = waveRows([{ ok: true, file: "0.7.12.md", wave: wave("0.7.12", "2026-10-20", "Headline") }]); | |
| 23 | expect(rows[0]!.title).toBe("0.7.12 · 2026-10-20 · Headline"); | |
| 21 | test("the terminal's row says the same line, size and all", () => { | |
| 22 | const entry = { ok: true as const, file: "0.7.12.md", wave: wave("0.7.12", "2026-10-20", "Headline") }; | |
| 23 | expect(waveRows([entry], () => ({ pulls: 7, apps: 3 }))[0]!.title).toBe("0.7.12 · 2026-10-20 · Headline · 7 changes in 3 apps"); | |
| 24 | expect(waveRows([entry], () => ({ pulls: 1, apps: 1 }))[0]!.title).toBe("0.7.12 · 2026-10-20 · Headline · 1 change in 1 app"); | |
| 24 | 25 | }); |
test/waves-page.test.ts +23 −7
| @@ -11,6 +11,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 11 | 11 | import { mergedPull, waveNaming } from "./helpers/wave-pulls.ts"; |
| 12 | 12 | import { waveRow } from "../src/web/pages/waves.ts"; |
| 13 | 13 | import { writeWave, type Wave } from "../src/story/wave.ts"; |
| 14 | import { pullRefs, sizeWords, waveSize } from "../src/story/waves.ts"; | |
| 14 | 15 | |
| 15 | 16 | const PASSWORD = "a-good-passphrase"; |
| 16 | 17 | |
| @@ -106,8 +107,8 @@ describe("waves page", () => { | ||
| 106 | 107 | expect(at("Tenth")).toBeGreaterThan(-1); |
| 107 | 108 | expect(at("Tenth")).toBeLessThan(at("Ninth")); |
| 108 | 109 | expect(at("Ninth")).toBeLessThan(at("Zeta")); |
| 109 | expect(body).toContain("0.10.0 · 2026-02-01 · 0 pulls"); | |
| 110 | expect(body).toContain("0.9.0 · 2026-01-01 · 0 pulls"); | |
| 110 | expect(body).toContain("0.10.0 · 2026-02-01 · <span data-wave-size>0 changes in 0 apps<"); | |
| 111 | expect(body).toContain("0.9.0 · 2026-01-01 · <span data-wave-size>0 changes in 0 apps<"); | |
| 111 | 112 | expect(at("0.10.0 · 2026-02-01")).toBeLessThan(at("0.9.0 · 2026-01-01")); |
| 112 | 113 | expect(body).toContain('href="/alice/pub/waves/0.10.0"'); |
| 113 | 114 | }); |
| @@ -227,13 +228,15 @@ describe("waves page", () => { | ||
| 227 | 228 | |
| 228 | 229 | test("a row counts the pulls this viewer can see, as the wave's page lists them", async () => { |
| 229 | 230 | const body = await (await get("/alice/pub/waves")).text(); |
| 230 | expect(body).toContain("0.1.0 · 2026-03-05 · 2 pulls"); | |
| 231 | expect(body).toContain("0.1.0 · 2026-03-05 · <span data-wave-size>2 changes in 2 apps<"); | |
| 231 | 232 | const guest = await (await get("/alice/pub/waves", false)).text(); |
| 232 | expect(guest).toContain("0.1.0 · 2026-03-05 · 1 pull<"); | |
| 233 | expect(guest).not.toContain("2 pulls"); | |
| 233 | expect(guest).toContain("0.1.0 · 2026-03-05 · <span data-wave-size>1 change in 1 app<"); | |
| 234 | expect(guest).not.toContain("2 changes"); | |
| 235 | expect(guest).not.toMatch(/\d+ pulls?</); | |
| 234 | 236 | expect(guest).not.toContain("Secret work"); |
| 235 | 237 | const page = await (await get("/alice/pub/waves/0.1.0", false)).text(); |
| 236 | 238 | expect(page).toContain("Shown work"); |
| 239 | expect(page).toContain("data-wave-size>1 change in 1 app<"); | |
| 237 | 240 | expect(page).not.toContain("Secret work"); |
| 238 | 241 | }); |
| 239 | 242 | |
| @@ -248,10 +251,10 @@ describe("waves page", () => { | ||
| 248 | 251 | |
| 249 | 252 | test("a wave whose first why is empty has a row with no sentence and no stray separator", () => { |
| 250 | 253 | const w = { ...wave("0.9.0", "2026-01-01", "Quiet"), highlights: [{ title: "T", why: "" }] }; |
| 251 | const r = waveRow(w, "/x", 1).text; | |
| 254 | const r = waveRow(w, "/x", { pulls: 1, apps: 1 }).text; | |
| 252 | 255 | expect(r).toContain("Quiet"); |
| 253 | 256 | expect(r).not.toContain("row-meta"); |
| 254 | expect(r).toContain("0.9.0 · 2026-01-01 · 1 pull"); | |
| 257 | expect(r).toContain("0.9.0 · 2026-01-01 · <span data-wave-size>1 change in 1 app"); | |
| 255 | 258 | expect(r).not.toMatch(/·\s*·/); |
| 256 | 259 | }); |
| 257 | 260 | |
| @@ -287,3 +290,16 @@ describe("waves page", () => { | ||
| 287 | 290 | expect(body).toContain("application/atom+xml"); |
| 288 | 291 | }); |
| 289 | 292 | }); |
| 293 | ||
| 294 | describe("a wave says its size in plain numbers", () => { | |
| 295 | const url = (n: number, repo = "r") => ({ title: `#${n} P`, url: `https://forge.example/o/${repo}/pulls/${n}` }); | |
| 296 | test("plural, singular, apps counted once each, a repeated pull once", () => { | |
| 297 | const w = { ...wave("1.0.0", "2026-01-01", "Big"), pulls: [url(1), url(2), url(2), url(3, "s"), url(4, "t")] }; | |
| 298 | expect(sizeWords(waveSize(pullRefs(w)))).toBe("4 changes in 3 apps"); | |
| 299 | const one = { ...wave("1.0.1", "2026-01-01", "Small"), pulls: [url(1)] }; | |
| 300 | expect(waveSize(pullRefs(one))).toEqual({ pulls: 1, apps: 1 }); | |
| 301 | expect(sizeWords(waveSize(pullRefs(one)))).toBe("1 change in 1 app"); | |
| 302 | expect(waveRow(w, "/x", waveSize(pullRefs(w))).text).toContain("4 changes in 3 apps"); | |
| 303 | expect(waveRow(one, "/x", waveSize(pullRefs(one))).text).toContain("1 change in 1 app"); | |
| 304 | }); | |
| 305 | }); |
waveSize takes the whole wave, so it cannot honour visibility. Take the visible refs instead, and the API must filter the same way for the viewer.