Public: anyone can read this; the crew can change it.
Home tells the truth in few words: figures agree with their sources, blurbs become lines #10652
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10440-home-tells-the-truth-in-few-words-figure → main 4 commits fork 6 threads · 6 unresolved
docs/features/living-home.md +9
| @@ -17,3 +17,12 @@ The feed — the composer, the timeline, what is on you, since you were here — | ||
| 17 | 17 | Left out on purpose: the waiting-on-you line, the clog, vitals and keeper lines (the boards and the Flow dialog still show them), and "slowest right now" (it needs a timing read the home does not have). The box posts to `/tell` today; it becomes the `/talk` entry (#7652) when that lands. |
| 18 | 18 | |
| 19 | 19 | The two scans behind the lists are read once per write (`writeStamp`), so a warm home reads no tables for them. |
| 20 | ||
| 21 | ## The figures tell the truth (#10440) | |
| 22 | ||
| 23 | - **Slots** in "Builders running" are `crew.concurrency` (`crewConcurrency` in `src/flow/slot_cap.ts`), or the repositories' slots when those are fewer: the sum of the repositories' slots is a ceiling, not a limit. Home and the terminal read the same figure. | |
| 24 | - **"Nothing ready"** is said only when the repository's map has nothing ready (`/api/v1/repos/:owner/:name/map`); `test/home-builders-idle.test.ts` checks home against that answer. | |
| 25 | - **Pace line:** "Behind pace" only when the used percent is below the elapsed percent; otherwise "Under the allowed burn". | |
| 26 | - **Just shipped** titles are cut at a word (`shortTitle`); the row's hover and screen-reader title carry the whole one. | |
| 27 | - **Dropped on purpose** (the issue named them: no decision uses them): the Traffic, Reporting browsers and Accounts tiles. The same numbers stay on `/settings/traffic`, `/boards/reach` and the KPI probe. | |
| 28 | - A stuck approved pull is one line ("… waits for a person to merge it."); the wall it hit is on the pull. |
src/crew/idle_reason.ts +5 −2
| @@ -19,7 +19,7 @@ import { standings } from "../core/standing.ts"; | ||
| 19 | 19 | import { grantedAccounts } from "../core/subscriptions.ts"; |
| 20 | 20 | import { slottedRuns, spentToday } from "./autonomy.ts"; |
| 21 | 21 | import { atCeiling } from "./dispatch.ts"; |
| 22 | import { heldNow } from "./held.ts"; | |
| 22 | import { heldNow, HOLDS } from "./held.ts"; | |
| 23 | 23 | import { pace } from "./pacing.ts"; |
| 24 | 24 | |
| 25 | 25 | export interface IdleReason { |
| @@ -54,7 +54,10 @@ export function idleReason(app: App, now: Date): IdleReason | null { | ||
| 54 | 54 | } |
| 55 | 55 | |
| 56 | 56 | const held = heldNow(app, repo); |
| 57 | return held.startable.length === 0 ? { word: "nothing ready", href: "/map" } : null; | |
| 57 | if (held.startable.length > 0) return null; | |
| 58 | // "Nothing ready" is true only when the map's ready list is empty; otherwise the ready issues are all held. | |
| 59 | const ready = HOLDS.reduce((n, h) => n + held.by[h].length, 0); | |
| 60 | return { word: ready === 0 ? "nothing ready" : "none startable", href: "/map" }; | |
| 58 | 61 | } |
| 59 | 62 | return null; |
| 60 | 63 | } |
src/crew/week.ts +2 −1
| @@ -149,6 +149,7 @@ export function weekDecision(input: WeekInput): WeekDecision | null { | ||
| 149 | 149 | ? `under a point over the last ${WEEK.burnHours} h (the meter moves in whole points and the crew ran)` |
| 150 | 150 | : `${pct(burn)} an hour over the last ${WEEK.burnHours} h`; |
| 151 | 151 | // `full`: the free percent and run-out time are said here; otherwise the crew line above says them. |
| 152 | const behindLabel = used < (1 - left / WINDOW_MS.week) * 100 ? "Behind pace" : "Under the allowed burn"; | |
| 152 | 153 | const say = (full: boolean): string => { |
| 153 | 154 | const weekLeft = full ? `${Math.round(meter.freePct)}% of the week left with ${timeGone}` : `the week has ${timeGone}`; |
| 154 | 155 | const runOut = full && runsOutAt !== null ? `, running out about ${utc(runsOutAt)}` : ""; |
| @@ -158,7 +159,7 @@ export function weekDecision(input: WeekInput): WeekDecision | null { | ||
| 158 | 159 | ? `On pace: ${weekLeft}; burning ${burnWords}, ${pct(allowed)} is allowed.` |
| 159 | 160 | : status === "ahead" |
| 160 | 161 | ? `Ahead of pace: ${weekLeft}, burning ${burnWords} against ${pct(allowed)} allowed${runOut}; ${slots === 0 ? "holding the crew for the reset" : `slowing to ${slots} of ${ceiling} slots`}.` |
| 161 | : `Behind pace: ${weekLeft}, burning ${burnWords} against ${pct(allowed)} allowed; ${slots === 0 ? "holding the crew for the reset" : `speeding up to ${slots} of ${ceiling} slots`}${release ? " and releasing the next issue" : ""}.`; | |
| 162 | : `${behindLabel}: ${weekLeft}, burning ${burnWords} against ${pct(allowed)} allowed; ${slots === 0 ? "holding the crew for the reset" : `speeding up to ${slots} of ${ceiling} slots`}${release ? " and releasing the next issue" : ""}.`; | |
| 162 | 163 | }; |
| 163 | 164 | return { status, targetPctPerHour, allowedPctPerHour: allowed, runsOutAt, slots, release, sentence: say(true), besideCrewLine: say(false) }; |
| 164 | 165 | } |
src/flow/alarm_stuck.ts +1 −1
| @@ -140,7 +140,7 @@ export function alarmStuck(from: App | Database, now: Date, scope: StuckScope = | ||
| 140 | 140 | repo, |
| 141 | 141 | number, |
| 142 | 142 | wall, |
| 143 | says: `${repo}#${number} is approved and waits for a person: ${wall.join("; ")}. Allow it (merge) or send it back.`, | |
| 143 | says: `${repo}#${number} is approved and waits for a person to merge it.`, | |
| 144 | 144 | }; |
| 145 | 145 | return { |
| 146 | 146 | fired: true, |
src/flow/kpis.ts +3 −1
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | import { backupVerdict, isWatched, type BackupVerdict } from "../snapshot/bucket.ts"; |
| 20 | 20 | import { lastDrill } from "../snapshot/drills.ts"; |
| 21 | 21 | import type { Database } from "bun:sqlite"; |
| 22 | import { crewConcurrency, shownSlots } from "./slot_cap.ts"; | |
| 22 | 23 | import { spentTodayEverywhere } from "../crew/autonomy.ts"; |
| 23 | 24 | import { ceilingHitAt, ceilingReached } from "./ceiling.ts"; |
| 24 | 25 | import type { IdleReason } from "../crew/idle_reason.ts"; |
| @@ -78,7 +79,8 @@ export function readBuilders(db: Database, idle: IdleReason | null = null): Kpis | ||
| 78 | 79 | (SELECT COALESCE(SUM(autonomy_slots), 0) FROM repo WHERE autonomy != 'off') AS slots`, |
| 79 | 80 | ) |
| 80 | 81 | .get() ?? { running: 0, slots: 0 }; |
| 81 | return { ...counts, idle }; | |
| 82 | const slots = shownSlots(counts.slots, crewConcurrency(db)); | |
outdated
Hand-rolled IIFE with a try/catch that swallows every error and returns NaN. Catching 'no settings table' this way hides real failures. Read the value through one named helper that returns a number or null, with no exception for control flow. Put the min in |
||
| 83 | return { ...counts, slots, idle }; | |
| 82 | 84 | } |
| 83 | 85 | |
| 84 | 86 | /** |
src/flow/moving.ts +16 −1
| @@ -15,6 +15,8 @@ import type { PipelineStage } from "./pipeline.ts"; | ||
| 15 | 15 | export interface MovingRow { |
| 16 | 16 | readonly who: string; |
| 17 | 17 | readonly title: string; |
| 18 | /** The title whole, for a hover or a screen reader: cutting `title` loses nothing. */ | |
| 19 | readonly full: string; | |
| 18 | 20 | readonly href: string; |
| 19 | 21 | readonly at: string; |
| 20 | 22 | /** `owner/name`, for the row's title attribute. */ |
| @@ -22,6 +24,18 @@ export interface MovingRow { | ||
| 22 | 24 | } |
| 23 | 25 | |
| 24 | 26 | export const MOVING_ROWS = 3; |
| 27 | /** Longest title a home row shows. */ | |
| 28 | export const TITLE_MAX = 60; | |
| 29 | ||
| 30 | /** A title cut at a word boundary, with an ellipsis; whole when it fits. */ | |
| 31 | export function shortTitle(title: string, max = TITLE_MAX): string { | |
| 32 | const t = title.replace(/\s+/g, " ").trim(); | |
| 33 | if (t.length <= max) return t; | |
| 34 | const cut = t.slice(0, max); | |
| 35 | const at = cut.lastIndexOf(" "); | |
outdated
The regex strips a trailing |
||
| 36 | // One long unbroken token has no boundary to cut at: it is cut at the limit. | |
| 37 | return `${(at > max / 2 ? cut.slice(0, at) : cut).replace(/[\s:,;.\-–]+$/, "")}…`; | |
| 38 | } | |
| 25 | 39 | /** Rows read before the viewer's access filters them; bounded, so the read is. */ |
| 26 | 40 | const SCAN = 40; |
| 27 | 41 | |
| @@ -66,7 +80,8 @@ function visible(db: Database, viewer: User, raw: readonly Raw[], path: string, | ||
| 66 | 80 | if (repo === undefined) continue; |
| 67 | 81 | out.push({ |
| 68 | 82 | who: r.who ?? "", |
| 69 | title: r.title, | |
| 83 | title: shortTitle(r.title), | |
| 84 | full: r.title.replace(/\s+/g, " ").trim(), | |
| 70 | 85 | href: `/${repo.ownerName}/${repo.name}/${r.kind === undefined ? path : r.kind === "pull" ? "pulls" : "issues"}/${r.number}`, |
| 71 | 86 | at: r.at, |
| 72 | 87 | repo: `${repo.ownerName}/${repo.name}`, |
src/flow/slot_cap.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | /** | |
| 2 | * How many builders the crew may run at once, as home and the terminal say it: | |
| 3 | * the repositories' slots add up to a ceiling, `crew.concurrency` is the limit. | |
| 4 | */ | |
| 5 | import type { Database } from "bun:sqlite"; | |
| 6 | import { capacityValue } from "../core/capacity.ts"; | |
| 7 | ||
| 8 | /** The crew's concurrency now, or null when the setting holds no positive whole number. */ | |
| 9 | export function crewConcurrency(db: Database): number | null { | |
| 10 | // A database that never had settings (a bare fixture) has no limit to read. | |
| 11 | if (db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'forge_setting'").get() === null) return null; | |
| 12 | const n = Number(capacityValue(db, "crew.concurrency")); | |
| 13 | return Number.isInteger(n) && n > 0 ? n : null; | |
| 14 | } | |
| 15 | ||
| 16 | /** The slots to show: the repositories' sum, never more than the crew runs at once. */ | |
| 17 | export function shownSlots(sum: number, cap: number | null): number { | |
| 18 | return cap === null ? sum : Math.min(sum, cap); | |
| 19 | } |
src/web/studio/kpi_row.ts +1 −19
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import type { Database } from "bun:sqlite"; |
| 14 | import { KPI_COUNTS, backupFull, backupOverdue, backupValue, buildersValue, checkTime, reactNote, shippedValue, speedWords, spendCeilingWords, spendFull, spendValue, trafficValue, trafficWords, usersValue, visitorsNote, visitorsValue, waitingWords } from "../../flow/kpi_words.ts"; | |
| 14 | import { KPI_COUNTS, backupFull, backupOverdue, backupValue, buildersValue, checkTime, reactNote, shippedValue, speedWords, spendCeilingWords, spendFull, spendValue, waitingWords } from "../../flow/kpi_words.ts"; | |
| 15 | 15 | import { perfRecord } from "../../perf/live.ts"; |
| 16 | 16 | import type { TrafficClass, TrafficMinute, TrafficTotals } from "../../perf/traffic.ts"; |
| 17 | 17 | import type { IdleReason } from "../../crew/idle_reason.ts"; |
| @@ -80,11 +80,6 @@ export function sparkline(hours: readonly number[]): Html { | ||
| 80 | 80 | return spark([...hours].reverse(), "Shipped each hour over the last 24 hours"); |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | /** Distinct visitors per day, oldest first, today on the right. */ | |
| 84 | export function visitorsSpark(days: readonly number[]): Html { | |
| 85 | return spark(days, `Distinct visitors each day over the last ${days.length} days`); | |
| 86 | } | |
| 87 | ||
| 88 | 83 | /** Lightest first, stacked from the bottom: the strangers sit on top and read darkest. */ |
| 89 | 84 | export const TRAFFIC_TONES: readonly (readonly [TrafficClass, string])[] = [ |
| 90 | 85 | ["internal", "0.12"], |
| @@ -124,7 +119,6 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now | ||
| 124 | 119 | const overdue = k.backup ? k.backup.state === "red" : backupOverdue(k.backupAt, now); |
| 125 | 120 | const spendWords = spendCeilingWords(k.spend); |
| 126 | 121 | const rate = shippedValue(k.landed, k.landedHours.length); |
| 127 | const trafficSays = traffic === null ? null : trafficWords(traffic); | |
| 128 | 122 | return html`<section class="stats kpi-row mb" aria-label="The forge's figures, each over its own window; hover one for what it counts"> |
| 129 | 123 | ${tile( |
| 130 | 124 | "Shipped per hour", |
| @@ -189,18 +183,6 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now | ||
| 189 | 183 | null, |
| 190 | 184 | k.bugs.overdue > 0 ? "attn" : null, |
| 191 | 185 | )} |
| 192 | ${traffic === null || trafficSays === null ? null : tile( | |
| 193 | "Traffic, 15 min", | |
| 194 | "/settings/traffic", | |
| 195 | trafficValue(traffic), | |
| 196 | `${traffic.requests} requests, ${traffic.refused} refused`, | |
| 197 | trafficSays.note, | |
| 198 | trafficSays.title, | |
| 199 | trafficColumns(traffic.minutes, W, H, "Answering time each minute over the last 15 minutes, by who asked"), | |
| 200 | trafficSays.attn ? "attn" : null, | |
| 201 | )} | |
| 202 | ${tile("Reporting browsers", "/boards/reach", visitorsValue(k.reach), `${visitorsValue(k.reach)}, ${visitorsNote(k.reach)}`, visitorsNote(k.reach), KPI_COUNTS.visitors, visitorsSpark(k.reach.visitorDays))} | |
Dead code: |
||
| 203 | ${tile("Accounts", "/boards/reach", usersValue(k.reach), usersValue(k.reach), "seen today", KPI_COUNTS.accounts)} | |
| 204 | 186 | ${tile( |
| 205 | 187 | "Reported page speed", |
| 206 | 188 | "/boards/reach", |
src/web/studio/living.ts +1 −1
| @@ -59,7 +59,7 @@ function listOf(label: string, rows: readonly MovingRow[], empty: string, headin | ||
| 59 | 59 | row({ |
| 60 | 60 | href: r.href, |
| 61 | 61 | ...(r.who === "" ? {} : { lead: html`<span class="muted small">${r.who}</span>` }), |
| 62 | title: html`<span class="truncate" title="${r.repo}">${r.title}</span>`, | |
| 62 | title: html`<span class="truncate" title="${r.repo}: ${r.full}">${r.title}</span>`, | |
| 63 | 63 | trail: html`<span class="muted small">${timeAgo(r.at)}</span>`, |
| 64 | 64 | }), |
| 65 | 65 | ); |
test/flow-alarm-stuck.test.ts +2 −1
| @@ -99,7 +99,8 @@ describe("alarmStuck", () => { | ||
| 99 | 99 | wall("h"); |
| 100 | 100 | const a = alarmStuck(app, NOW); |
| 101 | 101 | expect(a.wall).toEqual(["anyone signed out could read pack records"]); |
| 102 | expect(a.says).toContain("waits for a person: anyone signed out could read pack records"); | |
| 102 | expect(a.says).toBe(`${a.repo}#${a.number} is approved and waits for a person to merge it.`); | |
| 103 | expect(a.says.length).toBeLessThan(80); | |
| 103 | 104 | }); |
| 104 | 105 | |
| 105 | 106 | test("an event 29 minutes ago does not fire", () => { |
test/home-builders-idle.test.ts +43
| @@ -92,7 +92,15 @@ async function terminal(): Promise<string> { | ||
| 92 | 92 | /** The builders tile's anchor on home. */ |
| 93 | 93 | const tile = (page: string): string => /<a class="stat" href="[^"]*"[^>]*><span class="stat-label">Builders running.*?<\/a>/s.exec(page)?.[0] ?? ""; |
| 94 | 94 | |
| 95 | /** The repository's map as the API answers it: what home's "nothing ready" must agree with. */ | |
| 96 | async function mapReady(): Promise<number> { | |
| 97 | const res = await fetch(`${servers.url}/api/v1/repos/alice/engine/map`, { headers: { cookie: `forge_session=${createSession(app.db, admin.id).token}` } }); | |
| 98 | expect(res.status).toBe(200); | |
| 99 | return ((await res.json()) as { ready: unknown[] }).ready.length; | |
| 100 | } | |
| 101 | ||
| 95 | 102 | test("nothing ready: the map has nothing a builder may start", async () => { |
| 103 | expect(await mapReady()).toBe(0); | |
| 96 | 104 | const page = await home(); |
| 97 | 105 | expect(tile(page)).toContain("nothing ready"); |
| 98 | 106 | expect(tile(page)).toContain('href="/map"'); |
| @@ -102,6 +110,7 @@ test("nothing ready: the map has nothing a builder may start", async () => { | ||
| 102 | 110 | test("a ready issue and a builder with room: no reason is named", async () => { |
| 103 | 111 | const opened = openIssue(app, admin, repo, { title: "Renderer" }); |
| 104 | 112 | if (!opened.ok) throw new Error(String(opened.error)); |
| 113 | expect(await mapReady()).toBeGreaterThan(0); | |
| 105 | 114 | const page = await home(); |
| 106 | 115 | expect(tile(page)).not.toContain("nothing ready"); |
| 107 | 116 | expect(tile(page)).toContain("running now"); |
| @@ -191,3 +200,37 @@ test("a cached reason is dropped when the counts change: one of two slots runnin | ||
| 191 | 200 | reason = null; |
| 192 | 201 | expect(cachedKpis(app.db, now, forge).builders).toEqual({ running: 1, slots: 2, idle: null }); |
| 193 | 202 | }); |
| 203 | ||
| 204 | test("slots shown are the crew's concurrency, not the sum of the repositories' ceilings (#10440)", async () => { | |
| 205 | const wide = await updateRepoSettings(app, admin, repo, { autonomy: "autonomous", autonomyDirection: "Build.", autonomyCeilingUsd: 10, autonomySlots: 16, autonomyBy: admin.id }); | |
| 206 | expect(wide.ok).toBe(true); | |
outdated
Raw SQL built by interpolating the id into a string. Use bound parameters, like the repo's other tests. The test also leaves 'crew.concurrency' at 40 on failure, so use try/finally or afterEach cleanup so a failing assertion does not poison later tests. |
||
| 207 | const set = (v: string) => | |
| 208 | app.db.query("INSERT OR REPLACE INTO forge_setting (key, value, updated_by, updated_at) VALUES ('crew.concurrency', ?1, ?2, datetime('now'))").run(v, admin.id); | |
| 209 | try { | |
| 210 | set("4"); | |
| 211 | const page = await home(); | |
| 212 | expect(tile(page)).toMatch(/>\d of 4</); | |
| 213 | expect(tile(page)).not.toContain("of 16"); | |
| 214 | expect(kpiLines(readKpis(app.db, new Date()), null).find((l) => l.startsWith("Builders running"))).toMatch(/Builders running \d of 4/); | |
| 215 | // Never more than the repositories offer. | |
| 216 | set("40"); | |
| 217 | expect(readKpis(app.db, new Date()).builders.slots).toBe(16); | |
| 218 | } finally { | |
| 219 | app.db.query("DELETE FROM forge_setting WHERE key = 'crew.concurrency'").run(); | |
| 220 | } | |
| 221 | }); | |
| 222 | ||
| 223 | test("the slots figure is crew.concurrency on home and in the terminal, and the running figure is the runs' own count (#10440)", async () => { | |
| 224 | const wide = await updateRepoSettings(app, admin, repo, { autonomy: "autonomous", autonomyDirection: "Build.", autonomyCeilingUsd: 10, autonomySlots: 16, autonomyBy: admin.id }); | |
| 225 | expect(wide.ok).toBe(true); | |
| 226 | try { | |
| 227 | app.db.query("INSERT OR REPLACE INTO forge_setting (key, value, updated_by, updated_at) VALUES ('crew.concurrency', '3', ?1, datetime('now'))").run(admin.id); | |
| 228 | const running = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM run WHERE kind = 'agent' AND state = 'running'").get()!.n; | |
| 229 | const text = await terminal(); | |
| 230 | expect(text).toContain(`Builders running ${running} of 3`); | |
| 231 | expect(text).not.toContain("of 16"); | |
| 232 | expect(tile(await home())).toContain(`${running} of 3`); | |
| 233 | } finally { | |
| 234 | app.db.query("DELETE FROM forge_setting WHERE key = 'crew.concurrency'").run(); | |
| 235 | } | |
| 236 | }); |
test/home-kpi-page.test.ts +3 −7
| @@ -25,7 +25,7 @@ afterAll(async () => { | ||
| 25 | 25 | await rm(dataDir, { recursive: true, force: true }); |
| 26 | 26 | }); |
| 27 | 27 | |
| 28 | test("home renders the eleven tiles; only an admin sees them", async () => { | |
| 28 | test("home renders the eight tiles; only an admin sees them", async () => { | |
| 29 | 29 | const mk = async (username: string, first = false): Promise<User> => { |
| 30 | 30 | const r = await createUser(app.db, { |
| 31 | 31 | origin: "seed", |
| @@ -60,7 +60,7 @@ test("home renders the eleven tiles; only an admin sees them", async () => { | ||
| 60 | 60 | view(yesterday, "z", 400, 160); |
| 61 | 61 | const a = await home(admin); |
| 62 | 62 | expect(a).toContain('class="stats kpi-row mb"'); |
| 63 | expect((a.match(/class="stat"/g) ?? []).length).toBe(11); | |
| 63 | expect((a.match(/class="stat"/g) ?? []).length).toBe(8); | |
| 64 | 64 | for (const href of ["/board", "/releases", "/boards", "/settings/crew", "/settings/backups"]) expect(a).toContain(`class="stat" href="${href}"`); |
| 65 | 65 | // The tiles are §5 stats (#3982): none of the page-local kpi classes is left on home. |
| 66 | 66 | for (const name of ["kpis", "kpi", "kpi-value", "kpi-note", "kpi-spark"]) expect(a).not.toMatch(new RegExp(`class="[^"]*\\b${name}\\b(?!-)`)); |
| @@ -68,16 +68,12 @@ test("home renders the eleven tiles; only an admin sees them", async () => { | ||
| 68 | 68 | |
| 69 | 69 | // 2 accounts, the admin alone with a session today; visitors 3 today and 4 over 7 days; |
| 70 | 70 | // load p75 of [100,200,300,400,1900] is 400 ms, INP p75 of [40,80,120,160] is 120 ms. |
| 71 | expect(a).toContain(">1 of 2<"); | |
| 72 | expect(a).toContain(">3 today<"); | |
| 73 | expect(a).toContain(">4 in 7 days<"); | |
| 74 | 71 | expect(a).toContain(">400 ms<"); |
| 75 | 72 | expect(a).toContain(">reacts in 120 ms<"); |
| 76 | expect((a.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(3); | |
| 73 | expect((a.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(1); | |
| 77 | 74 | |
| 78 | 75 | // The figures count every repository, so a non-admin sees none. |
| 79 | 76 | expect(a).toContain("no forge ceiling"); |
| 80 | expect(a).toContain("last 30 days"); | |
| 81 | 77 | expect(await home(bob)).not.toContain('class="stats kpi-row mb"'); |
| 82 | 78 | expect(await home(bob)).not.toContain("last 30 days"); |
| 83 | 79 | expect(await home()).not.toContain('class="stats kpi-row mb"'); |
test/home-kpi-row.test.ts +4 −6
| @@ -256,8 +256,10 @@ describe("the reach tiles", () => { | ||
| 256 | 256 | test("say what they count in words, each linking to the reach board", () => { |
| 257 | 257 | const reach = { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, visitorDays: new Array<number>(30).fill(0), loadP75: 1250, inpP75: 180 }; |
| 258 | 258 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach, medianCheckMs: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); |
| 259 | for (const v of ["5 today", "31 in 7 days", "3 of 12", "seen today", "1.3 s", "reacts in 180 ms"]) expect(out).toContain(`>${v}<`); | |
| 260 | expect((out.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(3); | |
| 259 | for (const v of ["1.3 s", "reacts in 180 ms"]) expect(out).toContain(`>${v}<`); | |
| 260 | // Reporting browsers and Accounts feed no decision: only the page speed stays (#10440). | |
| 261 | for (const gone of ["Reporting browsers", "Accounts", "Traffic"]) expect(out).not.toContain(gone); | |
| 262 | expect((out.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(1); | |
| 261 | 263 | expect(out).not.toContain("INP"); |
| 262 | 264 | expect(out).not.toContain("p75"); |
| 263 | 265 | }); |
| @@ -289,10 +291,6 @@ describe("the visitors sparkline", () => { | ||
| 289 | 291 | expect(r.visitorDays[29]).toBe(2); |
| 290 | 292 | expect(r.visitorDays[0]).toBe(1); // 2026-09-20 is 29 days back; 09-19 is out |
| 291 | 293 | expect(r.visitorDays.reduce((a, b) => a + b, 0)).toBe(3); |
The sparkline test that asserted 30 points and the reach link was deleted instead of moved. The reach computation (visitorDays) is still tested, but nothing renders it now. That is more evidence the spark is dead; remove it together with the test. |
||
| 292 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: r, medianCheckMs: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); | |
| 293 | const svg = /<svg[^>]*last 30 days[^>]*><polyline points="([^"]+)"/.exec(out)!; | |
| 294 | expect(svg[1]!.split(" ")).toHaveLength(30); | |
| 295 | expect(out).toContain('href="/boards/reach"'); | |
| 296 | 294 | }); |
| 297 | 295 | }); |
| 298 | 296 |
test/home-kpis.test.ts +2 −2
| @@ -145,8 +145,8 @@ describe("home kpi row titles", () => { | ||
| 145 | 145 | }); |
| 146 | 146 | |
| 147 | 147 | test("no value or note lacks a title, and none brings back a git word", () => { |
| 148 | expect(full.match(/class="stat-(value num|note)"/g)).toHaveLength(18); | |
| 149 | expect(full.match(/class="stat-(value num|note)" title=/g)).toHaveLength(18); | |
| 148 | expect(full.match(/class="stat-(value num|note)"/g)).toHaveLength(14); | |
| 149 | expect(full.match(/class="stat-(value num|note)" title=/g)).toHaveLength(14); | |
| 150 | 150 | expect(full).not.toMatch(/commit|merge|branch|repositor|pull request/i); |
| 151 | 151 | }); |
| 152 | 152 | }); |
test/idle-reason.test.ts +3 −2
| @@ -129,11 +129,12 @@ describe("idleReason", () => { | ||
| 129 | 129 | expect(idleReason(app, new Date())?.word).toBe("ceiling reached"); |
| 130 | 130 | }); |
| 131 | 131 | |
| 132 | test("an issue the dispatcher holds is not ready: a run in flight on the only one says nothing ready", () => { | |
| 132 | test("an issue the dispatcher holds is not ready: a run in flight on the only one says none startable, not nothing ready", () => { | |
| 133 | 133 | const only = open("Held by a run"); |
| 134 | 134 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: bot.id, reason: "review", issueId: only.id }); |
| 135 | 135 | set("UPDATE run SET state = 'running' WHERE id = ?1", run.id); |
| 136 | expect(idleReason(app, NOW)).toEqual({ word: "nothing ready", href: "/map" }); | |
| 136 | // The map still lists it as ready: "nothing ready" would be false. | |
| 137 | expect(idleReason(app, NOW)).toEqual({ word: "none startable", href: "/map" }); | |
| 137 | 138 | }); |
| 138 | 139 | |
| 139 | 140 | test("the line says the word and the link", () => { |
test/kpi-spark.test.ts +1 −7
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { describe, expect, test } from "bun:test"; |
| 9 | import { daySpark, sparkline, visitorsSpark } from "../src/web/studio/kpi_row.ts"; | |
| 9 | import { daySpark, sparkline } from "../src/web/studio/kpi_row.ts"; | |
| 10 | 10 | |
| 11 | 11 | const LINE = 'fill="none" stroke="currentColor" stroke-width="1.5"'; |
| 12 | 12 | |
| @@ -49,12 +49,6 @@ describe("daySpark", () => { | ||
| 49 | 49 | }); |
| 50 | 50 | |
| 51 | 51 | describe("the numbers-only sparklines are unchanged", () => { |
| 52 | test("visitorsSpark", () => { | |
| 53 | expect(visitorsSpark([0, 2, 4]).toString()).toBe( | |
| 54 | `<svg viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="Distinct visitors each day over the last 3 days"><polyline points="0.0,22.0 48.0,12.0 96.0,2.0" ${LINE}/></svg>`, | |
| 55 | ); | |
| 56 | }); | |
| 57 | ||
| 58 | 52 | test("sparkline, newest first in, oldest on the left", () => { |
| 59 | 53 | expect(sparkline([2, 0]).toString()).toBe( |
| 60 | 54 | `<svg viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="Shipped each hour over the last 24 hours"><polyline points="0.0,22.0 96.0,2.0" ${LINE}/></svg>`, |
test/moving-title.test.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { shortTitle, TITLE_MAX } from "../src/flow/moving.ts"; | |
| 3 | ||
| 4 | test("a title that fits is whole; a long one is cut at a word, never mid-word", () => { | |
| 5 | expect(shortTitle("Fix the build")).toBe("Fix the build"); | |
| 6 | const long = "Sentinel: release was degraded 180 minutes today: Release 0.7.495 is unproven: https://example.test did not answer"; | |
| 7 | const cut = shortTitle(long); | |
| 8 | expect(cut.length).toBeLessThanOrEqual(TITLE_MAX + 1); | |
| 9 | expect(cut.endsWith("…")).toBe(true); | |
| 10 | expect(long.startsWith(cut.slice(0, -1))).toBe(true); | |
| 11 | expect(long[cut.length - 1]).toBe(" "); | |
| 12 | }); | |
| 13 | ||
| 14 | test("one long unbroken token is cut at the limit, and whitespace collapses first", () => { | |
| 15 | const token = "x".repeat(200); | |
| 16 | expect(shortTitle(token)).toBe(`${"x".repeat(TITLE_MAX)}…`); | |
| 17 | expect(shortTitle("a b \n c")).toBe("a b c"); | |
| 18 | expect(shortTitle(`word ${token}`)).toBe(`word ${"x".repeat(TITLE_MAX - 5)}…`); | |
| 19 | }); |
test/traffic-truth.test.ts +9 −5
| @@ -206,16 +206,15 @@ test("the tile says plainly when strangers hold most of the time, and not for a | ||
| 206 | 206 | expect(trafficWords(t).note).toContain("too much"); |
| 207 | 207 | expect(trafficWords(t).title).toStartWith("Something is wrong"); |
| 208 | 208 | const row = String(kpiRowFor(emptyKpis(), null, NOW, t)); |
| 209 | expect(row).toMatch( | |
| 210 | /<a class="stat" href="\/settings\/traffic" data-tone="attn"/, | |
| 211 | ); | |
| 209 | // The home row no longer carries a Traffic tile; the board at /settings/traffic does. | |
| 210 | expect(row).not.toContain("/settings/traffic"); | |
| 212 | 211 | |
| 213 | 212 | const handful = createPerfRecord(); |
| 214 | 213 | handful.add("/a", sample(5000, { deepStranger: true })); |
| 215 | 214 | expect(trafficAttention(handful.ledger.totals(END))).toBeNull(); |
| 216 | 215 | expect( |
| 217 | 216 | String(kpiRowFor(emptyKpis(), null, NOW, handful.ledger.totals(END))), |
| 218 | ).not.toMatch(/traffic" data-tone="attn"/); | |
| 217 | ).not.toContain("/settings/traffic"); | |
| 219 | 218 | |
| 220 | 219 | expect(trafficValue(createPerfRecord().ledger.totals(END))).toBe("—"); |
| 221 | 220 | }); |
| @@ -290,13 +289,18 @@ test("every tile's value equals the probe's, from known requests and page_vital | ||
| 290 | 289 | for (const n of probe.numbers) { |
| 291 | 290 | expect(n.counts.length).toBeGreaterThan(10); |
| 292 | 291 | expect([n.id, n.agrees, n.recomputed]).toEqual([n.id, true, n.value]); |
| 292 | // These figures are the probe's and the boards', no longer tiles on the home row. | |
| 293 | if (["traffic", "visitors", "users", "accounts"].includes(n.id)) { | |
| 294 | expect([n.id, tiles.has(n.label)]).toEqual([n.id, false]); | |
| 295 | continue; | |
| 296 | } | |
| 293 | 297 | const shown = tiles.get(n.label); |
| 294 | 298 | expect([n.label, n.id === "live" ? shown !== undefined : shown]).toEqual([ |
| 295 | 299 | n.label, |
| 296 | 300 | n.id === "live" ? true : n.value, |
| 297 | 301 | ]); |
| 298 | 302 | } |
| 299 | expect(tiles.size).toBe(probe.numbers.length); | |
| 303 | expect(tiles.size).toBe(probe.numbers.length - 3); | |
| 300 | 304 | // The live tile's short form is the start of the probe's full id. |
| 301 | 305 | expect(byId.get("live")!.value.startsWith(tiles.get("Live version")!)).toBe( |
| 302 | 306 | true, |
test/week.test.ts +10
| @@ -171,3 +171,13 @@ describe("efficiency", () => { | ||
| 171 | 171 | expect(efficiencyWords({ landings: 0, runs: 2, costUsd: 4 })).toContain("Nothing has landed"); |
| 172 | 172 | }); |
| 173 | 173 | }); |
| 174 | ||
| 175 | describe("the pace words (#10440)", () => { | |
| 176 | test("`Behind pace` only when less is used than the time gone; otherwise it says what is true", () => { | |
| 177 | // base: 84 h of 168 left, so half the week is gone. | |
| 178 | expect(used(20, { burnPctPerHour: 0 }).sentence).toContain("Behind pace"); | |
| 179 | const heavy = used(70, { burnPctPerHour: 0 }); | |
| 180 | expect(heavy.sentence).not.toContain("Behind pace"); | |
| 181 | expect(heavy.sentence).toContain("Under the allowed burn"); | |
| 182 | }); | |
| 183 | }); |
The label is chosen by an inline ternary inside a template string, and it recomputes elapsed time with
(1 - left / WINDOW_MS.week) * 100wheresayalready knows the pace. Computeconst label = ...once above and reuse it. The line is now unreadable.