@marcomigozzi/trailhead 0.14.0 → 0.15.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.
Files changed (92) hide show
  1. package/README.it.md +118 -30
  2. package/README.md +107 -29
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +1 -1
  8. package/bin/lib/codex-projection.test.js +41 -0
  9. package/bin/report-status-docs.test.js +195 -0
  10. package/bin/resolve-helpers-docs.test.js +36 -0
  11. package/bin/scope-confirm-skip-docs.test.js +70 -0
  12. package/bin/trailhead.js +3 -3
  13. package/bin/trailhead.test.js +341 -1
  14. package/bin/uat-setup-docs.test.js +65 -0
  15. package/package.json +1 -1
  16. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  17. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  18. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  19. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  20. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  21. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  22. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  23. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  24. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  25. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  26. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  27. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  28. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  29. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  30. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  31. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  32. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  33. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  34. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  35. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  36. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  37. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  38. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  39. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  40. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  41. package/plugins/trailhead/hooks/mod/README.md +38 -6
  42. package/plugins/trailhead/hooks/mod/map-pane-view.ts +168 -15
  43. package/plugins/trailhead/hooks/mod/map-pane.tsx +275 -106
  44. package/plugins/trailhead/hooks/mod/register.tsx +15 -2
  45. package/plugins/trailhead/hooks/mod/work-state-view.ts +17 -0
  46. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  47. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  48. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  49. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  50. package/plugins/trailhead/skills/_shared/choices.md +4 -2
  51. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  52. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  53. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  54. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  55. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  56. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  57. package/plugins/trailhead/skills/_shared/teamwork.md +16 -7
  58. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  59. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  60. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  61. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  62. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  63. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  64. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  65. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  66. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  67. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  68. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  69. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  70. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  71. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  72. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  73. package/plugins/trailhead/skills/trailhead-work/SKILL.md +14 -12
  74. package/plugins/trailhead/skills/trailhead-work/references/auto.md +4 -4
  75. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  76. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +26 -10
  77. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  78. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  79. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  80. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  81. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  82. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  83. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  84. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  85. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  86. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  87. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  88. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  89. package/plugins/trailhead/tests/map-pane-ui.test.ts +566 -19
  90. package/plugins/trailhead/tests/map-pane-view.test.ts +304 -16
  91. package/plugins/trailhead/tests/work-state-view.test.ts +25 -1
  92. package/plugins/trailhead/types/index.d.ts +37 -3
@@ -61,9 +61,28 @@ const MAPS = [
61
61
  { number: 198, title: 'trailhead mod', url: `${URL}/198` },
62
62
  { number: 14, title: 'older map', url: `${URL}/14` },
63
63
  ]
64
+ // The maps read: one GraphQL call that carries each map's sub-issue counts and the whiteboard's.
65
+ const MAPS_QUERY =
66
+ 'query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){issues(first:100,states:OPEN,labels:["trailhead:map"]){nodes{number title url subIssuesSummary{total completed}}}whiteboardOpen:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}whiteboardAll:issues(labels:["trailhead:whiteboard"]){totalCount}}}'
67
+ const MAPS_CALL = ['gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MAPS_QUERY}`]
68
+ const MAPS_LIST_CALL = ['gh', 'issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', '100', '--json', 'number,title,url']
69
+ // The pending-UAT read of one scope (a map label, or the whiteboard): the tickets with their bodies.
70
+ const uatCall = (scope: string) => ['gh', 'issue', 'list', '--label', 'trailhead:uat', '--label', scope, '--state', 'open', '--limit', '200', '--json', 'number,body']
71
+ const isGraphql = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql'
72
+ const isMapsList = (argv: readonly string[]) => argv[2] === 'list' && argv.includes('trailhead:map')
64
73
  const OTHER_TICKETS = [issue(140, 'Old frontier ticket', ['trailhead:build']), issue(141, 'Old held ticket', ['trailhead:task'], ['carol'])]
65
74
  const OTHER_HISTORY = OTHER_TICKETS.map(t => hrow(t.number, t.labels.map(l => l.name), null))
66
75
 
76
+ // The whiteboard (map-less tickets) and the pinned dashboard issue, as the dashboard view reads them.
77
+ const WB = ['trailhead:ticket', 'trailhead:whiteboard']
78
+ const WHITEBOARD = [
79
+ issue(301, 'Fix the README badge', [...WB, 'trailhead:bug']),
80
+ issue(302, 'Bump the CI node', [...WB, 'trailhead:task'], ['alice']),
81
+ issue(303, 'Waits on upstream', [...WB, 'trailhead:task', 'trailhead:blocked']),
82
+ issue(304, 'Quarantined loose one', [...WB, 'trailhead:task', 'trailhead:unverified']),
83
+ ]
84
+ const DASHBOARD = [{ number: 5, url: `${URL}/5` }]
85
+
67
86
  const stateText = (over: Record<string, unknown> = {}): string =>
68
87
  JSON.stringify({
69
88
  map: 198,
@@ -78,7 +97,7 @@ const stateText = (over: Record<string, unknown> = {}): string =>
78
97
  })
79
98
 
80
99
  // The plugin's atoms, which the engine resets on /clear.
81
- const ATOM_KEYS = ['workState', 'isOrphaned', 'promptSnapshot', 'mapPane', 'activeMap', 'viewedMap', 'pendingWork', 'paneAutoOpened']
100
+ const ATOM_KEYS = ['workState', 'isOrphaned', 'promptSnapshot', 'mapPane', 'activeMap', 'viewedMap', 'isDashboard', 'dashboardPane', 'pendingWork', 'paneAutoOpened', 'ownTicket']
82
101
 
83
102
  const PANE_PROPS = {
84
103
  title: 'Map',
@@ -104,9 +123,17 @@ const rig = (on: On, engine?: any) => {
104
123
  map: MAP as unknown,
105
124
  // The open maps list, and the second map's own answers (view, open list, history).
106
125
  maps: MAPS as unknown[],
126
+ // Sub-issue totals per map number, as the GraphQL read reports them (a map without one has no summary).
127
+ mapSummary: { 198: { total: 23, completed: 16 }, 14: { total: 5, completed: 2 } } as Record<number, unknown>,
128
+ // The whiteboard's open and total tickets as the same call reports them (null: the fields are absent).
129
+ whiteboardCounts: { open: 3, total: 9 } as { open: number; total: number } | null,
107
130
  otherMap: OTHER_MAP as unknown,
108
131
  otherTickets: OTHER_TICKETS as unknown[],
109
132
  otherHistory: OTHER_HISTORY as unknown[],
133
+ whiteboard: WHITEBOARD as unknown[],
134
+ // The UAT read's answer: one row per UAT ticket, `{ number, body }`; null makes the read fail.
135
+ uat: [] as unknown[] | null,
136
+ dashboard: DASHBOARD as unknown[],
110
137
  gh: null as Gh | null,
111
138
  // What `gh` answers when no test replaced it; a test can wrap it.
112
139
  defaultGh: null as unknown as Gh,
@@ -137,7 +164,16 @@ const rig = (on: On, engine?: any) => {
137
164
  },
138
165
  }
139
166
  const defaultGh: Gh = argv => {
140
- if (argv[2] === 'list' && argv.includes('trailhead:map')) return ok(JSON.stringify(r.maps))
167
+ if (argv.includes('trailhead:uat')) return r.uat === null ? { exitCode: 1, stdout: '', stderr: 'boom' } : ok(JSON.stringify(r.uat))
168
+ if (argv.includes('trailhead:whiteboard')) return ok(JSON.stringify(r.whiteboard))
169
+ if (argv.includes('trailhead:dashboard')) return ok(JSON.stringify(r.dashboard))
170
+ if (isGraphql(argv)) {
171
+ const nodes = (r.maps as { number: number }[]).map(m => ({ ...m, subIssuesSummary: r.mapSummary[m.number] }))
172
+ const wb = r.whiteboardCounts
173
+ const counts = wb === null ? {} : { whiteboardOpen: { totalCount: wb.open }, whiteboardAll: { totalCount: wb.total } }
174
+ return ok(JSON.stringify({ data: { repository: { issues: { nodes }, ...counts } } }))
175
+ }
176
+ if (isMapsList(argv)) return ok(JSON.stringify(r.maps))
141
177
  const isOther = argv.includes('14') || argv.includes('trailhead:map-14')
142
178
  if (argv[2] === 'view') return ok(JSON.stringify(isOther ? r.otherMap : r.map))
143
179
  return ok(JSON.stringify(argv.includes('all') ? (isOther ? r.otherHistory : r.history) : isOther ? r.otherTickets : r.tickets))
@@ -243,11 +279,11 @@ const settle = async (r: Rig) => {
243
279
  for (let i = 0; i < 10; i += 1) await r.clock.settle()
244
280
  }
245
281
  // The open-list reads; the history read rides along with each and is not counted.
246
- const fetches = (r: Rig) => r.calls.filter(argv => argv[2] === 'list' && argv.includes('open') && !argv.includes('trailhead:map')).length
282
+ const fetches = (r: Rig) => r.calls.filter(argv => argv[2] === 'list' && argv.includes('open') && !argv.includes('trailhead:map') && !argv.includes('trailhead:uat')).length
247
283
  // A gh that answers as the default does, except for the history read.
248
284
  const withHistory = (r: Rig, answer: GhOut): Gh => argv => {
249
285
  if (argv.includes('all')) return answer
250
- if (argv.includes('trailhead:map')) return ok(JSON.stringify(r.maps))
286
+ if (isGraphql(argv) || isMapsList(argv)) return r.defaultGh(argv)
251
287
  return argv[2] === 'view' ? ok(JSON.stringify(r.map)) : ok(JSON.stringify(r.tickets))
252
288
  }
253
289
  const hasKey = async (ui: any, key: string): Promise<boolean> => (await ui.find({ key })) !== undefined
@@ -275,7 +311,8 @@ for (const surface of SURFACES) {
275
311
  ['gh', 'issue', 'view', '198', '--json', 'title,body,url'],
276
312
  ['gh', 'issue', 'list', '--label', 'trailhead:map-198', '--state', 'open', '--limit', '200', '--json', 'number,title,labels,assignees,url'],
277
313
  ['gh', 'issue', 'list', '--label', 'trailhead:map-198', '--state', 'all', '--limit', '5000', '--json', 'number,labels,createdAt,closedAt'],
278
- ['gh', 'issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', '100', '--json', 'number,title,url'],
314
+ MAPS_CALL,
315
+ uatCall('trailhead:map-198'),
279
316
  ])
280
317
  expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
281
318
  expect((await ui.find({ type: 'Link' }))?.props).toMatchObject({ href: `${URL}/198` })
@@ -297,8 +334,10 @@ for (const surface of SURFACES) {
297
334
  expect(links).toContain(`${URL}/209`)
298
335
 
299
336
  // holders on every assigned row; the session's own ticket is marked and in the accent
300
- expect(await textOf(ui, 'holder-208')).toBe('@ToRvaLDz')
301
- expect((await ui.find({ key: 'holder-208' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
337
+ expect(await textOf(ui, 'self-208')).toBe('this session')
338
+ expect((await ui.find({ key: 'self-208' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
339
+ expect((await ui.find({ key: 'self-208' }))?.children[0].props).not.toHaveProperty('bold')
340
+ expect(await ui.find({ key: 'holder-208' })).toBeUndefined()
302
341
  expect(await textOf(ui, 'mark-208')).toBe('▸ ')
303
342
  expect((await ui.find({ key: 'mark-208' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
304
343
  expect((await ui.find({ key: 'ticket-208' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
@@ -319,6 +358,117 @@ for (const surface of SURFACES) {
319
358
  expect(await textOf(ui, 'age')).toBe('updated just now')
320
359
  })
321
360
 
361
+ test(`a UAT ticket sits under UAT between In progress and Paused, with its parent line and its work button [${surface}]`, async ($, on) => {
362
+ const r = rig(on, $)
363
+ r.work = stateText()
364
+ r.tickets = [
365
+ ...TICKETS,
366
+ issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat']),
367
+ issue(241, 'Device UAT seed', ['trailhead:seed', 'trailhead:blocked', 'trailhead:uat']),
368
+ issue(242, 'Held UAT', ['trailhead:task', 'trailhead:uat'], ['alice']),
369
+ issue(243, 'Paused UAT', ['trailhead:task', 'trailhead:uat', 'trailhead:paused']),
370
+ ]
371
+ r.uat = [
372
+ { number: 240, body: `Run it\n\nUAT of: [Burn-down view of the map](${URL}/209)\n` },
373
+ { number: 241, body: `UAT of: #212` },
374
+ { number: 243, body: `UAT of: [Paused parent](${URL}/150)` },
375
+ ]
376
+ await $.session.start(START)
377
+ const ui = await openAndMount($, r)
378
+
379
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 3')
380
+ expect((await ui.find({ key: 'head-uat' }))?.children[0]).toMatchObject({ props: { bold: true } })
381
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-frontier', 'group-in-progress', 'group-uat', 'group-paused', 'group-blocked'])
382
+ // each ticket once: a UAT row is in no other group, a paused UAT stays under Paused
383
+ expect(await keysOf(ui, 'row-')).toEqual([
384
+ 'row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-240', 'row-241', 'row-242', 'row-243', 'row-211', 'row-213',
385
+ ])
386
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
387
+ expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
388
+ // the parent line, dim, under the row; none without a parent
389
+ expect(await textOf(ui, 'uat-of-240')).toBe('tests Burn-down view of the map #209')
390
+ expect((await ui.find({ key: 'uat-of-240' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
391
+ expect(await textOf(ui, 'uat-of-241')).toBe('tests #212')
392
+ expect(await hasKey(ui, 'uat-of-242')).toBe(false)
393
+ expect(await textOf(ui, 'uat-of-243')).toBe('tests Paused parent #150')
394
+ // a marker, not a state: a frontier UAT task keeps its work button, a held one its holder, a seed stays dim
395
+ expect(await hasKey(ui, 'work-240')).toBe(true)
396
+ expect(await textOf(ui, 'holder-242')).toBe('@alice')
397
+ expect(await hasKey(ui, 'work-242')).toBe(false)
398
+ expect(await hasKey(ui, 'work-241')).toBe(false)
399
+ expect((await ui.find({ key: 'ticket-241' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
400
+ })
401
+
402
+ test(`no UAT ticket draws no UAT group [${surface}]`, async ($, on) => {
403
+ const none = rig(on, $)
404
+ await $.session.start(START)
405
+ const ui0 = await openAndMount($, none)
406
+ expect(await hasKey(ui0, 'group-uat')).toBe(false)
407
+ })
408
+
409
+ test(`a failed UAT read draws the UAT rows without the parent line, no error [${surface}]`, async ($, on) => {
410
+ const r = rig(on, $)
411
+ r.tickets = [...TICKETS, issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat'])]
412
+ r.uat = null
413
+ await $.session.start(START)
414
+ const ui = await openAndMount($, r)
415
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
416
+ expect(await hasKey(ui, 'uat-of-240')).toBe(false)
417
+ expect(await hasKey(ui, 'error')).toBe(false)
418
+ })
419
+
420
+ test(`the dashboard view groups UAT whiteboard tickets with their parent, reading the whiteboard scope [${surface}]`, async ($, on) => {
421
+ const r = rig(on, $)
422
+ r.whiteboard = [...WHITEBOARD, issue(306, 'Loose UAT', [...WB, 'trailhead:task', 'trailhead:uat'])]
423
+ r.uat = [{ number: 306, body: `UAT of: [Loose parent](${URL}/290)` }]
424
+ await $.session.start(START)
425
+ const ui = await openAndMount($, r)
426
+ await pressDashboard(ui, r)
427
+ expect(r.calls.some(argv => JSON.stringify(argv) === JSON.stringify(uatCall('trailhead:whiteboard')))).toBe(true)
428
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
429
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
430
+ expect(await textOf(ui, 'uat-of-306')).toBe('tests Loose parent #290')
431
+ })
432
+
433
+ test(`a paused ticket sits under Paused between In progress and Blocked, with no work button [${surface}]`, async ($, on) => {
434
+ const r = rig(on, $)
435
+ r.work = stateText()
436
+ r.tickets = [
437
+ ...TICKETS,
438
+ issue(230, 'Paused with a holder', ['trailhead:build', 'trailhead:paused'], ['ToRvaLDz']),
439
+ issue(231, 'Paused and released', ['trailhead:build', 'trailhead:paused']),
440
+ issue(232, 'Paused and blocked', ['trailhead:build', 'trailhead:paused', 'trailhead:blocked'], ['bob']),
441
+ ]
442
+ await $.session.start(START)
443
+ const ui = await openAndMount($, r)
444
+
445
+ expect(await textOf(ui, 'head-paused')).toBe('Paused · 3')
446
+ expect((await ui.find({ key: 'head-paused' }))?.children[0]).toMatchObject({ props: { bold: true } })
447
+ expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 2')
448
+ expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 2')
449
+ // a paused ticket appears in one group only: Frontier, In progress, Paused, Blocked in order
450
+ expect(await keysOf(ui, 'row-')).toEqual(['row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-230', 'row-231', 'row-232', 'row-211', 'row-213'])
451
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-frontier', 'group-in-progress', 'group-paused', 'group-blocked'])
452
+ expect(await textOf(ui, 'holder-230')).toBe('@ToRvaLDz')
453
+ expect((await ui.find({ key: 'holder-230' }))?.children[0].props).not.toHaveProperty('dimColor')
454
+ expect(await textOf(ui, 'holder-232')).toBe('@bob')
455
+ expect(await ui.find({ key: 'holder-231' })).toBeUndefined()
456
+ for (const n of [230, 231, 232]) expect(await hasKey(ui, `work-${n}`)).toBe(false)
457
+ expect((await ui.find({ key: 'ticket-231' }))?.children[0].props).not.toHaveProperty('dimColor')
458
+ })
459
+
460
+ test(`the dashboard view groups paused whiteboard tickets the same way [${surface}]`, async ($, on) => {
461
+ const r = rig(on, $)
462
+ r.whiteboard = [...WHITEBOARD, issue(305, 'Paused loose one', [...WB, 'trailhead:task', 'trailhead:paused'], ['alice'])]
463
+ await $.session.start(START)
464
+ const ui = await openAndMount($, r)
465
+ await pressDashboard(ui, r)
466
+ expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
467
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-305', 'row-303'])
468
+ expect(await hasKey(ui, 'work-305')).toBe(false)
469
+ expect(await textOf(ui, 'holder-305')).toBe('@alice')
470
+ })
471
+
322
472
  test(`ticket rows are spaced apart on the desktop only [${surface}]`, async ($, on) => {
323
473
  const r = rig(on)
324
474
  await $.session.start(START)
@@ -489,6 +639,70 @@ for (const surface of SURFACES) {
489
639
  expect(await ui.find({ key: 'mark-212' })).toBeUndefined()
490
640
  })
491
641
 
642
+ test(`another session's work state does not move this session's mark [${surface}]`, async ($, on) => {
643
+ const r = rig(on, $)
644
+ r.work = stateText()
645
+ await $.session.start(START)
646
+ const ui = await openAndMount($, r)
647
+ expect(await textOf(ui, 'self-208')).toBe('this session')
648
+
649
+ // a second session on the same map takes over the shared file
650
+ r.work = stateText({ ticket: 212, title: 'Native gh-backed tools', session: NEXT_SESSION })
651
+ await r.clock.advance(FLOOR)
652
+ await completeTurn($)
653
+ await settle(r)
654
+ expect(await textOf(ui, 'self-208')).toBe('this session')
655
+ expect(await textOf(ui, 'mark-208')).toBe('▸ ')
656
+ expect(await ui.find({ key: 'self-212' })).toBeUndefined()
657
+ expect(await textOf(ui, 'holder-212')).toBe('@alice')
658
+
659
+ // and then removes it: the last state read was not ours, so the mark stays
660
+ r.work = null
661
+ await r.clock.advance(FLOOR)
662
+ await completeTurn($)
663
+ await settle(r)
664
+ expect(await textOf(ui, 'self-208')).toBe('this session')
665
+ })
666
+
667
+ test(`this session's own Resolve, which removes the file, clears the mark [${surface}]`, async ($, on) => {
668
+ const r = rig(on, $)
669
+ r.work = stateText()
670
+ await $.session.start(START)
671
+ const ui = await openAndMount($, r)
672
+ expect(await textOf(ui, 'self-208')).toBe('this session')
673
+ r.work = null
674
+ await r.clock.advance(FLOOR)
675
+ await completeTurn($)
676
+ await settle(r)
677
+ expect(await ui.find({ key: 'self-208' })).toBeUndefined()
678
+ expect(await ui.find({ key: 'mark-208' })).toBeUndefined()
679
+ expect(await textOf(ui, 'holder-208')).toBe('@ToRvaLDz')
680
+ })
681
+
682
+ test(`each session marks its own ticket on the same map [${surface}]`, async ($, on) => {
683
+ const r = rig(on, $)
684
+ r.session = NEXT_SESSION
685
+ r.work = stateText({ ticket: 212, title: 'Native gh-backed tools', session: NEXT_SESSION })
686
+ await $.session.start(START)
687
+ const ui = await openAndMount($, r)
688
+ expect(await textOf(ui, 'self-212')).toBe('this session')
689
+ expect(await textOf(ui, 'mark-212')).toBe('▸ ')
690
+ expect(await textOf(ui, 'holder-208')).toBe('@ToRvaLDz')
691
+ expect(await ui.find({ key: 'self-208' })).toBeUndefined()
692
+ })
693
+
694
+ test(`a /clear starts the new session with no ticket of its own [${surface}]`, async ($, on) => {
695
+ const r = rig(on, $)
696
+ r.work = stateText()
697
+ await $.session.start(START)
698
+ const ui = await openAndMount($, r)
699
+ expect(await textOf(ui, 'self-208')).toBe('this session')
700
+ await $.command.run({ command: 'clear', args: '' })
701
+ await settle(r)
702
+ expect(await ui.find({ key: 'self-208' })).toBeUndefined()
703
+ expect(await ui.find({ key: 'mark-208' })).toBeUndefined()
704
+ })
705
+
492
706
  test(`unverified tickets are counted in the plural [${surface}]`, async ($, on) => {
493
707
  const r = rig(on, $)
494
708
  r.tickets = [issue(1, 'a', ['trailhead:unverified']), issue(2, 'b', ['trailhead:unverified']), issue(3, 'c', ['trailhead:build'])]
@@ -522,7 +736,7 @@ for (const surface of SURFACES) {
522
736
  await completeTurn($)
523
737
  await settle(r)
524
738
  // the open maps are still read, so one can be picked; no map is
525
- expect(r.calls).toEqual([['gh', 'issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', '100', '--json', 'number,title,url']])
739
+ expect(r.calls).toEqual([MAPS_CALL])
526
740
  })
527
741
 
528
742
  test(`first fetch in flight: the map number and Loading tickets [${surface}]`, async ($, on) => {
@@ -754,7 +968,7 @@ for (const surface of SURFACES) {
754
968
  await $.session.start(START)
755
969
  const ui = await openAndMount($, r)
756
970
  const buttons = (await ui.findAll({ type: 'Button' })).map((b: any) => b.key)
757
- expect(buttons).toEqual(['map-14', 'work-209', 'work-210', 'work-214', 'refresh', 'close'])
971
+ expect(buttons).toEqual(['open-whiteboard', 'map-14', 'work-209', 'work-210', 'work-214', 'refresh', 'close'])
758
972
  expect((await ui.find({ key: 'work-209' }))?.props).toMatchObject({ label: 'work', variant: 'primary' })
759
973
  expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
760
974
 
@@ -876,7 +1090,7 @@ for (const surface of SURFACES) {
876
1090
  await settle(r)
877
1091
  expect(await textOf(ui, 'map')).toBe('#14 older map')
878
1092
  expect(await hasKey(ui, 'viewing')).toBe(false)
879
- expect(await textOf(ui, 'map-aside-14')).toBe(' active')
1093
+ expect(await textOf(ui, 'map-aside-14')).toBe(' 3 open / 5 · active')
880
1094
  })
881
1095
 
882
1096
  test(`a manual /clear keeps the pane filled, with no turn and inside the floor [${surface}]`, async ($, on) => {
@@ -1142,7 +1356,7 @@ for (const surface of SURFACES) {
1142
1356
  expect(await keysOf(ui, 'map-row-')).toEqual(['map-row-14', 'map-row-198'])
1143
1357
  expect(await textOf(ui, 'map-mark-198')).toBe('● ')
1144
1358
  expect((await ui.find({ key: 'map-mark-198' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
1145
- expect(await textOf(ui, 'map-aside-198')).toBe(' active')
1359
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
1146
1360
  expect(await textOf(ui, 'map-label-198')).toBe('#198 trailhead mod')
1147
1361
  expect((await ui.find({ key: 'map-label-198' }))?.children[0]).toMatchObject({ props: { bold: true } })
1148
1362
  expect(await textOf(ui, 'map-mark-14')).toBe(' ')
@@ -1162,7 +1376,7 @@ for (const surface of SURFACES) {
1162
1376
  const ui = await openAndMount($, r)
1163
1377
  expect(await hasKey(ui, 'head-maps')).toBe(false)
1164
1378
  expect(await hasKey(ui, 'group-maps')).toBe(false)
1165
- expect((await ui.findAll({ type: 'Button' })).map((b: any) => b.key)).toEqual(['work-209', 'work-210', 'work-214', 'refresh', 'close'])
1379
+ expect((await ui.findAll({ type: 'Button' })).map((b: any) => b.key)).toEqual(['open-whiteboard', 'work-209', 'work-210', 'work-214', 'refresh', 'close'])
1166
1380
  })
1167
1381
 
1168
1382
  test(`a press on another map shows its tickets, the active-map marker is never written [${surface}]`, async ($, on) => {
@@ -1179,10 +1393,11 @@ for (const surface of SURFACES) {
1179
1393
  expect(await textOf(ui, 'burndown-counts')).toBe('open 2 · done 0 · removed 0 · peak 2')
1180
1394
  // the ticket in play belongs to the other map: nothing here is marked
1181
1395
  expect(await hasKey(ui, 'mark-208')).toBe(false)
1182
- expect(r.calls.slice(-4).map(argv => argv.slice(0, 4))).toEqual([
1396
+ expect(r.calls.slice(-5).map(argv => argv.slice(0, 4))).toEqual([
1183
1397
  ['gh', 'issue', 'view', '14'],
1184
1398
  ['gh', 'issue', 'list', '--label'],
1185
1399
  ['gh', 'issue', 'list', '--label'],
1400
+ ['gh', 'api', 'graphql', '-F'],
1186
1401
  ['gh', 'issue', 'list', '--label'],
1187
1402
  ])
1188
1403
  expect(r.calls.some(argv => argv.includes('trailhead:map-14'))).toBe(true)
@@ -1236,7 +1451,7 @@ for (const surface of SURFACES) {
1236
1451
  expect(await textOf(ui, 'map')).toBe('#14 older map')
1237
1452
  expect(await hasKey(ui, 'viewing')).toBe(false)
1238
1453
  expect(await textOf(ui, 'map-label-14')).toBe('#14 older map')
1239
- expect(await textOf(ui, 'map-aside-14')).toBe(' active')
1454
+ expect(await textOf(ui, 'map-aside-14')).toBe(' 3 open / 5 · active')
1240
1455
  expect((await ui.find({ key: 'map-198' }))?.props).toMatchObject({ label: '#198 trailhead mod' })
1241
1456
  })
1242
1457
 
@@ -1258,18 +1473,18 @@ for (const surface of SURFACES) {
1258
1473
  await settle(r)
1259
1474
  expect(await textOf(ui, 'map')).toBe('#14 older map')
1260
1475
  expect(await hasKey(ui, 'viewing')).toBe(false)
1261
- expect(await textOf(ui, 'map-aside-14')).toBe(' active')
1262
- expect(await hasKey(ui, 'map-aside-198')).toBe(false)
1476
+ expect(await textOf(ui, 'map-aside-14')).toBe(' 3 open / 5 · active')
1477
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23')
1263
1478
  })
1264
1479
 
1265
- test(`a failed maps read keeps the last list and sets no footer error [${surface}]`, async ($, on) => {
1480
+ test(`both maps reads failing keep the last list and set no footer error [${surface}]`, async ($, on) => {
1266
1481
  const r = rig(on, $)
1267
1482
  await $.session.start(START)
1268
1483
  const ui = await openAndMount($, r)
1269
1484
  expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1270
1485
 
1271
1486
  const base: Gh = argv => withHistory(r, ok(JSON.stringify(r.history)))(argv)
1272
- r.gh = argv => (argv.includes('trailhead:map') ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
1487
+ r.gh = argv => (isGraphql(argv) || isMapsList(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
1273
1488
  r.tickets = [issue(300, 'fresh one', ['trailhead:build'])]
1274
1489
  await r.clock.advance(FLOOR)
1275
1490
  await completeTurn($)
@@ -1279,7 +1494,7 @@ for (const surface of SURFACES) {
1279
1494
  expect(await textOf(ui, 'age')).toBe('updated just now')
1280
1495
 
1281
1496
  // unreadable output is the same
1282
- r.gh = argv => (argv.includes('trailhead:map') ? ok('not json') : base(argv))
1497
+ r.gh = argv => (isGraphql(argv) || isMapsList(argv) ? ok('not json') : base(argv))
1283
1498
  await r.clock.advance(FLOOR)
1284
1499
  await completeTurn($)
1285
1500
  await settle(r)
@@ -1371,4 +1586,336 @@ for (const surface of SURFACES) {
1371
1586
  expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
1372
1587
  expect(r.writes).toEqual([])
1373
1588
  })
1589
+
1590
+ const pressDashboard = async (ui: any, r: Rig) => {
1591
+ await ui.press({ key: 'open-whiteboard' })
1592
+ await settle(r)
1593
+ }
1594
+
1595
+ test(`the Whiteboard row shows the maps index and the whiteboard tickets [${surface}]`, async ($, on) => {
1596
+ const r = rig(on, $)
1597
+ await $.session.start(START)
1598
+ const ui = await openAndMount($, r)
1599
+ expect((await ui.find({ key: 'open-whiteboard' }))?.props).toMatchObject({ label: 'Whiteboard' })
1600
+ const before = r.calls.length
1601
+ await pressDashboard(ui, r)
1602
+
1603
+ expect(r.calls.slice(before)).toEqual([
1604
+ ['gh', 'issue', 'list', '--label', 'trailhead:ticket', '--label', 'trailhead:whiteboard', '--state', 'open', '--limit', '200', '--json', 'number,title,labels,assignees,url'],
1605
+ ['gh', 'issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', 'number,url'],
1606
+ MAPS_CALL,
1607
+ uatCall('trailhead:whiteboard'),
1608
+ ])
1609
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
1610
+ expect((await ui.find({ key: 'dashboard-title' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
1611
+ expect((await ui.findAll({ type: 'Link' })).map((l: any) => l.props.href)).toContain(`${URL}/5`)
1612
+ expect((await ui.find({ key: 'to-map' }))?.props).toMatchObject({ label: 'map' })
1613
+ expect(await hasKey(ui, 'dashboard')).toBe(false)
1614
+ // the view is the whiteboard: its row is not drawn again
1615
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(false)
1616
+
1617
+ // every open map is a button, the active one marked
1618
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1619
+ expect((await ui.find({ key: 'map-198' }))?.props).toMatchObject({ label: '#198 trailhead mod' })
1620
+ expect(await hasKey(ui, 'map-14')).toBe(true)
1621
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
1622
+
1623
+ // the whiteboard by state, the quarantined one only counted, no map tickets
1624
+ expect(await hasKey(ui, 'whiteboard')).toBe(true)
1625
+ expect(await textOf(ui, 'head-whiteboard')).toBe('Whiteboard · 3 open · 1 assigned')
1626
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 1')
1627
+ expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 1')
1628
+ expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 1')
1629
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
1630
+ expect(await textOf(ui, 'holder-302')).toBe('@alice')
1631
+ expect(await hasKey(ui, 'work-301')).toBe(true)
1632
+ expect(await textOf(ui, 'unverified')).toBe('1 unverified ticket hidden (quarantined)')
1633
+ expect(await hasKey(ui, 'burndown-section')).toBe(false)
1634
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1635
+ expect(r.writes).toEqual([])
1636
+ })
1637
+
1638
+ test(`a Whiteboard row above the maps list carries the counts, the header has no dashboard button [${surface}]`, async ($, on) => {
1639
+ const r = rig(on, $)
1640
+ r.maps = [MAPS[0]]
1641
+ await $.session.start(START)
1642
+ const ui = await openAndMount($, r)
1643
+ // one map open: no maps group, the row is still there, without a per-ticket query
1644
+ expect(await hasKey(ui, 'group-maps')).toBe(false)
1645
+ expect((await ui.find({ key: 'open-whiteboard' }))?.props).toMatchObject({ label: 'Whiteboard' })
1646
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
1647
+ expect((await ui.find({ key: 'whiteboard-aside' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
1648
+ expect(await hasKey(ui, 'dashboard')).toBe(false)
1649
+ expect(r.calls.filter(argv => argv.includes('trailhead:whiteboard'))).toEqual([])
1650
+ expect(r.calls.filter(isGraphql)).toEqual([MAPS_CALL])
1651
+ // above the maps list and the tickets, below the heading
1652
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
1653
+ expect(keys.indexOf('whiteboard-row')).toBeGreaterThan(keys.indexOf('header'))
1654
+ expect(keys.indexOf('whiteboard-row')).toBeLessThan(keys.indexOf('group-frontier'))
1655
+ })
1656
+
1657
+ test(`the Whiteboard row sits above the Maps list [${surface}]`, async ($, on) => {
1658
+ const r = rig(on, $)
1659
+ await $.session.start(START)
1660
+ const ui = await openAndMount($, r)
1661
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
1662
+ expect(keys.indexOf('whiteboard-row')).toBeLessThan(keys.indexOf('group-maps'))
1663
+ expect(keys.indexOf('whiteboard-row')).toBeGreaterThan(keys.indexOf('header'))
1664
+ })
1665
+
1666
+ test(`a press on the Whiteboard row shows the whiteboard, the map button goes back [${surface}]`, async ($, on) => {
1667
+ const r = rig(on, $)
1668
+ r.maps = [MAPS[0]]
1669
+ await $.session.start(START)
1670
+ const ui = await openAndMount($, r)
1671
+ await pressDashboard(ui, r)
1672
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
1673
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(false)
1674
+ await ui.press({ key: 'to-map' })
1675
+ await settle(r)
1676
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1677
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
1678
+ })
1679
+
1680
+ test(`the whiteboard counts are drawn as read, zero included, and follow the next read [${surface}]`, async ($, on) => {
1681
+ const r = rig(on, $)
1682
+ r.whiteboardCounts = { open: 0, total: 0 }
1683
+ await $.session.start(START)
1684
+ const ui = await openAndMount($, r)
1685
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 0 open / 0')
1686
+ r.whiteboardCounts = { open: 4, total: 11 }
1687
+ await r.clock.advance(FLOOR)
1688
+ await completeTurn($)
1689
+ await settle(r)
1690
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 4 open / 11')
1691
+ })
1692
+
1693
+ test(`a failed counts read shows the row name-only, never an error [${surface}]`, async ($, on) => {
1694
+ const r = rig(on, $)
1695
+ const base = r.defaultGh
1696
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
1697
+ await $.session.start(START)
1698
+ const ui = await openAndMount($, r)
1699
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(true)
1700
+ expect(await hasKey(ui, 'whiteboard-aside')).toBe(false)
1701
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1702
+
1703
+ // counts that were read once are not kept through a failed read
1704
+ r.gh = null
1705
+ await r.clock.advance(FLOOR)
1706
+ await completeTurn($)
1707
+ await settle(r)
1708
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
1709
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
1710
+ await r.clock.advance(FLOOR)
1711
+ await completeTurn($)
1712
+ await settle(r)
1713
+ expect(await hasKey(ui, 'whiteboard-aside')).toBe(false)
1714
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1715
+ })
1716
+
1717
+ test(`both maps reads failing keep the maps list but draw the row name-only [${surface}]`, async ($, on) => {
1718
+ const r = rig(on, $)
1719
+ await $.session.start(START)
1720
+ const ui = await openAndMount($, r)
1721
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
1722
+ const base: Gh = argv => withHistory(r, ok(JSON.stringify(r.history)))(argv)
1723
+ r.gh = argv => (isGraphql(argv) || isMapsList(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
1724
+ await r.clock.advance(FLOOR)
1725
+ await completeTurn($)
1726
+ await settle(r)
1727
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1728
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(true)
1729
+ expect(await hasKey(ui, 'whiteboard-aside')).toBe(false)
1730
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1731
+ })
1732
+
1733
+ test(`unreadable whiteboard fields leave the maps with counts and the row name-only [${surface}]`, async ($, on) => {
1734
+ const r = rig(on, $)
1735
+ r.whiteboardCounts = null
1736
+ await $.session.start(START)
1737
+ const ui = await openAndMount($, r)
1738
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
1739
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(true)
1740
+ expect(await hasKey(ui, 'whiteboard-aside')).toBe(false)
1741
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1742
+ })
1743
+
1744
+ test(`with no active map the Whiteboard row sits above the maps [${surface}]`, async ($, on) => {
1745
+ const none = rig(on, $)
1746
+ none.active = null
1747
+ await $.session.start(START)
1748
+ const ui = await openAndMount($, none)
1749
+ await completeTurn($)
1750
+ await settle(none)
1751
+ expect(await hasKey(ui, 'dashboard')).toBe(false)
1752
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
1753
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
1754
+ expect(keys.indexOf('whiteboard-row')).toBeLessThan(keys.indexOf('group-maps'))
1755
+ })
1756
+
1757
+ test(`the Whiteboard row stays in the loading view of a map [${surface}]`, async ($, on) => {
1758
+ const r = rig(on, $)
1759
+ let release: () => void = () => undefined
1760
+ const held = new Promise<void>(resolve => (release = resolve))
1761
+ const base = r.defaultGh
1762
+ r.gh = async argv => {
1763
+ if (argv[2] === 'view') await held
1764
+ return base(argv)
1765
+ }
1766
+ await $.session.start(START)
1767
+ r.open()
1768
+ const ui = await mountPane($)
1769
+ await settle(r)
1770
+ expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
1771
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(true)
1772
+ release()
1773
+ await settle(r)
1774
+ })
1775
+
1776
+ test(`the maps list shows each map's open and total from one call [${surface}]`, async ($, on) => {
1777
+ const r = rig(on, $)
1778
+ await $.session.start(START)
1779
+ const ui = await openAndMount($, r)
1780
+ expect(r.calls.filter(argv => argv.includes('trailhead:map') || isGraphql(argv))).toEqual([MAPS_CALL])
1781
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
1782
+ expect(await textOf(ui, 'map-aside-14')).toBe(' 3 open / 5')
1783
+ // a map the read gave no summary for is drawn name-only
1784
+ r.mapSummary = { 198: { total: 23, completed: 16 } }
1785
+ await r.clock.advance(FLOOR)
1786
+ await completeTurn($)
1787
+ await settle(r)
1788
+ expect(await hasKey(ui, 'map-aside-14')).toBe(false)
1789
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
1790
+ })
1791
+
1792
+ test(`a failing counts read falls back to the name-only list with no footer error [${surface}]`, async ($, on) => {
1793
+ const r = rig(on, $)
1794
+ const base = r.defaultGh
1795
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
1796
+ await $.session.start(START)
1797
+ const ui = await openAndMount($, r)
1798
+ expect(r.calls.filter(argv => argv.includes('trailhead:map') || isGraphql(argv))).toEqual([MAPS_CALL, MAPS_LIST_CALL])
1799
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1800
+ expect(await textOf(ui, 'map-aside-198')).toBe(' active')
1801
+ expect(await hasKey(ui, 'map-aside-14')).toBe(false)
1802
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1803
+
1804
+ // a reply with errors is a failed read too
1805
+ r.gh = argv => (isGraphql(argv) ? ok(JSON.stringify({ data: { repository: { issues: { nodes: [] } } }, errors: [{ message: 'x' }] })) : base(argv))
1806
+ await r.clock.advance(FLOOR)
1807
+ await completeTurn($)
1808
+ await settle(r)
1809
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1810
+ expect(await hasKey(ui, 'map-aside-14')).toBe(false)
1811
+ })
1812
+
1813
+ test(`the dashboard heading counts the whiteboard and every assigned trusted ticket is a row [${surface}]`, async ($, on) => {
1814
+ const r = rig(on, $)
1815
+ r.whiteboard = [
1816
+ ...WHITEBOARD,
1817
+ issue(305, 'Assigned but quarantined', [...WB, 'trailhead:task', 'trailhead:unverified'], ['dave']),
1818
+ issue(306, 'Paused with a holder', [...WB, 'trailhead:task', 'trailhead:paused'], ['erin']),
1819
+ ]
1820
+ await $.session.start(START)
1821
+ const ui = await openAndMount($, r)
1822
+ await pressDashboard(ui, r)
1823
+ expect(await textOf(ui, 'head-whiteboard')).toBe('Whiteboard · 4 open · 2 assigned')
1824
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
1825
+ expect(await textOf(ui, 'holder-302')).toBe('@alice')
1826
+ expect(await textOf(ui, 'holder-306')).toBe('@erin')
1827
+ expect(await hasKey(ui, 'row-305')).toBe(false)
1828
+ expect(await textOf(ui, 'unverified')).toBe('2 unverified tickets hidden (quarantined)')
1829
+ })
1830
+
1831
+ test(`the map button goes back to the map shown before [${surface}]`, async ($, on) => {
1832
+ const r = rig(on, $)
1833
+ await $.session.start(START)
1834
+ const ui = await openAndMount($, r)
1835
+ await pressMap(ui, r, 14)
1836
+ await pressDashboard(ui, r)
1837
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
1838
+
1839
+ await ui.press({ key: 'to-map' })
1840
+ await settle(r)
1841
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1842
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
1843
+ expect(await hasKey(ui, 'dashboard-title')).toBe(false)
1844
+ })
1845
+
1846
+ test(`a map pressed on the dashboard shows that map [${surface}]`, async ($, on) => {
1847
+ const r = rig(on, $)
1848
+ await $.session.start(START)
1849
+ const ui = await openAndMount($, r)
1850
+ await pressDashboard(ui, r)
1851
+ await pressMap(ui, r, 14)
1852
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1853
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
1854
+
1855
+ await pressDashboard(ui, r)
1856
+ await pressMap(ui, r, 198)
1857
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1858
+ expect(await hasKey(ui, 'viewing')).toBe(false)
1859
+ })
1860
+
1861
+ test(`a work press on a whiteboard ticket clears and runs the work verb [${surface}]`, async ($, on) => {
1862
+ const r = rig(on, $)
1863
+ await $.session.start(START)
1864
+ const ui = await openAndMount($, r)
1865
+ await pressDashboard(ui, r)
1866
+ await ui.press({ key: 'work-301' })
1867
+ await settle(r)
1868
+ expect(r.runs).toEqual([
1869
+ { command: 'clear', args: '' },
1870
+ { command: 'trailhead:work', args: '301' },
1871
+ ])
1872
+ // the dashboard stays shown across the reset
1873
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
1874
+ expect(await keysOf(ui, 'row-')).toContain('row-301')
1875
+ })
1876
+
1877
+ test(`an empty whiteboard says so, a failed read shows the reason [${surface}]`, async ($, on) => {
1878
+ const r = rig(on, $)
1879
+ r.whiteboard = []
1880
+ r.dashboard = []
1881
+ await $.session.start(START)
1882
+ const ui = await openAndMount($, r)
1883
+ await pressDashboard(ui, r)
1884
+ expect(await textOf(ui, 'no-tickets')).toBe('No open whiteboard tickets.')
1885
+ // no pinned index yet: the title is plain text
1886
+ expect((await ui.findAll({ type: 'Link' })).map((l: any) => l.props.href)).not.toContain(`${URL}/5`)
1887
+
1888
+ r.whiteboard = WHITEBOARD
1889
+ r.gh = argv => (argv.includes('trailhead:whiteboard') ? { exitCode: 1, stdout: '', stderr: 'boom' } : r.defaultGh(argv))
1890
+ await r.clock.advance(FLOOR)
1891
+ await ui.press({ key: 'refresh' })
1892
+ await settle(r)
1893
+ expect(await textOf(ui, 'age')).toBe('⚠ gh failed (exit 1) · list from 15s ago')
1894
+ // a list that may be stale says nothing about being empty
1895
+ expect(await hasKey(ui, 'no-tickets')).toBe(false)
1896
+ })
1897
+
1898
+ test(`with no active map the dashboard is one press away [${surface}]`, async ($, on) => {
1899
+ const r = rig(on, $)
1900
+ r.active = null
1901
+ await $.session.start(START)
1902
+ const ui = await openAndMount($, r)
1903
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
1904
+ await pressDashboard(ui, r)
1905
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
1906
+ await ui.press({ key: 'to-map' })
1907
+ await settle(r)
1908
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
1909
+ })
1910
+
1911
+ test(`the dashboard stays shown through /clear [${surface}]`, async ($, on) => {
1912
+ const r = rig(on, $)
1913
+ await $.session.start(START)
1914
+ const ui = await openAndMount($, r)
1915
+ await pressDashboard(ui, r)
1916
+ await $.command.run({ command: 'clear', args: '' } as never)
1917
+ await settle(r)
1918
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
1919
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
1920
+ })
1374
1921
  }