| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import type { Claim, WorkItem, WorkspaceSession } from "@g1t/contracts"; |
| 5 | |
| 6 | import { |
| 7 | type PlanView, |
| 8 | acceptForm, |
| 9 | allItemsPath, |
| 10 | applyMove, |
| 11 | blockersToRead, |
| 12 | budgetText, |
| 13 | canAccept, |
| 14 | capState, |
| 15 | daysSince, |
| 16 | duplicateComment, |
| 17 | dropPosition, |
| 18 | hillStep, |
| 19 | listRedirect, |
| 20 | menuHorizons, |
| 21 | moreText, |
| 22 | moveDown, |
| 23 | moveForm, |
| 24 | moveFrom, |
| 25 | moveUp, |
| 26 | onItems, |
| 27 | parseBudget, |
| 28 | planInput, |
| 29 | planPath, |
| 30 | progressOf, |
| 31 | quietLine, |
| 32 | quietText, |
| 33 | setAsideLine, |
| 34 | setAsidePath, |
| 35 | suggestionText, |
| 36 | triageLine, |
| 37 | triagePath, |
| 38 | waitingLabel, |
| 39 | waitingOn, |
| 40 | } from "./plan.ts"; |
| 41 | |
| 42 | function item(number: number, more: Partial<WorkItem> = {}): WorkItem { |
| 43 | return { |
| 44 | id: `wki_${number}`, |
| 45 | workspace: "acme", |
| 46 | number, |
| 47 | title: `Item ${number}`, |
| 48 | body: "", |
| 49 | labels: [], |
| 50 | state: "open", |
| 51 | reason: null, |
| 52 | resolutions: [], |
| 53 | author: { id: "usr_1", username: "ana" } as WorkItem["author"], |
| 54 | requestedBy: null, |
| 55 | createdAt: "2026-10-01T00:00:00Z", |
| 56 | updatedAt: "2026-10-01T00:00:00Z", |
| 57 | closedAt: null, |
| 58 | pullCount: 0, |
| 59 | commentCount: 0, |
| 60 | assignees: [], |
| 61 | blockedBy: [], |
| 62 | agent: null, |
| 63 | repos: [], |
| 64 | horizon: "next", |
| 65 | appetite: null, |
| 66 | budgetUsd: null, |
| 67 | urgent: false, |
| 68 | plannedAt: null, |
| 69 | ...more, |
| 70 | }; |
| 71 | } |
| 72 | |
| 73 | function view(more: Partial<PlanView> = {}): PlanView { |
| 74 | return { |
| 75 | now: [item(1, { horizon: "now" }), item(2, { horizon: "now" })], |
| 76 | next: [item(3), item(4), item(5)], |
| 77 | nextTotal: 3, |
| 78 | laterTotal: 4, |
| 79 | later: [ |
| 80 | { repo: "acme/web", count: 3 }, |
| 81 | { repo: null, count: 1 }, |
| 82 | ], |
| 83 | setAsideTotal: 0, |
| 84 | triageTotal: 2, |
| 85 | ...more, |
| 86 | }; |
| 87 | } |
| 88 | |
| 89 | const numbers = (items: WorkItem[]) => items.map((one) => one.number); |
| 90 | |
| 91 | test("the Plan, Triage and All items have their own addresses", () => { |
| 92 | assert.equal(planPath("Acme"), "/acme/-/work"); |
| 93 | assert.equal(planPath("acme", "all=1"), "/acme/-/work?all=1"); |
| 94 | assert.equal(triagePath("acme"), "/acme/-/work/triage"); |
| 95 | assert.equal(allItemsPath("acme", "state=closed"), "/acme/-/work/all?state=closed"); |
| 96 | assert.equal(setAsidePath("acme"), "/acme/-/work/all?q=horizon%3Aset_aside"); |
| 97 | }); |
| 98 | |
| 99 | test("an old link to the list with filters goes to All items with the same query", () => { |
| 100 | const at = (query: string) => listRedirect("acme", new URLSearchParams(query)); |
| 101 | assert.equal(at(""), null); |
| 102 | assert.equal(at("all=1"), null); |
| 103 | assert.equal(at("_routes=x"), null); |
| 104 | assert.equal(at("state=closed"), "/acme/-/work/all?state=closed"); |
| 105 | assert.equal(at("repo=web&label=bug"), "/acme/-/work/all?repo=web&label=bug"); |
| 106 | assert.equal(at("q=is%3Aclosed+label%3Abug"), "/acme/-/work/all?q=is%3Aclosed+label%3Abug"); |
| 107 | assert.equal(at("assignee=ana"), "/acme/-/work/all?assignee=ana"); |
| 108 | assert.equal(at("milestone=3&_routes=x"), "/acme/-/work/all?milestone=3"); |
| 109 | }); |
| 110 | |
| 111 | test("Now's cap says how full it is and warns only past it", () => { |
| 112 | assert.deepEqual(capState(3, 6), { count: 3, cap: 6, share: 0.5, full: false, over: false }); |
| 113 | assert.deepEqual(capState(6, 6), { count: 6, cap: 6, share: 1, full: true, over: false }); |
| 114 | assert.deepEqual(capState(8, 6), { count: 8, cap: 6, share: 1, full: true, over: true }); |
| 115 | assert.equal(capState(0, 0).cap, 1); |
| 116 | }); |
| 117 | |
| 118 | test("a drop lands before the row under it, or after the last", () => { |
| 119 | const list = [3, 4, 5]; |
| 120 | // From outside the list. |
| 121 | assert.deepEqual(dropPosition(list, 0, 9), { before: 3 }); |
| 122 | assert.deepEqual(dropPosition(list, 2, 9), { before: 5 }); |
| 123 | assert.deepEqual(dropPosition(list, 3, 9), { after: 5 }); |
| 124 | assert.deepEqual(dropPosition([], 0, 9), {}); |
| 125 | // Within the list: where it is already is no move. |
| 126 | assert.equal(dropPosition(list, 0, 3), null); |
| 127 | assert.equal(dropPosition(list, 1, 3), null); |
| 128 | assert.deepEqual(dropPosition(list, 3, 3), { after: 5 }); |
| 129 | assert.deepEqual(dropPosition(list, 2, 3), { before: 5 }); |
| 130 | assert.deepEqual(dropPosition(list, 0, 5), { before: 3 }); |
| 131 | assert.deepEqual(dropPosition(list, 1, 5), { before: 4 }); |
| 132 | assert.equal(dropPosition([3], 1, 3), null); |
| 133 | }); |
| 134 | |
| 135 | test("moving up and down is before the one above, after the one below", () => { |
| 136 | assert.deepEqual(moveUp([3, 4, 5], 4), { before: 3 }); |
| 137 | assert.equal(moveUp([3, 4, 5], 3), null); |
| 138 | assert.deepEqual(moveDown([3, 4, 5], 4), { after: 5 }); |
| 139 | assert.equal(moveDown([3, 4, 5], 5), null); |
| 140 | assert.equal(moveDown([3, 4, 5], 9), null); |
| 141 | }); |
| 142 | |
| 143 | test("a move shows at once where it lands", () => { |
| 144 | // Within Next. |
| 145 | assert.deepEqual(numbers(applyMove(view(), { number: 5, horizon: "next", top: true }).next), [5, 3, 4]); |
| 146 | assert.deepEqual(numbers(applyMove(view(), { number: 3, horizon: "next", after: 4 }).next), [4, 3, 5]); |
| 147 | // From Next into Now, before a row. |
| 148 | const into = applyMove(view(), { number: 4, horizon: "now", before: 2 }); |
| 149 | assert.deepEqual(numbers(into.now), [1, 4, 2]); |
| 150 | assert.equal(into.now[1]!.horizon, "now"); |
| 151 | assert.deepEqual(numbers(into.next), [3, 5]); |
| 152 | assert.equal(into.nextTotal, 2); |
| 153 | // Out of Now to the bottom of Next, all of which is shown. |
| 154 | const out = applyMove(view(), { number: 1, horizon: "next" }); |
| 155 | assert.deepEqual(numbers(out.next), [3, 4, 5, 1]); |
| 156 | assert.equal(out.nextTotal, 4); |
| 157 | // To the bottom of a Next that shows only its top: only counted. |
| 158 | const deep = applyMove(view({ nextTotal: 30 }), { number: 1, horizon: "next" }); |
| 159 | assert.deepEqual(numbers(deep.next), [3, 4, 5]); |
| 160 | assert.equal(deep.nextTotal, 31); |
| 161 | // To Later: counted in its group. |
| 162 | const later = applyMove(view(), { number: 3, horizon: "later" }); |
| 163 | assert.deepEqual(numbers(later.next), [4, 5]); |
| 164 | assert.equal(later.laterTotal, 5); |
| 165 | assert.deepEqual(later.later, [ |
| 166 | { repo: "acme/web", count: 3 }, |
| 167 | { repo: null, count: 2 }, |
| 168 | ]); |
| 169 | const filed = item(6, { repos: [{ repoId: "rep_1", path: "acme/api", role: "origin", legacyNumber: null, landed: false }] }); |
| 170 | assert.deepEqual(applyMove(view({ next: [filed], nextTotal: 1 }), { number: 6, horizon: "later" }).later.at(-1), { repo: "acme/api", count: 1 }); |
| 171 | // From an open Later group into Next: it leaves Later's count and its group. |
| 172 | const fromLater = item(7, { horizon: "later" }); |
| 173 | const pulled = applyMove(view(), { number: 7, horizon: "next", before: 4 }, fromLater); |
| 174 | assert.deepEqual(numbers(pulled.next), [3, 7, 4, 5]); |
| 175 | assert.equal(pulled.laterTotal, 3); |
| 176 | assert.deepEqual(pulled.later, [{ repo: "acme/web", count: 3 }]); |
| 177 | const same = view(); |
| 178 | assert.equal(applyMove(same, { number: 7, horizon: "later" }, fromLater), same); |
| 179 | // Set aside and Triage. |
| 180 | assert.equal(applyMove(view(), { number: 3, horizon: "set_aside" }).setAsideTotal, 1); |
| 181 | assert.equal(applyMove(view(), { number: 2, horizon: "triage" }).triageTotal, 3); |
| 182 | }); |
| 183 | |
| 184 | test("a row's menu offers Now only to who may move work into or out of it", () => { |
| 185 | assert.deepEqual(menuHorizons("next", true), ["triage", "now", "later", "set_aside"]); |
| 186 | assert.deepEqual(menuHorizons("next", false), ["triage", "later", "set_aside"]); |
| 187 | assert.deepEqual(menuHorizons("now", false), []); |
| 188 | assert.deepEqual(menuHorizons("now", true), ["triage", "next", "later", "set_aside"]); |
| 189 | }); |
| 190 | |
| 191 | test("who is on an item: assignees, claimants and live sessions, each once", () => { |
| 192 | const claim = { item: 3, state: "active", actor: { id: "usr_2", username: "bo" } } as Claim; |
| 193 | const ended = { item: 3, state: "released", actor: { id: "usr_3", username: "cy" } } as Claim; |
| 194 | const live = { id: "ses_1", item: 3, state: "active", actor: "ana" } as WorkspaceSession; |
| 195 | const gone = { id: "ses_2", item: 3, state: "ended", actor: "di" } as WorkspaceSession; |
| 196 | const on = onItems([item(3, { assignees: ["ana"] }), item(4)], [claim, ended], [live, gone]); |
| 197 | assert.deepEqual(on[3]!.people, ["ana", "bo"]); |
| 198 | assert.deepEqual(on[3]!.sessions, [live]); |
| 199 | assert.deepEqual(on[4], { people: [], sessions: [] }); |
| 200 | }); |
| 201 | |
| 202 | test("what an item waits on", () => { |
| 203 | const items = [item(3, { blockedBy: [1, 7] }), item(4, { blockedBy: [7, 8] })]; |
| 204 | assert.deepEqual(blockersToRead(items, new Set([1])), [7, 8]); |
| 205 | assert.deepEqual(blockersToRead(items, new Set(), 2), [1, 7]); |
| 206 | assert.deepEqual(waitingOn(items[1]!, new Set([8])), [7]); |
| 207 | assert.equal(waitingLabel([7, 8]), "waiting on #7, #8"); |
| 208 | }); |
| 209 | |
| 210 | test("a move from a form", () => { |
| 211 | const form = (fields: Record<string, string>) => { |
| 212 | const data = new FormData(); |
| 213 | for (const [key, value] of Object.entries(fields)) data.set(key, value); |
| 214 | return data; |
| 215 | }; |
| 216 | assert.deepEqual(planInput(form({ horizon: "now", before: "4" })), { horizon: "now", before: 4 }); |
| 217 | assert.deepEqual(planInput(form({ horizon: "next", after: "9" })), { horizon: "next", after: 9 }); |
| 218 | assert.deepEqual(planInput(form({ horizon: "next", top: "1", before: "4" })), { horizon: "next", top: true }); |
| 219 | assert.deepEqual(planInput(form({ appetite: "none" })), { appetite: null }); |
| 220 | assert.deepEqual(planInput(form({ appetite: "medium", budget: "$1,200" })), { appetite: "medium", budget_usd: 1200 }); |
| 221 | assert.deepEqual(planInput(form({ budget: "" })), { budget_usd: null }); |
| 222 | assert.deepEqual(planInput(form({ urgent: "1" })), { urgent: true }); |
| 223 | assert.deepEqual(planInput(form({ urgent: "0" })), { urgent: false }); |
| 224 | assert.equal(typeof planInput(form({ horizon: "someday" })), "string"); |
| 225 | assert.equal(typeof planInput(form({ before: "x" })), "string"); |
| 226 | assert.equal(typeof planInput(form({ appetite: "huge" })), "string"); |
| 227 | // And back: a move as its fetcher posts it. |
| 228 | assert.deepEqual(moveForm({ number: 4, horizon: "now", before: 2 }), { action: "plan", number: "4", horizon: "now", before: "2" }); |
| 229 | assert.deepEqual(moveForm({ number: 4, horizon: "next", top: true }), { action: "plan", number: "4", horizon: "next", top: "1" }); |
| 230 | assert.deepEqual(moveForm({ number: 4, horizon: "later" }), { action: "plan", number: "4", horizon: "later" }); |
| 231 | assert.deepEqual(moveFrom(form(moveForm({ number: 4, horizon: "now", before: 2 }))), { number: 4, horizon: "now", before: 2 }); |
| 232 | assert.deepEqual(moveFrom(form(moveForm({ number: 4, horizon: "next", after: 9 }))), { number: 4, horizon: "next", after: 9 }); |
| 233 | assert.deepEqual(moveFrom(form(moveForm({ number: 4, horizon: "next", top: true }))), { number: 4, horizon: "next", top: true }); |
| 234 | assert.deepEqual(moveFrom(form({ number: "4", horizon: "later" })), { number: 4, horizon: "later" }); |
| 235 | assert.equal(moveFrom(form({ number: "4", appetite: "small" })), null); |
| 236 | }); |
| 237 | |
| 238 | test("budgets", () => { |
| 239 | assert.equal(parseBudget("40"), 40); |
| 240 | assert.equal(parseBudget(" $12.345 "), 12.35); |
| 241 | assert.equal(parseBudget(""), null); |
| 242 | assert.equal(typeof parseBudget("0"), "string"); |
| 243 | assert.equal(typeof parseBudget("-5"), "string"); |
| 244 | assert.equal(typeof parseBudget("200000"), "string"); |
| 245 | assert.equal(typeof parseBudget("ten"), "string"); |
| 246 | assert.equal(budgetText(40), "$40"); |
| 247 | assert.equal(budgetText(12.5), "$12.50"); |
| 248 | assert.equal(budgetText(1200), "$1,200"); |
| 249 | }); |
| 250 | |
| 251 | test("n more", () => { |
| 252 | assert.equal(moreText(10, 10), null); |
| 253 | assert.equal(moreText(10, 1234), "1,224 more"); |
| 254 | }); |
| 255 | |
| 256 | test("a hill's stage, in its four steps", () => { |
| 257 | assert.deepEqual((["figuring_out", "decided", "building", "landing"] as const).map(hillStep), [0, 1, 2, 3]); |
| 258 | }); |
| 259 | |
| 260 | test("a quiet row says for how long, from the last time anything happened on it", () => { |
| 261 | const now = Date.parse("2026-10-19T12:00:00Z"); |
| 262 | assert.equal(daysSince("2026-10-13T11:00:00Z", now), 6); |
| 263 | assert.equal(daysSince(null, now), null); |
| 264 | assert.equal(daysSince("2026-10-20T00:00:00Z", now), 0); |
| 265 | assert.equal(quietText({ quiet: true, activeAt: "2026-10-13T11:00:00Z" }, now), "Quiet for 6 days"); |
| 266 | assert.equal(quietText({ quiet: true, activeAt: "2026-10-18T11:00:00Z" }, now), "Quiet for 1 day"); |
| 267 | assert.equal(quietText({ quiet: true, activeAt: null }, now), "Quiet"); |
| 268 | assert.equal(quietText({ quiet: false, activeAt: "2026-10-01T00:00:00Z" }, now), null); |
| 269 | }); |
| 270 | |
| 271 | test("the week: what went quiet, what was let go, and Triage waiting", () => { |
| 272 | assert.equal(quietLine(0), null); |
| 273 | assert.equal(quietLine(1), "1 in Now has gone quiet, with nothing happening on it for 5 days or more."); |
| 274 | assert.equal(quietLine(3), "3 in Now have gone quiet, with nothing happening on them for 5 days or more."); |
| 275 | assert.equal(setAsideLine(0), null); |
| 276 | assert.equal(setAsideLine(4), "4 set aside in the last 30 days, untouched for 90 days in Later"); |
| 277 | assert.equal(triageLine(1, 0), "1 new work item waits in Triage to be placed."); |
| 278 | assert.equal(triageLine(5, 2), "5 new work items wait in Triage to be placed. 2 of them have waited more than a week."); |
| 279 | assert.equal(triageLine(3, 3), "3 new work items wait in Triage to be placed. All have waited more than a week."); |
| 280 | assert.equal(triageLine(1, 1), "1 new work item waits in Triage to be placed. It has waited more than a week."); |
| 281 | assert.deepEqual(progressOf([{ number: 4, hill: "building" }]), { 4: { number: 4, hill: "building" } }); |
| 282 | }); |
| 283 | |
| 284 | test("accepting a suggestion moves it where it says, at the bottom, with its appetite", () => { |
| 285 | const next = { horizon: "next" as const, appetite: "medium" as const }; |
| 286 | assert.equal(suggestionText(next), "Next · M"); |
| 287 | assert.equal(suggestionText({ horizon: "later", appetite: null }), "Later"); |
| 288 | assert.deepEqual(acceptForm(next), { horizon: "next", appetite: "medium" }); |
| 289 | assert.deepEqual(acceptForm({ horizon: "later", appetite: null }), { horizon: "later" }); |
| 290 | // It is a plan move as the form posts it: no position, so the horizon puts it at the bottom. |
| 291 | assert.deepEqual(planInput(new URLSearchParams(acceptForm(next))), { horizon: "next", appetite: "medium" }); |
| 292 | assert.equal(canAccept({ horizon: "now" }, false), false); |
| 293 | assert.equal(canAccept({ horizon: "now" }, true), true); |
| 294 | assert.equal(canAccept({ horizon: "next" }, false), true); |
| 295 | assert.equal(duplicateComment(12), "Duplicate of #12"); |
| 296 | }); |