@marcomigozzi/trailhead 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +158 -34
- package/README.md +147 -33
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +6 -6
- package/bin/lib/codex-projection.test.js +43 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +6 -6
- package/bin/trailhead.test.js +355 -16
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +209 -36
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
- package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
- package/plugins/trailhead/hooks/mod/register.tsx +312 -40
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +11 -4
- package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
- package/plugins/trailhead/skills/_shared/substrate.md +9 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
- package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +62 -3
|
@@ -5,22 +5,40 @@ import {
|
|
|
5
5
|
axisStart,
|
|
6
6
|
burndownEnd,
|
|
7
7
|
burndownSeries,
|
|
8
|
+
CANCEL_LABEL,
|
|
9
|
+
countsLabel,
|
|
8
10
|
failureReason,
|
|
9
11
|
footerStatus,
|
|
10
12
|
groupTickets,
|
|
13
|
+
headingRoom,
|
|
11
14
|
HISTORY_LIMIT,
|
|
15
|
+
isTimerLate,
|
|
16
|
+
keepOpenTotal,
|
|
12
17
|
mapRowLayout,
|
|
13
18
|
nextViewed,
|
|
19
|
+
openTotalLabel,
|
|
20
|
+
openTotalQuery,
|
|
14
21
|
parseActiveMap,
|
|
22
|
+
parseDashboard,
|
|
15
23
|
parseHistory,
|
|
24
|
+
parseIssuesPage,
|
|
16
25
|
parseMap,
|
|
17
26
|
parseMaps,
|
|
27
|
+
parseMapsRead,
|
|
28
|
+
parseOpenTotal,
|
|
18
29
|
parseTickets,
|
|
30
|
+
parseUatParents,
|
|
19
31
|
rasterCells,
|
|
20
32
|
refreshDecision,
|
|
21
33
|
rowLayout,
|
|
22
34
|
sparkline,
|
|
35
|
+
ticketsFromRows,
|
|
36
|
+
uatParentLine,
|
|
23
37
|
viewTarget,
|
|
38
|
+
withUatParents,
|
|
39
|
+
whiteboardHeading,
|
|
40
|
+
whiteboardRowLayout,
|
|
41
|
+
withLabel,
|
|
24
42
|
} from '../hooks/mod/map-pane-view'
|
|
25
43
|
|
|
26
44
|
const issue = (over: Record<string, unknown> = {}) => ({
|
|
@@ -57,10 +75,10 @@ test('parseTickets maps a gh list to rows with type, state and holders', () => {
|
|
|
57
75
|
)
|
|
58
76
|
expect(out?.unverifiedCount).toBe(0)
|
|
59
77
|
expect(out?.tickets).toEqual([
|
|
60
|
-
{ number: 209, title: 'Burn-down view', type: 'build', state: 'frontier', assignees: [], url: 'https://github.com/o/r/issues/209' },
|
|
61
|
-
{ number: 212, title: 'Held', type: 'research', state: 'in-progress', assignees: ['alice'], url: 'https://github.com/o/r/issues/209' },
|
|
62
|
-
{ number: 211, title: 'Gated', type: 'bug', state: 'blocked', assignees: ['bob'], url: 'https://github.com/o/r/issues/209' },
|
|
63
|
-
{ number: 214, title: 'No type', type: null, state: 'frontier', assignees: [], url: 'https://github.com/o/r/issues/209' },
|
|
78
|
+
{ number: 209, title: 'Burn-down view', type: 'build', state: 'frontier', assignees: [], url: 'https://github.com/o/r/issues/209', isUat: false, isNeedsYou: false, uatOf: null },
|
|
79
|
+
{ number: 212, title: 'Held', type: 'research', state: 'in-progress', assignees: ['alice'], url: 'https://github.com/o/r/issues/209', isUat: false, isNeedsYou: false, uatOf: null },
|
|
80
|
+
{ number: 211, title: 'Gated', type: 'bug', state: 'blocked', assignees: ['bob'], url: 'https://github.com/o/r/issues/209', isUat: false, isNeedsYou: false, uatOf: null },
|
|
81
|
+
{ number: 214, title: 'No type', type: null, state: 'frontier', assignees: [], url: 'https://github.com/o/r/issues/209', isUat: false, isNeedsYou: false, uatOf: null },
|
|
64
82
|
])
|
|
65
83
|
})
|
|
66
84
|
|
|
@@ -140,13 +158,124 @@ test('parseMap has a null destination when the section is missing or empty, and
|
|
|
140
158
|
})
|
|
141
159
|
|
|
142
160
|
test('groupTickets splits by state, ascending by number', () => {
|
|
143
|
-
const t = (number: number, state: 'frontier' | 'in-progress' | 'blocked') => ({
|
|
161
|
+
const t = (number: number, state: 'frontier' | 'in-progress' | 'blocked' | 'paused') => ({
|
|
144
162
|
number, title: '', type: null, state, assignees: [], url: '',
|
|
145
163
|
})
|
|
146
164
|
const g = groupTickets([t(30, 'frontier'), t(5, 'blocked'), t(9, 'in-progress'), t(12, 'frontier'), t(2, 'blocked')])
|
|
147
165
|
expect(g.frontier.map(x => x.number)).toEqual([12, 30])
|
|
148
166
|
expect(g.inProgress.map(x => x.number)).toEqual([9])
|
|
149
167
|
expect(g.blocked.map(x => x.number)).toEqual([2, 5])
|
|
168
|
+
expect(g.paused).toEqual([])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('groupTickets puts a paused ticket in the Paused group only', () => {
|
|
172
|
+
const t = (number: number, state: 'frontier' | 'in-progress' | 'blocked' | 'paused') => ({
|
|
173
|
+
number, title: '', type: null, state, assignees: [], url: '',
|
|
174
|
+
})
|
|
175
|
+
const g = groupTickets([t(8, 'paused'), t(3, 'paused'), t(4, 'in-progress'), t(5, 'blocked'), t(6, 'frontier')])
|
|
176
|
+
expect(g.paused.map(x => x.number)).toEqual([3, 8])
|
|
177
|
+
expect([g.frontier, g.inProgress, g.blocked].map(x => x.map(y => y.number))).toEqual([[6], [4], [5]])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('parseTickets: a paused label wins over blocked and in-progress, with the holder kept', () => {
|
|
181
|
+
const state = (names: string[], assignees: string[] = []) =>
|
|
182
|
+
parseTickets(list(issue({ labels: labels(...names), assignees: assignees.map(login => ({ login })) })))?.tickets[0]
|
|
183
|
+
expect(state(['trailhead:build', 'trailhead:paused'])).toMatchObject({ state: 'paused', assignees: [] })
|
|
184
|
+
expect(state(['trailhead:build', 'trailhead:paused', 'trailhead:blocked'], ['alice'])).toMatchObject({ state: 'paused', assignees: ['alice'] })
|
|
185
|
+
expect(state(['trailhead:build', 'trailhead:paused'], ['alice'])?.state).toBe('paused')
|
|
186
|
+
expect(state(['trailhead:build'], ['alice'])?.state).toBe('in-progress')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('parseTickets sets isUat from the trailhead:uat label, with no parent yet', () => {
|
|
190
|
+
const t = (...names: string[]) => parseTickets(list(issue({ labels: labels(...names) })))?.tickets[0]
|
|
191
|
+
expect(t('trailhead:task', 'trailhead:uat')).toMatchObject({ isUat: true, uatOf: null, state: 'frontier' })
|
|
192
|
+
expect(t('trailhead:task')).toMatchObject({ isUat: false, uatOf: null })
|
|
193
|
+
// a marker, not a state: the state is unchanged
|
|
194
|
+
expect(t('trailhead:seed', 'trailhead:blocked', 'trailhead:uat')).toMatchObject({ isUat: true, state: 'blocked' })
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('parseTickets sets isNeedsYou from the trailhead:needs-you label and keeps the state', () => {
|
|
198
|
+
const t = (names: string[], assignees: { login: string }[] = []) => parseTickets(list(issue({ labels: labels(...names), assignees })))?.tickets[0]
|
|
199
|
+
expect(t(['trailhead:task', 'trailhead:needs-you'])).toMatchObject({ isNeedsYou: true, state: 'frontier' })
|
|
200
|
+
expect(t(['trailhead:task', 'trailhead:needs-you', 'trailhead:paused'])).toMatchObject({ isNeedsYou: true, state: 'paused' })
|
|
201
|
+
expect(t(['trailhead:task', 'trailhead:needs-you', 'trailhead:blocked'])).toMatchObject({ isNeedsYou: true, state: 'blocked' })
|
|
202
|
+
expect(t(['trailhead:task', 'trailhead:needs-you'], [{ login: 'alice' }])).toMatchObject({ isNeedsYou: true, state: 'in-progress' })
|
|
203
|
+
expect(t(['trailhead:task'])).toMatchObject({ isNeedsYou: false })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const body = (text: string, number = 5) => ({ number, body: text })
|
|
207
|
+
const uatList = (...rows: unknown[]) => JSON.stringify(rows)
|
|
208
|
+
|
|
209
|
+
test('parseUatParents maps a ticket to the parent named by its UAT of: line', () => {
|
|
210
|
+
const out = parseUatParents(
|
|
211
|
+
uatList(
|
|
212
|
+
body('Intro\n\nUAT of: [Burn-down view](https://github.com/o/r/issues/209)\n\n- [ ] step', 301),
|
|
213
|
+
body('UAT of: #210', 302),
|
|
214
|
+
body('no pointer here', 303),
|
|
215
|
+
body('mentions UAT of: [x](https://github.com/o/r/issues/1) mid-line', 304),
|
|
216
|
+
body('UAT of: [Win](https://github.com/o/r/issues/212)\r\nmore', 305),
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
expect(out?.get(301)).toEqual({ number: 209, title: 'Burn-down view' })
|
|
220
|
+
expect(out?.get(302)).toEqual({ number: 210, title: '' })
|
|
221
|
+
expect(out?.get(303)).toBeUndefined()
|
|
222
|
+
expect(out?.get(304)).toBeUndefined()
|
|
223
|
+
expect(out?.get(305)).toEqual({ number: 212, title: 'Win' })
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('parseUatParents strips control characters from a parent title and skips unusable rows', () => {
|
|
227
|
+
const out = parseUatParents(
|
|
228
|
+
uatList(body('UAT of: [a\u0007bcd](https://github.com/o/r/issues/7)', 1), { number: 'x', body: 'UAT of: #3' }, { number: 2, body: 5 }, 'junk', body('UAT of: [t](https://example.com/nope)', 3)),
|
|
229
|
+
)
|
|
230
|
+
expect(out?.get(1)).toEqual({ number: 7, title: 'abcd' })
|
|
231
|
+
expect(out?.size).toBe(1)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('parseUatParents is null on non-JSON or a non-list', () => {
|
|
235
|
+
expect(parseUatParents('{nope')).toBeNull()
|
|
236
|
+
expect(parseUatParents('{}')).toBeNull()
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('withUatParents sets the parent on a UAT ticket only, and leaves the tickets alone without a read', () => {
|
|
240
|
+
const t = (number: number, isUat: boolean) => ({ number, title: '', type: null, state: 'frontier' as const, assignees: [], url: '', isUat, isNeedsYou: false, uatOf: null })
|
|
241
|
+
const tickets = [t(1, true), t(2, false), t(3, true)]
|
|
242
|
+
const out = withUatParents(tickets, new Map([[1, { number: 9, title: 'P' }], [2, { number: 8, title: 'Q' }]]))
|
|
243
|
+
expect(out.map(x => x.uatOf)).toEqual([{ number: 9, title: 'P' }, null, null])
|
|
244
|
+
expect(tickets[0]?.uatOf).toBeNull()
|
|
245
|
+
expect(withUatParents(tickets, null)).toEqual(tickets)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('uatParentLine draws tests <title> #<n>, fitted to the width, and nothing without a parent', () => {
|
|
249
|
+
const t = (uatOf: { number: number; title: string } | null) => ({ number: 1, title: '', type: null, state: 'frontier' as const, assignees: [], url: '', isUat: true, isNeedsYou: false, uatOf })
|
|
250
|
+
expect(uatParentLine(t(null), 60)).toBeNull()
|
|
251
|
+
expect(uatParentLine(t({ number: 209, title: 'Burn-down view' }), 60)).toBe('tests Burn-down view #209')
|
|
252
|
+
expect(uatParentLine(t({ number: 210, title: '' }), 60)).toBe('tests #210')
|
|
253
|
+
const cut = uatParentLine(t({ number: 209, title: 'x'.repeat(200) }), 30) as string
|
|
254
|
+
expect(Array.from(cut).length).toBe(30)
|
|
255
|
+
expect(cut.endsWith('… #209')).toBe(true)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('groupTickets lists a UAT ticket once, under UAT, ascending; a paused one stays under Paused', () => {
|
|
259
|
+
const t = (number: number, state: 'frontier' | 'in-progress' | 'blocked' | 'paused', isUat: boolean) => ({
|
|
260
|
+
number, title: '', type: null, state, assignees: [], url: '', isUat, isNeedsYou: false, uatOf: null,
|
|
261
|
+
})
|
|
262
|
+
const g = groupTickets([t(9, 'frontier', true), t(4, 'blocked', true), t(6, 'in-progress', true), t(7, 'paused', true), t(2, 'frontier', false), t(3, 'blocked', false)])
|
|
263
|
+
expect(g.uat.map(x => x.number)).toEqual([4, 6, 9])
|
|
264
|
+
expect(g.paused.map(x => x.number)).toEqual([7])
|
|
265
|
+
expect([g.frontier, g.inProgress, g.blocked].map(x => x.map(y => y.number))).toEqual([[2], [], [3]])
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('groupTickets lists a needs-you ticket once, under Needs you, ascending, whatever its state', () => {
|
|
269
|
+
const t = (number: number, state: 'frontier' | 'in-progress' | 'blocked' | 'paused', over: Record<string, unknown> = {}) => ({
|
|
270
|
+
number, title: '', type: null, state, assignees: [], url: '', isUat: false, isNeedsYou: true, uatOf: null, ...over,
|
|
271
|
+
})
|
|
272
|
+
const g = groupTickets([
|
|
273
|
+
t(9, 'frontier'), t(4, 'blocked'), t(6, 'in-progress', { assignees: ['alice'] }), t(7, 'paused'), t(8, 'frontier', { isUat: true }),
|
|
274
|
+
t(2, 'frontier', { isNeedsYou: false }), t(3, 'blocked', { isNeedsYou: false }),
|
|
275
|
+
])
|
|
276
|
+
expect(g.needsYou.map(x => x.number)).toEqual([4, 6, 7, 8, 9])
|
|
277
|
+
expect([g.frontier, g.inProgress, g.uat, g.paused, g.blocked].map(x => x.map(y => y.number))).toEqual([[2], [], [], [], [3]])
|
|
278
|
+
expect(groupTickets([t(1, 'frontier', { isNeedsYou: false })]).needsYou).toEqual([])
|
|
150
279
|
})
|
|
151
280
|
|
|
152
281
|
test('failureReason names one short cause', () => {
|
|
@@ -172,6 +301,16 @@ test('refreshDecision runs now outside the floor and waits the remainder inside
|
|
|
172
301
|
expect(refreshDecision(500, 1000, 15000)).toEqual({ run: false, waitMs: 15000 })
|
|
173
302
|
})
|
|
174
303
|
|
|
304
|
+
test('isTimerLate holds a timer dead only once its due time is past by more than the grace', () => {
|
|
305
|
+
// in time, or late within the grace: still alive
|
|
306
|
+
expect(isTimerLate(1000, 5000, 2000)).toBe(false)
|
|
307
|
+
expect(isTimerLate(7000, 5000, 2000)).toBe(false)
|
|
308
|
+
// past the grace: the host dropped it (a refused dispatch never fires)
|
|
309
|
+
expect(isTimerLate(7001, 5000, 2000)).toBe(true)
|
|
310
|
+
// a clock that moved back never makes a timer late
|
|
311
|
+
expect(isTimerLate(0, 5000, 2000)).toBe(false)
|
|
312
|
+
})
|
|
313
|
+
|
|
175
314
|
test('ageLabel reads just now, seconds, minutes, hours', () => {
|
|
176
315
|
expect(ageLabel(0)).toBe('just now')
|
|
177
316
|
expect(ageLabel(-5)).toBe('just now')
|
|
@@ -191,10 +330,11 @@ const ticket = (over: Record<string, unknown> = {}) => ({
|
|
|
191
330
|
state: 'frontier' as const,
|
|
192
331
|
assignees: [] as string[],
|
|
193
332
|
url: 'https://github.com/o/r/issues/209',
|
|
333
|
+
isNeedsYou: false,
|
|
194
334
|
...over,
|
|
195
335
|
})
|
|
196
336
|
const layout = (t: ReturnType<typeof ticket>, over: Record<string, unknown> = {}) =>
|
|
197
|
-
rowLayout(t, { isInPlay: false, hasMarkColumn: false, pendingWork: null, columns: 60, ...over })
|
|
337
|
+
rowLayout(t, { isInPlay: false, hasMarkColumn: false, pendingWork: null, pendingPhase: null, columns: 60, ...over })
|
|
198
338
|
|
|
199
339
|
test('rowLayout: a frontier row carries the work button, the number, the title and the type', () => {
|
|
200
340
|
expect(layout(ticket())).toEqual({
|
|
@@ -208,10 +348,38 @@ test('rowLayout: a frontier row carries the work button, the number, the title a
|
|
|
208
348
|
})
|
|
209
349
|
|
|
210
350
|
test('rowLayout: the pending ticket shows queued in place of the button', () => {
|
|
211
|
-
expect(layout(ticket(), { pendingWork: 209 }).aside).toEqual({ kind: 'queued', text: 'queued' })
|
|
351
|
+
expect(layout(ticket(), { pendingWork: 209, pendingPhase: 'queued' }).aside).toEqual({ kind: 'queued', text: 'queued' })
|
|
212
352
|
expect(layout(ticket(), { pendingWork: 210 }).aside).toEqual({ kind: 'work', text: 'work' })
|
|
213
353
|
})
|
|
214
354
|
|
|
355
|
+
test('rowLayout: the queued aside counts the cancel button in the row width', () => {
|
|
356
|
+
const t = ticket({ title: 'x'.repeat(200) })
|
|
357
|
+
for (const columns of [30, 40, 60]) {
|
|
358
|
+
const r = layout(t, { columns, pendingWork: 209, pendingPhase: 'queued' })
|
|
359
|
+
const used = (r.mark?.length ?? 0) + r.label.length + (r.type?.length ?? 0) + (r.aside?.text.length ?? 0) + 1 + CANCEL_LABEL.length + 4 + 1
|
|
360
|
+
expect(used).toBeLessThanOrEqual(columns)
|
|
361
|
+
// Less title room than the work button leaves.
|
|
362
|
+
expect(r.label.length).toBeLessThan(layout(t, { columns }).label.length)
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('rowLayout: a starting row reads starting with no button room', () => {
|
|
367
|
+
expect(layout(ticket(), { pendingWork: 209, pendingPhase: 'starting' }).aside).toEqual({ kind: 'starting', text: 'starting' })
|
|
368
|
+
expect(layout(ticket(), { pendingWork: 209, pendingPhase: 'queued' }).aside).toEqual({ kind: 'queued', text: 'queued' })
|
|
369
|
+
// a pending press with no phase yet is never offered a cancel
|
|
370
|
+
expect(layout(ticket(), { pendingWork: 209, pendingPhase: null }).aside).toEqual({ kind: 'starting', text: 'starting' })
|
|
371
|
+
// another ticket's phase never touches this row
|
|
372
|
+
expect(layout(ticket(), { pendingWork: 210, pendingPhase: 'starting' }).aside).toEqual({ kind: 'work', text: 'work' })
|
|
373
|
+
const t = ticket({ title: 'x'.repeat(200) })
|
|
374
|
+
for (const columns of [30, 40, 60]) {
|
|
375
|
+
const r = layout(t, { columns, pendingWork: 209, pendingPhase: 'starting' })
|
|
376
|
+
const used = (r.mark?.length ?? 0) + r.label.length + (r.type?.length ?? 0) + (r.aside?.text.length ?? 0) + 1
|
|
377
|
+
expect(used).toBeLessThanOrEqual(columns)
|
|
378
|
+
// No cancel button to make room for: more title than the queued row keeps.
|
|
379
|
+
expect(r.label.length).toBeGreaterThan(layout(t, { columns, pendingWork: 209, pendingPhase: 'queued' }).label.length)
|
|
380
|
+
}
|
|
381
|
+
})
|
|
382
|
+
|
|
215
383
|
test('rowLayout: holders are drawn as @login on in-progress and blocked rows', () => {
|
|
216
384
|
expect(layout(ticket({ state: 'in-progress', assignees: ['alice'] })).aside).toEqual({ kind: 'holder', text: '@alice' })
|
|
217
385
|
expect(layout(ticket({ state: 'in-progress', assignees: ['alice', 'bob'] })).aside).toEqual({ kind: 'holder', text: '@alice @bob' })
|
|
@@ -223,6 +391,73 @@ test('rowLayout: holders are drawn as @login on in-progress and blocked rows', (
|
|
|
223
391
|
expect(loose.isDim).toBe(true)
|
|
224
392
|
})
|
|
225
393
|
|
|
394
|
+
const needsYou = (over: Record<string, unknown> = {}) => ticket({ isNeedsYou: true, ...over })
|
|
395
|
+
|
|
396
|
+
test('rowLayout: a needs-you row without a holder has a work button, a paused one too, never resume', () => {
|
|
397
|
+
expect(layout(needsYou()).aside).toEqual({ kind: 'work', text: 'work' })
|
|
398
|
+
expect(layout(needsYou({ state: 'paused' })).aside).toEqual({ kind: 'work', text: 'work' })
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test('rowLayout: a needs-you row with any holder shows the holder, never a button', () => {
|
|
402
|
+
const holder = { kind: 'holder', text: '@alice' }
|
|
403
|
+
expect(layout(needsYou({ state: 'paused', assignees: ['alice'] })).aside).toEqual(holder)
|
|
404
|
+
expect(layout(needsYou({ state: 'blocked', assignees: ['alice'] })).aside).toEqual(holder)
|
|
405
|
+
expect(layout(needsYou({ state: 'in-progress', assignees: ['alice'] })).aside).toEqual(holder)
|
|
406
|
+
// a press pending does not turn a held row into a button either
|
|
407
|
+
expect(layout(needsYou({ state: 'paused', assignees: ['alice'] }), { pendingWork: 209 }).aside).toEqual(holder)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
test('rowLayout: a needs-you row blocked with no holder shows nothing and is dim', () => {
|
|
411
|
+
const row = layout(needsYou({ state: 'blocked' }))
|
|
412
|
+
expect(row.aside).toBeNull()
|
|
413
|
+
expect(row.isDim).toBe(true)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('rowLayout: a needs-you row shows queued while its press is pending and this session in play', () => {
|
|
417
|
+
expect(layout(needsYou(), { pendingWork: 209, pendingPhase: 'queued' }).aside).toEqual({ kind: 'queued', text: 'queued' })
|
|
418
|
+
expect(layout(needsYou({ state: 'paused' }), { pendingWork: 209, pendingPhase: 'queued' }).aside).toEqual({ kind: 'queued', text: 'queued' })
|
|
419
|
+
expect(layout(needsYou({ state: 'in-progress', assignees: ['alice'] }), { isInPlay: true }).aside).toEqual({ kind: 'self', text: 'this session' })
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test('rowLayout: a paused row has a resume button, whoever holds it', () => {
|
|
423
|
+
expect(layout(ticket({ state: 'paused', assignees: ['alice'] })).aside).toEqual({ kind: 'resume', text: 'resume' })
|
|
424
|
+
expect(layout(ticket({ state: 'paused' })).aside).toEqual({ kind: 'resume', text: 'resume' })
|
|
425
|
+
expect(layout(ticket({ state: 'paused' })).isDim).toBe(false)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
test('rowLayout: a paused row reads queued while its press is pending', () => {
|
|
429
|
+
expect(layout(ticket({ number: 209, state: 'paused' }), { pendingWork: 209, pendingPhase: 'queued' }).aside).toEqual({ kind: 'queued', text: 'queued' })
|
|
430
|
+
expect(layout(ticket({ number: 209, state: 'paused' }), { pendingWork: 7 }).aside).toEqual({ kind: 'resume', text: 'resume' })
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test('rowLayout: a paused ticket in play keeps this session', () => {
|
|
434
|
+
expect(layout(ticket({ state: 'paused', assignees: ['me'] }), { isInPlay: true, hasMarkColumn: true }).aside).toEqual({ kind: 'self', text: 'this session' })
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
test('rowLayout: the resume button chrome is counted in the row width', () => {
|
|
438
|
+
const t = ticket({ title: 'x'.repeat(200), state: 'paused' })
|
|
439
|
+
for (const columns of [30, 40, 60]) {
|
|
440
|
+
const r = layout(t, { columns })
|
|
441
|
+
const used = (r.mark?.length ?? 0) + r.label.length + (r.type?.length ?? 0) + (r.aside?.text.length ?? 0) + 4 + 1
|
|
442
|
+
expect(used).toBeLessThanOrEqual(columns)
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
test('rowLayout: the ticket in play says this session instead of its holder', () => {
|
|
447
|
+
const r = layout(ticket({ state: 'in-progress', assignees: ['me'] }), { isInPlay: true, hasMarkColumn: true })
|
|
448
|
+
expect(r.aside).toEqual({ kind: 'self', text: 'this session' })
|
|
449
|
+
expect(layout(ticket({ state: 'in-progress', assignees: ['me'] }), { hasMarkColumn: true }).aside).toEqual({ kind: 'holder', text: '@me' })
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
test('rowLayout: the this-session aside is counted in the row width', () => {
|
|
453
|
+
const t = ticket({ title: 'x'.repeat(200), state: 'in-progress', assignees: ['me'] })
|
|
454
|
+
for (const columns of [30, 40, 60]) {
|
|
455
|
+
const r = layout(t, { columns, isInPlay: true, hasMarkColumn: true })
|
|
456
|
+
const used = (r.mark?.length ?? 0) + r.label.length + (r.type?.length ?? 0) + (r.aside?.text.length ?? 0) + 1
|
|
457
|
+
expect(used).toBeLessThanOrEqual(columns)
|
|
458
|
+
}
|
|
459
|
+
})
|
|
460
|
+
|
|
226
461
|
test('rowLayout: the mark column marks the ticket in play and pads the others', () => {
|
|
227
462
|
expect(layout(ticket({ state: 'in-progress', assignees: ['me'] }), { isInPlay: true, hasMarkColumn: true })).toMatchObject({ mark: '▸ ', isInPlay: true })
|
|
228
463
|
expect(layout(ticket(), { hasMarkColumn: true }).mark).toBe(' ')
|
|
@@ -473,9 +708,9 @@ test('parseMaps keeps the open maps sorted by number, titles flattened', () => {
|
|
|
473
708
|
]),
|
|
474
709
|
)
|
|
475
710
|
expect(out).toEqual([
|
|
476
|
-
{ number: 14, title: 'older map' },
|
|
477
|
-
{ number: 20, title: '' },
|
|
478
|
-
{ number: 198, title: 'trailhead mod' },
|
|
711
|
+
{ number: 14, title: 'older map', counts: null },
|
|
712
|
+
{ number: 20, title: '', counts: null },
|
|
713
|
+
{ number: 198, title: 'trailhead mod', counts: null },
|
|
479
714
|
])
|
|
480
715
|
expect(parseMaps('[]')).toEqual([])
|
|
481
716
|
})
|
|
@@ -486,6 +721,234 @@ test('parseMaps rejects what is not a list', () => {
|
|
|
486
721
|
expect(parseMaps('null')).toBeNull()
|
|
487
722
|
})
|
|
488
723
|
|
|
724
|
+
const WB_FIELDS = { whiteboardOpen: { totalCount: 3 }, whiteboardAll: { totalCount: 9 } }
|
|
725
|
+
const gql = (nodes: unknown[], extra: Record<string, unknown> = {}, repo: Record<string, unknown> = WB_FIELDS) =>
|
|
726
|
+
JSON.stringify({ data: { repository: { issues: { nodes }, ...repo } }, ...extra })
|
|
727
|
+
|
|
728
|
+
test('parseMapsRead reads each map with its open and total sub-issues, sorted by number', () => {
|
|
729
|
+
const out = parseMapsRead(
|
|
730
|
+
gql([
|
|
731
|
+
{ number: 198, title: 'trailhead\u202e mod\n', url: 'https://x/198', subIssuesSummary: { total: 23, completed: 16 } },
|
|
732
|
+
{ number: 14, title: 'older map', url: 'https://x/14', subIssuesSummary: { total: 5, completed: 5 } },
|
|
733
|
+
{ number: 0, title: 'bad number', subIssuesSummary: { total: 1, completed: 0 } },
|
|
734
|
+
'junk',
|
|
735
|
+
]),
|
|
736
|
+
)
|
|
737
|
+
expect(out?.maps).toEqual([
|
|
738
|
+
{ number: 14, title: 'older map', counts: { open: 0, total: 5 } },
|
|
739
|
+
{ number: 198, title: 'trailhead mod', counts: { open: 7, total: 23 } },
|
|
740
|
+
])
|
|
741
|
+
expect(parseMapsRead(gql([]))?.maps).toEqual([])
|
|
742
|
+
// completed above total never goes negative
|
|
743
|
+
expect(parseMapsRead(gql([{ number: 3, title: 't', subIssuesSummary: { total: 2, completed: 4 } }]))?.maps).toEqual([
|
|
744
|
+
{ number: 3, title: 't', counts: { open: 0, total: 2 } },
|
|
745
|
+
])
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
test('parseMapsRead keeps a row with a malformed summary, name-only', () => {
|
|
749
|
+
const out = parseMapsRead(
|
|
750
|
+
gql([
|
|
751
|
+
{ number: 1, title: 'a' },
|
|
752
|
+
{ number: 2, title: 'b', subIssuesSummary: { total: 'x', completed: 1 } },
|
|
753
|
+
{ number: 3, title: 'c', subIssuesSummary: null },
|
|
754
|
+
]),
|
|
755
|
+
)
|
|
756
|
+
expect(out?.maps).toEqual([
|
|
757
|
+
{ number: 1, title: 'a', counts: null },
|
|
758
|
+
{ number: 2, title: 'b', counts: null },
|
|
759
|
+
{ number: 3, title: 'c', counts: null },
|
|
760
|
+
])
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
test('parseMapsRead reads the whiteboard counts beside the maps', () => {
|
|
764
|
+
expect(parseMapsRead(gql([]))?.whiteboard).toEqual({ open: 3, total: 9 })
|
|
765
|
+
// nothing on the whiteboard is a real count, drawn as is
|
|
766
|
+
const none = { whiteboardOpen: { totalCount: 0 }, whiteboardAll: { totalCount: 0 } }
|
|
767
|
+
expect(parseMapsRead(gql([], {}, none))?.whiteboard).toEqual({ open: 0, total: 0 })
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
test('parseMapsRead gives a null whiteboard, maps intact, when its fields are missing or unusable', () => {
|
|
771
|
+
const node = { number: 1, title: 'a', subIssuesSummary: { total: 4, completed: 1 } }
|
|
772
|
+
const bad: Record<string, unknown>[] = [
|
|
773
|
+
{},
|
|
774
|
+
{ whiteboardOpen: { totalCount: 3 } },
|
|
775
|
+
{ whiteboardAll: { totalCount: 9 } },
|
|
776
|
+
{ whiteboardOpen: null, whiteboardAll: { totalCount: 9 } },
|
|
777
|
+
{ whiteboardOpen: { totalCount: -1 }, whiteboardAll: { totalCount: 9 } },
|
|
778
|
+
{ whiteboardOpen: { totalCount: 1.5 }, whiteboardAll: { totalCount: 9 } },
|
|
779
|
+
{ whiteboardOpen: { totalCount: '3' }, whiteboardAll: { totalCount: 9 } },
|
|
780
|
+
{ whiteboardOpen: { totalCount: 10 }, whiteboardAll: { totalCount: 9 } },
|
|
781
|
+
]
|
|
782
|
+
for (const repo of bad) {
|
|
783
|
+
expect(parseMapsRead(gql([node], {}, repo))).toEqual({
|
|
784
|
+
maps: [{ number: 1, title: 'a', counts: { open: 3, total: 4 } }],
|
|
785
|
+
whiteboard: null,
|
|
786
|
+
isMapsCapped: false,
|
|
787
|
+
})
|
|
788
|
+
}
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
test('parseMapsRead is null on anything but a clean reply', () => {
|
|
792
|
+
const node = { number: 1, title: 'a', subIssuesSummary: { total: 1, completed: 0 } }
|
|
793
|
+
expect(parseMapsRead('not json')).toBeNull()
|
|
794
|
+
expect(parseMapsRead('[]')).toBeNull()
|
|
795
|
+
expect(parseMapsRead('{}')).toBeNull()
|
|
796
|
+
expect(parseMapsRead('{"data":null}')).toBeNull()
|
|
797
|
+
expect(parseMapsRead('{"data":{"repository":null}}')).toBeNull()
|
|
798
|
+
expect(parseMapsRead('{"data":{"repository":{"issues":{"nodes":"x"}}}}')).toBeNull()
|
|
799
|
+
expect(parseMapsRead(JSON.stringify({ errors: [{ message: 'boom' }] }))).toBeNull()
|
|
800
|
+
// partial data next to errors is still a failed read
|
|
801
|
+
expect(parseMapsRead(gql([node], { errors: [{ message: 'partial' }] }))).toBeNull()
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
// A maps connection with a `totalCount` beside its nodes, as the maps read asks for.
|
|
805
|
+
const gqlTotal = (nodes: unknown[], totalCount: unknown) =>
|
|
806
|
+
JSON.stringify({ data: { repository: { issues: { nodes, totalCount }, ...WB_FIELDS } } })
|
|
807
|
+
const mapNodes = (n: number) => Array.from({ length: n }, (_, i) => ({ number: i + 1, title: `m${i + 1}`, subIssuesSummary: { total: 1, completed: 0 } }))
|
|
808
|
+
|
|
809
|
+
test('parseMapsRead marks the maps read capped only when more open maps exist than were read', () => {
|
|
810
|
+
expect(parseMapsRead(gqlTotal(mapNodes(3), 3))?.isMapsCapped).toBe(false)
|
|
811
|
+
expect(parseMapsRead(gqlTotal(mapNodes(3), 4))?.isMapsCapped).toBe(true)
|
|
812
|
+
// exactly the limit, with no more behind it, is not capped
|
|
813
|
+
expect(parseMapsRead(gqlTotal(mapNodes(100), 100))?.isMapsCapped).toBe(false)
|
|
814
|
+
expect(parseMapsRead(gqlTotal(mapNodes(100), 101))?.isMapsCapped).toBe(true)
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
test('parseMapsRead without a usable totalCount falls back to the read limit', () => {
|
|
818
|
+
expect(parseMapsRead(gqlTotal(mapNodes(3), undefined))?.isMapsCapped).toBe(false)
|
|
819
|
+
expect(parseMapsRead(gqlTotal(mapNodes(100), undefined))?.isMapsCapped).toBe(true)
|
|
820
|
+
expect(parseMapsRead(gqlTotal(mapNodes(100), 'x'))?.isMapsCapped).toBe(true)
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
test('openTotalQuery counts the open tickets of each map and the whiteboard in one query', () => {
|
|
824
|
+
const q = openTotalQuery([3, 198])
|
|
825
|
+
expect(q).toContain('query($owner:String!,$repo:String!)')
|
|
826
|
+
expect(q).toContain('repository(owner:$owner,name:$repo)')
|
|
827
|
+
expect(q).toContain('m3:issues(states:OPEN,labels:["trailhead:map-3"]){totalCount}')
|
|
828
|
+
expect(q).toContain('m198:issues(states:OPEN,labels:["trailhead:map-198"]){totalCount}')
|
|
829
|
+
expect(q).toContain('wb:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}')
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
test('openTotalQuery with no open map still counts the whiteboard', () => {
|
|
833
|
+
const q = openTotalQuery([])
|
|
834
|
+
expect(q).toContain('wb:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}')
|
|
835
|
+
expect(q).not.toContain('trailhead:map-')
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
const totals = (counts: Record<string, unknown>) =>
|
|
839
|
+
JSON.stringify({ data: { repository: Object.fromEntries(Object.entries(counts).map(([k, v]) => [k, { totalCount: v }])) } })
|
|
840
|
+
|
|
841
|
+
test('parseOpenTotal sums every map alias and the whiteboard', () => {
|
|
842
|
+
expect(parseOpenTotal(totals({ m3: 4, m198: 7, wb: 2 }), [3, 198])).toBe(13)
|
|
843
|
+
expect(parseOpenTotal(totals({ wb: 0 }), [])).toBe(0)
|
|
844
|
+
expect(parseOpenTotal(totals({ m3: 0, wb: 5 }), [3])).toBe(5)
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
test('parseOpenTotal is null on errors, a missing alias or a bad count (never a partial sum)', () => {
|
|
848
|
+
const ok = { m3: 4, wb: 2 }
|
|
849
|
+
expect(parseOpenTotal(totals(ok), [3])).toBe(6)
|
|
850
|
+
expect(parseOpenTotal('not json', [3])).toBeNull()
|
|
851
|
+
expect(parseOpenTotal('[]', [3])).toBeNull()
|
|
852
|
+
expect(parseOpenTotal('{"data":null}', [3])).toBeNull()
|
|
853
|
+
expect(parseOpenTotal('{"data":{"repository":null}}', [3])).toBeNull()
|
|
854
|
+
expect(parseOpenTotal(JSON.stringify({ errors: [{ message: 'boom' }] }), [3])).toBeNull()
|
|
855
|
+
expect(parseOpenTotal(JSON.stringify({ ...JSON.parse(totals(ok)), errors: [{ message: 'partial' }] }), [3])).toBeNull()
|
|
856
|
+
// a map alias or the whiteboard missing
|
|
857
|
+
expect(parseOpenTotal(totals({ wb: 2 }), [3])).toBeNull()
|
|
858
|
+
expect(parseOpenTotal(totals({ m3: 4 }), [3])).toBeNull()
|
|
859
|
+
expect(parseOpenTotal(totals({ m3: 4, wb: 2 }), [3, 198])).toBeNull()
|
|
860
|
+
// negative, fractional, textual, null counts
|
|
861
|
+
for (const bad of [-1, 1.5, '4', null]) expect(parseOpenTotal(totals({ m3: bad, wb: 2 }), [3])).toBeNull()
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
test('keepOpenTotal keeps the last total only over the same maps and the same cut', () => {
|
|
865
|
+
const kept = { total: 10, maps: [14, 198], isMapsCapped: false }
|
|
866
|
+
expect(keepOpenTotal(kept, [14, 198], false)).toBe(kept)
|
|
867
|
+
expect(keepOpenTotal(kept, [14], false)).toBeNull()
|
|
868
|
+
expect(keepOpenTotal(kept, [14, 198, 300], false)).toBeNull()
|
|
869
|
+
expect(keepOpenTotal(kept, [14, 199], false)).toBeNull()
|
|
870
|
+
expect(keepOpenTotal(kept, [14, 198], true)).toBeNull()
|
|
871
|
+
expect(keepOpenTotal(null, [14, 198], false)).toBeNull()
|
|
872
|
+
expect(keepOpenTotal({ total: 3, maps: [], isMapsCapped: false }, [], false)).toEqual({ total: 3, maps: [], isMapsCapped: false })
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
test('openTotalLabel words the total across all maps and the whiteboard', () => {
|
|
876
|
+
expect(openTotalLabel(13, false, 80)).toBe('13 open across all maps + whiteboard')
|
|
877
|
+
expect(openTotalLabel(0, false, 80)).toBe('0 open across all maps + whiteboard')
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
test('openTotalLabel words a truncated maps read as a lower bound', () => {
|
|
881
|
+
expect(openTotalLabel(13, true, 80)).toBe('at least 13 open across all maps + whiteboard')
|
|
882
|
+
expect(openTotalLabel(13, true, 20)).toBe('≥13 open in total')
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
test('openTotalLabel shortens when the long form does not fit, then gives up', () => {
|
|
886
|
+
const long = '13 open across all maps + whiteboard'
|
|
887
|
+
expect(openTotalLabel(13, false, long.length)).toBe(long)
|
|
888
|
+
expect(openTotalLabel(13, false, long.length - 1)).toBe('13 open in total')
|
|
889
|
+
expect(openTotalLabel(13, false, '13 open in total'.length)).toBe('13 open in total')
|
|
890
|
+
expect(openTotalLabel(13, false, '13 open in total'.length - 1)).toBeNull()
|
|
891
|
+
expect(openTotalLabel(13, true, '≥13 open in total'.length - 1)).toBeNull()
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
test('openTotalLabel is null when the total is unknown', () => {
|
|
895
|
+
expect(openTotalLabel(null, false, 80)).toBeNull()
|
|
896
|
+
expect(openTotalLabel(null, true, 80)).toBeNull()
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
test('countsLabel words open over total', () => {
|
|
900
|
+
expect(countsLabel({ open: 7, total: 23 })).toBe('7 open / 23')
|
|
901
|
+
expect(countsLabel({ open: 0, total: 0 })).toBe('0 open / 0')
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
test('whiteboardRowLayout puts the counts beside the name, a null read leaves it name-only', () => {
|
|
905
|
+
expect(whiteboardRowLayout({ open: 3, total: 9 }, 60)).toEqual({ mark: ' ', label: 'Whiteboard', aside: ' 3 open / 9' })
|
|
906
|
+
expect(whiteboardRowLayout({ open: 0, total: 0 }, 60).aside).toBe(' 0 open / 0')
|
|
907
|
+
expect(whiteboardRowLayout(null, 60)).toEqual({ mark: ' ', label: 'Whiteboard', aside: null })
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
test('whiteboardRowLayout keeps the name whole and drops the counts when they do not fit', () => {
|
|
911
|
+
// mark 2 + `[ Whiteboard ]` 14 + ` 3 open / 9` 11 = 27 cells
|
|
912
|
+
expect(whiteboardRowLayout({ open: 3, total: 9 }, 27).aside).toBe(' 3 open / 9')
|
|
913
|
+
const narrow = whiteboardRowLayout({ open: 3, total: 9 }, 26)
|
|
914
|
+
expect(narrow.aside).toBeNull()
|
|
915
|
+
expect(narrow.label).toBe('Whiteboard')
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
test('mapRowLayout asides: counts first, then active, joined by a dot', () => {
|
|
919
|
+
const m = { number: 198, title: 'trailhead mod', counts: { open: 7, total: 23 } }
|
|
920
|
+
expect(mapRowLayout(m, { active: 198, viewed: null, columns: 60 }).aside).toBe(' 7 open / 23 · active')
|
|
921
|
+
expect(mapRowLayout(m, { active: 14, viewed: null, columns: 60 }).aside).toBe(' 7 open / 23')
|
|
922
|
+
expect(mapRowLayout({ ...m, counts: null }, { active: 198, viewed: null, columns: 60 }).aside).toBe(' active')
|
|
923
|
+
expect(mapRowLayout({ ...m, counts: null }, { active: 14, viewed: null, columns: 60 }).aside).toBeNull()
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
test('mapRowLayout cuts the title before the counts, which stay whole', () => {
|
|
927
|
+
const m = { number: 14, title: 'x'.repeat(200), counts: { open: 7, total: 23 } }
|
|
928
|
+
const r = mapRowLayout(m, { active: 198, viewed: null, columns: 40 })
|
|
929
|
+
expect(r.aside).toBe(' 7 open / 23')
|
|
930
|
+
expect(r.label.endsWith('…')).toBe(true)
|
|
931
|
+
expect(r.mark.length + r.label.length + 4 + (r.aside?.length ?? 0)).toBeLessThanOrEqual(40)
|
|
932
|
+
// a map with no title falls back to the number
|
|
933
|
+
expect(mapRowLayout({ ...m, title: '' }, { active: 198, viewed: null, columns: 40 }).label).toBe('#14')
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
const wbTicket = (number: number, assignees: string[] = []) => ({
|
|
937
|
+
number,
|
|
938
|
+
title: `t${number}`,
|
|
939
|
+
type: null,
|
|
940
|
+
state: assignees.length > 0 ? ('in-progress' as const) : ('frontier' as const),
|
|
941
|
+
assignees,
|
|
942
|
+
url: '',
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
test('whiteboardHeading counts the open and the assigned tickets', () => {
|
|
946
|
+
expect(whiteboardHeading([wbTicket(1), wbTicket(2, ['a']), wbTicket(3, ['a', 'b'])], false)).toBe('Whiteboard · 3 open · 2 assigned')
|
|
947
|
+
expect(whiteboardHeading([], false)).toBe('Whiteboard · 0 open · 0 assigned')
|
|
948
|
+
expect(whiteboardHeading([wbTicket(1)], true)).toBe('Whiteboard · 200+ open · 0 assigned')
|
|
949
|
+
expect(whiteboardHeading(null, false)).toBe('Whiteboard')
|
|
950
|
+
})
|
|
951
|
+
|
|
489
952
|
test('viewTarget is the viewed map, else the active one', () => {
|
|
490
953
|
expect(viewTarget(198, null)).toBe(198)
|
|
491
954
|
expect(viewTarget(198, 14)).toBe(14)
|
|
@@ -494,10 +957,10 @@ test('viewTarget is the viewed map, else the active one', () => {
|
|
|
494
957
|
})
|
|
495
958
|
|
|
496
959
|
test('nextViewed resets when the active map changed or the viewed map left the open list', () => {
|
|
497
|
-
const maps = [{ number: 14, title: 'a' }, { number: 198, title: 'b' }]
|
|
960
|
+
const maps = [{ number: 14, title: 'a', counts: null }, { number: 198, title: 'b', counts: null }]
|
|
498
961
|
expect(nextViewed(14, { activeChanged: false, maps })).toBe(14)
|
|
499
962
|
expect(nextViewed(14, { activeChanged: true, maps })).toBeNull()
|
|
500
|
-
expect(nextViewed(14, { activeChanged: false, maps: [{ number: 198, title: 'b' }] })).toBeNull()
|
|
963
|
+
expect(nextViewed(14, { activeChanged: false, maps: [{ number: 198, title: 'b', counts: null }] })).toBeNull()
|
|
501
964
|
expect(nextViewed(14, { activeChanged: false, maps: [] })).toBeNull()
|
|
502
965
|
// no list read yet (or a failed read): nothing to judge by
|
|
503
966
|
expect(nextViewed(14, { activeChanged: false, maps: null })).toBe(14)
|
|
@@ -505,7 +968,7 @@ test('nextViewed resets when the active map changed or the viewed map left the o
|
|
|
505
968
|
})
|
|
506
969
|
|
|
507
970
|
test('mapRowLayout marks the active map, bolds the viewed one and presses the others', () => {
|
|
508
|
-
const m = { number: 14, title: 'older map' }
|
|
971
|
+
const m = { number: 14, title: 'older map', counts: null }
|
|
509
972
|
expect(mapRowLayout(m, { active: 198, viewed: null, columns: 60 })).toEqual({
|
|
510
973
|
mark: ' ',
|
|
511
974
|
label: '#14 older map',
|
|
@@ -514,10 +977,10 @@ test('mapRowLayout marks the active map, bolds the viewed one and presses the ot
|
|
|
514
977
|
isViewed: false,
|
|
515
978
|
isPressable: true,
|
|
516
979
|
})
|
|
517
|
-
const act = mapRowLayout({ number: 198, title: 'trailhead mod' }, { active: 198, viewed: null, columns: 60 })
|
|
980
|
+
const act = mapRowLayout({ number: 198, title: 'trailhead mod', counts: null }, { active: 198, viewed: null, columns: 60 })
|
|
518
981
|
expect(act).toMatchObject({ mark: '● ', aside: ' active', isActive: true, isViewed: true, isPressable: false })
|
|
519
982
|
// viewing another map: the active one becomes pressable (back to active), the viewed one is not
|
|
520
|
-
expect(mapRowLayout({ number: 198, title: 't' }, { active: 198, viewed: 14, columns: 60 })).toMatchObject({
|
|
983
|
+
expect(mapRowLayout({ number: 198, title: 't', counts: null }, { active: 198, viewed: 14, columns: 60 })).toMatchObject({
|
|
521
984
|
isActive: true,
|
|
522
985
|
isViewed: false,
|
|
523
986
|
isPressable: true,
|
|
@@ -526,14 +989,14 @@ test('mapRowLayout marks the active map, bolds the viewed one and presses the ot
|
|
|
526
989
|
})
|
|
527
990
|
|
|
528
991
|
test('mapRowLayout cuts the title to the room left by the mark and the aside', () => {
|
|
529
|
-
const long = { number: 198, title: 'x'.repeat(200) }
|
|
992
|
+
const long = { number: 198, title: 'x'.repeat(200), counts: null }
|
|
530
993
|
const act = mapRowLayout(long, { active: 198, viewed: null, columns: 30 })
|
|
531
994
|
expect(act.label.endsWith('…')).toBe(true)
|
|
532
995
|
expect(act.mark.length + act.label.length + (act.aside?.length ?? 0)).toBeLessThanOrEqual(30)
|
|
533
|
-
const other = mapRowLayout({ number: 14, title: 'x'.repeat(200) }, { active: 198, viewed: null, columns: 30 })
|
|
996
|
+
const other = mapRowLayout({ number: 14, title: 'x'.repeat(200), counts: null }, { active: 198, viewed: null, columns: 30 })
|
|
534
997
|
// a Button draws `[ label ]`: four cells of chrome
|
|
535
998
|
expect(other.mark.length + other.label.length + 4).toBeLessThanOrEqual(30)
|
|
536
|
-
expect(mapRowLayout({ number: 14, title: '' }, { active: 198, viewed: null, columns: 30 }).label).toBe('#14')
|
|
999
|
+
expect(mapRowLayout({ number: 14, title: '', counts: null }, { active: 198, viewed: null, columns: 30 }).label).toBe('#14')
|
|
537
1000
|
})
|
|
538
1001
|
|
|
539
1002
|
test('footerStatus keeps a status that fits beside the two buttons on their row', () => {
|
|
@@ -563,3 +1026,122 @@ test('footerStatus on a pane too narrow for the buttons never throws', () => {
|
|
|
563
1026
|
expect(footerStatus('updated just now', 10)).toEqual({ text: 'updated j…', isInline: false })
|
|
564
1027
|
expect(footerStatus('updated just now', 0)).toEqual({ text: '…', isInline: false })
|
|
565
1028
|
})
|
|
1029
|
+
|
|
1030
|
+
test('parseTickets on the whiteboard keeps only the whiteboard rows, unverified ones still counted', () => {
|
|
1031
|
+
const rows = [
|
|
1032
|
+
{ number: 1, title: 'map one', labels: [{ name: 'trailhead:build' }], assignees: [], url: '' },
|
|
1033
|
+
{ number: 2, title: 'loose', labels: [{ name: 'trailhead:whiteboard' }, { name: 'trailhead:bug' }], assignees: [], url: '' },
|
|
1034
|
+
{ number: 3, title: 'held', labels: [{ name: 'trailhead:whiteboard' }], assignees: [{ login: 'al' }], url: '' },
|
|
1035
|
+
{ number: 4, title: 'q', labels: [{ name: 'trailhead:whiteboard' }, { name: 'trailhead:unverified' }], assignees: [], url: '' },
|
|
1036
|
+
]
|
|
1037
|
+
const parsed = parseTickets(JSON.stringify(rows), { isWhiteboard: true })
|
|
1038
|
+
expect(parsed?.tickets.map(t => [t.number, t.type, t.state])).toEqual([
|
|
1039
|
+
[2, 'bug', 'frontier'],
|
|
1040
|
+
[3, null, 'in-progress'],
|
|
1041
|
+
])
|
|
1042
|
+
expect(parsed?.unverifiedCount).toBe(1)
|
|
1043
|
+
expect(parseTickets(JSON.stringify(rows))?.tickets.map(t => t.number)).toEqual([1])
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
test('parseDashboard takes the lowest numbered index, none when the repo has none, null when unreadable', () => {
|
|
1047
|
+
expect(parseDashboard(JSON.stringify([{ number: 9, url: 'https://x/9' }, { number: 5, url: 'https://x/5' }]))).toEqual({
|
|
1048
|
+
ref: { number: 5, url: 'https://x/5' },
|
|
1049
|
+
})
|
|
1050
|
+
expect(parseDashboard(JSON.stringify([{ number: 5, url: 'javascript:alert(1)' }]))).toEqual({ ref: { number: 5, url: '' } })
|
|
1051
|
+
expect(parseDashboard('[]')).toEqual({ ref: null })
|
|
1052
|
+
expect(parseDashboard('nope')).toBeNull()
|
|
1053
|
+
expect(parseDashboard('{}')).toBeNull()
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
test('mapRowLayout on the dashboard makes every map pressable, the active one still marked', () => {
|
|
1057
|
+
const r = mapRowLayout({ number: 198, title: 'trailhead mod', counts: null }, { active: 198, viewed: null, columns: 60, isDashboard: true })
|
|
1058
|
+
expect(r).toMatchObject({ isActive: true, isViewed: false, isPressable: true, mark: '● ', aside: ' active' })
|
|
1059
|
+
expect(mapRowLayout({ number: 198, title: 'trailhead mod', counts: null }, { active: 198, viewed: null, columns: 60 }).isPressable).toBe(false)
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
test('headingRoom leaves the switch button its cells, never below zero', () => {
|
|
1063
|
+
// `[ dashboard ]` is 13 cells, plus one gap
|
|
1064
|
+
expect(headingRoom(60, 'dashboard')).toBe(46)
|
|
1065
|
+
expect(headingRoom(60, 'map')).toBe(52)
|
|
1066
|
+
expect(headingRoom(5, 'dashboard')).toBe(0)
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
// One page of the GraphQL open-ticket read, as `gh api graphql` prints it.
|
|
1070
|
+
const node = (number: number, names: string[], logins: string[] = []) => ({
|
|
1071
|
+
number,
|
|
1072
|
+
title: `t${number}`,
|
|
1073
|
+
url: `https://github.com/o/r/issues/${number}`,
|
|
1074
|
+
labels: { nodes: labels(...names) },
|
|
1075
|
+
assignees: { nodes: logins.map(login => ({ login })) },
|
|
1076
|
+
})
|
|
1077
|
+
const page = (nodes: unknown[], pageInfo: unknown = { hasNextPage: false, endCursor: null }) =>
|
|
1078
|
+
JSON.stringify({ data: { repository: { issues: { nodes, pageInfo } } } })
|
|
1079
|
+
|
|
1080
|
+
test('parseIssuesPage flattens the nodes to the rows gh issue list prints, with the page info', () => {
|
|
1081
|
+
const out = parseIssuesPage(page([node(7, ['trailhead:build', 'trailhead:map-1'], ['alice'])], { hasNextPage: true, endCursor: 'abc' }))
|
|
1082
|
+
expect(out).toEqual({
|
|
1083
|
+
rows: [
|
|
1084
|
+
{
|
|
1085
|
+
number: 7,
|
|
1086
|
+
title: 't7',
|
|
1087
|
+
url: 'https://github.com/o/r/issues/7',
|
|
1088
|
+
labels: [{ name: 'trailhead:build' }, { name: 'trailhead:map-1' }],
|
|
1089
|
+
assignees: [{ login: 'alice' }],
|
|
1090
|
+
},
|
|
1091
|
+
],
|
|
1092
|
+
hasNextPage: true,
|
|
1093
|
+
endCursor: 'abc',
|
|
1094
|
+
})
|
|
1095
|
+
// the flattened rows go through the same parser as a gh list
|
|
1096
|
+
expect(parseTickets(JSON.stringify(out?.rows))?.tickets.map(t => [t.number, t.state])).toEqual([[7, 'in-progress']])
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
test('parseIssuesPage carries a string body and leaves any other out', () => {
|
|
1100
|
+
const withBody = { ...node(7, ['trailhead:uat']), body: 'UAT of: #3' }
|
|
1101
|
+
const out = parseIssuesPage(page([withBody, node(8, ['trailhead:uat']), { ...node(9, []), body: null }, { ...node(10, []), body: 5 }]))
|
|
1102
|
+
expect(out?.rows[0]).toEqual({
|
|
1103
|
+
number: 7,
|
|
1104
|
+
title: 't7',
|
|
1105
|
+
url: 'https://github.com/o/r/issues/7',
|
|
1106
|
+
labels: [{ name: 'trailhead:uat' }],
|
|
1107
|
+
assignees: [],
|
|
1108
|
+
body: 'UAT of: #3',
|
|
1109
|
+
})
|
|
1110
|
+
expect(out?.rows.slice(1).map(r => 'body' in r)).toEqual([false, false, false])
|
|
1111
|
+
})
|
|
1112
|
+
|
|
1113
|
+
test('parseIssuesPage is null on anything but a clean page', () => {
|
|
1114
|
+
expect(parseIssuesPage('nope')).toBeNull()
|
|
1115
|
+
expect(parseIssuesPage('[]')).toBeNull()
|
|
1116
|
+
expect(parseIssuesPage(JSON.stringify({ data: { repository: null } }))).toBeNull()
|
|
1117
|
+
expect(parseIssuesPage(JSON.stringify({ data: { repository: { issues: { nodes: {} } } } }))).toBeNull()
|
|
1118
|
+
// errors beside partial data, a missing or malformed pageInfo, a next page with no cursor
|
|
1119
|
+
expect(parseIssuesPage(JSON.stringify({ data: { repository: { issues: { nodes: [], pageInfo: { hasNextPage: false } } } }, errors: [{ message: 'x' }] }))).toBeNull()
|
|
1120
|
+
expect(parseIssuesPage(JSON.stringify({ data: { repository: { issues: { nodes: [] } } } }))).toBeNull()
|
|
1121
|
+
expect(parseIssuesPage(page([], { hasNextPage: 'yes', endCursor: null }))).toBeNull()
|
|
1122
|
+
expect(parseIssuesPage(page([], { hasNextPage: true, endCursor: null }))).toBeNull()
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
test('parseIssuesPage skips a malformed node and keeps an empty page', () => {
|
|
1126
|
+
expect(parseIssuesPage(page([node(1, []), null, 'x']))?.rows).toHaveLength(1)
|
|
1127
|
+
expect(parseIssuesPage(page([]))).toEqual({ rows: [], hasNextPage: false, endCursor: null })
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
test('the whiteboard read keeps the rows that carry both labels (GraphQL labels is any-of)', () => {
|
|
1131
|
+
const rows = parseIssuesPage(
|
|
1132
|
+
page([
|
|
1133
|
+
node(1, ['trailhead:whiteboard', 'trailhead:ticket', 'trailhead:bug']),
|
|
1134
|
+
node(2, ['trailhead:whiteboard']),
|
|
1135
|
+
node(3, ['trailhead:whiteboard', 'trailhead:ticket', 'trailhead:unverified']),
|
|
1136
|
+
]),
|
|
1137
|
+
)?.rows as unknown[]
|
|
1138
|
+
const parsed = ticketsFromRows(withLabel(rows, 'trailhead:ticket'), { isWhiteboard: true })
|
|
1139
|
+
expect(parsed?.tickets.map(t => t.number)).toEqual([1])
|
|
1140
|
+
expect(parsed?.unverifiedCount).toBe(1)
|
|
1141
|
+
})
|
|
1142
|
+
|
|
1143
|
+
test('ticketsFromRows says capped at the limit, as parseTickets does', () => {
|
|
1144
|
+
const rows = Array.from({ length: 200 }, (_, i) => issue({ number: i + 1 }))
|
|
1145
|
+
expect(ticketsFromRows(rows)?.isCapped).toBe(true)
|
|
1146
|
+
expect(ticketsFromRows(rows.slice(1))?.isCapped).toBe(false)
|
|
1147
|
+
})
|