| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import type { Claim, WorkItem, WorkSession } from "@g1t/contracts"; |
| 5 | |
| 6 | import { |
| 7 | ITEM_KEYS, |
| 8 | activityCounts, |
| 9 | activityFilterOf, |
| 10 | activityParts, |
| 11 | budgetOf, |
| 12 | itemCommandOf, |
| 13 | itemStage, |
| 14 | keyFor, |
| 15 | neighbours, |
| 16 | planSummary, |
| 17 | sameSet, |
| 18 | toggled, |
| 19 | trailOf, |
| 20 | trailState, |
| 21 | typedNumber, |
| 22 | typedUsername, |
| 23 | } from "./work-item-page.ts"; |
| 24 | |
| 25 | test("each property has its key, and the page's keys are letters alone", () => { |
| 26 | assert.equal(itemCommandOf({ key: "a" }, false), "assignee"); |
| 27 | assert.equal(itemCommandOf({ key: "p" }, false), "project"); |
| 28 | assert.equal(itemCommandOf({ key: "h" }, false), "plan"); |
| 29 | assert.equal(itemCommandOf({ key: "m" }, false), "milestone"); |
| 30 | assert.equal(itemCommandOf({ key: "l" }, false), "labels"); |
| 31 | assert.equal(itemCommandOf({ key: "b" }, false), "blocked"); |
| 32 | assert.equal(itemCommandOf({ key: "d" }, false), "agent"); |
| 33 | assert.equal(itemCommandOf({ key: "e" }, false), "edit"); |
| 34 | assert.equal(itemCommandOf({ key: "i" }, false), "claim"); |
| 35 | assert.equal(itemCommandOf({ key: "j" }, false), "next"); |
| 36 | assert.equal(itemCommandOf({ key: "k" }, false), "previous"); |
| 37 | // Caps Lock on still means the key. |
| 38 | assert.equal(itemCommandOf({ key: "A" }, false), "assignee"); |
| 39 | assert.equal(itemCommandOf({ key: "x" }, false), null); |
| 40 | assert.equal(itemCommandOf({ key: "Enter" }, false), null); |
| 41 | }); |
| 42 | |
| 43 | test("Shift C closes, Shift Y moves to the next cycle, and Shift with any other letter does nothing", () => { |
| 44 | assert.equal(itemCommandOf({ key: "C", shiftKey: true }, false), "close"); |
| 45 | assert.equal(itemCommandOf({ key: "Y", shiftKey: true }, false), "cycle-next"); |
| 46 | assert.equal(itemCommandOf({ key: "c" }, false), null); |
| 47 | assert.equal(itemCommandOf({ key: "A", shiftKey: true }, false), null); |
| 48 | }); |
| 49 | |
| 50 | test("no key fires while typing, with a modifier held, or held down", () => { |
| 51 | assert.equal(itemCommandOf({ key: "a" }, true), null); |
| 52 | assert.equal(itemCommandOf({ key: "C", shiftKey: true }, true), null); |
| 53 | assert.equal(itemCommandOf({ key: "k", metaKey: true }, false), null); |
| 54 | assert.equal(itemCommandOf({ key: "k", ctrlKey: true }, false), null); |
| 55 | assert.equal(itemCommandOf({ key: "j", altKey: true }, false), null); |
| 56 | assert.equal(itemCommandOf({ key: "j", repeat: true }, false), null); |
| 57 | }); |
| 58 | |
| 59 | test("every command the keys give is listed for the shortcuts dialog", () => { |
| 60 | for (const key of "aphmlbdeijky") { |
| 61 | const command = itemCommandOf({ key }, false); |
| 62 | assert.ok(command && ITEM_KEYS.some((one) => one.command === command), key); |
| 63 | assert.equal(keyFor(command!), key.toUpperCase()); |
| 64 | } |
| 65 | assert.equal(keyFor("close"), "Shift C"); |
| 66 | assert.equal(keyFor("cycle-next"), "Shift Y"); |
| 67 | }); |
| 68 | |
| 69 | test("the list an item was opened from travels in its link's state and comes back clean", () => { |
| 70 | const trail = { numbers: [12, 14, 12, 9], label: "All items", href: "/acme/-/work/all" }; |
| 71 | const state = trailState(trail); |
| 72 | assert.deepEqual(state, { workTrail: { numbers: [12, 14, 9], label: "All items", href: "/acme/-/work/all" } }); |
| 73 | assert.deepEqual(trailOf(state), { numbers: [12, 14, 9], label: "All items", href: "/acme/-/work/all" }); |
| 74 | assert.equal(trailState({ ...trail, numbers: [] }), undefined); |
| 75 | assert.equal(trailState(null), undefined); |
| 76 | }); |
| 77 | |
| 78 | test("anything else in the location state is no list", () => { |
| 79 | assert.equal(trailOf(null), null); |
| 80 | assert.equal(trailOf("12,14"), null); |
| 81 | assert.equal(trailOf({ workTrail: { numbers: "12", label: "x", href: "/" } }), null); |
| 82 | assert.equal(trailOf({ workTrail: { numbers: [0, -1, 1.5], label: "x", href: "/" } }), null); |
| 83 | assert.deepEqual(trailOf({ workTrail: { numbers: [3, "4", 5], label: "x", href: "/" } }), { numbers: [3, 5], label: "x", href: "/" }); |
| 84 | }); |
| 85 | |
| 86 | test("J and K step to the next and previous item, and stop at the ends", () => { |
| 87 | assert.deepEqual(neighbours([12, 14, 9], 14), { previous: 12, next: 9, index: 2, total: 3 }); |
| 88 | assert.deepEqual(neighbours([12, 14, 9], 12), { previous: null, next: 14, index: 1, total: 3 }); |
| 89 | assert.deepEqual(neighbours([12, 14, 9], 9), { previous: 14, next: null, index: 3, total: 3 }); |
| 90 | assert.equal(neighbours([12, 14, 9], 30), null); |
| 91 | }); |
| 92 | |
| 93 | const comment = (id: string, kind: "comment" | "event") => ({ id, kind }); |
| 94 | |
| 95 | test("Activity's filter keeps comments, events or both", () => { |
| 96 | const comments = [comment("c1", "comment"), comment("e1", "event"), comment("c2", "comment")]; |
| 97 | const extras = [{ id: "ses_1" }, { id: "claim_1" }]; |
| 98 | assert.deepEqual(activityParts(comments, extras, "all"), { comments, extras }); |
| 99 | assert.deepEqual(activityParts(comments, extras, "comments"), { comments: [comments[0], comments[2]], extras: [] }); |
| 100 | assert.deepEqual(activityParts(comments, extras, "events"), { comments: [comments[1]], extras }); |
| 101 | assert.deepEqual(activityCounts(comments, extras.length), { all: 5, comments: 2, events: 3 }); |
| 102 | assert.equal(activityFilterOf("events"), "events"); |
| 103 | assert.equal(activityFilterOf("everything"), "all"); |
| 104 | assert.equal(activityFilterOf(null), "all"); |
| 105 | }); |
| 106 | |
| 107 | const item = (over: Partial<WorkItem> = {}): WorkItem => |
| 108 | ({ |
| 109 | id: "wki_1", |
| 110 | workspace: "acme", |
| 111 | number: 12, |
| 112 | title: "Item page", |
| 113 | body: "", |
| 114 | labels: [], |
| 115 | state: "open", |
| 116 | reason: null, |
| 117 | resolutions: [], |
| 118 | author: { id: "usr_1", username: "ana" }, |
| 119 | requestedBy: null, |
| 120 | createdAt: "2026-10-01T00:00:00Z", |
| 121 | updatedAt: "2026-10-01T00:00:00Z", |
| 122 | closedAt: null, |
| 123 | pullCount: 0, |
| 124 | commentCount: 0, |
| 125 | assignees: [], |
| 126 | blockedBy: [], |
| 127 | agent: null, |
| 128 | repos: [], |
| 129 | horizon: "now", |
| 130 | appetite: null, |
| 131 | budgetUsd: null, |
| 132 | urgent: false, |
| 133 | plannedAt: null, |
| 134 | ...over, |
| 135 | }) as WorkItem; |
| 136 | |
| 137 | const now = Date.parse("2026-10-21T00:00:00Z"); |
| 138 | |
| 139 | test("the stage says why: a pull request open is in review", () => { |
| 140 | const stage = itemStage(item(), { repos: [{ path: "acme/web", pulls: [{ number: 318, status: "open" }] }] }, now); |
| 141 | assert.deepEqual(stage, { stage: "review", label: "In review", why: "#318 open in acme/web" }); |
| 142 | }); |
| 143 | |
| 144 | test("claimed, a session on it, or a draft is in progress, saying which", () => { |
| 145 | const claim = { state: "active", actor: { username: "ana" } } as Claim; |
| 146 | const session = { state: "active", parentId: null } as WorkSession; |
| 147 | const stage = itemStage(item(), { claims: [claim], sessions: [session], repos: [{ path: "acme/web", pulls: [{ number: 4, status: "draft" }] }] }, now); |
| 148 | assert.equal(stage.stage, "progress"); |
| 149 | assert.equal(stage.why, "Claimed by ana, a session on it, #4 draft in acme/web"); |
| 150 | assert.equal(itemStage(item({ assignees: ["bo"] }), {}, now).why, "Assigned to bo"); |
| 151 | }); |
| 152 | |
| 153 | test("nobody on it is up next; landing names what is still to land; closed ones say how", () => { |
| 154 | assert.deepEqual(itemStage(item(), {}, now), { stage: "next", label: "Up next", why: "Nobody is on it yet" }); |
| 155 | const landing = item({ |
| 156 | repos: [ |
| 157 | { repoId: "r1", path: "acme/web", role: "target", landed: true }, |
| 158 | { repoId: "r2", path: "acme/api", role: "target", landed: false }, |
| 159 | ] as WorkItem["repos"], |
| 160 | }); |
| 161 | assert.equal(itemStage(landing, {}, now).why, "Still to land in acme/api"); |
| 162 | const done = item({ |
| 163 | state: "closed", |
| 164 | reason: "completed", |
| 165 | closedAt: "2026-10-20T00:00:00Z", |
| 166 | resolutions: [{ repo: "acme/web", pull: 3 }] as WorkItem["resolutions"], |
| 167 | }); |
| 168 | assert.deepEqual(itemStage(done, {}, now), { stage: "done", label: "Done", why: "Landed in acme/web" }); |
| 169 | assert.equal(itemStage(item({ state: "closed", reason: "not_planned", closedAt: "2026-10-20T00:00:00Z" }), {}, now).why, "Closed as not planned"); |
| 170 | }); |
| 171 | |
| 172 | test("the pickers read a budget, a number and a username from what is typed", () => { |
| 173 | assert.equal(budgetOf("5"), 5); |
| 174 | assert.equal(budgetOf("$12.50"), 12.5); |
| 175 | assert.equal(budgetOf("none"), null); |
| 176 | assert.equal(budgetOf("now"), undefined); |
| 177 | assert.equal(budgetOf("0"), undefined); |
| 178 | assert.equal(budgetOf("1.234"), undefined); |
| 179 | assert.equal(typedNumber("#212"), 212); |
| 180 | assert.equal(typedNumber("212"), 212); |
| 181 | assert.equal(typedNumber("#0"), null); |
| 182 | assert.equal(typedNumber("fix 212"), null); |
| 183 | assert.equal(typedUsername("@Ana"), "ana"); |
| 184 | assert.equal(typedUsername("bo-2"), "bo-2"); |
| 185 | assert.equal(typedUsername("two words"), null); |
| 186 | assert.equal(typedUsername(""), null); |
| 187 | }); |
| 188 | |
| 189 | test("a multi-choice picker toggles and saves only a change", () => { |
| 190 | assert.deepEqual(toggled(["a", "b"], "b"), ["a"]); |
| 191 | assert.deepEqual(toggled(["a"], "b"), ["a", "b"]); |
| 192 | assert.equal(sameSet(["a", "b"], ["b", "a"]), true); |
| 193 | assert.equal(sameSet(["a"], ["a", "b"]), false); |
| 194 | }); |
| 195 | |
| 196 | test("the plan reads as one line", () => { |
| 197 | const label = (h: string) => ({ now: "Now", next: "Next" })[h as "now"] ?? h; |
| 198 | assert.equal(planSummary({ horizon: "now", appetite: "medium", budgetUsd: 5, urgent: false }, label), "Now · M · $5"); |
| 199 | assert.equal(planSummary({ horizon: "next", appetite: null, budgetUsd: 2.5, urgent: true }, label), "Next · $2.50 · Urgent"); |
| 200 | }); |