Public: anyone can read this; the crew can change it.
The terminal's milestones screen shows each milestone's pace: issues closed per day as a sparkline and when it lands at this pace #8119
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8058-the-terminal-s-milestones-screen-shows-e → main 2 commits fork
3 files
changed +339
−15
src/tui/screens/common.ts +52 −8
| @@ -48,6 +48,19 @@ export class ListState { | ||
| 48 | 48 | offset = 0; |
| 49 | 49 | /** Rows the last render had room for — what `ctrl+d` pages by. */ |
| 50 | 50 | height = 10; |
| 51 | /** Screen rows each drawn item took, from `offset`, when some item had a note; else undefined. */ | |
| 52 | heights: number[] | undefined; | |
| 53 | ||
| 54 | /** The item at list row `y`: where a click landed, however tall the items above it. */ | |
| 55 | at(y: number): number { | |
| 56 | if (this.heights === undefined) return this.offset + y; | |
| 57 | let top = 0; | |
| 58 | for (let i = 0; i < this.heights.length; i++) { | |
| 59 | top += this.heights[i]!; | |
| 60 | if (y < top) return this.offset + i; | |
| 61 | } | |
| 62 | return -1; | |
| 63 | } | |
| 51 | 64 | |
| 52 | 65 | clamp(length: number): void { |
| 53 | 66 | this.index = Math.max(0, Math.min(this.index, Math.max(0, length - 1))); |
| @@ -215,6 +228,8 @@ export interface ListRow { | ||
| 215 | 228 | readonly hollow?: boolean; |
| 216 | 229 | readonly secondary?: string; |
| 217 | 230 | readonly meta?: string; |
| 231 | /** A muted line under the row, indented past its dot; the item is then two rows tall. */ | |
| 232 | readonly note?: string; | |
| 218 | 233 | } |
| 219 | 234 | |
| 220 | 235 | /** A scrolling list of `row()`s with a scrollbar when it overflows. */ |
| @@ -228,15 +243,44 @@ export function drawList( | ||
| 228 | 243 | ): void { |
| 229 | 244 | if (r.w <= 0 || r.h <= 0) return; |
| 230 | 245 | const body = { x: r.x, y: r.y, w: Math.max(0, r.w - 1), h: r.h }; |
| 231 | const from = state.window(r.h, rows.length); | |
| 232 | for (let i = 0; i < r.h; i++) { | |
| 233 | const item = rows[from + i]; | |
| 234 | if (item === undefined) break; | |
| 235 | row(s, { x: body.x, y: body.y + i, w: body.w, h: 1 }, c, { | |
| 246 | const noted = rows.some((i) => i.note !== undefined && i.note !== ""); | |
| 247 | const tall = (i: number): number => (noted && rows[i]!.note ? 2 : 1); | |
| 248 | let from: number; | |
| 249 | if (!noted) { | |
| 250 | state.heights = undefined; | |
| 251 | from = state.window(r.h, rows.length); | |
| 252 | } else { | |
| 253 | // Scroll by item, so the cursor's whole entry (row and note) is in view. | |
| 254 | state.clamp(rows.length); | |
| 255 | from = Math.max(0, Math.min(state.offset, state.index)); | |
| 256 | const span = (a: number, b: number): number => { | |
| 257 | let h = 0; | |
| 258 | for (let i = a; i <= b; i++) h += tall(i); | |
| 259 | return h; | |
| 260 | }; | |
| 261 | while (from < state.index && span(from, state.index) > r.h) from++; | |
| 262 | // Never leave a gap under the last item when the ones above would fit. | |
| 263 | while (from > 0 && span(from - 1, rows.length - 1) <= r.h) from--; | |
| 264 | state.offset = from; | |
| 265 | } | |
| 266 | let y = 0; | |
| 267 | const heights: number[] = []; | |
| 268 | for (let i = from; i < rows.length && y < r.h; i++) { | |
| 269 | const item = rows[i]!; | |
| 270 | const h = Math.min(tall(i), r.h - y); | |
| 271 | row(s, { x: body.x, y: body.y + y, w: body.w, h: 1 }, c, { | |
| 236 | 272 | ...item, |
| 237 | selected: focused && from + i === state.index, | |
| 238 | focused: from + i === state.index, | |
| 273 | selected: focused && i === state.index, | |
| 274 | focused: i === state.index, | |
| 239 | 275 | }); |
| 276 | if (item.note && h > 1 && body.w > 4) | |
| 277 | s.text(body.x + 4, body.y + y + 1, truncate(item.note, body.w - 4), style(c.mutedFg, c.bg), body.x + body.w); | |
| 278 | heights.push(h); | |
| 279 | y += h; | |
| 280 | } | |
| 281 | if (noted) { | |
| 282 | state.heights = heights; | |
| 283 | state.height = Math.max(1, heights.length); | |
| 240 | 284 | } |
| 241 | 285 | scrollbar(s, { x: r.x + r.w - 1, y: r.y, w: 1, h: r.h }, c, { |
| 242 | 286 | total: rows.length, |
| @@ -276,7 +320,7 @@ export function hitList( | ||
| 276 | 320 | ): void { |
| 277 | 321 | ctx.hit(r, { |
| 278 | 322 | click: (e) => { |
| 279 | const index = state.offset + e.y; | |
| 323 | const index = state.at(e.y); | |
| 280 | 324 | if (index < 0 || index >= count) return; |
| 281 | 325 | if (index === state.index || e.double) { |
| 282 | 326 | state.index = index; |
src/tui/screens/limbs.ts +24 −7
| @@ -50,6 +50,7 @@ import { binding } from "../app/keys.ts"; | ||
| 50 | 50 | import { fill, len, split } from "../ui/layout.ts"; |
| 51 | 51 | import { style } from "../term/style.ts"; |
| 52 | 52 | import { truncate } from "../term/width.ts"; |
| 53 | import { type Pace } from "../../core/pace.ts"; | |
| 53 | 54 | import type { Session } from "../session.ts"; |
| 54 | 55 | import { issueScreen } from "./issue.ts"; |
| 55 | 56 | import { local } from "./where.ts"; |
| @@ -121,6 +122,22 @@ export function replanLines( | ||
| 121 | 122 | return { lines, start, shown }; |
| 122 | 123 | } |
| 123 | 124 | |
| 125 | const BLOCKS = "▁▂▃▄▅▆▇█"; | |
| 126 | ||
| 127 | /** Issues closed per day as block characters, scaled to the largest day; all quiet is the lowest block. */ | |
| 128 | export function paceSpark(days: readonly number[]): string { | |
| 129 | const top = Math.max(1, ...days); | |
| 130 | return days.map((n) => BLOCKS[Math.min(7, Math.floor((Math.max(0, n) / top) * 8))]).join(""); | |
| 131 | } | |
| 132 | ||
| 133 | /** The pace line for `width` cells: sparkline, then the sentence; the sparkline goes first when narrow. */ | |
| 134 | export function paceText(pace: Pace | undefined, width: number): string { | |
| 135 | if (pace === undefined || pace.line === "" || width <= 0) return ""; | |
| 136 | const spark = paceSpark(pace.closedPerDay); | |
| 137 | const both = `${spark} ${pace.line}`; | |
| 138 | return both.length <= width ? both : truncate(pace.line, width); | |
| 139 | } | |
| 140 | ||
| 124 | 141 | const line = (n: number, title: string): string => `#${n} ${title}`; |
| 125 | 142 | |
| 126 | 143 | /** What a preview says before anything is written, as dialog lines. */ |
| @@ -162,7 +179,7 @@ export function limbsScreen( | ||
| 162 | 179 | /** What each limb here cost and landed, by issue number; empty unless owner facts. */ |
| 163 | 180 | let spends = new Map<number, NonNullable<Milestone["spend"]>>(); |
| 164 | 181 | /** Each limb's pace line, by issue number: on pace for a date, no movement, or done. */ |
| 165 | let paces = new Map<number, string>(); | |
| 182 | let paces = new Map<number, Pace>(); | |
| 166 | 183 | /** The receipt of the last move made here, which `u` reverses once. */ |
| 167 | 184 | let last: string | undefined; |
| 168 | 185 | /** The limbs with the planner, by issue id (#761). */ |
| @@ -206,7 +223,7 @@ export function limbsScreen( | ||
| 206 | 223 | if (f.user?.kind === "user" && isWatching(f.db, f.user.id, m.issue.id)) |
| 207 | 224 | watching.add(m.issue.id); |
| 208 | 225 | if (m.spend !== undefined) spends.set(l.issue.number, m.spend); |
| 209 | if (m.pace.line !== "") paces.set(l.issue.number, m.pace.line); | |
| 226 | if (m.pace.line !== "") paces.set(l.issue.number, m.pace); | |
| 210 | 227 | } |
| 211 | 228 | loaded = true; |
| 212 | 229 | if (follow !== undefined) { |
| @@ -361,8 +378,10 @@ export function limbsScreen( | ||
| 361 | 378 | ctx.push(limbsScreen(f, ctx, repo, String(number), limb.name)); |
| 362 | 379 | }; |
| 363 | 380 | |
| 364 | const rows = (): ListRow[] => | |
| 381 | /** One row per limb; a limb with a pace says it in a note under the row, `width` cells wide. */ | |
| 382 | const rows = (width: number): ListRow[] => | |
| 365 | 383 | limbs.map((l, n) => ({ |
| 384 | note: paceText(paces.get(l.issue.number), width), | |
| 366 | 385 | title: `${n + 1}. ${line(l.issue.number, l.name)}`, |
| 367 | 386 | secondary: withPlanner.has(l.issue.id) ? "with the planner" : l.deferred ? "deferred" : "", |
| 368 | 387 | meta: `${watching.has(l.issue.id) ? "watching · " : ""}${l.weight.done}/${l.weight.total} done`, |
| @@ -391,9 +410,8 @@ export function limbsScreen( | ||
| 391 | 410 | render(area, s) { |
| 392 | 411 | const c = ctx.theme.c; |
| 393 | 412 | const spend = current() === undefined ? undefined : spends.get(current()!.issue.number); |
| 394 | const pace = current() === undefined ? undefined : paces.get(current()!.issue.number); | |
| 395 | 413 | const answered = current() === undefined ? undefined : replanned.get(current()!.issue.number); |
| 396 | const fixed = (pace === undefined ? 0 : 1) + (spend ? 2 : 0); | |
| 414 | const fixed = spend ? 2 : 0; | |
| 397 | 415 | // The foot never takes more than half of what the header leaves. |
| 398 | 416 | const block = |
| 399 | 417 | answered === undefined |
| @@ -412,9 +430,8 @@ export function limbsScreen( | ||
| 412 | 430 | ? "Nothing here waits for anything yet, so there is no milestone to show." |
| 413 | 431 | : `${plural(limbs.length, "limb")} in the order the crew takes ready work${may() ? "" : " · you can read this order, not change it"}`; |
| 414 | 432 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 415 | drawList(s, list!, c, state, rows()); | |
| 433 | drawList(s, list!, c, state, rows(list!.w - 5)); | |
| 416 | 434 | const lines = [ |
| 417 | ...(pace === undefined ? [] : [pace]), | |
| 418 | 435 | ...(spend === undefined ? [] : spendLines(spend)), |
| 419 | 436 | ...block.lines, |
| 420 | 437 | ]; |
test/tui-limbs-pace.test.ts added +263
| @@ -0,0 +1,263 @@ | ||
| 1 | /** | |
| 2 | * The limbs screen draws each milestone's pace under its row (#8058): a | |
| 3 | * sparkline of issues closed per day and `pace.line` word for word, the | |
| 4 | * same line the milestones page draws from the same `readPlan`. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { linkIssue } from "../src/actions/dependencies.ts"; | |
| 12 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { getIssueById, type Issue } from "../src/core/issues.ts"; | |
| 15 | import { readPlan } from "../src/core/milestones.ts"; | |
| 16 | import { PACE_DAYS } from "../src/core/pace.ts"; | |
| 17 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 18 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 19 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 20 | import { paceSpark, paceText } from "../src/tui/screens/limbs.ts"; | |
| 21 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 22 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | import { settleApp } from "./helpers/tui.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | const SPARK = /[▁▂▃▄▅▆▇█]{14}/; | |
| 29 | ||
| 30 | describe("pace under each milestone in the terminal", () => { | |
| 31 | let dataDir: string; | |
| 32 | let app: App; | |
| 33 | let alice: User; | |
| 34 | let repo: Repo; | |
| 35 | let manyId = ""; | |
| 36 | let moving: Issue; | |
| 37 | let still: Issue; | |
| 38 | const plan = (): ReturnType<typeof readPlan> => readPlan(app.db, getRepoById(app.db, manyId)!, { spend: false }); | |
| 39 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 40 | const current = (i: Issue) => getIssueById(app.db, i.id)!; | |
| 41 | const open = (title: string): Issue => { | |
| 42 | const made = openIssue(app, alice, fresh(), { title }); | |
| 43 | if (!made.ok) throw new Error(String(made.error)); | |
| 44 | return made.value; | |
| 45 | }; | |
| 46 | ||
| 47 | function boot(rows = 32, cols = 120, name = "alice/pace") { | |
| 48 | const terminal = headlessTerminal(cols, rows); | |
| 49 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice }; | |
| 50 | const { app: tui } = createForgeApp(app, identity, { terminal, exitHooks: false }); | |
| 51 | tui.start(); | |
| 52 | const press = async (...keys: string[]) => { | |
| 53 | for (const key of keys) tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 54 | await settleApp(tui); | |
| 55 | }; | |
| 56 | const toLimbs = async () => { | |
| 57 | await settleApp(tui); | |
| 58 | tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 59 | await settleApp(tui); | |
| 60 | await press(..."Milestones in a repository".split(""), "enter"); | |
| 61 | await press(...name.split(""), "enter"); | |
| 62 | }; | |
| 63 | const mouse = async (kind: "press" | "wheel-down" | "wheel-up", x: number, y: number) => { | |
| 64 | tui.dispatch({ type: "mouse", kind, button: 0, x, y, ctrl: false, alt: false, shift: false }); | |
| 65 | await settleApp(tui); | |
| 66 | }; | |
| 67 | const text = () => tui.screen.toText(); | |
| 68 | /** The screen row holding `needle`. */ | |
| 69 | const rowOf = (needle: string): number => text().split("\n").findIndex((r) => /\d+\. #\d+ /.test(r) && r.includes(needle)); | |
| 70 | /** Click the row that holds `needle`, `below` rows under it. */ | |
| 71 | const click = (needle: string, below = 0) => mouse("press", 12, rowOf(needle) + below); | |
| 72 | return { text, press, toLimbs, click, mouse, rowOf, stop: () => tui.stop() }; | |
| 73 | } | |
| 74 | ||
| 75 | beforeAll(async () => { | |
| 76 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-pace-")); | |
| 77 | app = createApp({ dataDir, port: 0 }); | |
| 78 | const user = await createUser(app.db, { | |
| 79 | origin: "seed", | |
| 80 | trusted: true, | |
| 81 | username: "alice", | |
| 82 | email: "[email protected]", | |
| 83 | password: "a-good-passphrase", | |
| 84 | adminIfFirst: true, | |
| 85 | }); | |
| 86 | if (!user.ok) throw new Error(String(user.error)); | |
| 87 | alice = getUserByName(app.db, "alice")!; | |
| 88 | const made = await createRepo(app, alice, { name: "pace", visibility: "public" }); | |
| 89 | if (!made.ok) throw new Error(String(made.error)); | |
| 90 | repo = made.value; | |
| 91 | moving = open("Moving"); | |
| 92 | still = open("Still"); | |
| 93 | const leaves = [1, 2, 3, 4, 5].map((n) => open(`Moving leaf ${n}`)); | |
| 94 | for (const leaf of leaves) linkIssue(app, alice, fresh(), current(moving), current(leaf)); | |
| 95 | linkIssue(app, alice, fresh(), current(still), current(open("Still leaf"))); | |
| 96 | // Three closed on three different days, two left open. | |
| 97 | for (const [leaf, daysAgo] of [[leaves[0]!, 1], [leaves[1]!, 3], [leaves[2]!, 5]] as const) { | |
| 98 | expect(setIssueState(app, alice, fresh(), current(leaf), "closed").ok).toBe(true); | |
| 99 | const at = new Date(Date.now() - daysAgo * 86_400_000).toISOString(); | |
| 100 | app.db.query("UPDATE issue SET closed_at = ?1 WHERE id = ?2").run(at, leaf.id); | |
| 101 | } | |
| 102 | }); | |
| 103 | ||
| 104 | /** A repository of `n` milestones, each with a pace line, behind one that says none. */ | |
| 105 | async function manyMilestones(): Promise<void> { | |
| 106 | const made = await createRepo(app, alice, { name: "many", visibility: "public" }); | |
| 107 | if (!made.ok) throw new Error(String(made.error)); | |
| 108 | const many = made.value; | |
| 109 | const mk = (title: string): Issue => { | |
| 110 | const o = openIssue(app, alice, many, { title }); | |
| 111 | if (!o.ok) throw new Error(String(o.error)); | |
| 112 | return o.value; | |
| 113 | }; | |
| 114 | const fresh2 = () => getRepoById(app.db, many.id)!; | |
| 115 | const cur = (i: Issue) => getIssueById(app.db, i.id)!; | |
| 116 | // Quiet: finished, but with no recorded day, so the page has no line to say. | |
| 117 | const quiet = mk("Quiet"); | |
| 118 | const quietLeaf = mk("Quiet leaf"); | |
| 119 | linkIssue(app, alice, fresh2(), cur(quiet), cur(quietLeaf)); | |
| 120 | expect(setIssueState(app, alice, fresh2(), cur(quietLeaf), "closed").ok).toBe(true); | |
| 121 | app.db.query("UPDATE issue SET closed_at = NULL WHERE id = ?1").run(quietLeaf.id); | |
| 122 | for (let n = 1; n <= 8; n++) { | |
| 123 | const m = mk(`Mile ${n}`); | |
| 124 | const done = mk(`Mile ${n} done`); | |
| 125 | linkIssue(app, alice, fresh2(), cur(m), cur(done)); | |
| 126 | const open = mk(`Mile ${n} open`); | |
| 127 | linkIssue(app, alice, fresh2(), cur(m), cur(open)); | |
| 128 | // Mile 2 has a limb of its own, so `enter` has something to open. | |
| 129 | if (n === 2) linkIssue(app, alice, fresh2(), cur(open), cur(mk("Mile 2 deep"))); | |
| 130 | expect(setIssueState(app, alice, fresh2(), cur(done), "closed").ok).toBe(true); | |
| 131 | } | |
| 132 | manyId = many.id; | |
| 133 | } | |
| 134 | ||
| 135 | beforeAll(manyMilestones); | |
| 136 | ||
| 137 | afterAll(async () => { | |
| 138 | app.close(); | |
| 139 | await rm(dataDir, { recursive: true, force: true }); | |
| 140 | }); | |
| 141 | ||
| 142 | test("a milestone that closed issues on three days shows the page's sentence and a sparkline of PACE_DAYS cells", async () => { | |
| 143 | const m = readPlan(app.db, fresh(), { spend: false }).milestone(moving.number)!; | |
| 144 | expect(m.pace.closedPerDay.filter((n) => n > 0)).toHaveLength(3); | |
| 145 | expect(m.pace.line).toContain("on pace for"); | |
| 146 | const spark = paceSpark(m.pace.closedPerDay); | |
| 147 | expect([...spark]).toHaveLength(PACE_DAYS); | |
| 148 | ||
| 149 | const h = boot(); | |
| 150 | await h.toLimbs(); | |
| 151 | expect(h.text()).toContain(`${spark} ${m.pace.line}`); | |
| 152 | h.stop(); | |
| 153 | }); | |
| 154 | ||
| 155 | test("a milestone with no pace line draws no pace row and stays one row tall", async () => { | |
| 156 | const quiet = plan().limbs.find((l) => l.name === "Quiet"); | |
| 157 | expect(quiet).toBeDefined(); | |
| 158 | const m = plan().milestone(quiet!.issue.number)!; | |
| 159 | expect(m.pace.line).toBe(""); | |
| 160 | const h = boot(32, 120, "alice/many"); | |
| 161 | await h.toLimbs(); | |
| 162 | const y = h.rowOf("Quiet"); | |
| 163 | expect(y).toBeGreaterThan(0); | |
| 164 | // The row under it is the next milestone, not a pace row. | |
| 165 | const lines = h.text().split("\n"); | |
| 166 | expect(lines[y + 1]).toContain("Mile 1"); | |
| 167 | expect(lines[y]).not.toMatch(SPARK); | |
| 168 | expect(lines[y + 1]).not.toMatch(SPARK); | |
| 169 | h.stop(); | |
| 170 | }); | |
| 171 | ||
| 172 | test("the sparkline of a quiet fortnight is the lowest block; no line is no text", () => { | |
| 173 | const m = readPlan(app.db, fresh(), { spend: false }).milestone(still.number)!; | |
| 174 | expect(m.pace.closedPerDay.every((n) => n === 0)).toBe(true); | |
| 175 | expect(paceSpark(m.pace.closedPerDay)).toBe("▁".repeat(PACE_DAYS)); | |
| 176 | expect(paceText({ ...m.pace, line: "" }, 80)).toBe(""); | |
| 177 | }); | |
| 178 | ||
| 179 | test("a narrow terminal drops the sparkline before the sentence, by the width the list has", async () => { | |
| 180 | const m = readPlan(app.db, fresh(), { spend: false }).milestone(moving.number)!; | |
| 181 | const both = PACE_DAYS + 1 + m.pace.line.length; | |
| 182 | expect(paceText(m.pace, both)).toMatch(SPARK); | |
| 183 | expect(paceText(m.pace, both - 1)).toBe(m.pace.line); | |
| 184 | // 40 columns less the scrollbar and the 4-cell indent is 35 cells for the note. | |
| 185 | expect(35).toBeLessThan(both); | |
| 186 | expect(35).toBeGreaterThanOrEqual(m.pace.line.length); | |
| 187 | const h = boot(32, 40); | |
| 188 | await h.toLimbs(); | |
| 189 | expect(h.text()).toContain(m.pace.line); | |
| 190 | expect(h.text()).not.toMatch(SPARK); | |
| 191 | h.stop(); | |
| 192 | // The terminal refuses anything under 40x10, so narrower than the sentence is the helper's. | |
| 193 | const cut = paceText(m.pace, 10); | |
| 194 | expect([...cut].length).toBeLessThanOrEqual(10); | |
| 195 | expect(cut).toContain("on pace"); | |
| 196 | }); | |
| 197 | ||
| 198 | test("a width of nothing or less draws nothing, and the smallest terminal the app accepts does not throw", async () => { | |
| 199 | const m = readPlan(app.db, fresh(), { spend: false }).milestone(moving.number)!; | |
| 200 | for (const w of [0, -1, -40]) expect(paceText(m.pace, w)).toBe(""); | |
| 201 | for (const [rows, cols] of [[10, 40], [3, 4]] as const) { | |
| 202 | const h = boot(rows, cols); | |
| 203 | await h.toLimbs(); | |
| 204 | expect(typeof h.text()).toBe("string"); | |
| 205 | h.stop(); | |
| 206 | } | |
| 207 | }); | |
| 208 | ||
| 209 | test("a short terminal scrolls by milestone: the cursor's row and its pace line are both on screen", async () => { | |
| 210 | const h = boot(10, 120, "alice/many"); | |
| 211 | await h.toLimbs(); | |
| 212 | for (let n = 1; n <= 8; n++) { | |
| 213 | await h.press("j"); | |
| 214 | const lines = h.text().split("\n"); | |
| 215 | const y = lines.findIndex((r) => new RegExp(`\\d+\\. #\\d+ Mile ${n}\\b`).test(r)); | |
| 216 | expect(y).toBeGreaterThanOrEqual(0); | |
| 217 | expect(lines[y + 1]).toContain("on pace for"); | |
| 218 | } | |
| 219 | h.stop(); | |
| 220 | }); | |
| 221 | ||
| 222 | test("a click on a pace row selects its milestone; a second click opens it", async () => { | |
| 223 | const h = boot(32, 120, "alice/many"); | |
| 224 | await h.toLimbs(); | |
| 225 | // Milestones with leaves of their own open on enter; the cursor moves first. | |
| 226 | await h.click("Mile 3", 1); | |
| 227 | await h.press("r"); | |
| 228 | expect(h.text()).toContain("Raise “Mile 3”?"); | |
| 229 | h.stop(); | |
| 230 | }); | |
| 231 | ||
| 232 | test("a double click on a pace row opens that milestone", async () => { | |
| 233 | const h = boot(32, 120, "alice/many"); | |
| 234 | await h.toLimbs(); | |
| 235 | expect(h.text()).not.toContain("Mile 2 open"); | |
| 236 | await h.click("Mile 2", 1); | |
| 237 | await h.click("Mile 2", 1); | |
| 238 | expect(h.text()).toContain("Mile 2 open"); | |
| 239 | h.stop(); | |
| 240 | }); | |
| 241 | ||
| 242 | test("the wheel moves the cursor and keeps its pace line on screen", async () => { | |
| 243 | const h = boot(10, 120, "alice/many"); | |
| 244 | await h.toLimbs(); | |
| 245 | await h.mouse("wheel-down", 12, 4); | |
| 246 | await h.mouse("wheel-down", 12, 4); | |
| 247 | const lines = h.text().split("\n"); | |
| 248 | // Two notches of three rows put the cursor on the seventh limb: Mile 6. | |
| 249 | const y = lines.findIndex((r) => /\d+\. #\d+ Mile 6\b/.test(r)); | |
| 250 | expect(y).toBeGreaterThanOrEqual(0); | |
| 251 | expect(lines[y + 1]).toContain("on pace for"); | |
| 252 | h.stop(); | |
| 253 | }); | |
| 254 | ||
| 255 | test("raise and lower still address the row under the cursor", async () => { | |
| 256 | const h = boot(); | |
| 257 | await h.toLimbs(); | |
| 258 | await h.press("j"); | |
| 259 | await h.press("r"); | |
| 260 | expect(h.text()).toContain("Raise"); | |
| 261 | h.stop(); | |
| 262 | }); | |
| 263 | }); |
new above
offline