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