Public: anyone can read this; the crew can change it.
/forges shows each sent forge's crew spend today against its starter budget, carried by its heartbeat #5884
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5866-forges-shows-each-sent-forge-s-crew-spen → main 4 commits fork 6 threads · 6 unresolved
9 files
changed +189
−8
README.md +3 −1
| @@ -22,7 +22,9 @@ A forge this one spawned (hosted) runs the apps it was given too: its `bakery` | ||
| 22 | 22 | answers at `bakery--<child>--<this forge's host>`, in this forge's lanes, on the |
| 23 | 23 | child's own data, and `/forges` says how many are running and why any is not. Each row also ends with the first step its person has not taken (`not claimed yet`, `no everyday account yet`, `no tell yet`, or `going: last tell <time>`; `has not reported yet` before the child's first beat), read from the child's heartbeat; the terminal's forges screen and the MCP `forges` tool (`nextStep`) say the same. |
| 24 | 24 | In the terminal an admin's palette offers "Forges you sent": each forge with its |
| 25 | claim state and the same apps-running note, never a claim link. | |
| 25 | claim state and the same apps-running note, never a claim link. Both carry the | |
| 26 | crew's spend today against the daily ceiling that pauses it (`$3.20 of $10 today`), as the | |
| 27 | child's heartbeat last reported it. | |
| 26 | 28 | |
| 27 | 29 | Each agent's scorecard — pulls landed of opened, review rounds, red checks, |
| 28 | 30 | reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the |
src/http/mcp.ts +1 −1
| @@ -2673,7 +2673,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2673 | 2673 | { |
| 2674 | 2674 | name: "forges", |
| 2675 | 2675 | description: |
| 2676 | "The forges sent from here (admin): name, address, whom for, apps, claimed or not, last activity.", | |
| 2676 | "The forges sent from here (admin): name, address, for, apps, claimed, spendTodayUsd, ceilingUsd.", | |
| 2677 | 2677 | inputSchema: { type: "object", properties: {} }, |
| 2678 | 2678 | writes: false, |
| 2679 | 2679 | async run(ctx, user) { |
src/spawn/clients.ts +23
| @@ -37,6 +37,10 @@ export interface ClientRow { | ||
| 37 | 37 | readonly lastActivityAt: string | null; |
| 38 | 38 | /** The apps this parent runs for the child; null when it runs none here. */ |
| 39 | 39 | readonly running: ChildAppsSummary | null; |
| 40 | /** Dollars the child's crew spent today, from its last heartbeat; null when it has not said. */ | |
| 41 | readonly spendTodayUsd: number | null; | |
| 42 | /** The child's forge cap in dollars; null when none or unsaid. */ | |
| 43 | readonly ceilingUsd: number | null; | |
| 40 | 44 | /** The first step its person has not taken, read from the child's heartbeat. */ |
| 41 | 45 | readonly nextStep: NextStep; |
| 42 | 46 | } |
| @@ -188,6 +192,8 @@ export async function clientRows(app: App): Promise<ClientRow[]> { | ||
| 188 | 192 | claimed: states[i]?.claimed ?? null, |
| 189 | 193 | lastActivityAt: states[i]?.lastActivityAt ?? null, |
| 190 | 194 | running: hostedChildApps(app, c.name) ?? null, |
| 195 | spendTodayUsd: beats[i]?.beat.spendTodayUsd ?? null, | |
| 196 | ceilingUsd: beats[i]?.beat.ceilingUsd ?? null, | |
| 191 | 197 | nextStep: nextStepOf(states[i]?.claimed ?? null, beats[i]?.beat), |
| 192 | 198 | })) |
| 193 | 199 | .sort( |
| @@ -218,3 +224,20 @@ export function runningWords(r: ClientRow): string | null { | ||
| 218 | 224 | if (a.notRunning.length === 0) return word; |
| 219 | 225 | return `${word} · ${a.notRunning.map((n) => `${n.slug}: ${n.why}`).join("; ")}`; |
| 220 | 226 | } |
| 227 | ||
| 228 | const money = (n: number): string => (Number.isInteger(n) ? String(n) : n.toFixed(2)); | |
| 229 | ||
| 230 | /** | |
| 231 | * "$3.20 of $10 today", with "ceiling reached" at the cap; `warn` from 80%. | |
| 232 | * Null when the child has not reported spend. | |
| 233 | */ | |
| 234 | export function spendWords(r: ClientRow): { words: string; warn: boolean } | null { | |
| 235 | if (r.spendTodayUsd === null) return null; | |
| 236 | const spent = `$${r.spendTodayUsd.toFixed(2)}`; | |
| 237 | if (r.ceilingUsd === null) return { words: `${spent} today`, warn: false }; | |
| 238 | const share = r.spendTodayUsd / r.ceilingUsd; | |
| 239 | const base = `${spent} of $${money(r.ceilingUsd)} today`; | |
| 240 | return share >= 1 | |
| 241 | ? { words: `${base} · ceiling reached`, warn: true } | |
| 242 | : { words: base, warn: share >= 0.8 }; | |
| 243 | } |
src/spawn/heartbeat.ts +32 −1
| @@ -17,6 +17,7 @@ import { createHmac, timingSafeEqual } from "node:crypto"; | ||
| 17 | 17 | import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; |
| 18 | 18 | import { join } from "node:path"; |
| 19 | 19 | import type { App } from "../app.ts"; |
| 20 | import { spentTodayEverywhere } from "../crew/autonomy.ts"; | |
| 20 | 21 | import { beatKeyOf, listChildren } from "../actions/spawn.ts"; |
| 21 | 22 | import releases from "../core/releases.json" with { type: "json" }; |
| 22 | 23 | import { forgeLinkOf, forgeTipOf, FOLLOW_MODES, type FollowMode, type ForgeLink } from "./chain.ts"; |
| @@ -55,6 +56,10 @@ export interface Beat { | ||
| 55 | 56 | readonly upstreamBehind?: number | null; |
| 56 | 57 | /** True while a converge issue is open on the child; absent from an older child. */ |
| 57 | 58 | readonly conflict?: boolean; |
| 59 | /** Dollars the child's crew spent since 00:00 UTC; absent from an older child or when it sent a bad value. */ | |
| 60 | readonly spendTodayUsd?: number; | |
| 61 | /** The child's forge cap in dollars (the daily ceiling that pauses its crew), null when it has none; absent when unsaid or bad. */ | |
| 62 | readonly ceilingUsd?: number | null; | |
| 58 | 63 | /** Whether its person has claimed it; absent from an older child or when the value was bad. */ |
| 59 | 64 | readonly claimed?: boolean; |
| 60 | 65 | /** Whether it has an everyday account (or chose to go without); absent likewise. */ |
| @@ -64,8 +69,13 @@ export interface Beat { | ||
| 64 | 69 | } |
| 65 | 70 | |
| 66 | 71 | const OPTIONAL = new Set([ |
| 67 | "forgeTip", "severedAt", "mode", "upstreamBehind", "conflict", "claimed", "everyday", "lastTellAt", | |
| 72 | "forgeTip", "severedAt", "mode", "upstreamBehind", "conflict", "spendTodayUsd", "ceilingUsd", "claimed", "everyday", "lastTellAt", | |
| 68 | 73 | ]); |
| 74 | const USD_MAX = 1_000_000; | |
| 75 | ||
| 76 | /** Dollars: a finite number from `min` to a million; anything else is not a sum of money. */ | |
| 77 | const dollars = (v: unknown, min: number): v is number => | |
| 78 | typeof v === "number" && Number.isFinite(v) && v >= min && v <= USD_MAX; | |
| 69 | 79 | |
| 70 | 80 | export function sign(key: string, body: string): string { |
| 71 | 81 | return createHmac("sha256", key).update(body).digest("hex"); |
| @@ -118,12 +128,17 @@ export function parseBeat(raw: string): Beat | undefined { | ||
| 118 | 128 | if (typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0) return undefined; |
| 119 | 129 | if (typeof version !== "string" || !VERSION.test(version)) return undefined; |
| 120 | 130 | if (!count(openProposals) || !count(children)) return undefined; |
| 131 | // A bad sum drops the field, not the beat. | |
| 132 | const spend = dollars(d["spendTodayUsd"], 0) ? d["spendTodayUsd"] : undefined; | |
| 133 | const ceil = d["ceilingUsd"] === null ? null : dollars(d["ceilingUsd"], 0.01) ? d["ceilingUsd"] : undefined; | |
| 121 | 134 | return { |
| 122 | 135 | name, |
| 123 | 136 | at, |
| 124 | 137 | version, |
| 125 | 138 | openProposals, |
| 126 | 139 | children, |
| 140 | ...(spend === undefined ? {} : { spendTodayUsd: spend }), | |
| 141 | ...(ceil === undefined ? {} : { ceilingUsd: ceil }), | |
| 127 | 142 | ...(forgeTip === undefined ? {} : { forgeTip: forgeTip as string | null }), |
| 128 | 143 | ...(severedAt === undefined ? {} : { severedAt: severedAt as string | null }), |
| 129 | 144 | ...(mode === undefined ? {} : { mode: mode as FollowMode }), |
| @@ -236,6 +251,15 @@ export function standing(app: App, name: string, at = Date.now()): Beat { | ||
| 236 | 251 | }; |
| 237 | 252 | } |
| 238 | 253 | |
| 254 | /** What the crew spent today, and the daily ceiling that pauses it (null when the forge sets none). */ | |
| 255 | export function spendReport(app: App, now = new Date()): { spendTodayUsd: number; ceilingUsd: number | null } { | |
| 256 | const cap = app.config.crewCeilingUsd; | |
| 257 | return { | |
| 258 | spendTodayUsd: Math.min(Math.round(spentTodayEverywhere(app.db, now) * 100) / 100, USD_MAX), | |
| 259 | ceilingUsd: cap === undefined || !(cap > 0) ? null : Math.min(cap, USD_MAX), | |
| 260 | }; | |
| 261 | } | |
| 262 | ||
| 239 | 263 | /** What a fork says of how it follows: its tip, whether it cut the link, and how far behind it is. */ |
| 240 | 264 | async function forkReport(app: App, forge: ForgeLink) { |
| 241 | 265 | const up = await upstreamState(app); |
| @@ -267,6 +291,7 @@ export async function sendBeat( | ||
| 267 | 291 | const body = JSON.stringify({ |
| 268 | 292 | ...standing(app, link.name, lastAt), |
| 269 | 293 | children: Math.min(own, COUNT_MAX), |
| 294 | ...spendReport(app), | |
| 270 | 295 | ...progress(app), |
| 271 | 296 | // Only a forge that carries a fork of its parent's code has these to say. |
| 272 | 297 | ...(forge === undefined |
| @@ -316,6 +341,10 @@ export interface ChildStanding { | ||
| 316 | 341 | readonly upstreamBehind: number | null; |
| 317 | 342 | /** A converge issue is open on the child; false when none or unsaid. */ |
| 318 | 343 | readonly conflict: boolean; |
| 344 | /** Dollars the child's crew spent today, as last reported; null when unsaid. */ | |
| 345 | readonly spendTodayUsd: number | null; | |
| 346 | /** The child's forge cap in dollars; null when it has none or did not say. */ | |
| 347 | readonly ceilingUsd: number | null; | |
| 319 | 348 | } |
| 320 | 349 | |
| 321 | 350 | /** How many releases of this forge are newer than `version`; null when it is not one of them. */ |
| @@ -347,6 +376,8 @@ export async function childStandings( | ||
| 347 | 376 | mode: heard?.beat.mode ?? null, |
| 348 | 377 | upstreamBehind: heard?.beat.upstreamBehind ?? null, |
| 349 | 378 | conflict: heard?.beat.conflict ?? false, |
| 379 | spendTodayUsd: heard?.beat.spendTodayUsd ?? null, | |
| 380 | ceilingUsd: heard?.beat.ceilingUsd ?? null, | |
| 350 | 381 | }); |
| 351 | 382 | } |
| 352 | 383 | return { items, more: Math.max(0, all.length - MAX_CHILDREN) }; |
src/tui/screens/forges.ts +3 −3
| @@ -1,12 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The forges this one sent (#2211) in a card: one row per client with its |
| 3 | * claim state and the apps-running note, in the words /forges uses. Never a | |
| 3 | * claim state, the apps-running note and the crew's spend today, in the words /forges uses. Never a | |
| 4 | 4 | * claim link; that stays on the page. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import type { Ctx, Screen } from "../app/app.ts"; |
| 8 | 8 | import { binding } from "../app/keys.ts"; |
| 9 | import { CLAIM_WORDS, claimState, runningWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 9 | import { CLAIM_WORDS, claimState, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 10 | 10 | import { card } from "../ui/widgets.ts"; |
| 11 | 11 | import { drawFacts } from "./common.ts"; |
| 12 | 12 | |
| @@ -15,7 +15,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] { | ||
| 15 | 15 | if (rows.length === 0) return [["", "You have not sent a forge yet"]]; |
| 16 | 16 | return rows.map((r) => [ |
| 17 | 17 | r.name, |
| 18 | [r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], runningWords(r), r.nextStep.words] | |
| 18 | [r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], runningWords(r), r.nextStep.words, spendWords(r)?.words ?? null] | |
| 19 | 19 | .filter((m): m is string => m !== null) |
| 20 | 20 | .join(" · "), |
| 21 | 21 | ]); |
src/web/pages/forges.ts +11 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Ctx } from "../../http/context.ts"; |
| 10 | import { CLAIM_WORDS, claimState, clientRows, runningWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 10 | import { CLAIM_WORDS, claimState, clientRows, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 11 | 11 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 12 | 12 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 13 | 13 | import { html, type Html } from "../html.ts"; |
| @@ -28,6 +28,15 @@ function runningNote(r: ClientRow): Html | null { | ||
| 28 | 28 | >`; |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | /** The crew's spend today against its cap; attention-toned from 80%. */ | |
No test renders this. A page test that seeds a beat at 85% and expects |
||
| 32 | function spendNote(r: ClientRow): Html | null { | |
| 33 | const s = spendWords(r); | |
| 34 | if (s === null) return null; | |
| 35 | return s.warn | |
| 36 | ? html`<span data-spend="warn">${badge(s.words, { tone: "attn" })}</span>` | |
| 37 | : html`<span data-spend="ok">${s.words}</span>`; | |
| 38 | } | |
| 39 | ||
| 31 | 40 | function forgeRow(r: ClientRow): Html { |
| 32 | 41 | const state = claimState(r); |
| 33 | 42 | const meta = [ |
| @@ -35,6 +44,7 @@ function forgeRow(r: ClientRow): Html { | ||
| 35 | 44 | r.for === null ? null : html`for ${r.for}`, |
| 36 | 45 | r.apps.length === 0 ? null : html`${r.apps.join(", ")}`, |
| 37 | 46 | runningNote(r), |
| 47 | spendNote(r), | |
| 38 | 48 | r.sentAt === null ? null : html`sent ${time(r.sentAt)}`, |
| 39 | 49 | r.lastActivityAt === null |
| 40 | 50 | ? null |
test/family-heartbeat.test.ts +23 −1
| @@ -11,7 +11,7 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { createRepo } from "../src/actions/repos.ts"; |
| 12 | 12 | import { beatKeyOf, childDirOf } from "../src/actions/spawn.ts"; |
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 15 | 15 | import { commitFiles } from "../src/git/write.ts"; |
| 16 | 16 | import { repoDir } from "../src/git/repos.ts"; |
| 17 | 17 | import { createToken } from "../src/core/tokens.ts"; |
| @@ -335,12 +335,14 @@ describe("family heartbeats", () => { | ||
| 335 | 335 | expect(sent[0]?.sig).toBe(sign(key, sent[0]!.body)); |
| 336 | 336 | expect(Object.keys(JSON.parse(sent[0]!.body)).sort()).toEqual([ |
| 337 | 337 | "at", |
| 338 | "ceilingUsd", | |
| 338 | 339 | "children", |
| 339 | 340 | "claimed", |
| 340 | 341 | "everyday", |
| 341 | 342 | "lastTellAt", |
| 342 | 343 | "name", |
| 343 | 344 | "openProposals", |
| 345 | "spendTodayUsd", | |
| 344 | 346 | "version", |
| 345 | 347 | ]); |
| 346 | 348 | // A forge that was not spawned has no one to tell. |
| @@ -441,4 +443,24 @@ describe("family heartbeats", () => { | ||
| 441 | 443 | await rm(join(dataDir, "upstream.json"), { force: true }); |
| 442 | 444 | } |
| 443 | 445 | }); |
| 446 | ||
| 447 | test("a stored beat with spend shows on /forges and in the MCP forges tool", async () => { | |
| 448 | const body = beat({ at: Date.now() + 60_000, spendTodayUsd: 8.5, ceilingUsd: 10 }); | |
| 449 | expect((await post(body, sign(key, body))).status).toBe(200); | |
| 450 | const page = await (await fetch(`${servers.url}/forges`, { headers: { cookie: adminCookie } })).text(); | |
| 451 | expect(page).toContain('data-spend="warn"'); | |
| 452 | expect(page).toContain("$8.50 of $10 today"); | |
| 453 | const alice = getUserByName(parent.db, "alice")!; | |
| 454 | const res = await fetch(`${servers.url}/mcp`, { | |
| 455 | method: "POST", | |
| 456 | headers: { | |
| 457 | "content-type": "application/json", | |
| 458 | accept: "application/json, text/event-stream", | |
| 459 | authorization: `Bearer ${createToken(parent.db, alice.id, "mcp", "rw").token}`, | |
| 460 | }, | |
| 461 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "forges", arguments: {} } }), | |
| 462 | }); | |
| 463 | const out = JSON.parse(((await res.json()) as any).result.content[0].text); | |
| 464 | expect(out.forges.find((f: any) => f.name === "maria")).toMatchObject({ spendTodayUsd: 8.5, ceilingUsd: 10 }); | |
| 465 | }); | |
| 444 | 466 | }); |
test/forges-spend.test.ts added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | /** | |
| 2 | * The crew's spend on /forges (#5866): a heartbeat may carry spendTodayUsd | |
| 3 | * and ceilingUsd, a bad value drops the field and not the beat, and the row | |
| 4 | * says "$3.20 of $10 today", attention-toned from 80%, "ceiling reached" at 100%. | |
| 5 | */ | |
| 6 | ||
| 7 | import { describe, expect, test } from "bun:test"; | |
| 8 | import { parseBeat, spendReport } from "../src/spawn/heartbeat.ts"; | |
| 9 | import { spendWords, type ClientRow } from "../src/spawn/clients.ts"; | |
| 10 | import { createApp } from "../src/app.ts"; | |
| 11 | import { mkdtempSync } from "node:fs"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { createRun } from "../src/core/runs.ts"; | |
| 16 | import { createUser } from "../src/core/users.ts"; | |
| 17 | ||
| 18 | const beat = (o: Record<string, unknown> = {}) => | |
| 19 | JSON.stringify({ name: "maria", at: Date.now(), version: "0.7.114", openProposals: 1, children: 0, ...o }); | |
| 20 | ||
| 21 | const row = (spendTodayUsd: number | null, ceilingUsd: number | null) => | |
| 22 | ({ spendTodayUsd, ceilingUsd }) as ClientRow; | |
| 23 | ||
| 24 | describe("a beat's spend", () => { | |
| 25 | test("with spend and a cap", () => { | |
| 26 | expect(parseBeat(beat({ spendTodayUsd: 3.2, ceilingUsd: 10 }))).toMatchObject({ spendTodayUsd: 3.2, ceilingUsd: 10 }); | |
| 27 | expect(parseBeat(beat({ spendTodayUsd: 0, ceilingUsd: null }))).toMatchObject({ spendTodayUsd: 0, ceilingUsd: null }); | |
| 28 | }); | |
| 29 | test("without spend", () => { | |
| 30 | const b = parseBeat(beat()); | |
| 31 | expect(b?.name).toBe("maria"); | |
| 32 | expect(b).not.toHaveProperty("spendTodayUsd"); | |
| 33 | expect(b).not.toHaveProperty("ceilingUsd"); | |
| 34 | }); | |
| 35 | test("a negative or non-numeric value drops the field, not the beat", () => { | |
| 36 | for (const bad of [-1, "3", {}, true, 1e9]) { | |
| 37 | const b = parseBeat(beat({ spendTodayUsd: bad, ceilingUsd: bad })); | |
| 38 | expect(b?.name).toBe("maria"); | |
| 39 | expect(b).not.toHaveProperty("spendTodayUsd"); | |
| 40 | expect(b).not.toHaveProperty("ceilingUsd"); | |
| 41 | } | |
| 42 | const half = parseBeat(beat({ spendTodayUsd: 2, ceilingUsd: -5 })); | |
| 43 | expect(half).toMatchObject({ spendTodayUsd: 2 }); | |
| 44 | expect(half).not.toHaveProperty("ceilingUsd"); | |
| 45 | }); | |
| 46 | test("a child reports what its crew spent today and the daily ceiling", async () => { | |
| 47 | const app = createApp({ | |
| 48 | dataDir: mkdtempSync(join(tmpdir(), "forge-spend-")), | |
| 49 | url: "https://x.test", | |
| 50 | port: 0, | |
| 51 | crewCeilingUsd: 10, | |
| 52 | }); | |
| 53 | expect(spendReport(app)).toEqual({ spendTodayUsd: 0, ceilingUsd: 10 }); | |
| 54 | const user = await createUser(app.db, { | |
| 55 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true, | |
| 56 | }); | |
| 57 | if (!user.ok) throw new Error(user.error); | |
| 58 | const repo = await createRepo(app, user.value, { name: "one", visibility: "private" }); | |
| 59 | if (!repo.ok) throw new Error("repo"); | |
| 60 | const run = createRun(app.db, { repoId: repo.value.id, kind: "agent" }); | |
| 61 | app.db.query("UPDATE run SET cost_usd = 3.204 WHERE id = ?1").run(run.id); | |
| 62 | expect(spendReport(app).spendTodayUsd).toBe(3.2); | |
| 63 | const bare = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-spend-")), url: "https://x.test", port: 0 }); | |
| 64 | expect(spendReport(bare).ceilingUsd).toBeNull(); | |
| 65 | }); | |
| 66 | }); | |
| 67 | ||
| 68 | describe("the row's words", () => { | |
| 69 | test("says nothing when the child has not reported", () => { | |
| 70 | expect(spendWords(row(null, 10))).toBeNull(); | |
| 71 | }); | |
| 72 | test("50%: plain", () => { | |
| 73 | expect(spendWords(row(5, 10))).toEqual({ words: "$5.00 of $10 today", warn: false }); | |
| 74 | expect(spendWords(row(3.2, 10))?.words).toBe("$3.20 of $10 today"); | |
| 75 | }); | |
| 76 | test("85%: attention", () => { | |
| 77 | expect(spendWords(row(8.5, 10))).toEqual({ words: "$8.50 of $10 today", warn: true }); | |
| 78 | }); | |
| 79 | test("100%: ceiling reached", () => { | |
| 80 | expect(spendWords(row(10, 10))).toEqual({ words: "$10.00 of $10 today · ceiling reached", warn: true }); | |
| 81 | }); | |
| 82 | test("no cap: just the sum", () => { | |
| 83 | expect(spendWords(row(4, null))).toEqual({ words: "$4.00 today", warn: false }); | |
| 84 | }); | |
| 85 | }); |
test/tui-forges.test.ts +8
| @@ -25,6 +25,8 @@ const row = (over: Partial<ClientRow>): ClientRow => ({ | ||
| 25 | 25 | claimed: false, |
| 26 | 26 | lastActivityAt: null, |
| 27 | 27 | running: null, |
| 28 | spendTodayUsd: null, | |
| 29 | ceilingUsd: null, | |
| 28 | 30 | nextStep: { key: "unreported", words: "has not reported yet", lastTellAt: null }, |
| 29 | 31 | ...over, |
| 30 | 32 | }); |
| @@ -94,6 +96,12 @@ describe("forges you sent in the terminal", () => { | ||
| 94 | 96 | } |
| 95 | 97 | }); |
| 96 | 98 | |
| 99 | test("a row carries the crew's spend today, and says when the ceiling is reached", () => { | |
| 100 | expect(forgeLines([row({ spendTodayUsd: 10, ceilingUsd: 10 })])[0]![1]).toContain("ceiling reached"); | |
| 101 | expect(forgeLines([row({ spendTodayUsd: 3.2, ceilingUsd: 10 })])[0]![1]).toContain("$3.20 of $10 today"); | |
| 102 | expect(forgeLines([row({})])[0]![1]).not.toContain("today"); | |
| 103 | }); | |
| 104 | ||
| 97 | 105 | test("a row says who, the claim state and the apps note; never a link", () => { |
| 98 | 106 | const up = { total: 3, running: 3, notRunning: [] }; |
| 99 | 107 | const down = { total: 2, running: 1, notRunning: [{ slug: "notes", why: "build failed" }] }; |
new above
offline
Nit:
forgeCeiling(app.config)in src/flow/ceiling.ts already names this cap. Calling it here (plus the> 0guard) keeps one definition of the forge ceiling.