pi-odd-worktree 0.0.0-stage → 1.0.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.md +234 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- package/tools/worktree-tools.test.js +434 -0
|
@@ -0,0 +1,4401 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// T2 (issues-panel S1,S3) tests: the migrated lib/issues-view.js — the pure
|
|
3
|
+
// four-bucket issues overlay (list on the left, issue detail on the right) that
|
|
4
|
+
// `/issues` mounts once that command is wired. The view moved whole from the
|
|
5
|
+
// source package's issues panel, so these tests pin the same contract there.
|
|
6
|
+
//
|
|
7
|
+
// Contract under test, as observable output only: the component renders
|
|
8
|
+
// exactly the requested rows at the requested width; tabs carry counts;
|
|
9
|
+
// search opens/filters/leaves keeping the filter; pagination bounds and the
|
|
10
|
+
// totals line; selection movement; right-pane scroll to the last line;
|
|
11
|
+
// labels/state/author/dates/reactions/comments; label editor; comment
|
|
12
|
+
// input; close confirm; the lazy page/label loader seams (S19);
|
|
13
|
+
// open-only listing with no state toggle (S10a); empty/error/
|
|
14
|
+
// gh-unavailable states; no key outside S8 mutates state.
|
|
15
|
+
//
|
|
16
|
+
// S9/S10/S11/S13/S14/S15 additions: the fourth `All` bucket, a visited tab that
|
|
17
|
+
// reuses its rows and page, the fixed page indicator on the search row, the
|
|
18
|
+
// server search seam (query in every page request, no local re-filter, cursors
|
|
19
|
+
// bound to their query, stale answers dropped), and the write lock with the
|
|
20
|
+
// comment caret.
|
|
21
|
+
//
|
|
22
|
+
// S17: the generic `?` key-help overlay is gone; the footer legend is the
|
|
23
|
+
// only key reference.
|
|
24
|
+
|
|
25
|
+
const { test, describe, afterEach } = require('node:test');
|
|
26
|
+
const assert = require('node:assert/strict');
|
|
27
|
+
|
|
28
|
+
const { createIssuesView } = require('../lib/issues-view.js');
|
|
29
|
+
const ghIssues = require('../lib/gh-issues.js');
|
|
30
|
+
const layout = require('../lib/view-layout.js');
|
|
31
|
+
|
|
32
|
+
// Optional real host helpers, guarded exactly like tools/odd-view.test.js:
|
|
33
|
+
// CI installs no dependencies, so the byte-level key assertions run only
|
|
34
|
+
// when the installed matcher resolves and skip honestly when it does not.
|
|
35
|
+
let realTui = null;
|
|
36
|
+
try {
|
|
37
|
+
realTui = require('@earendil-works/pi-tui');
|
|
38
|
+
} catch {
|
|
39
|
+
realTui = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
ghIssues.setGhRunner(null);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function strip(value) {
|
|
47
|
+
return String(value).replace(/\x1b\[[0-9;]*m/g, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const helpers = {
|
|
51
|
+
visibleWidth: (text) => [...strip(text)].length,
|
|
52
|
+
truncateToWidth: (text, width) => {
|
|
53
|
+
const chars = [...String(text)];
|
|
54
|
+
return chars.length > width ? chars.slice(0, Math.max(0, width - 1)).join('') + '…' : String(text);
|
|
55
|
+
},
|
|
56
|
+
matchesKey: (key, name) => key === name,
|
|
57
|
+
isKeyRelease: (key) => key === 'release',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const plainTheme = { fg: (_role, text) => String(text), bg: (_role, text) => String(text) };
|
|
61
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
62
|
+
|
|
63
|
+
// S11: a theme that RECORDS which role it was asked to paint each string with
|
|
64
|
+
// and paints every role with its own SGR code, so a rendered line can be read
|
|
65
|
+
// for the ROLE behind a token instead of trusting an eyeballed colour. A role
|
|
66
|
+
// the map does not name still gets its own code, so an unexpected role shows up
|
|
67
|
+
// instead of passing silently.
|
|
68
|
+
const ROLE_SGR = { accent: 96, customMessageLabel: 33, muted: 90, dim: 2, border: 34, error: 91, text: 39, mdHeading: 35 };
|
|
69
|
+
// T9 (S13): the title role is `customMessageLabel`, the one the Pi Agents view
|
|
70
|
+
// paints its own frame title with (gentle-pi/lib/agents-view.ts:42,437), so the
|
|
71
|
+
// expectations below name the role LITERALLY instead of reading it back out of
|
|
72
|
+
// the table they are meant to pin. `TITLE_PAINT` is the raw escape a recording
|
|
73
|
+
// theme emits for that role + bold.
|
|
74
|
+
const TITLE_PAINT = '\x1b[1m\x1b[33m';
|
|
75
|
+
const CLOSE_PAINT = '\x1b[0m\x1b[22m';
|
|
76
|
+
|
|
77
|
+
function recordingTheme() {
|
|
78
|
+
const calls = [];
|
|
79
|
+
const code = (role) => ROLE_SGR[role] ?? 95;
|
|
80
|
+
return {
|
|
81
|
+
calls,
|
|
82
|
+
fg(role, text) {
|
|
83
|
+
const value = String(text);
|
|
84
|
+
calls.push({ role, text: value });
|
|
85
|
+
return `\x1b[${code(role)}m${value}\x1b[0m`;
|
|
86
|
+
},
|
|
87
|
+
bold(text) {
|
|
88
|
+
const value = String(text);
|
|
89
|
+
calls.push({ role: 'bold', text: value });
|
|
90
|
+
return `\x1b[1m${value}\x1b[22m`;
|
|
91
|
+
},
|
|
92
|
+
bg(role, text) {
|
|
93
|
+
calls.push({ role: `bg:${role}`, text: String(text) });
|
|
94
|
+
return String(text);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// S16: every permission the editors gate on, confirmed true. Fixtures that
|
|
100
|
+
// mean to open an editor carry this explicitly; a fixture without it is a
|
|
101
|
+
// repository where editing is not permitted.
|
|
102
|
+
const EDITABLE = { canEditLabels: true, canEditAssignees: true, canCreateLabels: true, canCloseIssues: true };
|
|
103
|
+
|
|
104
|
+
function issue(number, overrides = {}) {
|
|
105
|
+
return {
|
|
106
|
+
number,
|
|
107
|
+
title: `issue title ${number}`,
|
|
108
|
+
state: 'open',
|
|
109
|
+
author: 'octo',
|
|
110
|
+
labels: [{ name: 'bug' }],
|
|
111
|
+
createdAt: new Date(Date.now() - 3600_000).toISOString(),
|
|
112
|
+
updatedAt: new Date(Date.now() - 1800_000).toISOString(),
|
|
113
|
+
commentsCount: 0,
|
|
114
|
+
reactions: { total: 0, plusOne: 3 },
|
|
115
|
+
url: `https://example.test/i/${number}`,
|
|
116
|
+
body: `body of issue ${number}`,
|
|
117
|
+
...overrides,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function pagesOf(lists) {
|
|
122
|
+
const names = ['My issues', 'Assigned', 'Involved'];
|
|
123
|
+
return lists.map((rows, at) => ({ id: `p${at}`, label: names[at], issues: rows, total: rows.length }));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// One server page answer and one issue row carrying its cursor, as the
|
|
127
|
+
// `loadIssuePage` seam delivers them.
|
|
128
|
+
function pageResult(issues, extra = {}) {
|
|
129
|
+
return { ok: true, issues, total: issues.length, hasNextPage: false, ...extra };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function cursorRow(cursor, row) {
|
|
133
|
+
return { ...row, cursor };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function makeView(opts = {}) {
|
|
137
|
+
const paints = [];
|
|
138
|
+
let settled = 'pending';
|
|
139
|
+
const actions = opts.actions || {};
|
|
140
|
+
const view = createIssuesView({
|
|
141
|
+
pages: opts.pages,
|
|
142
|
+
issues: opts.issues,
|
|
143
|
+
loadIssue: opts.loadIssue,
|
|
144
|
+
loadPages: opts.loadPages,
|
|
145
|
+
loadIssuePage: opts.loadIssuePage,
|
|
146
|
+
loadLabels: opts.loadLabels,
|
|
147
|
+
loadAssignees: opts.loadAssignees,
|
|
148
|
+
assigned: opts.assigned,
|
|
149
|
+
saveAssignment: opts.saveAssignment,
|
|
150
|
+
actions,
|
|
151
|
+
labels: opts.labels,
|
|
152
|
+
helpers: opts.helpers || helpers,
|
|
153
|
+
theme: opts.theme || plainTheme,
|
|
154
|
+
rowsAvailable: () => opts.rows || 24,
|
|
155
|
+
requestRender: () => { paints.push(true); },
|
|
156
|
+
done: (result) => { settled = result; },
|
|
157
|
+
checkoutName: opts.checkoutName,
|
|
158
|
+
ghAvailable: opts.ghAvailable,
|
|
159
|
+
loadError: opts.loadError,
|
|
160
|
+
assignmentEnabled: opts.assignmentEnabled,
|
|
161
|
+
// S9: the remote-search debounce clock is injected, so a burst of typing is
|
|
162
|
+
// observed without wall-clock waits.
|
|
163
|
+
scheduleQuery: opts.scheduleQuery,
|
|
164
|
+
cancelSchedule: opts.cancelSchedule,
|
|
165
|
+
});
|
|
166
|
+
return { view, done: () => settled, paints };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A scheduler the test owns: nothing fires until `fire()` is called, so the
|
|
170
|
+
// debounce window (and any stale callback inside it) is fully deterministic.
|
|
171
|
+
function manualScheduler() {
|
|
172
|
+
const entries = [];
|
|
173
|
+
return {
|
|
174
|
+
entries,
|
|
175
|
+
schedule: (fn, delayMs) => {
|
|
176
|
+
const entry = { fn, delayMs, cancelled: false };
|
|
177
|
+
entries.push(entry);
|
|
178
|
+
return entry;
|
|
179
|
+
},
|
|
180
|
+
cancel: (entry) => { if (entry) entry.cancelled = true; },
|
|
181
|
+
live: () => entries.filter((entry) => !entry.cancelled),
|
|
182
|
+
fire() {
|
|
183
|
+
const live = entries.filter((entry) => !entry.cancelled);
|
|
184
|
+
for (const entry of live) entry.fn();
|
|
185
|
+
return live.length;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A promise whose settlement the test owns, so no expectation depends on
|
|
191
|
+
// wall-clock timing.
|
|
192
|
+
function deferred() {
|
|
193
|
+
let resolve = () => {};
|
|
194
|
+
let reject = () => {};
|
|
195
|
+
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
196
|
+
return { promise, resolve, reject };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function pagesResult(lists, extra = {}) {
|
|
200
|
+
const names = ['My issues', 'Assigned', 'Involved'];
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
pages: lists.map((rows, at) => ({ id: `p${at}`, label: names[at], issues: rows, total: rows.length })),
|
|
204
|
+
...extra,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// S3: the issues reader asks view-layout for the smaller 30% list share, so
|
|
209
|
+
// every geometry helper here reads the same allocation the view paints.
|
|
210
|
+
function readerPanes(width) {
|
|
211
|
+
return layout.paneWidths(layout.innerWidth(width), { listShare: layout.READER_LIST_SHARE });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function bodyOf(view, width) {
|
|
215
|
+
const lines = view.render(width);
|
|
216
|
+
for (const line of lines) assert.equal(helpers.visibleWidth(line), width, `not ${width} wide: "${strip(line)}"`);
|
|
217
|
+
return strip(lines.join('\n'));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The RIGHT (document) pane of every body row, one entry per row, so a test can
|
|
221
|
+
// name WHICH row a token landed on instead of only that it was painted.
|
|
222
|
+
function rightRows(view, width, rows = 24) {
|
|
223
|
+
const panes = readerPanes(width);
|
|
224
|
+
return view.render(width)
|
|
225
|
+
.slice(4, Math.max(4, rows - 2))
|
|
226
|
+
.map((line) => [...strip(line)].slice(5 + panes.left, 5 + panes.left + panes.right).join('').trimEnd());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The body row the caret is painted on. Written as a helper because the defect
|
|
230
|
+
// these tests pin is WHICH row carries it, not that it is painted at all.
|
|
231
|
+
function caretRow(rows) {
|
|
232
|
+
const at = rows.findIndex((row) => row.includes('█'));
|
|
233
|
+
assert.notEqual(at, -1, `no caret painted in the pane: ${JSON.stringify(rows)}`);
|
|
234
|
+
return at;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Only the LEFT pane of every body row, so a list assertion can never be
|
|
238
|
+
// satisfied by the preview on the right: S3 puts titles left and the number
|
|
239
|
+
// right, and the two are trimmed to different widths.
|
|
240
|
+
function leftColumn(view, width, rows = 24) {
|
|
241
|
+
const panes = readerPanes(width);
|
|
242
|
+
return view.render(width)
|
|
243
|
+
.slice(4, Math.max(4, rows - 2))
|
|
244
|
+
.map((line) => [...strip(line)].slice(2, 2 + panes.left).join(''))
|
|
245
|
+
.join('\n');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
describe('issues view tabs and list', () => {
|
|
249
|
+
test('renders the framed card with the three tabs and their counts', async () => {
|
|
250
|
+
const { view } = makeView({
|
|
251
|
+
pages: pagesOf([[issue(1), issue(2)], [issue(3)], []]),
|
|
252
|
+
issues: [issue(1), issue(2)],
|
|
253
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
254
|
+
});
|
|
255
|
+
await tick();
|
|
256
|
+
// Width 124: the footer grew by the two mandated copy keys (K4), so
|
|
257
|
+
// the card needs the extra room to show its full tail incl. esc close.
|
|
258
|
+
const body = bodyOf(view, 124);
|
|
259
|
+
assert.match(body, /My issues.*2/, 'first tab carries its count');
|
|
260
|
+
assert.match(body, /Assigned.*1/, 'second tab carries its count');
|
|
261
|
+
assert.match(body, /Involved.*0/, 'third tab carries its count');
|
|
262
|
+
assert.match(body, /issue title 1/, 'the current page rows are listed');
|
|
263
|
+
assert.match(body, /esc close/, 'the footer names the close key');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('tab switches buckets (D3)', async () => {
|
|
267
|
+
const { view } = makeView({
|
|
268
|
+
pages: pagesOf([[issue(1)], [issue(2)], [issue(3)]]),
|
|
269
|
+
issues: [issue(1)],
|
|
270
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
271
|
+
});
|
|
272
|
+
await tick();
|
|
273
|
+
assert.match(bodyOf(view, 100), /issue title 1/);
|
|
274
|
+
view.handleInput('tab');
|
|
275
|
+
assert.equal(view.state.page, 1);
|
|
276
|
+
assert.match(bodyOf(view, 100), /issue title 2/);
|
|
277
|
+
view.handleInput('shift+tab');
|
|
278
|
+
assert.equal(view.state.page, 0);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('up/down and j/k move the selection; home/end jump', async () => {
|
|
282
|
+
const rows = [issue(1), issue(2), issue(3)];
|
|
283
|
+
const { view } = makeView({
|
|
284
|
+
pages: pagesOf([rows, [], []]),
|
|
285
|
+
issues: rows,
|
|
286
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
287
|
+
});
|
|
288
|
+
await tick();
|
|
289
|
+
view.handleInput('down');
|
|
290
|
+
assert.equal(view.state.index, 1);
|
|
291
|
+
view.handleInput('j');
|
|
292
|
+
assert.equal(view.state.index, 2);
|
|
293
|
+
view.handleInput('up');
|
|
294
|
+
assert.equal(view.state.index, 1);
|
|
295
|
+
view.handleInput('k');
|
|
296
|
+
assert.equal(view.state.index, 0);
|
|
297
|
+
view.handleInput('end');
|
|
298
|
+
assert.equal(view.state.index, 2);
|
|
299
|
+
view.handleInput('home');
|
|
300
|
+
assert.equal(view.state.index, 0);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('the tab carries the issue count and the fixed indicator only the page position', async () => {
|
|
304
|
+
const rows = [issue(1, { title: 'alpha bug' })];
|
|
305
|
+
const { view } = makeView({
|
|
306
|
+
pages: pagesOf([rows, [], []]),
|
|
307
|
+
issues: rows,
|
|
308
|
+
rows: 12,
|
|
309
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { title: 'alpha bug' }), comments: [] }),
|
|
310
|
+
});
|
|
311
|
+
await tick();
|
|
312
|
+
const body = bodyOf(view, 100);
|
|
313
|
+
assert.match(body, /My issues \(1\)/, 'the open tab names its count');
|
|
314
|
+
assert.ok(!/page/i.test(strip(view.render(100)[1])), 'the search row carries no page marker (S3)');
|
|
315
|
+
assert.ok(!leftColumn(view, 100, 12).includes('page'), 'the list spends no row on the indicator');
|
|
316
|
+
assert.ok(!/\b1 issues?\b/.test(body.replace('My issues', '')), 'no second total is repeated below the list');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('the window follows the body budget and scrolls instead of paging (S3)', async () => {
|
|
320
|
+
const rows = Array.from({ length: 10 }, (_, i) => issue(i + 1));
|
|
321
|
+
const { view } = makeView({
|
|
322
|
+
pages: [{ id: 'p0', label: 'My issues', issues: rows, total: rows.length }],
|
|
323
|
+
issues: rows,
|
|
324
|
+
rows: 12,
|
|
325
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
326
|
+
});
|
|
327
|
+
await tick();
|
|
328
|
+
// The body is 12 rows minus the 6 frame rows, so six single-row titles fit
|
|
329
|
+
// and the rest scroll into view instead of moving to a second page.
|
|
330
|
+
const visible = () => leftColumn(view, 100, 12).split('\n').map((row) => row.trim()).filter(Boolean);
|
|
331
|
+
assert.equal(visible().length, 6, 'the window holds exactly the body budget');
|
|
332
|
+
assert.equal(view.state.listStart, 0, 'the window starts at the first row');
|
|
333
|
+
assert.ok(!/page/i.test(bodyOf(view, 100)), 'no page marker is printed');
|
|
334
|
+
assert.ok(!/\b10 issues\b/.test(bodyOf(view, 100)), 'the count is not repeated under the list');
|
|
335
|
+
view.handleInput(']');
|
|
336
|
+
assert.equal(view.state.listStart, 4, '→ scrolls to the last full window of the one list');
|
|
337
|
+
assert.equal(visible().at(-1), 'issue title 10', 'the last row is reachable by scrolling');
|
|
338
|
+
view.handleInput('[');
|
|
339
|
+
assert.equal(view.state.listStart, 0, '← scrolls back to the first screen');
|
|
340
|
+
view.handleInput('[');
|
|
341
|
+
assert.equal(view.state.listStart, 0, '← never scrolls before the first row');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('the state filter is gone: f changes nothing and closed rows never list (S10a)', async () => {
|
|
345
|
+
const rows = [issue(1), issue(2, { state: 'closed' })];
|
|
346
|
+
const { view } = makeView({
|
|
347
|
+
pages: pagesOf([rows, [], []]),
|
|
348
|
+
issues: rows,
|
|
349
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
350
|
+
});
|
|
351
|
+
await tick();
|
|
352
|
+
const before = bodyOf(view, 100);
|
|
353
|
+
assert.match(before, /issue title 1/);
|
|
354
|
+
assert.ok(!before.includes('issue title 2'), 'closed rows are never listed');
|
|
355
|
+
const snapshot = JSON.stringify(view.state);
|
|
356
|
+
view.handleInput('f');
|
|
357
|
+
assert.equal(JSON.stringify(view.state), snapshot, 'f no longer toggles any state');
|
|
358
|
+
assert.equal(bodyOf(view, 100), before, 'f repaints nothing');
|
|
359
|
+
assert.ok(!/f[:(]/.test(before), 'no state indicator survives in the frame');
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe('issues view search', () => {
|
|
364
|
+
test('the idle search row reads as a search widget, never the broken "T// search"', async () => {
|
|
365
|
+
const { view } = makeView({
|
|
366
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
367
|
+
issues: [issue(1)],
|
|
368
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
369
|
+
});
|
|
370
|
+
await tick();
|
|
371
|
+
const body = bodyOf(view, 100);
|
|
372
|
+
assert.ok(!body.includes('T// search'), 'the broken hint is gone');
|
|
373
|
+
assert.match(body, /› Search/, 'the idle row names the Search widget');
|
|
374
|
+
assert.ok(!body.includes('(T)') && !body.includes('(t)'), 'no parenthetical hint sits above the footer');
|
|
375
|
+
assert.match(bodyOf(view, 160), /t search/, 'the footer names the search key in lowercase');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('lowercase t opens the search and uppercase T keeps working', async () => {
|
|
379
|
+
const rows = [issue(1)];
|
|
380
|
+
const { view } = makeView({
|
|
381
|
+
pages: pagesOf([rows, [], []]),
|
|
382
|
+
issues: rows,
|
|
383
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
384
|
+
});
|
|
385
|
+
await tick();
|
|
386
|
+
view.render(160);
|
|
387
|
+
view.handleInput('t');
|
|
388
|
+
assert.equal(view.state.searching, true, 'lowercase t opens the search box');
|
|
389
|
+
view.handleInput('escape');
|
|
390
|
+
view.handleInput('T');
|
|
391
|
+
assert.equal(view.state.searching, true, 'uppercase T still opens it');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('Delete clears the whole active query, Backspace one character, and only typing advertises clearing', async () => {
|
|
395
|
+
const rows = [issue(1, { title: 'alpha crash' }), issue(2, { title: 'beta typo' })];
|
|
396
|
+
const { view } = makeView({
|
|
397
|
+
pages: pagesOf([rows, [], []]),
|
|
398
|
+
issues: rows,
|
|
399
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
400
|
+
});
|
|
401
|
+
await tick();
|
|
402
|
+
view.handleInput('T');
|
|
403
|
+
for (const ch of 'alpha cr') view.handleInput(ch);
|
|
404
|
+
const footerOf = () => strip(view.render(80).at(-2));
|
|
405
|
+
assert.match(bodyOf(view, 80), /› alpha cr█/);
|
|
406
|
+
assert.match(footerOf(), /del clear/, 'the footer advertises clearing while the input is active');
|
|
407
|
+
assert.ok(!bodyOf(view, 80).includes('beta typo'), 'the query filters the other row out');
|
|
408
|
+
view.handleInput('backspace');
|
|
409
|
+
assert.equal(view.state.query, 'alpha c', 'Backspace deletes one character');
|
|
410
|
+
view.handleInput('delete');
|
|
411
|
+
assert.equal(view.state.query, '', 'Delete clears the WHOLE query');
|
|
412
|
+
const body = bodyOf(view, 80);
|
|
413
|
+
assert.match(body, /› █/, 'the input row shows the emptied query');
|
|
414
|
+
assert.match(footerOf(), /del clear/, 'the clearing hint stays while the input is still active');
|
|
415
|
+
assert.match(body, /beta typo/, 'clearing the query lists every row again');
|
|
416
|
+
view.handleInput('enter');
|
|
417
|
+
assert.ok(!footerOf().includes('del clear'), 'the clearing hint leaves with the input');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test('T opens the box, typing filters live, Enter leaves keeping the filter', async () => {
|
|
421
|
+
const rows = [issue(1, { title: 'alpha crash' }), issue(2, { title: 'beta typo' })];
|
|
422
|
+
const { view } = makeView({
|
|
423
|
+
pages: pagesOf([rows, [], []]),
|
|
424
|
+
issues: rows,
|
|
425
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
426
|
+
});
|
|
427
|
+
await tick();
|
|
428
|
+
view.handleInput('T');
|
|
429
|
+
assert.equal(view.state.searching, true);
|
|
430
|
+
for (const ch of 'beta') view.handleInput(ch);
|
|
431
|
+
const filtered = bodyOf(view, 100);
|
|
432
|
+
assert.match(filtered, /issue title 2/);
|
|
433
|
+
assert.ok(!filtered.includes('alpha crash'), 'live filter hides the other row');
|
|
434
|
+
view.handleInput('enter');
|
|
435
|
+
assert.equal(view.state.searching, false);
|
|
436
|
+
assert.equal(view.state.query, 'beta', 'the filter is kept after leaving');
|
|
437
|
+
assert.ok(!bodyOf(view, 100).includes('alpha crash'));
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('/ also opens; Esc leaves keeping the filter; @ and # tokens filter', async () => {
|
|
441
|
+
const rows = [
|
|
442
|
+
issue(7, { title: 'login flow', author: 'alice' }),
|
|
443
|
+
issue(8, { title: 'logout flow', author: 'bob' }),
|
|
444
|
+
];
|
|
445
|
+
const { view } = makeView({
|
|
446
|
+
pages: pagesOf([rows, [], []]),
|
|
447
|
+
issues: rows,
|
|
448
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
449
|
+
});
|
|
450
|
+
await tick();
|
|
451
|
+
view.handleInput('/');
|
|
452
|
+
for (const ch of '@bob') view.handleInput(ch);
|
|
453
|
+
assert.match(bodyOf(view, 100), /issue title 8/);
|
|
454
|
+
view.handleInput('escape');
|
|
455
|
+
assert.equal(view.state.searching, false);
|
|
456
|
+
assert.equal(view.state.query, '@bob');
|
|
457
|
+
view.handleInput('T');
|
|
458
|
+
view.state.query = '';
|
|
459
|
+
view.state.searching = true;
|
|
460
|
+
for (const ch of '#7') view.handleInput(ch);
|
|
461
|
+
assert.match(bodyOf(view, 100), /issue title 7/);
|
|
462
|
+
assert.ok(!bodyOf(view, 100).includes('issue title 8'), '#number selects the issue');
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
describe('issues view right pane', () => {
|
|
467
|
+
test('renders labels, state, author, dates, reactions, body and separated comments', async () => {
|
|
468
|
+
const comments = [
|
|
469
|
+
{ id: 1, author: 'bob', createdAt: new Date(Date.now() - 600_000).toISOString(), body: 'first comment', reactions: { total: 1, plusOne: 2 } },
|
|
470
|
+
{ id: 2, author: 'carol', createdAt: new Date(Date.now() - 300_000).toISOString(), body: 'second comment', reactions: { total: 0, plusOne: 0 } },
|
|
471
|
+
];
|
|
472
|
+
const { view } = makeView({
|
|
473
|
+
pages: pagesOf([[issue(1, { title: 'Big bug', labels: [{ name: 'bug' }, { name: 'urgent' }] })], [], []]),
|
|
474
|
+
issues: [issue(1, { title: 'Big bug', labels: [{ name: 'bug' }, { name: 'urgent' }] })],
|
|
475
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { title: 'Big bug', labels: [{ name: 'bug' }, { name: 'urgent' }] }), comments }),
|
|
476
|
+
rows: 30,
|
|
477
|
+
});
|
|
478
|
+
await tick();
|
|
479
|
+
const body = bodyOf(view, 100);
|
|
480
|
+
assert.match(body, /Big bug/);
|
|
481
|
+
assert.match(body, /open/);
|
|
482
|
+
assert.match(body, /bug/, 'labels row names the labels');
|
|
483
|
+
assert.match(body, /urgent/);
|
|
484
|
+
assert.match(body, /octo/, 'author is shown');
|
|
485
|
+
assert.match(body, /created/, 'created date is shown');
|
|
486
|
+
assert.match(body, /updated/, 'updated date is shown');
|
|
487
|
+
assert.match(body, /👍 3/, 'reactions are shown');
|
|
488
|
+
assert.match(body, /2 comments/);
|
|
489
|
+
assert.match(body, /first comment/);
|
|
490
|
+
assert.match(body, /second comment/);
|
|
491
|
+
assert.match(body, /bob/);
|
|
492
|
+
assert.match(body, /carol/);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('ctrl+shift+j/pgDn scroll to the last line of a long issue; home resets to the top', async () => {
|
|
496
|
+
const longBody = Array.from({ length: 100 }, (_, i) => `body line ${i + 1}`).join('\n');
|
|
497
|
+
const { view } = makeView({
|
|
498
|
+
pages: pagesOf([[issue(1, { body: longBody })], [], []]),
|
|
499
|
+
issues: [issue(1, { body: longBody })],
|
|
500
|
+
rows: 14,
|
|
501
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { body: longBody }), comments: [] }),
|
|
502
|
+
});
|
|
503
|
+
await tick();
|
|
504
|
+
view.handleInput('ctrl+shift+j');
|
|
505
|
+
assert.ok(view.state.scroll > 0, 'ctrl+shift+j scrolls the issue pane');
|
|
506
|
+
for (let i = 0; i < 20 && !bodyOf(view, 100).includes('body line 100'); i += 1) view.handleInput('pagedown');
|
|
507
|
+
const bottom = bodyOf(view, 100);
|
|
508
|
+
assert.match(bottom, /body line 100/, 'the last line is reachable via the pane scroll keys');
|
|
509
|
+
view.handleInput('home');
|
|
510
|
+
assert.equal(view.state.scroll, 0);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('a failed loadIssue renders its error instead of fake content', async () => {
|
|
514
|
+
const { view } = makeView({
|
|
515
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
516
|
+
issues: [issue(1)],
|
|
517
|
+
loadIssue: () => ({ ok: false, error: 'boom' }),
|
|
518
|
+
});
|
|
519
|
+
await tick();
|
|
520
|
+
assert.match(bodyOf(view, 100), /boom/);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// S1: extended paging and printable Shift+J/K share a full-page action;
|
|
525
|
+
// ctrl+d/u retain their half-page action and j/k select. These also exercise
|
|
526
|
+
// the installed matcher with the bytes a terminal emits.
|
|
527
|
+
describe('round3: every tab is read at open, and a switch never refetches', { concurrency: false }, () => {
|
|
528
|
+
// The reported defect: opening Issues showed `All (0)` while the repository
|
|
529
|
+
// had open issues, and each tab refetched on every switch. The reader must
|
|
530
|
+
// read all four buckets ONCE when it opens, show their real totals, and reuse
|
|
531
|
+
// them while switching.
|
|
532
|
+
function serverView(extra = {}) {
|
|
533
|
+
const requests = [];
|
|
534
|
+
const queue = [];
|
|
535
|
+
const view = createIssuesView({
|
|
536
|
+
helpers,
|
|
537
|
+
prefetchBuckets: true,
|
|
538
|
+
rowsAvailable: () => 24,
|
|
539
|
+
requestRender: () => {},
|
|
540
|
+
done: () => {},
|
|
541
|
+
loadIssuePage: (request) => {
|
|
542
|
+
requests.push(request);
|
|
543
|
+
return new Promise((resolve) => queue.push({ request, resolve }));
|
|
544
|
+
},
|
|
545
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
546
|
+
...extra,
|
|
547
|
+
});
|
|
548
|
+
return { view, requests, queue };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function answer(queue, bucketId, numbers, total = numbers.length) {
|
|
552
|
+
const at = queue.findIndex((entry) => entry.request.bucketId === bucketId);
|
|
553
|
+
assert.ok(at >= 0, `no pending request for ${bucketId}`);
|
|
554
|
+
const [entry] = queue.splice(at, 1);
|
|
555
|
+
entry.resolve({ ok: true, issues: numbers.map((n) => ({ ...issue(n), cursor: `c${n}` })), total, hasNextPage: false });
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
test('all four buckets are requested when the view opens', async () => {
|
|
559
|
+
const { view, requests } = serverView();
|
|
560
|
+
view.render(120);
|
|
561
|
+
await tick();
|
|
562
|
+
assert.deepEqual(requests.map((entry) => entry.bucketId).sort(), ['all', 'assigned', 'involved', 'mine']);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test('an unvisited tab shows its real total instead of a false zero, and switching reads nothing', async () => {
|
|
566
|
+
const { view, requests, queue } = serverView();
|
|
567
|
+
// ONE layout: a resize legitimately invalidates recorded page boundaries,
|
|
568
|
+
// so the reuse assertion belongs to a stable viewport.
|
|
569
|
+
view.render(160);
|
|
570
|
+
await tick();
|
|
571
|
+
answer(queue, 'mine', [1]);
|
|
572
|
+
answer(queue, 'assigned', []);
|
|
573
|
+
answer(queue, 'involved', []);
|
|
574
|
+
answer(queue, 'all', [1], 1);
|
|
575
|
+
await tick();
|
|
576
|
+
const frame = bodyOf(view, 160);
|
|
577
|
+
assert.match(frame, /All \(1\)/, `All reports the repository total: ${frame.split('\n')[2]}`);
|
|
578
|
+
assert.match(frame, /My issues \(1\)/, 'the active bucket keeps its own count');
|
|
579
|
+
const before = requests.length;
|
|
580
|
+
for (let step = 0; step < 3; step += 1) {
|
|
581
|
+
view.handleInput('tab');
|
|
582
|
+
await tick();
|
|
583
|
+
}
|
|
584
|
+
assert.equal(requests.length, before, 'switching between loaded tabs reads nothing again');
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test('a bucket that has not answered yet is unknown, never a zero', async () => {
|
|
588
|
+
const { view, queue } = serverView();
|
|
589
|
+
view.render(120);
|
|
590
|
+
await tick();
|
|
591
|
+
// The active bucket answers; the other three are still in flight.
|
|
592
|
+
answer(queue, 'mine', [1]);
|
|
593
|
+
await tick();
|
|
594
|
+
const tabs = bodyOf(view, 160).split('\n').find((line) => line.includes('My issues')) ?? '';
|
|
595
|
+
assert.match(tabs, /Assigned \(–\)/, `unread bucket is unknown: ${tabs}`);
|
|
596
|
+
assert.doesNotMatch(tabs, /Assigned \(0\)/, 'an unread bucket never claims zero');
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
describe('issues view preview scroll variants (S15)', () => {
|
|
601
|
+
const skipReal = realTui ? false : 'requires @earendil-works/pi-tui';
|
|
602
|
+
const longBody = Array.from({ length: 100 }, (_, i) => `body line ${i + 1}`).join('\n');
|
|
603
|
+
|
|
604
|
+
function realKeyView() {
|
|
605
|
+
return makeView({
|
|
606
|
+
helpers: realTui,
|
|
607
|
+
pages: pagesOf([[issue(1, { body: longBody }), issue(2, { body: longBody })], [], []]),
|
|
608
|
+
issues: [issue(1, { body: longBody }), issue(2, { body: longBody })],
|
|
609
|
+
rows: 14,
|
|
610
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { body: longBody }), comments: [] }),
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
test('ctrl+d/ctrl+u and shifted J/K scroll the pane, j/k still move the list', { skip: skipReal }, async () => {
|
|
615
|
+
const { view } = realKeyView();
|
|
616
|
+
await tick();
|
|
617
|
+
view.render(100);
|
|
618
|
+
view.handleInput('\x1b[6~'); // PageDown: the full-page step
|
|
619
|
+
const paged = view.state.scroll;
|
|
620
|
+
assert.ok(paged > 0, 'PageDown scrolls the pane');
|
|
621
|
+
view.handleInput('home');
|
|
622
|
+
assert.equal(view.state.scroll, 0, 'home returns to the top first');
|
|
623
|
+
view.handleInput('\x04'); // ctrl+d
|
|
624
|
+
const half = view.state.scroll;
|
|
625
|
+
assert.ok(half > 0, 'raw ctrl+d scrolls the pane down');
|
|
626
|
+
assert.ok(half < paged, 'ctrl+d moves half a page, less than PageDown');
|
|
627
|
+
assert.equal(view.state.index, 0, 'ctrl+d never moves the list selection');
|
|
628
|
+
view.handleInput('\x15'); // ctrl+u
|
|
629
|
+
assert.equal(view.state.scroll, 0, 'raw ctrl+u scrolls the pane back up');
|
|
630
|
+
view.handleInput('\x1b[100;5u'); // Kitty ctrl+d
|
|
631
|
+
assert.ok(view.state.scroll > 0, 'Kitty ctrl+d scrolls the pane down');
|
|
632
|
+
view.handleInput('\x1b[117;5u'); // Kitty ctrl+u
|
|
633
|
+
assert.equal(view.state.scroll, 0, 'Kitty ctrl+u scrolls the pane back up');
|
|
634
|
+
view.handleInput('\x04');
|
|
635
|
+
view.handleInput('K'); // shifted K scrolls up
|
|
636
|
+
assert.equal(view.state.scroll, 0, 'K scrolls the pane back up');
|
|
637
|
+
view.handleInput('J'); // shifted J scrolls down
|
|
638
|
+
assert.ok(view.state.scroll > 0, 'J scrolls the pane down');
|
|
639
|
+
assert.equal(view.state.index, 0, 'J never moves the list selection');
|
|
640
|
+
view.handleInput('home');
|
|
641
|
+
view.handleInput('j');
|
|
642
|
+
assert.equal(view.state.index, 1, 'j still moves the list selection');
|
|
643
|
+
view.handleInput('k');
|
|
644
|
+
assert.equal(view.state.index, 0, 'k still moves the list selection');
|
|
645
|
+
view.handleInput('\n'); // legacy Enter is not a scroll alias
|
|
646
|
+
assert.equal(view.state.scroll, 0, 'LF never scrolls the pane');
|
|
647
|
+
view.handleInput('\x0b');
|
|
648
|
+
assert.equal(view.state.scroll, 0, 'legacy ctrl+k never scrolls the pane');
|
|
649
|
+
view.handleInput('\x1b[6~');
|
|
650
|
+
assert.equal(view.state.scroll, paged, 'PageDown still scrolls a full page');
|
|
651
|
+
view.handleInput('\x1b[5~');
|
|
652
|
+
assert.equal(view.state.scroll, 0, 'PageUp still scrolls the page back');
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// S10b: the issues viewer is read-only with respect to the checkout↔issue
|
|
657
|
+
// association. The options the existing callers still pass (`assigned`,
|
|
658
|
+
// `saveAssignment`, `assignmentEnabled`) are accepted and ignored: no mark,
|
|
659
|
+
// no toggle, no save, no unsaved claim — and no key that writes.
|
|
660
|
+
describe('issues view read-only association (S10b)', () => {
|
|
661
|
+
const ASSIGNED = [1];
|
|
662
|
+
|
|
663
|
+
function inertView(overrides = {}) {
|
|
664
|
+
const saves = [];
|
|
665
|
+
const rows = [issue(1), issue(2)];
|
|
666
|
+
const made = makeView({
|
|
667
|
+
pages: pagesOf([rows, [], []]),
|
|
668
|
+
issues: rows,
|
|
669
|
+
assigned: ASSIGNED,
|
|
670
|
+
saveAssignment: (nums) => { saves.push(nums); return { ok: true }; },
|
|
671
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions: EDITABLE }), comments: [] }),
|
|
672
|
+
...overrides,
|
|
673
|
+
});
|
|
674
|
+
return { ...made, saves, rows };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
test('no association mark, hint or unsaved claim renders even when assigned and saveAssignment are passed', async () => {
|
|
678
|
+
const { view } = inertView({ assignmentEnabled: true });
|
|
679
|
+
await tick();
|
|
680
|
+
for (const width of [80, 160]) {
|
|
681
|
+
const body = bodyOf(view, width);
|
|
682
|
+
assert.ok(!body.includes('space assign'), `width ${width}: no Space hint`);
|
|
683
|
+
assert.ok(!body.includes('ctrl+s save'), `width ${width}: no save hint`);
|
|
684
|
+
assert.ok(!body.includes('unsaved'), `width ${width}: no unsaved claim`);
|
|
685
|
+
assert.ok(!/\d+ assigned/.test(body), `width ${width}: no assigned count`);
|
|
686
|
+
// S3: the left row is the title alone — the number lives in the preview.
|
|
687
|
+
const left = leftColumn(view, width);
|
|
688
|
+
assert.match(left, /issue title 1/, 'the issue row still renders');
|
|
689
|
+
assert.ok(!left.includes('#1'), 'no issue number in the left list');
|
|
690
|
+
assert.ok(!/[✓✎]/.test(left), 'no assignment mark on the row');
|
|
691
|
+
assert.match(left, /issue title 2/, 'the other row still renders');
|
|
692
|
+
assert.ok(!/[✓✎]/.test(left), 'no assignment mark on the other row either');
|
|
693
|
+
}
|
|
694
|
+
assert.match(bodyOf(view, 160), /x close/, 'the footer legend stays');
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test('space and ctrl+s change nothing: no save call, no state change, no repaint difference', async () => {
|
|
698
|
+
const { view, saves } = inertView();
|
|
699
|
+
await tick();
|
|
700
|
+
const snapshot = JSON.stringify(view.state);
|
|
701
|
+
const rendered = view.render(80).join('\n');
|
|
702
|
+
for (const key of ['space', ' ', 'ctrl+s']) view.handleInput(key);
|
|
703
|
+
await tick();
|
|
704
|
+
assert.deepEqual(saves, [], 'the injected save callback is never reached');
|
|
705
|
+
assert.equal(JSON.stringify(view.state), snapshot, 'no state changed');
|
|
706
|
+
assert.equal(view.render(80).join('\n'), rendered, 'nothing repainted differently');
|
|
707
|
+
assert.ok(!bodyOf(view, 80).includes('unsaved'), 'no pending-assignment claim appears');
|
|
708
|
+
assert.ok(!/[✓✎]/.test(bodyOf(view, 80)), 'no assignment mark appears');
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test('the association options are inert: the frame is identical with them and with the gate off', async () => {
|
|
712
|
+
const common = {
|
|
713
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
714
|
+
issues: [issue(1)],
|
|
715
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
716
|
+
};
|
|
717
|
+
const plain = makeView(common);
|
|
718
|
+
const passed = makeView({ ...common, assigned: ASSIGNED, saveAssignment: () => ({ ok: true }), assignmentEnabled: true });
|
|
719
|
+
const gatedOff = makeView({ ...common, assigned: ASSIGNED, saveAssignment: () => ({ ok: true }), assignmentEnabled: false });
|
|
720
|
+
await tick();
|
|
721
|
+
const expected = plain.view.render(80).join('\n');
|
|
722
|
+
assert.equal(passed.view.render(80).join('\n'), expected, 'assigned/saveAssignment/assignmentEnabled change no cell');
|
|
723
|
+
assert.equal(gatedOff.view.render(80).join('\n'), expected, 'assignmentEnabled false changes no cell either');
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test('reading, search and the GitHub actions stay usable with the options passed', async () => {
|
|
727
|
+
const writes = [];
|
|
728
|
+
const { view } = inertView({
|
|
729
|
+
assignmentEnabled: false,
|
|
730
|
+
actions: {
|
|
731
|
+
setState: () => { writes.push('setState'); return { ok: true }; },
|
|
732
|
+
postComment: () => { writes.push('postComment'); return { ok: true }; },
|
|
733
|
+
editLabels: () => { writes.push('editLabels'); return { ok: true }; },
|
|
734
|
+
openBrowser: () => { writes.push('openBrowser'); return { ok: true }; },
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
await tick();
|
|
738
|
+
view.handleInput('T');
|
|
739
|
+
for (const ch of 'title 2') view.handleInput(ch);
|
|
740
|
+
assert.match(bodyOf(view, 80), /issue title 2/);
|
|
741
|
+
assert.ok(!bodyOf(view, 80).includes('issue title 1'), 'search still filters');
|
|
742
|
+
view.handleInput('enter');
|
|
743
|
+
view.handleInput('T');
|
|
744
|
+
for (let i = 0; i < 7; i += 1) view.handleInput('backspace');
|
|
745
|
+
view.handleInput('enter');
|
|
746
|
+
assert.match(leftColumn(view, 80), /issue title 1/, 'the query clears again');
|
|
747
|
+
const beforeF = bodyOf(view, 80);
|
|
748
|
+
view.handleInput('f');
|
|
749
|
+
assert.equal(bodyOf(view, 80), beforeF, 'the removed state key repaints nothing');
|
|
750
|
+
view.handleInput('x');
|
|
751
|
+
assert.equal(view.state.mode, 'confirm', 'close still asks first');
|
|
752
|
+
view.handleInput('enter');
|
|
753
|
+
await tick();
|
|
754
|
+
assert.deepEqual(writes, ['setState'], 'the GitHub action ran, the association did not');
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
describe('issues view editors and confirmations', () => {
|
|
759
|
+
test('L opens the label editor; toggle plus create plus ctrl+s applies', async () => {
|
|
760
|
+
const calls = [];
|
|
761
|
+
const rows = [issue(1, { labels: [{ name: 'bug' }] })];
|
|
762
|
+
const { view } = makeView({
|
|
763
|
+
pages: pagesOf([rows, [], []]),
|
|
764
|
+
issues: rows,
|
|
765
|
+
labels: [{ name: 'bug' }, { name: 'docs' }],
|
|
766
|
+
actions: {
|
|
767
|
+
editLabels: (number, add, remove, created) => { calls.push([number, add, remove, created]); return { ok: true }; },
|
|
768
|
+
},
|
|
769
|
+
loadIssue: (n) => ({
|
|
770
|
+
ok: true,
|
|
771
|
+
issue: issue(n, { labels: [{ name: 'bug' }], permissions: { canEditLabels: true, canCreateLabels: true } }),
|
|
772
|
+
comments: [],
|
|
773
|
+
}),
|
|
774
|
+
});
|
|
775
|
+
await tick();
|
|
776
|
+
view.handleInput('L');
|
|
777
|
+
assert.equal(view.state.mode, 'labels');
|
|
778
|
+
let body = bodyOf(view, 100);
|
|
779
|
+
assert.match(body, /bug/);
|
|
780
|
+
assert.match(body, /docs/);
|
|
781
|
+
view.handleInput('down');
|
|
782
|
+
view.handleInput('space');
|
|
783
|
+
view.handleInput('ctrl+s');
|
|
784
|
+
await tick();
|
|
785
|
+
assert.equal(calls.length, 1, 'one apply call');
|
|
786
|
+
assert.deepEqual(calls[0][0], 1);
|
|
787
|
+
assert.ok(calls[0][1].includes('docs'), 'docs was added');
|
|
788
|
+
assert.equal(view.state.mode, 'list', 'success closes the editor');
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
test('a failed label apply keeps the editor and shows the error', async () => {
|
|
792
|
+
const rows = [issue(1, { labels: [{ name: 'bug' }] })];
|
|
793
|
+
const { view } = makeView({
|
|
794
|
+
pages: pagesOf([rows, [], []]),
|
|
795
|
+
issues: rows,
|
|
796
|
+
labels: [{ name: 'bug' }, { name: 'docs' }],
|
|
797
|
+
actions: { editLabels: () => ({ ok: false, error: 'nope' }) },
|
|
798
|
+
loadIssue: (n) => ({
|
|
799
|
+
ok: true,
|
|
800
|
+
issue: issue(n, { labels: [{ name: 'bug' }], permissions: { canEditLabels: true, canCreateLabels: true } }),
|
|
801
|
+
comments: [],
|
|
802
|
+
}),
|
|
803
|
+
});
|
|
804
|
+
await tick();
|
|
805
|
+
view.handleInput('L');
|
|
806
|
+
view.handleInput('down');
|
|
807
|
+
view.handleInput('space');
|
|
808
|
+
view.handleInput('ctrl+s');
|
|
809
|
+
await tick();
|
|
810
|
+
assert.equal(view.state.mode, 'labels', 'failure keeps the editor open');
|
|
811
|
+
assert.match(bodyOf(view, 100), /nope/);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test('c opens the comment input; Enter is a newline, ctrl+s posts and refreshes the detail, esc asks before discarding', async () => {
|
|
815
|
+
const calls = [];
|
|
816
|
+
const loads = [];
|
|
817
|
+
let comments = [];
|
|
818
|
+
const { view } = makeView({
|
|
819
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
820
|
+
issues: [issue(1)],
|
|
821
|
+
actions: {
|
|
822
|
+
postComment: (number, text) => {
|
|
823
|
+
calls.push([number, text]);
|
|
824
|
+
comments = [{ author: 'octo', createdAt: new Date().toISOString(), body: text }];
|
|
825
|
+
return { ok: true };
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
loadIssue: (n, detailOptions) => {
|
|
829
|
+
loads.push([n, detailOptions]);
|
|
830
|
+
return { ok: true, issue: issue(n), comments: comments.slice() };
|
|
831
|
+
},
|
|
832
|
+
});
|
|
833
|
+
await tick();
|
|
834
|
+
view.handleInput('c');
|
|
835
|
+
assert.equal(view.state.mode, 'comment');
|
|
836
|
+
for (const ch of 'hi') view.handleInput(ch);
|
|
837
|
+
view.handleInput('enter');
|
|
838
|
+
for (const ch of 'there') view.handleInput(ch);
|
|
839
|
+
assert.match(bodyOf(view, 100), /hi/);
|
|
840
|
+
view.handleInput('ctrl+s');
|
|
841
|
+
await tick();
|
|
842
|
+
assert.equal(calls.length, 1);
|
|
843
|
+
assert.deepEqual(calls[0], [1, 'hi\nthere']);
|
|
844
|
+
assert.equal(view.state.mode, 'list');
|
|
845
|
+
const refreshes = loads.filter(([, detailOptions]) => detailOptions && detailOptions.refresh === true);
|
|
846
|
+
assert.deepEqual(refreshes.map(([number]) => number), [1], 'the posted issue detail is refetched, never served from cache');
|
|
847
|
+
const posted = bodyOf(view, 100);
|
|
848
|
+
assert.match(posted, /hi/);
|
|
849
|
+
assert.match(posted, /there/, 'the new comment is visible without reopening the issue');
|
|
850
|
+
|
|
851
|
+
view.handleInput('c');
|
|
852
|
+
for (const ch of 'draft') view.handleInput(ch);
|
|
853
|
+
view.handleInput('escape');
|
|
854
|
+
assert.equal(view.state.mode, 'comment', 'esc on a non-empty draft asks instead of discarding');
|
|
855
|
+
assert.match(bodyOf(view, 100), /Discard this draft/, 'the ask names what is at stake');
|
|
856
|
+
view.handleInput('y');
|
|
857
|
+
assert.equal(view.state.mode, 'list', 'y discards the draft');
|
|
858
|
+
assert.equal(calls.length, 1, 'discard posts nothing');
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test('a failed refresh after a posted comment is visible and never claims updated data', async () => {
|
|
862
|
+
let posted = false;
|
|
863
|
+
const { view } = makeView({
|
|
864
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
865
|
+
issues: [issue(1)],
|
|
866
|
+
actions: { postComment: () => { posted = true; return { ok: true }; } },
|
|
867
|
+
loadIssue: (n, detailOptions) => {
|
|
868
|
+
if (posted && detailOptions && detailOptions.refresh === true) return { ok: false, error: 'gh timed out' };
|
|
869
|
+
return { ok: true, issue: issue(n), comments: [] };
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
await tick();
|
|
873
|
+
view.handleInput('c');
|
|
874
|
+
for (const ch of 'still posted') view.handleInput(ch);
|
|
875
|
+
view.handleInput('ctrl+s');
|
|
876
|
+
await tick();
|
|
877
|
+
const body = bodyOf(view, 100);
|
|
878
|
+
assert.match(body, /unreadable: gh timed out/, 'the failed refresh is visible');
|
|
879
|
+
assert.match(body, /comment posted/, 'the posted result is reported truthfully');
|
|
880
|
+
assert.ok(!body.includes('(end of issue)'), 'cached content is never presented as refreshed');
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test('a failed comment post keeps the draft and shows the error', async () => {
|
|
884
|
+
const { view } = makeView({
|
|
885
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
886
|
+
issues: [issue(1)],
|
|
887
|
+
actions: { postComment: () => ({ ok: false, error: 'rate limited' }) },
|
|
888
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
889
|
+
});
|
|
890
|
+
await tick();
|
|
891
|
+
view.handleInput('c');
|
|
892
|
+
for (const ch of 'hey') view.handleInput(ch);
|
|
893
|
+
view.handleInput('ctrl+s');
|
|
894
|
+
await tick();
|
|
895
|
+
assert.equal(view.state.mode, 'comment');
|
|
896
|
+
assert.match(bodyOf(view, 100), /rate limited/);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test('x and X close the issue, and a closed issue leaves the open list (S10a)', async () => {
|
|
900
|
+
const calls = [];
|
|
901
|
+
const openRows = [issue(1), issue(2)];
|
|
902
|
+
const { view } = makeView({
|
|
903
|
+
pages: pagesOf([openRows, [], []]),
|
|
904
|
+
issues: openRows,
|
|
905
|
+
actions: { setState: (number, state) => { calls.push([number, state]); return { ok: true }; } },
|
|
906
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions: EDITABLE }), comments: [] }),
|
|
907
|
+
});
|
|
908
|
+
await tick();
|
|
909
|
+
view.handleInput('x');
|
|
910
|
+
assert.equal(view.state.mode, 'confirm');
|
|
911
|
+
assert.match(bodyOf(view, 100), /Confirm close #1/, 'the confirm names the close');
|
|
912
|
+
view.handleInput('enter');
|
|
913
|
+
await tick();
|
|
914
|
+
assert.deepEqual(calls, [[1, 'closed']], 'x closes through the injected action');
|
|
915
|
+
const afterClose = bodyOf(view, 100);
|
|
916
|
+
assert.ok(!leftColumn(view, 100).includes('issue title 1'), 'the closed issue leaves the open list at once');
|
|
917
|
+
assert.match(afterClose, /My issues \(1\)/, 'the tab count drops with it');
|
|
918
|
+
assert.match(leftColumn(view, 100), /issue title 2/, 'the remaining open issue stays listed');
|
|
919
|
+
|
|
920
|
+
view.handleInput('X');
|
|
921
|
+
assert.equal(view.state.mode, 'confirm', 'X asks the same close confirmation');
|
|
922
|
+
assert.match(bodyOf(view, 100), /Confirm close #2/, 'X names a close, never a reopen');
|
|
923
|
+
view.handleInput('enter');
|
|
924
|
+
await tick();
|
|
925
|
+
assert.deepEqual(calls, [[1, 'closed'], [2, 'closed']], 'X closes too: the view has no reopen path');
|
|
926
|
+
assert.match(bodyOf(view, 100), /no issues/, 'the emptied list says so honestly');
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
test('o opens in the browser without changing state; r reloads', async () => {
|
|
930
|
+
let opened = null;
|
|
931
|
+
let rows = [issue(1)];
|
|
932
|
+
const { view } = makeView({
|
|
933
|
+
pages: pagesOf([rows, [], []]),
|
|
934
|
+
issues: rows,
|
|
935
|
+
actions: {
|
|
936
|
+
openBrowser: (number) => { opened = number; return { ok: true }; },
|
|
937
|
+
reload: () => ({ ok: true, issues: [issue(1), issue(5, { title: 'fresh issue' })] }),
|
|
938
|
+
},
|
|
939
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
940
|
+
});
|
|
941
|
+
await tick();
|
|
942
|
+
const before = JSON.stringify({ page: view.state.page, index: view.state.index });
|
|
943
|
+
view.handleInput('o');
|
|
944
|
+
assert.equal(opened, 1);
|
|
945
|
+
assert.equal(JSON.stringify({ page: view.state.page, index: view.state.index }), before);
|
|
946
|
+
view.handleInput('r');
|
|
947
|
+
await tick();
|
|
948
|
+
assert.match(bodyOf(view, 100), /fresh issue/);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test('the generic ? help overlay is gone and the footer legend stays (S17)', async () => {
|
|
952
|
+
const { view } = makeView({
|
|
953
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
954
|
+
issues: [issue(1)],
|
|
955
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions: EDITABLE }), comments: [] }),
|
|
956
|
+
});
|
|
957
|
+
await tick();
|
|
958
|
+
const before = bodyOf(view, 160);
|
|
959
|
+
assert.ok(!before.includes('? help'), 'no ? hint anywhere');
|
|
960
|
+
assert.ok(!before.includes('Keys'), 'no key-help overlay before the key');
|
|
961
|
+
view.handleInput('?');
|
|
962
|
+
const after = bodyOf(view, 160);
|
|
963
|
+
assert.equal(after, before, '? repaints nothing: there is no overlay to open');
|
|
964
|
+
assert.equal(view.state.help, undefined, 'no help state survives');
|
|
965
|
+
assert.match(after, /esc close/, 'the footer shortcut legend survives');
|
|
966
|
+
assert.match(after, /x close/, 'the footer documents the close key');
|
|
967
|
+
assert.ok(!after.includes('f open/closed'), 'the removed state-toggle key is gone from the legend');
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
describe('issues view states and key discipline', () => {
|
|
972
|
+
test('empty page renders the empty state, not fake rows', async () => {
|
|
973
|
+
const { view } = makeView({
|
|
974
|
+
pages: pagesOf([[], [], []]),
|
|
975
|
+
issues: [],
|
|
976
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
977
|
+
});
|
|
978
|
+
await tick();
|
|
979
|
+
assert.match(bodyOf(view, 80), /no issues/i);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
test('loadError renders the error state', async () => {
|
|
983
|
+
const { view } = makeView({
|
|
984
|
+
pages: pagesOf([[], [], []]),
|
|
985
|
+
issues: [],
|
|
986
|
+
loadError: 'network down',
|
|
987
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
988
|
+
});
|
|
989
|
+
await tick();
|
|
990
|
+
assert.match(bodyOf(view, 80), /network down/);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test('gh unavailable renders its own state', async () => {
|
|
994
|
+
const { view } = makeView({
|
|
995
|
+
pages: pagesOf([[], [], []]),
|
|
996
|
+
issues: [],
|
|
997
|
+
ghAvailable: false,
|
|
998
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
999
|
+
});
|
|
1000
|
+
await tick();
|
|
1001
|
+
assert.match(bodyOf(view, 80), /gh unavailable/i);
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
test('end selects the last issue and leaves the detail scroll at the top', async () => {
|
|
1005
|
+
const longBody = Array.from({ length: 60 }, (_, i) => `body line ${i + 1}`).join('\n');
|
|
1006
|
+
const rows = [issue(1, { body: longBody }), issue(2, { body: longBody }), issue(3, { body: longBody })];
|
|
1007
|
+
const { view } = makeView({
|
|
1008
|
+
pages: pagesOf([rows, [], []]),
|
|
1009
|
+
issues: rows,
|
|
1010
|
+
rows: 14,
|
|
1011
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { body: longBody }), comments: [] }),
|
|
1012
|
+
});
|
|
1013
|
+
await tick();
|
|
1014
|
+
view.handleInput('ctrl+shift+j');
|
|
1015
|
+
assert.ok(view.state.scroll > 0, 'precondition: the pane is scrolled');
|
|
1016
|
+
view.handleInput('end');
|
|
1017
|
+
assert.equal(view.state.index, 2, 'end selects the last issue');
|
|
1018
|
+
assert.equal(view.state.scroll, 0, 'end resets the newly selected issue to its top');
|
|
1019
|
+
const bottom = bodyOf(view, 100);
|
|
1020
|
+
assert.match(bottom, /body line 1/, 'the top of the last issue is shown, not the scrolled bottom');
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
test('home selects the first issue and leaves the detail scroll at the top', async () => {
|
|
1024
|
+
const longBody = Array.from({ length: 60 }, (_, i) => `body line ${i + 1}`).join('\n');
|
|
1025
|
+
const rows = [issue(1, { body: longBody }), issue(2, { body: longBody }), issue(3, { body: longBody })];
|
|
1026
|
+
const { view } = makeView({
|
|
1027
|
+
pages: pagesOf([rows, [], []]),
|
|
1028
|
+
issues: rows,
|
|
1029
|
+
rows: 14,
|
|
1030
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { body: longBody }), comments: [] }),
|
|
1031
|
+
});
|
|
1032
|
+
await tick();
|
|
1033
|
+
view.handleInput('end');
|
|
1034
|
+
view.handleInput('ctrl+shift+j');
|
|
1035
|
+
assert.ok(view.state.scroll > 0, 'precondition: the pane is scrolled');
|
|
1036
|
+
view.handleInput('home');
|
|
1037
|
+
assert.equal(view.state.index, 0, 'home selects the first issue');
|
|
1038
|
+
assert.equal(view.state.scroll, 0, 'home resets the newly selected issue to its top');
|
|
1039
|
+
const top = bodyOf(view, 100);
|
|
1040
|
+
assert.match(top, /body line 1/, 'the top of the first issue is shown');
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
test('a bounded fetch marks the count on the tab and never repeats it below the list', async () => {
|
|
1044
|
+
const rows = Array.from({ length: 5 }, (_, i) => issue(i + 1));
|
|
1045
|
+
const truncated = makeView({
|
|
1046
|
+
pages: [{ id: 'p0', label: 'My issues', issues: rows, total: rows.length, truncated: true }],
|
|
1047
|
+
issues: rows,
|
|
1048
|
+
rows: 12,
|
|
1049
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1050
|
+
});
|
|
1051
|
+
await tick();
|
|
1052
|
+
assert.match(bodyOf(truncated.view, 100), /My issues \(5\+\)/, 'the tab marks the bounded count');
|
|
1053
|
+
assert.ok(!/5\+ issues/.test(bodyOf(truncated.view, 100)), 'the bound is never repeated below the list');
|
|
1054
|
+
const plain = makeView({
|
|
1055
|
+
pages: [{ id: 'p0', label: 'My issues', issues: rows, total: rows.length }],
|
|
1056
|
+
issues: rows,
|
|
1057
|
+
rows: 12,
|
|
1058
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1059
|
+
});
|
|
1060
|
+
await tick();
|
|
1061
|
+
assert.match(bodyOf(plain.view, 100), /My issues \(5\)/, 'a plain fetch renders the exact count');
|
|
1062
|
+
assert.ok(!bodyOf(plain.view, 100).includes('5+'), 'a plain fetch never carries the + marker');
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
test('esc closes with null; no key outside S8 changes state', async () => {
|
|
1066
|
+
const { view, done } = makeView({
|
|
1067
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
1068
|
+
issues: [issue(1)],
|
|
1069
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1070
|
+
});
|
|
1071
|
+
await tick();
|
|
1072
|
+
const snapshot = JSON.stringify(view.state);
|
|
1073
|
+
const rendered = view.render(80).join('\n');
|
|
1074
|
+
// `a` is no longer foreign: S16 binds it to the assignee editor (and it
|
|
1075
|
+
// stays inert for this permission-less fixture, which S16 covers).
|
|
1076
|
+
// ctrl+u/d are real half-page bindings, checked in the scroll tests.
|
|
1077
|
+
for (const key of ['e', 'p', 'z', 'd', 'w', 'v', 'u', 'g', 'i', 'm', 'h', 'b', 's', 'n', 'alt+x']) {
|
|
1078
|
+
view.handleInput(key);
|
|
1079
|
+
}
|
|
1080
|
+
assert.equal(JSON.stringify(view.state), snapshot, 'foreign keys change nothing');
|
|
1081
|
+
assert.equal(view.render(80).join('\n'), rendered, 'foreign keys repaint nothing');
|
|
1082
|
+
view.handleInput('escape');
|
|
1083
|
+
assert.equal(done(), null);
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
describe('issues view relative dates past a month (adopted reference idea 2)', () => {
|
|
1088
|
+
const at = (msAgo) => new Date(Date.now() - msAgo).toISOString();
|
|
1089
|
+
const DAY = 86400_000;
|
|
1090
|
+
|
|
1091
|
+
function datedView(createdAgo, updatedAgo) {
|
|
1092
|
+
return makeView({
|
|
1093
|
+
pages: pagesOf([[issue(1, { createdAt: at(createdAgo), updatedAt: at(updatedAgo) })], [], []]),
|
|
1094
|
+
issues: [issue(1, { createdAt: at(createdAgo), updatedAt: at(updatedAgo) })],
|
|
1095
|
+
rows: 30,
|
|
1096
|
+
loadIssue: (n) => ({
|
|
1097
|
+
ok: true,
|
|
1098
|
+
issue: issue(n, { createdAt: at(createdAgo), updatedAt: at(updatedAgo) }),
|
|
1099
|
+
comments: [],
|
|
1100
|
+
}),
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
test('renders mo and y units instead of an ISO date', async () => {
|
|
1105
|
+
const { view } = datedView(60 * DAY, 400 * DAY);
|
|
1106
|
+
await tick();
|
|
1107
|
+
const body = bodyOf(view, 100);
|
|
1108
|
+
assert.match(body, /2mo ago/, 'sixty days renders as months');
|
|
1109
|
+
assert.match(body, /1y ago/, 'four hundred days renders as years');
|
|
1110
|
+
assert.ok(!/\d{4}-\d{2}-\d{2}/.test(body), 'no ISO date fallback for old dates');
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
test('month/year boundaries cut at 30 days and 365 days', async () => {
|
|
1114
|
+
const first = datedView(29 * DAY, 29 * DAY);
|
|
1115
|
+
await tick();
|
|
1116
|
+
assert.match(bodyOf(first.view, 100), /29d ago/, 'twenty-nine days still renders as days');
|
|
1117
|
+
const second = datedView(30 * DAY, 364 * DAY);
|
|
1118
|
+
await tick();
|
|
1119
|
+
const middle = bodyOf(second.view, 100);
|
|
1120
|
+
assert.match(middle, /1mo ago/, 'thirty days renders as one month');
|
|
1121
|
+
assert.match(middle, /12mo ago/, '364 days still renders as months');
|
|
1122
|
+
const third = datedView(365 * DAY, 730 * DAY);
|
|
1123
|
+
await tick();
|
|
1124
|
+
const old = bodyOf(third.view, 100);
|
|
1125
|
+
assert.match(old, /1y ago/);
|
|
1126
|
+
assert.match(old, /2y ago/);
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
test('keeps just now, minutes, hours and days', async () => {
|
|
1130
|
+
const now = datedView(10_000, 5 * 60_000);
|
|
1131
|
+
await tick();
|
|
1132
|
+
const fresh = bodyOf(now.view, 100);
|
|
1133
|
+
assert.match(fresh, /just now/);
|
|
1134
|
+
assert.match(fresh, /5m ago/);
|
|
1135
|
+
const hours = datedView(3 * 3600_000, 9 * DAY);
|
|
1136
|
+
await tick();
|
|
1137
|
+
const older = bodyOf(hours.view, 100);
|
|
1138
|
+
assert.match(older, /3h ago/);
|
|
1139
|
+
assert.match(older, /9d ago/);
|
|
1140
|
+
});
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
describe('issues view detail assignees (adopted reference idea 1)', () => {
|
|
1144
|
+
test('the detail shows one assignee row when the issue is assigned', async () => {
|
|
1145
|
+
const { view } = makeView({
|
|
1146
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
1147
|
+
issues: [issue(1)],
|
|
1148
|
+
rows: 30,
|
|
1149
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { assignees: ['alice', 'bob'] }), comments: [] }),
|
|
1150
|
+
});
|
|
1151
|
+
await tick();
|
|
1152
|
+
assert.match(bodyOf(view, 100), /assignees: alice, bob/);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
test('the detail hides the assignee row when nobody is assigned', async () => {
|
|
1156
|
+
const { view } = makeView({
|
|
1157
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
1158
|
+
issues: [issue(1)],
|
|
1159
|
+
rows: 30,
|
|
1160
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1161
|
+
});
|
|
1162
|
+
await tick();
|
|
1163
|
+
assert.ok(!bodyOf(view, 100).includes('assignees'), 'no assignee row without assignees');
|
|
1164
|
+
});
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
describe('issues view preview metadata (S9)', () => {
|
|
1168
|
+
test('shows every assignee, projects and a nonzero linked PR count, and omits empty metadata', async () => {
|
|
1169
|
+
const full = issue(5, {
|
|
1170
|
+
assignees: [{ login: 'ann' }, { login: 'bob' }],
|
|
1171
|
+
projects: [{ title: 'Roadmap', url: 'https://example.test/p/1' }],
|
|
1172
|
+
linkedPullRequestCount: 2,
|
|
1173
|
+
});
|
|
1174
|
+
const rich = makeView({
|
|
1175
|
+
pages: pagesOf([[full], [], []]),
|
|
1176
|
+
issues: [full],
|
|
1177
|
+
loadIssue: () => ({ ok: true, issue: full, comments: [] }),
|
|
1178
|
+
});
|
|
1179
|
+
await tick();
|
|
1180
|
+
const body = bodyOf(rich.view, 120);
|
|
1181
|
+
assert.match(body, /assignees: ann, bob/, 'every assigned person is shown');
|
|
1182
|
+
assert.match(body, /projects: Roadmap/, 'the associated project is shown');
|
|
1183
|
+
assert.match(body, /linked PRs: 2/, 'the nonzero PR count is shown');
|
|
1184
|
+
|
|
1185
|
+
const bare = issue(6, { metadataError: 'Project metadata is unavailable or incomplete (read:project)' });
|
|
1186
|
+
const empty = makeView({
|
|
1187
|
+
pages: pagesOf([[bare], [], []]),
|
|
1188
|
+
issues: [bare],
|
|
1189
|
+
loadIssue: () => ({ ok: true, issue: bare, comments: [] }),
|
|
1190
|
+
});
|
|
1191
|
+
await tick();
|
|
1192
|
+
const thin = bodyOf(empty.view, 120);
|
|
1193
|
+
assert.ok(!/projects?:/i.test(thin), 'an issue without projects names none');
|
|
1194
|
+
assert.ok(!/linked PR/i.test(thin), 'a zero PR count is omitted, never rendered as "0 PR"');
|
|
1195
|
+
assert.ok(!/read:project|scope/i.test(thin), 'no scope text leaks into the compact preview');
|
|
1196
|
+
assert.match(thin, /metadata unavailable/, 'unavailable metadata is one concise status');
|
|
1197
|
+
});
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
describe('issues view Copy removal (S7)', () => {
|
|
1201
|
+
test('y and Y are inert: no clipboard write and no copy notice', async () => {
|
|
1202
|
+
const calls = [];
|
|
1203
|
+
const rows = [issue(76, { title: 'copy me' })];
|
|
1204
|
+
const { view } = makeView({
|
|
1205
|
+
pages: pagesOf([rows, [], []]),
|
|
1206
|
+
issues: rows,
|
|
1207
|
+
actions: { copyText: (text) => { calls.push(text); return { ok: true, via: 'test-clipboard' }; } },
|
|
1208
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { title: 'copy me' }), comments: [] }),
|
|
1209
|
+
});
|
|
1210
|
+
await tick();
|
|
1211
|
+
const before = bodyOf(view, 160);
|
|
1212
|
+
view.handleInput('y');
|
|
1213
|
+
view.handleInput('Y');
|
|
1214
|
+
await tick();
|
|
1215
|
+
assert.deepEqual(calls, [], 'y and Y never touch the clipboard');
|
|
1216
|
+
const after = bodyOf(view, 160);
|
|
1217
|
+
assert.equal(after, before, 'both keys repaint nothing');
|
|
1218
|
+
assert.ok(!after.includes('Copied'), 'no copy claim is rendered');
|
|
1219
|
+
assert.ok(!after.includes('clipboard'), 'no clipboard failure path is rendered either');
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
test('Y copies neither the issue URL nor anything else', async () => {
|
|
1223
|
+
const url = 'https://example.test/i/76';
|
|
1224
|
+
const rows = [issue(76, { url })];
|
|
1225
|
+
const copies = [];
|
|
1226
|
+
const { view } = makeView({
|
|
1227
|
+
pages: pagesOf([rows, [], []]),
|
|
1228
|
+
issues: rows,
|
|
1229
|
+
actions: { copyText: (text) => { copies.push(text); return { ok: true, via: 'test' }; } },
|
|
1230
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { url }), comments: [] }),
|
|
1231
|
+
});
|
|
1232
|
+
await tick();
|
|
1233
|
+
view.handleInput('Y');
|
|
1234
|
+
await tick();
|
|
1235
|
+
assert.deepEqual(copies, [], 'the URL is not copied');
|
|
1236
|
+
assert.ok(!bodyOf(view, 100).includes('Copied'), 'nothing claims a copy');
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
test('a clipboard failure seam is never consulted, so no miss is claimed either', async () => {
|
|
1240
|
+
const { view } = makeView({
|
|
1241
|
+
pages: pagesOf([[issue(76)], [], []]),
|
|
1242
|
+
issues: [issue(76)],
|
|
1243
|
+
actions: { copyText: () => ({ ok: false, error: 'no clipboard tool available' }) },
|
|
1244
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1245
|
+
});
|
|
1246
|
+
await tick();
|
|
1247
|
+
view.handleInput('y');
|
|
1248
|
+
await tick();
|
|
1249
|
+
const body = bodyOf(view, 100);
|
|
1250
|
+
assert.ok(!body.includes('clipboard unavailable'), 'the copy seam is never consulted');
|
|
1251
|
+
assert.ok(!body.includes('Copied'), 'no success or miss notice exists');
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
test('the footer never names a copy key', async () => {
|
|
1255
|
+
const { view } = makeView({
|
|
1256
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
1257
|
+
issues: [issue(1)],
|
|
1258
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1259
|
+
});
|
|
1260
|
+
await tick();
|
|
1261
|
+
assert.ok(!bodyOf(view, 160).includes('y/Y'), 'no copy hint anywhere');
|
|
1262
|
+
});
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
describe('issues view comment discard confirmation (adopted reference idea 4)', () => {
|
|
1266
|
+
function commentView(post) {
|
|
1267
|
+
return makeView({
|
|
1268
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
1269
|
+
issues: [issue(1)],
|
|
1270
|
+
actions: { postComment: post },
|
|
1271
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
test('esc on a non-empty draft asks once; n keeps editing with the text intact', async () => {
|
|
1276
|
+
const { view } = commentView(() => ({ ok: true }));
|
|
1277
|
+
await tick();
|
|
1278
|
+
view.handleInput('c');
|
|
1279
|
+
for (const ch of 'keep me') view.handleInput(ch);
|
|
1280
|
+
view.handleInput('escape');
|
|
1281
|
+
assert.equal(view.state.mode, 'comment', 'still editing after the first esc');
|
|
1282
|
+
assert.match(bodyOf(view, 100), /Discard this draft\? \(y discard/);
|
|
1283
|
+
view.handleInput('n');
|
|
1284
|
+
assert.equal(view.state.mode, 'comment', 'n returns to editing');
|
|
1285
|
+
assert.equal(view.state.commentText, 'keep me', 'the draft survives the round trip');
|
|
1286
|
+
assert.match(bodyOf(view, 100), /keep me/);
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
test('esc at the ask returns to editing too', async () => {
|
|
1290
|
+
const { view } = commentView(() => ({ ok: true }));
|
|
1291
|
+
await tick();
|
|
1292
|
+
view.handleInput('c');
|
|
1293
|
+
for (const ch of 'keep me') view.handleInput(ch);
|
|
1294
|
+
view.handleInput('escape');
|
|
1295
|
+
view.handleInput('escape');
|
|
1296
|
+
assert.equal(view.state.mode, 'comment', 'esc at the ask returns to editing');
|
|
1297
|
+
assert.equal(view.state.commentText, 'keep me');
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
test('y at the ask discards the draft without posting', async () => {
|
|
1301
|
+
const calls = [];
|
|
1302
|
+
const { view } = commentView((number, text) => { calls.push([number, text]); return { ok: true }; });
|
|
1303
|
+
await tick();
|
|
1304
|
+
view.handleInput('c');
|
|
1305
|
+
for (const ch of 'throw away') view.handleInput(ch);
|
|
1306
|
+
view.handleInput('escape');
|
|
1307
|
+
view.handleInput('y');
|
|
1308
|
+
await tick();
|
|
1309
|
+
assert.equal(view.state.mode, 'list');
|
|
1310
|
+
assert.equal(view.state.commentText, '');
|
|
1311
|
+
assert.deepEqual(calls, [], 'discarding posts nothing');
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
test('esc on an empty draft discards at once without asking', async () => {
|
|
1315
|
+
const { view } = commentView(() => ({ ok: true }));
|
|
1316
|
+
await tick();
|
|
1317
|
+
view.handleInput('c');
|
|
1318
|
+
view.handleInput('escape');
|
|
1319
|
+
assert.equal(view.state.mode, 'list', 'an empty draft needs no confirmation');
|
|
1320
|
+
});
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
describe('issues view comments render whole (adopted reference idea 5: no silent cut)', () => {
|
|
1324
|
+
test('renders every comment with no silent cut and no cut line', async () => {
|
|
1325
|
+
const now = new Date().toISOString();
|
|
1326
|
+
const comments = Array.from({ length: 25 }, (_, i) => ({
|
|
1327
|
+
id: i, author: 'alice', createdAt: now, body: `comment body ${i}`, reactions: { total: 0, plusOne: 0 },
|
|
1328
|
+
}));
|
|
1329
|
+
const { view } = makeView({
|
|
1330
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
1331
|
+
issues: [issue(1)],
|
|
1332
|
+
rows: 130,
|
|
1333
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments }),
|
|
1334
|
+
});
|
|
1335
|
+
await tick();
|
|
1336
|
+
const body = bodyOf(view, 100);
|
|
1337
|
+
assert.match(body, /comment body 0/);
|
|
1338
|
+
assert.match(body, /comment body 24/, 'the last comment renders too');
|
|
1339
|
+
assert.ok(!body.includes('showing last'), 'no cut means no cut line');
|
|
1340
|
+
});
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
describe('issues view lazy page loading (S19)', () => {
|
|
1344
|
+
test('mount paints an honest loading state instead of claiming zero issues', async () => {
|
|
1345
|
+
const gate = deferred();
|
|
1346
|
+
const { view } = makeView({
|
|
1347
|
+
loadPages: () => gate.promise,
|
|
1348
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1349
|
+
});
|
|
1350
|
+
const wide = bodyOf(view, 160);
|
|
1351
|
+
assert.match(wide, /loading issues/, 'the wait is named');
|
|
1352
|
+
assert.ok(!/0 issues/.test(wide), 'a pending fetch never renders a zero count');
|
|
1353
|
+
assert.ok(!/page/i.test(wide), 'a pending fetch renders no page marker');
|
|
1354
|
+
assert.match(wide, /esc close/, 'the footer legend is there from the first paint');
|
|
1355
|
+
|
|
1356
|
+
gate.resolve(pagesResult([[issue(1)], [], []]));
|
|
1357
|
+
await tick();
|
|
1358
|
+
const loaded = bodyOf(view, 160);
|
|
1359
|
+
assert.match(loaded, /issue title 1/, 'the first paint after the fetch lists the rows');
|
|
1360
|
+
assert.match(loaded, /My issues \(1\)/);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
test('mounting and the first render never block on the fetch', async () => {
|
|
1364
|
+
const gate = deferred();
|
|
1365
|
+
const started = Date.now();
|
|
1366
|
+
const { view } = makeView({
|
|
1367
|
+
loadPages: () => gate.promise,
|
|
1368
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1369
|
+
});
|
|
1370
|
+
const painted = strip(view.render(80).join('\n'));
|
|
1371
|
+
const elapsed = Date.now() - started;
|
|
1372
|
+
assert.match(painted, /loading issues/);
|
|
1373
|
+
// The gate is still open: a blocking mount could not have painted at all.
|
|
1374
|
+
assert.ok(elapsed < 150, `mount plus first paint took ${elapsed}ms with the fetch pending`);
|
|
1375
|
+
gate.resolve(pagesResult([[issue(1)], [], []]));
|
|
1376
|
+
await tick();
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
test('the loader is asked for the filter state and its rows replace the pages', async () => {
|
|
1380
|
+
const asked = [];
|
|
1381
|
+
const { view } = makeView({
|
|
1382
|
+
loadPages: ({ state }) => {
|
|
1383
|
+
asked.push(state);
|
|
1384
|
+
return pagesResult([[issue(1), issue(2, { state: 'closed' })], [issue(3)], [issue(4)]]);
|
|
1385
|
+
},
|
|
1386
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1387
|
+
});
|
|
1388
|
+
await tick();
|
|
1389
|
+
assert.deepEqual(asked, ['open'], 'the first fetch asks for the default state');
|
|
1390
|
+
const body = bodyOf(view, 80);
|
|
1391
|
+
assert.match(body, /My issues/);
|
|
1392
|
+
assert.match(body, /Assigned/);
|
|
1393
|
+
assert.match(body, /Involved/);
|
|
1394
|
+
view.handleInput('tab');
|
|
1395
|
+
assert.match(bodyOf(view, 80), /issue title 3/, 'each page keeps its own rows');
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
test('a failed load renders the honest error and paints no fake rows', async () => {
|
|
1399
|
+
const { view } = makeView({
|
|
1400
|
+
loadPages: () => ({ ok: false, error: 'gh issue list failed: HTTP 401: Bad credentials' }),
|
|
1401
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1402
|
+
});
|
|
1403
|
+
await tick();
|
|
1404
|
+
const body = bodyOf(view, 80);
|
|
1405
|
+
assert.match(body, /failed to load issues: gh issue list failed: HTTP 401: Bad credentials/);
|
|
1406
|
+
assert.ok(!body.includes('issue title'), 'no rows were invented');
|
|
1407
|
+
assert.match(bodyOf(view, 160), /esc close/, 'the footer legend survives the error');
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
test('a partially failed load keeps the pages it has and names the failure', async () => {
|
|
1411
|
+
const { view } = makeView({
|
|
1412
|
+
loadPages: () => pagesResult([[issue(1)], [], []], { loadError: 'gh issue list failed: timed out' }),
|
|
1413
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1414
|
+
});
|
|
1415
|
+
await tick();
|
|
1416
|
+
const body = bodyOf(view, 160);
|
|
1417
|
+
assert.match(body, /issue title 1/, 'the pages that did load render');
|
|
1418
|
+
assert.match(body, /timed out/, 'the failure is named, never hidden');
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
test('a stale answer never wins over the newest request', async () => {
|
|
1422
|
+
const pending = [];
|
|
1423
|
+
const { view } = makeView({
|
|
1424
|
+
loadPages: ({ state }) => {
|
|
1425
|
+
const entry = deferred();
|
|
1426
|
+
pending.push({ state, entry });
|
|
1427
|
+
return entry.promise;
|
|
1428
|
+
},
|
|
1429
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1430
|
+
});
|
|
1431
|
+
assert.deepEqual(pending.map((p) => p.state), ['open'], 'the mount read asks for open');
|
|
1432
|
+
view.handleInput('r');
|
|
1433
|
+
view.handleInput('r');
|
|
1434
|
+
assert.deepEqual(pending.map((p) => p.state), ['open', 'open', 'open'], 'every reload asks for open again');
|
|
1435
|
+
const newest = pending[2].entry;
|
|
1436
|
+
const stale = pending[1].entry;
|
|
1437
|
+
newest.resolve(pagesResult([[issue(1)], [], []]));
|
|
1438
|
+
await tick();
|
|
1439
|
+
stale.resolve(pagesResult([[issue(2)], [], []]));
|
|
1440
|
+
await tick();
|
|
1441
|
+
const body = bodyOf(view, 80);
|
|
1442
|
+
assert.match(body, /My issues \(1\)/, 'the newest open-only answer is the one shown');
|
|
1443
|
+
assert.match(body, /issue title 1/, 'the newest answer paints its row');
|
|
1444
|
+
assert.ok(!body.includes('issue title 2'), 'the stale answer never paints');
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
test('closing stops every later repaint and a late load changes nothing', async () => {
|
|
1448
|
+
const gate = deferred();
|
|
1449
|
+
const { view, done, paints } = makeView({
|
|
1450
|
+
loadPages: () => gate.promise,
|
|
1451
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1452
|
+
});
|
|
1453
|
+
const before = paints.length;
|
|
1454
|
+
view.handleInput('escape');
|
|
1455
|
+
assert.equal(done(), null, 'esc still closes with null');
|
|
1456
|
+
gate.resolve(pagesResult([[issue(1)], [], []]));
|
|
1457
|
+
await tick();
|
|
1458
|
+
assert.equal(paints.length, before, 'a late answer repaints nothing after close');
|
|
1459
|
+
});
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
describe('issues view open-only list (S10a)', () => {
|
|
1463
|
+
// A state-honest source, like `gh --state <state>`: each state carries
|
|
1464
|
+
// only its own rows and its own total. The view only ever asks for open.
|
|
1465
|
+
function stateSource(rows) {
|
|
1466
|
+
const asked = [];
|
|
1467
|
+
const pages = (state) => {
|
|
1468
|
+
asked.push(state);
|
|
1469
|
+
const matching = rows.filter((row) => String(row.state || 'open') === state);
|
|
1470
|
+
return pagesResult([matching, [], []]);
|
|
1471
|
+
};
|
|
1472
|
+
return { asked, pages };
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
test('the default fetch asks for open and the tab names the open count', async () => {
|
|
1476
|
+
const rows = [issue(1), issue(2, { state: 'closed' }), issue(3, { state: 'closed' })];
|
|
1477
|
+
const source = stateSource(rows);
|
|
1478
|
+
const { view } = makeView({
|
|
1479
|
+
loadPages: ({ state }) => source.pages(state),
|
|
1480
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1481
|
+
});
|
|
1482
|
+
await tick();
|
|
1483
|
+
assert.deepEqual(source.asked, ['open'], 'the first fetch asks gh for open only');
|
|
1484
|
+
const body = bodyOf(view, 80);
|
|
1485
|
+
assert.match(body, /My issues \(1\)/, 'the tab counts the open row it lists');
|
|
1486
|
+
assert.ok(!body.includes('My issues (3)'), 'the all-state total is never shown while only open issues list');
|
|
1487
|
+
assert.ok(!body.includes('issue title 2'), 'closed rows stay out of the list');
|
|
1488
|
+
assert.ok(!/page/i.test(body), 'no page marker is printed (S3)');
|
|
1489
|
+
assert.ok(!/f[:(]/.test(body), 'no state indicator is left in the frame');
|
|
1490
|
+
assert.ok(!bodyOf(view, 160).includes('open/closed'), 'the removed toggle never reaches the legend');
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
test('an all-state total never inflates the open count (reported symptom)', async () => {
|
|
1494
|
+
const rows = [issue(1), issue(2, { state: 'closed' }), issue(3, { state: 'closed' })];
|
|
1495
|
+
const { view } = makeView({
|
|
1496
|
+
// A source that answers an open request with every state and an
|
|
1497
|
+
// all-state total: the counts must follow the rows actually shown.
|
|
1498
|
+
loadPages: () => pagesResult([rows, [], []]),
|
|
1499
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1500
|
+
});
|
|
1501
|
+
await tick();
|
|
1502
|
+
const body = bodyOf(view, 80);
|
|
1503
|
+
assert.match(body, /My issues \(1\)/, 'the tab counts the one open issue it lists');
|
|
1504
|
+
assert.ok(!body.includes('My issues (3)'), 'closed issues never inflate the open tab');
|
|
1505
|
+
assert.ok(!/page/i.test(body), 'no page marker is printed (S3)');
|
|
1506
|
+
assert.ok(!body.includes('issue title 2'), 'no closed row is listed under open');
|
|
1507
|
+
assert.ok(!/\b2 issues\b|\b3 issues\b/.test(body), 'no closed row inflates a count');
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
test('f is inert: no other state is ever requested and closed rows never count', async () => {
|
|
1511
|
+
const rows = [issue(1), issue(2, { state: 'closed' }), issue(3, { state: 'closed' })];
|
|
1512
|
+
const source = stateSource(rows);
|
|
1513
|
+
const asked = [];
|
|
1514
|
+
const { view } = makeView({
|
|
1515
|
+
loadPages: ({ state }) => source.pages(state),
|
|
1516
|
+
actions: {
|
|
1517
|
+
reload: ({ state }) => { asked.push(`reload:${state}`); return source.pages(state); },
|
|
1518
|
+
},
|
|
1519
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1520
|
+
});
|
|
1521
|
+
await tick();
|
|
1522
|
+
assert.deepEqual(source.asked, ['open'], 'the mount fetch asks for open');
|
|
1523
|
+
const before = bodyOf(view, 80);
|
|
1524
|
+
view.handleInput('f');
|
|
1525
|
+
await tick();
|
|
1526
|
+
assert.deepEqual(source.asked, ['open'], 'f never asks gh for another state');
|
|
1527
|
+
assert.deepEqual(asked, [], 'f never refetches through the reload seam');
|
|
1528
|
+
assert.equal(bodyOf(view, 80), before, 'f repaints nothing');
|
|
1529
|
+
view.handleInput('r');
|
|
1530
|
+
await tick();
|
|
1531
|
+
assert.deepEqual(asked, ['reload:open'], 'a reload asks for open, never for closed');
|
|
1532
|
+
assert.equal(source.asked.includes('closed'), false, 'the closed state is never requested');
|
|
1533
|
+
const reloaded = bodyOf(view, 80);
|
|
1534
|
+
assert.match(reloaded, /My issues \(1\)/, 'only the open row is counted');
|
|
1535
|
+
assert.ok(!reloaded.includes('issue title 2'), 'closed rows stay out of the reloaded list');
|
|
1536
|
+
assert.equal(view.state.index, 0, 'the selection stays on the first open row');
|
|
1537
|
+
assert.equal(view.state.listStart, 0, 'the list window stays at the top');
|
|
1538
|
+
assert.equal(view.state.scroll, 0, 'the detail scroll stays at the top');
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
test('a bounded fetch marks the count on the tab only', async () => {
|
|
1542
|
+
const rows = Array.from({ length: 5 }, (_, i) => issue(i + 1));
|
|
1543
|
+
const { view } = makeView({
|
|
1544
|
+
loadPages: () => ({
|
|
1545
|
+
ok: true,
|
|
1546
|
+
pages: [{ id: 'p0', label: 'My issues', issues: rows, total: 500, truncated: true }],
|
|
1547
|
+
}),
|
|
1548
|
+
rows: 12,
|
|
1549
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1550
|
+
});
|
|
1551
|
+
await tick();
|
|
1552
|
+
const body = bodyOf(view, 80);
|
|
1553
|
+
assert.match(body, /My issues \(500\+\)/, 'the tab marks the bounded count');
|
|
1554
|
+
assert.ok(!/500\+ issues|5\+ issues/.test(body), 'the bound is never repeated below the list');
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
test('opening, searching and the removed state key issue no write action', async () => {
|
|
1558
|
+
const writes = [];
|
|
1559
|
+
const rows = [issue(1), issue(2, { state: 'closed' })];
|
|
1560
|
+
const { view } = makeView({
|
|
1561
|
+
loadPages: ({ state }) => pagesResult([
|
|
1562
|
+
rows.filter((row) => (row.state || 'open') === state), [], [],
|
|
1563
|
+
]),
|
|
1564
|
+
loadLabels: () => ({ ok: true, labels: [{ name: 'bug' }] }),
|
|
1565
|
+
actions: {
|
|
1566
|
+
reload: ({ state }) => pagesResult([rows.filter((row) => (row.state || 'open') === state), [], []]),
|
|
1567
|
+
editLabels: () => { writes.push('editLabels'); return { ok: true }; },
|
|
1568
|
+
postComment: () => { writes.push('postComment'); return { ok: true }; },
|
|
1569
|
+
setState: () => { writes.push('setState'); return { ok: true }; },
|
|
1570
|
+
openBrowser: () => { writes.push('openBrowser'); return { ok: true }; },
|
|
1571
|
+
},
|
|
1572
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1573
|
+
});
|
|
1574
|
+
await tick();
|
|
1575
|
+
view.handleInput('T');
|
|
1576
|
+
for (const ch of 'issue') view.handleInput(ch);
|
|
1577
|
+
view.handleInput('enter');
|
|
1578
|
+
view.handleInput('f');
|
|
1579
|
+
await tick();
|
|
1580
|
+
view.handleInput('tab');
|
|
1581
|
+
view.handleInput('down');
|
|
1582
|
+
assert.deepEqual(writes, [], 'no key outside the four write actions writes');
|
|
1583
|
+
});
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
describe('issues view lazy labels (S19)', () => {
|
|
1587
|
+
test('labels load on the first L, never at mount, and only once', async () => {
|
|
1588
|
+
let labelCalls = 0;
|
|
1589
|
+
const { view } = makeView({
|
|
1590
|
+
loadPages: () => pagesResult([[issue(1, { labels: [{ name: 'bug' }] })], [], []]),
|
|
1591
|
+
loadLabels: () => {
|
|
1592
|
+
labelCalls += 1;
|
|
1593
|
+
return { ok: true, labels: [{ name: 'bug' }, { name: 'docs' }] };
|
|
1594
|
+
},
|
|
1595
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions: EDITABLE }), comments: [] }),
|
|
1596
|
+
});
|
|
1597
|
+
await tick();
|
|
1598
|
+
assert.equal(labelCalls, 0, 'opening the view never fetches labels');
|
|
1599
|
+
view.handleInput('L');
|
|
1600
|
+
await tick();
|
|
1601
|
+
assert.equal(labelCalls, 1, 'the first L fetches them');
|
|
1602
|
+
const body = bodyOf(view, 80);
|
|
1603
|
+
assert.match(body, /docs/, 'the editor lists the fetched labels');
|
|
1604
|
+
view.handleInput('escape');
|
|
1605
|
+
view.handleInput('L');
|
|
1606
|
+
await tick();
|
|
1607
|
+
assert.equal(labelCalls, 1, 'a second open reuses the loaded labels');
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
test('a label load failure is shown honestly and retried on the next open', async () => {
|
|
1611
|
+
let labelCalls = 0;
|
|
1612
|
+
const { view } = makeView({
|
|
1613
|
+
loadPages: () => pagesResult([[issue(1, { labels: [{ name: 'bug' }] })], [], []]),
|
|
1614
|
+
loadLabels: () => {
|
|
1615
|
+
labelCalls += 1;
|
|
1616
|
+
return { ok: false, error: 'gh label list failed: rate limited' };
|
|
1617
|
+
},
|
|
1618
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions: EDITABLE }), comments: [] }),
|
|
1619
|
+
});
|
|
1620
|
+
await tick();
|
|
1621
|
+
view.handleInput('L');
|
|
1622
|
+
await tick();
|
|
1623
|
+
const body = bodyOf(view, 160);
|
|
1624
|
+
assert.match(body, /labels unreadable: gh label list failed: rate limited/);
|
|
1625
|
+
assert.match(body, /\+ create new/, 'creating a new label stays reachable');
|
|
1626
|
+
view.handleInput('escape');
|
|
1627
|
+
view.handleInput('L');
|
|
1628
|
+
await tick();
|
|
1629
|
+
assert.equal(labelCalls, 2, 'reopening the editor retries the failed fetch');
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
test('a pending label fetch shows the wait and only esc leaves the editor', async () => {
|
|
1633
|
+
const gate = deferred();
|
|
1634
|
+
const { view } = makeView({
|
|
1635
|
+
loadPages: () => pagesResult([[issue(1)], [], []]),
|
|
1636
|
+
loadLabels: () => gate.promise,
|
|
1637
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions: EDITABLE }), comments: [] }),
|
|
1638
|
+
});
|
|
1639
|
+
await tick();
|
|
1640
|
+
view.handleInput('L');
|
|
1641
|
+
const loading = bodyOf(view, 80);
|
|
1642
|
+
assert.match(loading, /loading labels/);
|
|
1643
|
+
view.handleInput('space');
|
|
1644
|
+
assert.equal(view.state.labelIndex, 0, 'toggling waits for the real list');
|
|
1645
|
+
gate.resolve({ ok: true, labels: [{ name: 'bug' }] });
|
|
1646
|
+
await tick();
|
|
1647
|
+
assert.match(bodyOf(view, 80), /bug/, 'the labels land in the open editor');
|
|
1648
|
+
view.handleInput('escape');
|
|
1649
|
+
assert.equal(view.state.mode, 'list');
|
|
1650
|
+
});
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
describe('issues view over the real gh client (argv, status, stderr)', () => {
|
|
1654
|
+
test('reads ask for the requested state, and a gh failure surfaces honestly', async () => {
|
|
1655
|
+
const calls = [];
|
|
1656
|
+
ghIssues.setGhRunner((_bin, argv) => {
|
|
1657
|
+
calls.push(argv.slice());
|
|
1658
|
+
if (argv[0] === 'issue' && argv[1] === 'list') {
|
|
1659
|
+
return { status: 1, stdout: '', stderr: 'HTTP 401: Bad credentials', error: null };
|
|
1660
|
+
}
|
|
1661
|
+
return { status: 0, stdout: '[]', stderr: '', error: null };
|
|
1662
|
+
});
|
|
1663
|
+
const { view } = makeView({
|
|
1664
|
+
loadPages: ({ state }) => ghIssues.listIssues({ query: 'author:@me', state, page: 1, pageSize: 30 }),
|
|
1665
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1666
|
+
});
|
|
1667
|
+
await tick();
|
|
1668
|
+
view.handleInput('T');
|
|
1669
|
+
for (const ch of 'crash') view.handleInput(ch);
|
|
1670
|
+
view.handleInput('enter');
|
|
1671
|
+
view.handleInput('f');
|
|
1672
|
+
await tick();
|
|
1673
|
+
view.handleInput('r');
|
|
1674
|
+
await tick();
|
|
1675
|
+
const lists = calls.filter((argv) => argv[0] === 'issue' && argv[1] === 'list');
|
|
1676
|
+
assert.equal(lists.length, 2, 'the mount read and the reload');
|
|
1677
|
+
assert.deepEqual(
|
|
1678
|
+
lists.map((argv) => argv[argv.indexOf('--state') + 1]),
|
|
1679
|
+
['open', 'open'],
|
|
1680
|
+
'open is the only state ever requested',
|
|
1681
|
+
);
|
|
1682
|
+
assert.ok(!calls.some((argv) => argv.includes('closed')), 'the closed state is never requested');
|
|
1683
|
+
assert.ok(lists.every((argv) => argv.includes('--search')), 'each read carries the page query');
|
|
1684
|
+
const body = bodyOf(view, 80);
|
|
1685
|
+
assert.match(body, /failed to load issues: gh issue list failed: HTTP 401: Bad credentials/);
|
|
1686
|
+
const writes = calls.filter((argv) => argv[0] === 'issue'
|
|
1687
|
+
&& ['edit', 'comment', 'close', 'reopen'].includes(argv[1]));
|
|
1688
|
+
assert.deepEqual(writes, [], 'opening, searching and toggling write nothing');
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
test('eagerly supplied pages keep naming their reported total (legacy contract)', async () => {
|
|
1692
|
+
const { view } = makeView({
|
|
1693
|
+
pages: [{ id: 'p0', label: 'My issues', issues: [issue(1)], total: 45 }],
|
|
1694
|
+
issues: [issue(1)],
|
|
1695
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1696
|
+
});
|
|
1697
|
+
await tick();
|
|
1698
|
+
const body = bodyOf(view, 80);
|
|
1699
|
+
assert.match(body, /My issues \(45\)/, 'the eager page keeps its own reported total');
|
|
1700
|
+
assert.ok(!/page/i.test(body), 'no page marker is printed (S3)');
|
|
1701
|
+
assert.ok(!/\b45 issues\b/.test(body), 'the total is named once, on the tab');
|
|
1702
|
+
});
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
describe('issues view concise header (S25)', () => {
|
|
1706
|
+
const CHECKOUT = 'feat-yolo-worktree-confirmations';
|
|
1707
|
+
|
|
1708
|
+
test('the header repeats neither the checkout nor the active tab', async () => {
|
|
1709
|
+
const { view } = makeView({
|
|
1710
|
+
checkoutName: CHECKOUT,
|
|
1711
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
1712
|
+
issues: [issue(1)],
|
|
1713
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions: EDITABLE }), comments: [] }),
|
|
1714
|
+
});
|
|
1715
|
+
await tick();
|
|
1716
|
+
const header = strip(view.render(80)[0]);
|
|
1717
|
+
assert.match(header, /◈ Issues/, 'the header names the surface');
|
|
1718
|
+
assert.ok(!header.includes('My issues'), 'the header never repeats the active tab the tab row shows');
|
|
1719
|
+
assert.ok(!header.includes(CHECKOUT), 'the header never repeats the checkout/branch');
|
|
1720
|
+
assert.ok(!bodyOf(view, 80).includes(CHECKOUT), 'no part of the frame repeats the checkout');
|
|
1721
|
+
assert.ok(!/#\d/.test(header), 'the header repeats no issue id the list or preview already shows');
|
|
1722
|
+
const body = bodyOf(view, 160);
|
|
1723
|
+
assert.match(body, /My issues \(1\)/, 'the tab count survives the trim');
|
|
1724
|
+
assert.match(body, /x close/, 'the close legend survives the trim');
|
|
1725
|
+
assert.ok(!/page/i.test(body), 'no page marker is printed (S3)');
|
|
1726
|
+
assert.ok(!body.includes('f open/closed'), 'the removed state toggle legend stays gone');
|
|
1727
|
+
const beforeSpace = strip(view.render(80).join('\n'));
|
|
1728
|
+
view.handleInput('space');
|
|
1729
|
+
const after = strip(view.render(80).join('\n'));
|
|
1730
|
+
assert.equal(after, beforeSpace, 'space changes nothing, the header included');
|
|
1731
|
+
assert.ok(!after.includes('unsaved'), 'the header never claims a pending assignment');
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
test('the loading header is the surface name alone, with no checkout', async () => {
|
|
1735
|
+
const gate = deferred();
|
|
1736
|
+
const { view } = makeView({
|
|
1737
|
+
checkoutName: CHECKOUT,
|
|
1738
|
+
loadPages: () => gate.promise,
|
|
1739
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1740
|
+
});
|
|
1741
|
+
const loading = bodyOf(view, 160);
|
|
1742
|
+
assert.match(loading, /◈ Issues/, 'the header still names the surface');
|
|
1743
|
+
assert.ok(!loading.includes(CHECKOUT), 'the loading header repeats no checkout');
|
|
1744
|
+
assert.match(loading, /loading issues/, 'the loading state survives');
|
|
1745
|
+
gate.resolve(pagesResult([[issue(1)], [], []]));
|
|
1746
|
+
await tick();
|
|
1747
|
+
assert.ok(!bodyOf(view, 160).includes(CHECKOUT), 'the loaded frame repeats no checkout either');
|
|
1748
|
+
});
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
describe('issues view dedicated search row (S26)', () => {
|
|
1752
|
+
function chrome(view, width) {
|
|
1753
|
+
return view.render(width).map(strip);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
test('the search widget sits directly below the title and the tabs follow it', async () => {
|
|
1757
|
+
const { view } = makeView({
|
|
1758
|
+
checkoutName: 'feat-yolo-worktree-confirmations',
|
|
1759
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
1760
|
+
issues: [issue(1)],
|
|
1761
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1762
|
+
});
|
|
1763
|
+
await tick();
|
|
1764
|
+
for (const width of [80, 160]) {
|
|
1765
|
+
const lines = chrome(view, width);
|
|
1766
|
+
assert.match(lines[0], /◈ Issues/, 'row 1 is the concise title');
|
|
1767
|
+
assert.ok(!/search/i.test(lines[0]), 'the title buries nothing about search');
|
|
1768
|
+
assert.match(lines[1], /› Search(?! \(T\))/, 'row 2 is the dedicated Search widget');
|
|
1769
|
+
assert.match(lines[2], /My issues \(1\)/, 'row 3 carries the tabs and their counts');
|
|
1770
|
+
assert.match(lines[2], /Assigned \(0\)/, 'every tab count stays');
|
|
1771
|
+
assert.ok(!/f[:(]/.test(lines[3]), 'row 4 carries notices only, no state toggle');
|
|
1772
|
+
assert.equal(lines.length, 24, 'the chrome plus body still fills the terminal exactly');
|
|
1773
|
+
}
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
test('an active query renders in the search widget at 80 and 160', async () => {
|
|
1777
|
+
const rows = [issue(1, { title: 'alpha crash' }), issue(2, { title: 'beta typo' })];
|
|
1778
|
+
const { view } = makeView({
|
|
1779
|
+
pages: pagesOf([rows, [], []]),
|
|
1780
|
+
issues: rows,
|
|
1781
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1782
|
+
});
|
|
1783
|
+
await tick();
|
|
1784
|
+
view.handleInput('T');
|
|
1785
|
+
for (const ch of 'beta') view.handleInput(ch);
|
|
1786
|
+
for (const width of [80, 160]) {
|
|
1787
|
+
const lines = chrome(view, width);
|
|
1788
|
+
assert.match(lines[1], /› beta█/, 'the query renders in the search widget');
|
|
1789
|
+
assert.ok(!lines[1].includes('enter/esc keep'), 'the input row repeats no key hint');
|
|
1790
|
+
assert.match(lines[2], /My issues/, 'the tabs stay below the search row');
|
|
1791
|
+
const footer = lines[lines.length - 2];
|
|
1792
|
+
assert.match(footer, /enter\/esc keep/, 'the footer names the search keys of this interaction once');
|
|
1793
|
+
assert.ok(!footer.includes('esc close'), 'the close hint belongs to the reading interaction only');
|
|
1794
|
+
}
|
|
1795
|
+
view.handleInput('enter');
|
|
1796
|
+
const settled = chrome(view, 80);
|
|
1797
|
+
assert.match(settled[1], /› beta(?! \(T)/, 'a kept filter shows its query in the widget');
|
|
1798
|
+
assert.ok(!settled.join('\n').includes('alpha crash'), 'the kept filter still filters the list');
|
|
1799
|
+
});
|
|
1800
|
+
|
|
1801
|
+
test('errors and notices keep their own visible row, even while typing', async () => {
|
|
1802
|
+
const rows = [issue(1)];
|
|
1803
|
+
const { view } = makeView({
|
|
1804
|
+
pages: pagesOf([rows, [], []]),
|
|
1805
|
+
issues: rows,
|
|
1806
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1807
|
+
});
|
|
1808
|
+
await tick();
|
|
1809
|
+
view.handleInput('L');
|
|
1810
|
+
let lines = chrome(view, 80);
|
|
1811
|
+
assert.match(lines[3], /labels cannot be edited/, 'the refusal lands on the notice row');
|
|
1812
|
+
view.handleInput('T');
|
|
1813
|
+
view.handleInput('1');
|
|
1814
|
+
lines = chrome(view, 80);
|
|
1815
|
+
assert.match(lines[1], /› 1█/, 'the query row stays the input');
|
|
1816
|
+
assert.match(lines[3], /labels cannot be edited/, 'the error never disappears while typing');
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
test('a long notice truncates inside the frame instead of overflowing it', async () => {
|
|
1820
|
+
const longError = 'the issue page could not be read: gh timed out after 30s while querying the remote repository for this bucket';
|
|
1821
|
+
const { view } = makeView({
|
|
1822
|
+
pages: pagesOf([[issue(76)], [], []]),
|
|
1823
|
+
issues: [issue(76)],
|
|
1824
|
+
loadError: longError,
|
|
1825
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1826
|
+
});
|
|
1827
|
+
await tick();
|
|
1828
|
+
for (const width of [80, 160]) {
|
|
1829
|
+
for (const line of view.render(width)) {
|
|
1830
|
+
assert.equal(helpers.visibleWidth(line), width, `every row keeps the exact cell width at ${width}`);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
assert.match(chrome(view, 80)[3], /the issue page could not be read/, 'the notice still shows on the notice row');
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
test('the loading frame keeps the search widget directly under the title', async () => {
|
|
1837
|
+
const gate = deferred();
|
|
1838
|
+
const { view } = makeView({
|
|
1839
|
+
loadPages: () => gate.promise,
|
|
1840
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1841
|
+
});
|
|
1842
|
+
const loading = chrome(view, 160);
|
|
1843
|
+
assert.match(loading[0], /◈ Issues/, 'row 1 is the title');
|
|
1844
|
+
assert.match(loading[1], /› Search/, 'row 2 is the search widget while loading');
|
|
1845
|
+
assert.match(loading.join('\n'), /loading issues/, 'the loading state stays');
|
|
1846
|
+
assert.match(loading[loading.length - 2], /esc close/, 'the footer legend stays');
|
|
1847
|
+
gate.resolve(pagesResult([[issue(1)], [], []]));
|
|
1848
|
+
await tick();
|
|
1849
|
+
assert.match(chrome(view, 160)[1], /› Search/, 'the widget stays under the title after loading');
|
|
1850
|
+
});
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
describe('issues view frame header (S27)', () => {
|
|
1854
|
+
test('no top-right esc text; right border and corners stay cell-aligned', async () => {
|
|
1855
|
+
const { view } = makeView({
|
|
1856
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
1857
|
+
issues: [issue(1)],
|
|
1858
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1859
|
+
});
|
|
1860
|
+
await tick();
|
|
1861
|
+
for (const width of [80, 160]) {
|
|
1862
|
+
const lines = view.render(width);
|
|
1863
|
+
const first = strip(lines[0]);
|
|
1864
|
+
assert.ok(!/esc/.test(first), 'the header carries no esc text');
|
|
1865
|
+
assert.ok(first.startsWith('╭─'), 'the top-left corner is intact');
|
|
1866
|
+
assert.ok(first.endsWith('─╮'), 'the top-right corner is intact');
|
|
1867
|
+
for (const line of lines) {
|
|
1868
|
+
assert.equal(helpers.visibleWidth(line), width, `cell width at ${width}`);
|
|
1869
|
+
assert.match(strip(line), /[│╮╯]$/, 'the right border stays in the last column');
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
const wide = view.render(160);
|
|
1873
|
+
assert.match(strip(wide[wide.length - 2]), /esc close/, 'the footer still names Esc');
|
|
1874
|
+
const narrow = view.render(80);
|
|
1875
|
+
assert.match(strip(narrow[narrow.length - 2]), /↑↓ move/, 'the narrow footer keeps the legend head');
|
|
1876
|
+
assert.equal(helpers.visibleWidth(narrow[narrow.length - 2]), 80, 'the truncated footer stays cell-exact');
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
test('the loader and error frames also drop the top-right esc', async () => {
|
|
1880
|
+
const gate = deferred();
|
|
1881
|
+
const loading = makeView({
|
|
1882
|
+
loadPages: () => gate.promise,
|
|
1883
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1884
|
+
});
|
|
1885
|
+
for (const width of [80, 160]) {
|
|
1886
|
+
const lines = loading.view.render(width);
|
|
1887
|
+
assert.ok(!/esc/.test(strip(lines[0])), 'no esc in the loading header');
|
|
1888
|
+
for (const line of lines) assert.equal(helpers.visibleWidth(line), width);
|
|
1889
|
+
}
|
|
1890
|
+
const failed = makeView({
|
|
1891
|
+
loadPages: () => ({ ok: false, error: 'gh issue list failed: HTTP 401' }),
|
|
1892
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1893
|
+
});
|
|
1894
|
+
await tick();
|
|
1895
|
+
const lines = failed.view.render(80);
|
|
1896
|
+
assert.ok(!/esc/.test(strip(lines[0])), 'no esc in the error header');
|
|
1897
|
+
for (const line of lines) assert.equal(helpers.visibleWidth(line), 80);
|
|
1898
|
+
const wide = failed.view.render(160);
|
|
1899
|
+
assert.match(strip(wide[wide.length - 2]), /esc close/, 'the footer keeps the Esc shortcut');
|
|
1900
|
+
gate.resolve(pagesResult([[issue(1)], [], []]));
|
|
1901
|
+
await tick();
|
|
1902
|
+
});
|
|
1903
|
+
});
|
|
1904
|
+
|
|
1905
|
+
// S2: the footer legend is ONE terminal row; a legend wider than the card is
|
|
1906
|
+
// compacted (short tokens, then the lowest-priority keys dropped) instead of
|
|
1907
|
+
// being ellipsis-clipped inside the frame.
|
|
1908
|
+
describe('S2: one-row key legends', () => {
|
|
1909
|
+
const WIDTHS = [40, 60, 80, 120];
|
|
1910
|
+
|
|
1911
|
+
test('the footer legend owns exactly one row at every width', async () => {
|
|
1912
|
+
for (const width of WIDTHS) {
|
|
1913
|
+
const { view } = makeView({
|
|
1914
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
1915
|
+
issues: [issue(1)],
|
|
1916
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1917
|
+
});
|
|
1918
|
+
await tick();
|
|
1919
|
+
const lines = view.render(width);
|
|
1920
|
+
for (const line of lines) assert.equal(helpers.visibleWidth(line), width, `width ${width}: row is not ${width} wide`);
|
|
1921
|
+
const legend = lines.map(strip).filter((line) => /esc close|↑↓ move/.test(line));
|
|
1922
|
+
assert.equal(legend.length, 1, `width ${width}: exactly one footer row, got ${legend.length}`);
|
|
1923
|
+
assert.ok(!legend[0].includes('…'), `width ${width}: compacted, never ellipsis-clipped: ${legend[0]}`);
|
|
1924
|
+
assert.match(legend[0], /↑↓ move/, `width ${width}: the movement keys survive`);
|
|
1925
|
+
assert.match(legend[0], /esc close/, `width ${width}: the close key survives`);
|
|
1926
|
+
}
|
|
1927
|
+
});
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
// S13: the issues frame says each thing once. The search key is named by the
|
|
1931
|
+
// search row alone, the issue total by the tab alone, the active bucket by the
|
|
1932
|
+
// tab row (never by the header too), and while the box holds the keyboard the
|
|
1933
|
+
// footer names its keys once instead of the input and the footer both doing it.
|
|
1934
|
+
describe('S13: the issues frame repeats nothing', () => {
|
|
1935
|
+
const rows = [issue(1), issue(2)];
|
|
1936
|
+
const footerOf = (lines) => lines[lines.length - 2];
|
|
1937
|
+
const tokensOf = (footer) => footer.split('·').map((token) => token.trim());
|
|
1938
|
+
|
|
1939
|
+
async function opened(opts = {}) {
|
|
1940
|
+
const { view } = makeView({
|
|
1941
|
+
pages: pagesOf([rows, [], []]),
|
|
1942
|
+
issues: rows,
|
|
1943
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
1944
|
+
...opts,
|
|
1945
|
+
});
|
|
1946
|
+
await tick();
|
|
1947
|
+
return view;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
test('the active bucket is named by the tab, not by the header too', async () => {
|
|
1951
|
+
const view = await opened();
|
|
1952
|
+
const lines = view.render(80).map(strip);
|
|
1953
|
+
assert.match(lines[0], /◈ Issues/, 'the header names the surface');
|
|
1954
|
+
assert.ok(!lines[0].includes('My issues'), 'the header never repeats the tab below it');
|
|
1955
|
+
assert.match(lines[2], /▸ My issues \(2\)/, 'the tab row marks the active bucket and its count');
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
test('the search key is named by the footer alone, never above it', async () => {
|
|
1959
|
+
const view = await opened();
|
|
1960
|
+
const lines = view.render(80).map(strip);
|
|
1961
|
+
const footer = footerOf(lines);
|
|
1962
|
+
assert.match(lines[1], /› Search/, 'the search row names the widget');
|
|
1963
|
+
assert.ok(!lines[1].includes('(T)'), 'no key hint rides the search row');
|
|
1964
|
+
assert.equal((lines.join('\n')).match(/\(T\)/g), null, 'no parenthesised T hint survives');
|
|
1965
|
+
assert.ok(tokensOf(footer).some((token) => /^t( search)?$/.test(token)), `the footer owns the search key: ${footer}`);
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
test('while searching the footer names the search keys once and the input carries only the query', async () => {
|
|
1969
|
+
const view = await opened();
|
|
1970
|
+
view.handleInput('T');
|
|
1971
|
+
view.handleInput('t');
|
|
1972
|
+
const lines = view.render(80).map(strip);
|
|
1973
|
+
const body = lines.join('\n');
|
|
1974
|
+
assert.match(lines[1], /› t█/, 'the input row carries the live query');
|
|
1975
|
+
assert.ok(!lines[1].includes('enter'), 'the input row repeats no key hint');
|
|
1976
|
+
assert.ok(!lines[1].includes('esc'), 'the input row names no escape key');
|
|
1977
|
+
const footer = footerOf(lines);
|
|
1978
|
+
assert.match(footer, /enter\/esc keep/, 'the footer names the search keys of this interaction');
|
|
1979
|
+
assert.ok(!footer.includes('esc close'), 'the close hint belongs to the reading interaction only');
|
|
1980
|
+
assert.equal((body.match(/esc/g) || []).length, 1, `one escape hint for the current interaction: ${body}`);
|
|
1981
|
+
assert.equal((body.match(/enter/g) || []).length, 1, 'one enter hint for the current interaction');
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
test('leaving the box restores the reading legend with its one escape hint', async () => {
|
|
1985
|
+
const view = await opened();
|
|
1986
|
+
view.handleInput('T');
|
|
1987
|
+
view.handleInput('t');
|
|
1988
|
+
view.handleInput('enter');
|
|
1989
|
+
const lines = view.render(80).map(strip);
|
|
1990
|
+
const footer = footerOf(lines);
|
|
1991
|
+
assert.match(footer, /esc close/, 'the reading legend returns');
|
|
1992
|
+
assert.ok(!footer.includes('enter/esc keep'), 'the search keys leave with the box');
|
|
1993
|
+
assert.equal((lines.join('\n').match(/esc/g) || []).length, 1, 'still one escape hint');
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
test('the issue total lives in the tab, never a second time under the list', async () => {
|
|
1997
|
+
const view = await opened();
|
|
1998
|
+
const body = bodyOf(view, 80);
|
|
1999
|
+
assert.match(body, /My issues \(2\)/, 'the tab carries the total');
|
|
2000
|
+
assert.ok(!/\b2 issues\b/.test(body), 'no row under the list repeats it');
|
|
2001
|
+
assert.ok(!/page/i.test(strip(view.render(80)[1])), 'the search row carries no page marker');
|
|
2002
|
+
});
|
|
2003
|
+
|
|
2004
|
+
test('no chrome row repeats the focused issue title the list and preview already show', async () => {
|
|
2005
|
+
const view = await opened();
|
|
2006
|
+
const lines = view.render(100).map(strip);
|
|
2007
|
+
for (const row of lines.slice(0, 4)) {
|
|
2008
|
+
assert.ok(!row.includes('issue title 1'), `chrome repeats no focused title: ${row}`);
|
|
2009
|
+
}
|
|
2010
|
+
});
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
// S16/D2: label and assignee editing is a repository capability, never the
|
|
2014
|
+
// fact that the user is authenticated. Issue detail carries
|
|
2015
|
+
// `permissions:{canEditLabels,canEditAssignees,canCreateLabels}`; absent or
|
|
2016
|
+
// unknown reads as false, so the footer hint disappears AND the key cannot
|
|
2017
|
+
// enter the editor — the same gate for both, and no loader or write runs.
|
|
2018
|
+
describe('S16: repository permissions gate the label and assignee editors', () => {
|
|
2019
|
+
const users = () => ({ ok: true, users: [{ login: 'ann', name: 'Ann' }, { login: 'bob', name: '' }, { login: 'carol', name: 'Carol' }] });
|
|
2020
|
+
|
|
2021
|
+
test('the footer names each editor only for a permission confirmed true', async () => {
|
|
2022
|
+
const cases = [
|
|
2023
|
+
[EDITABLE, true],
|
|
2024
|
+
[{}, false],
|
|
2025
|
+
[undefined, false],
|
|
2026
|
+
[{ canEditLabels: 'yes', canEditAssignees: 1 }, false],
|
|
2027
|
+
];
|
|
2028
|
+
for (const [permissions, shown] of cases) {
|
|
2029
|
+
const rows = [issue(1)];
|
|
2030
|
+
const { view } = makeView({
|
|
2031
|
+
pages: pagesOf([rows, [], []]),
|
|
2032
|
+
issues: rows,
|
|
2033
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions }), comments: [] }),
|
|
2034
|
+
});
|
|
2035
|
+
await tick();
|
|
2036
|
+
const body = bodyOf(view, 220);
|
|
2037
|
+
assert.equal(body.includes('l labels'), shown, `labels hint for ${JSON.stringify(permissions)}`);
|
|
2038
|
+
assert.equal(body.includes('a assign'), shown, `assignees hint for ${JSON.stringify(permissions)}`);
|
|
2039
|
+
}
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
test('l and a neither enter an editor nor load or write without permission', async () => {
|
|
2043
|
+
for (const permissions of [undefined, {}, { canEditLabels: false, canEditAssignees: false }]) {
|
|
2044
|
+
const calls = [];
|
|
2045
|
+
const rows = [issue(1)];
|
|
2046
|
+
const { view } = makeView({
|
|
2047
|
+
pages: pagesOf([rows, [], []]),
|
|
2048
|
+
issues: rows,
|
|
2049
|
+
labels: [{ name: 'bug' }],
|
|
2050
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions }), comments: [] }),
|
|
2051
|
+
loadAssignees: () => { calls.push('loadAssignees'); return users(); },
|
|
2052
|
+
actions: {
|
|
2053
|
+
editLabels: () => { calls.push('editLabels'); return { ok: true }; },
|
|
2054
|
+
editAssignees: () => { calls.push('editAssignees'); return { ok: true }; },
|
|
2055
|
+
},
|
|
2056
|
+
});
|
|
2057
|
+
await tick();
|
|
2058
|
+
view.handleInput('l');
|
|
2059
|
+
view.handleInput('a');
|
|
2060
|
+
await tick();
|
|
2061
|
+
assert.equal(view.state.mode, 'list', `no editor opens for ${JSON.stringify(permissions)}`);
|
|
2062
|
+
assert.deepEqual(calls, [], 'no loader and no write ran');
|
|
2063
|
+
assert.match(bodyOf(view, 124), /permission/, 'the refusal names the missing permission');
|
|
2064
|
+
}
|
|
2065
|
+
});
|
|
2066
|
+
|
|
2067
|
+
test('a opens the assignee editor preselected and saves add/remove deltas', async () => {
|
|
2068
|
+
const calls = [];
|
|
2069
|
+
const loads = [];
|
|
2070
|
+
const rows = [issue(1)];
|
|
2071
|
+
const { view } = makeView({
|
|
2072
|
+
pages: pagesOf([rows, [], []]),
|
|
2073
|
+
issues: rows,
|
|
2074
|
+
loadIssue: (n, opts) => {
|
|
2075
|
+
loads.push(opts);
|
|
2076
|
+
const assignees = loads.length === 1 ? [{ login: 'bob' }] : [{ login: 'ann' }];
|
|
2077
|
+
return { ok: true, issue: issue(n, { permissions: EDITABLE, assignees }), comments: [] };
|
|
2078
|
+
},
|
|
2079
|
+
loadAssignees: () => users(),
|
|
2080
|
+
actions: { editAssignees: (number, changes) => { calls.push([number, changes]); return { ok: true }; } },
|
|
2081
|
+
});
|
|
2082
|
+
await tick();
|
|
2083
|
+
view.handleInput('a');
|
|
2084
|
+
assert.equal(view.state.mode, 'assignees');
|
|
2085
|
+
await tick();
|
|
2086
|
+
const editor = bodyOf(view, 100);
|
|
2087
|
+
assert.match(editor, /\[x\] bob/, 'the current assignee starts selected');
|
|
2088
|
+
assert.match(editor, /\[ \] ann/, 'an unassigned user starts unselected');
|
|
2089
|
+
view.handleInput('space');
|
|
2090
|
+
view.handleInput('down');
|
|
2091
|
+
view.handleInput('space');
|
|
2092
|
+
view.handleInput('ctrl+s');
|
|
2093
|
+
await tick();
|
|
2094
|
+
assert.deepEqual(calls, [[1, { add: ['ann'], remove: ['bob'] }]], 'one delta call: add ann, remove bob');
|
|
2095
|
+
assert.equal(view.state.mode, 'list', 'success leaves the editor');
|
|
2096
|
+
assert.deepEqual(loads[1], { refresh: true }, 'the saved detail is refetched, never served from the cache');
|
|
2097
|
+
const body = bodyOf(view, 100);
|
|
2098
|
+
assert.match(body, /assignees: ann/, 'the preview shows the refetched assignees');
|
|
2099
|
+
assert.ok(!body.includes('assignees: bob'), 'the pre-save detail is gone');
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
test('the create-label row and key need canCreateLabels, not just triage', async () => {
|
|
2103
|
+
const created = [];
|
|
2104
|
+
const rows = [issue(1, { labels: [{ name: 'bug' }] })];
|
|
2105
|
+
const { view } = makeView({
|
|
2106
|
+
pages: pagesOf([rows, [], []]),
|
|
2107
|
+
issues: rows,
|
|
2108
|
+
labels: [{ name: 'bug' }],
|
|
2109
|
+
loadIssue: (n) => ({
|
|
2110
|
+
ok: true,
|
|
2111
|
+
issue: issue(n, { labels: [{ name: 'bug' }], permissions: { canEditLabels: true, canCreateLabels: false } }),
|
|
2112
|
+
comments: [],
|
|
2113
|
+
}),
|
|
2114
|
+
actions: { editLabels: (_n, add, remove, made) => { created.push([add, remove, made]); return { ok: true }; } },
|
|
2115
|
+
});
|
|
2116
|
+
await tick();
|
|
2117
|
+
view.handleInput('l');
|
|
2118
|
+
assert.equal(view.state.mode, 'labels', 'triage permission alone still opens the editor');
|
|
2119
|
+
const editor = bodyOf(view, 100);
|
|
2120
|
+
assert.ok(!editor.includes('create new'), 'no create row without canCreateLabels');
|
|
2121
|
+
view.handleInput('n');
|
|
2122
|
+
assert.equal(view.state.labelCreating, false, 'n starts no creation');
|
|
2123
|
+
view.handleInput('ctrl+s');
|
|
2124
|
+
await tick();
|
|
2125
|
+
assert.deepEqual(created, [[[], [], []]], 'no label creation was sent');
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2128
|
+
test('a rejected assignee save keeps the editor, the toggles and a visible error', async () => {
|
|
2129
|
+
const loads = [];
|
|
2130
|
+
const rows = [issue(1)];
|
|
2131
|
+
const { view } = makeView({
|
|
2132
|
+
pages: pagesOf([rows, [], []]),
|
|
2133
|
+
issues: rows,
|
|
2134
|
+
loadIssue: (n, opts) => {
|
|
2135
|
+
loads.push(opts);
|
|
2136
|
+
return { ok: true, issue: issue(n, { permissions: EDITABLE, assignees: [{ login: 'bob' }] }), comments: [] };
|
|
2137
|
+
},
|
|
2138
|
+
loadAssignees: () => users(),
|
|
2139
|
+
actions: { editAssignees: () => ({ ok: false, error: 'permission denied by GitHub' }) },
|
|
2140
|
+
});
|
|
2141
|
+
await tick();
|
|
2142
|
+
view.handleInput('a');
|
|
2143
|
+
await tick();
|
|
2144
|
+
view.handleInput('space');
|
|
2145
|
+
view.handleInput('ctrl+s');
|
|
2146
|
+
await tick();
|
|
2147
|
+
assert.equal(view.state.mode, 'assignees', 'the editor stays open');
|
|
2148
|
+
const body = bodyOf(view, 100);
|
|
2149
|
+
assert.match(body, /permission denied by GitHub/, 'the server rejection is visible');
|
|
2150
|
+
assert.match(body, /\[x\] ann/, 'the local toggle survives the rejection');
|
|
2151
|
+
assert.match(body, /\[x\] bob/, 'the stored assignee is unchanged');
|
|
2152
|
+
assert.equal(loads.length, 1, 'a failed save refetches nothing');
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
test('moving focus to an issue without permissions withdraws both hints and both keys', async () => {
|
|
2156
|
+
const calls = [];
|
|
2157
|
+
const rows = [issue(1), issue(2)];
|
|
2158
|
+
const { view } = makeView({
|
|
2159
|
+
pages: pagesOf([rows, [], []]),
|
|
2160
|
+
issues: rows,
|
|
2161
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, n === 1 ? { permissions: EDITABLE } : {}), comments: [] }),
|
|
2162
|
+
loadAssignees: () => { calls.push('loadAssignees'); return users(); },
|
|
2163
|
+
});
|
|
2164
|
+
await tick();
|
|
2165
|
+
assert.match(bodyOf(view, 220), /l labels/, 'the permitted issue shows the hint');
|
|
2166
|
+
view.handleInput('j');
|
|
2167
|
+
await tick();
|
|
2168
|
+
const body = bodyOf(view, 220);
|
|
2169
|
+
assert.ok(!body.includes('L labels'), 'the labels hint is gone');
|
|
2170
|
+
assert.ok(!body.includes('a assign'), 'the assignees hint is gone');
|
|
2171
|
+
view.handleInput('a');
|
|
2172
|
+
await tick();
|
|
2173
|
+
assert.equal(view.state.mode, 'list', 'the key cannot enter the editor');
|
|
2174
|
+
assert.deepEqual(calls, [], 'no assignee loader ran');
|
|
2175
|
+
});
|
|
2176
|
+
});
|
|
2177
|
+
|
|
2178
|
+
// «igual en issues en los atajos … si intento darle a close debe revisar si
|
|
2179
|
+
// tengo permisos primero, si no tengo alerta y no me deja ni intentar cerrarla»
|
|
2180
|
+
// and «si no tengo permiso de crear label pero si de asignar deberia poder
|
|
2181
|
+
// entrar a labels pero no deberia aparecer opcion ni keybinding para crear
|
|
2182
|
+
// label». Closing (and reopening) is the same TRIAGE-level capability the label
|
|
2183
|
+
// and assignee editors need, so the key resolves the repository permission
|
|
2184
|
+
// BEFORE it opens the confirmation and never reaches the write without it.
|
|
2185
|
+
// Reading the issue detail is that evidence's cache, so `r` re-reads it.
|
|
2186
|
+
describe('issues close/reopen permission gate', () => {
|
|
2187
|
+
const TRIAGE = { canEditLabels: true, canEditAssignees: true, canCloseIssues: true, canCreateLabels: false };
|
|
2188
|
+
const READ_ONLY = { canEditLabels: false, canEditAssignees: false, canCloseIssues: false, canCreateLabels: false };
|
|
2189
|
+
|
|
2190
|
+
test('x and X open no confirmation and write nothing without canCloseIssues', async () => {
|
|
2191
|
+
for (const permissions of [undefined, {}, READ_ONLY, { canCloseIssues: 'yes' }]) {
|
|
2192
|
+
const calls = [];
|
|
2193
|
+
const rows = [issue(1)];
|
|
2194
|
+
const { view } = makeView({
|
|
2195
|
+
pages: pagesOf([rows, [], []]),
|
|
2196
|
+
issues: rows,
|
|
2197
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions }), comments: [] }),
|
|
2198
|
+
actions: { setState: (n, state) => { calls.push([n, state]); return { ok: true }; } },
|
|
2199
|
+
});
|
|
2200
|
+
await tick();
|
|
2201
|
+
view.handleInput('x');
|
|
2202
|
+
assert.equal(view.state.mode, 'list', `x opens no confirmation for ${JSON.stringify(permissions)}`);
|
|
2203
|
+
view.handleInput('X');
|
|
2204
|
+
assert.equal(view.state.mode, 'list', `X opens no confirmation for ${JSON.stringify(permissions)}`);
|
|
2205
|
+
assert.deepEqual(calls, [], 'no state write was attempted');
|
|
2206
|
+
const body = bodyOf(view, 200);
|
|
2207
|
+
assert.ok(!body.includes('x close'), 'the footer names no close key without the permission');
|
|
2208
|
+
assert.match(bodyOf(view, 124), /permission/, 'the refusal names the missing permission');
|
|
2209
|
+
}
|
|
2210
|
+
});
|
|
2211
|
+
|
|
2212
|
+
test('with TRIAGE the close key is offered, confirms and then writes', async () => {
|
|
2213
|
+
const calls = [];
|
|
2214
|
+
const rows = [issue(1)];
|
|
2215
|
+
const { view } = makeView({
|
|
2216
|
+
pages: pagesOf([rows, [], []]),
|
|
2217
|
+
issues: rows,
|
|
2218
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions: TRIAGE }), comments: [] }),
|
|
2219
|
+
actions: { setState: (n, state) => { calls.push([n, state]); return { ok: true }; } },
|
|
2220
|
+
});
|
|
2221
|
+
await tick();
|
|
2222
|
+
assert.match(bodyOf(view, 200), /x close/, 'the triage viewer is offered the close key');
|
|
2223
|
+
view.handleInput('x');
|
|
2224
|
+
assert.equal(view.state.mode, 'confirm', 'the confirmation still asks first');
|
|
2225
|
+
view.handleInput('enter');
|
|
2226
|
+
await tick();
|
|
2227
|
+
assert.deepEqual(calls, [[1, 'closed']], 'the close reaches the injected action');
|
|
2228
|
+
});
|
|
2229
|
+
|
|
2230
|
+
test('with TRIAGE the labels editor opens and names no create keybinding', async () => {
|
|
2231
|
+
const created = [];
|
|
2232
|
+
const rows = [issue(1, { labels: [{ name: 'bug' }] })];
|
|
2233
|
+
const { view } = makeView({
|
|
2234
|
+
pages: pagesOf([rows, [], []]),
|
|
2235
|
+
issues: rows,
|
|
2236
|
+
labels: [{ name: 'bug' }, { name: 'docs' }],
|
|
2237
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { labels: [{ name: 'bug' }], permissions: TRIAGE }), comments: [] }),
|
|
2238
|
+
actions: { editLabels: (_n, add, remove, made) => { created.push([add, remove, made]); return { ok: true }; } },
|
|
2239
|
+
});
|
|
2240
|
+
await tick();
|
|
2241
|
+
view.handleInput('l');
|
|
2242
|
+
assert.equal(view.state.mode, 'labels', 'triage permission alone still opens the editor');
|
|
2243
|
+
assert.match(bodyOf(view, 120), /Labels \(space toggle/, 'the existing labels are listed');
|
|
2244
|
+
const editor = bodyOf(view, 120);
|
|
2245
|
+
assert.ok(!editor.includes('n new'), 'the editor names no create keybinding');
|
|
2246
|
+
assert.ok(!editor.includes('create new'), 'no create row is listed');
|
|
2247
|
+
view.handleInput('n');
|
|
2248
|
+
assert.equal(view.state.labelCreating, false, 'n starts no creation');
|
|
2249
|
+
view.handleInput('ctrl+s');
|
|
2250
|
+
await tick();
|
|
2251
|
+
assert.deepEqual(created, [[[], [], []]], 'no label creation was sent');
|
|
2252
|
+
});
|
|
2253
|
+
|
|
2254
|
+
test('with WRITE the same editor offers the create row and the n keybinding', async () => {
|
|
2255
|
+
const rows = [issue(1, { labels: [{ name: 'bug' }] })];
|
|
2256
|
+
const { view } = makeView({
|
|
2257
|
+
pages: pagesOf([rows, [], []]),
|
|
2258
|
+
issues: rows,
|
|
2259
|
+
labels: [{ name: 'bug' }],
|
|
2260
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { labels: [{ name: 'bug' }], permissions: EDITABLE }), comments: [] }),
|
|
2261
|
+
actions: { editLabels: () => ({ ok: true }) },
|
|
2262
|
+
});
|
|
2263
|
+
await tick();
|
|
2264
|
+
view.handleInput('l');
|
|
2265
|
+
assert.equal(view.state.mode, 'labels');
|
|
2266
|
+
const editor = bodyOf(view, 120);
|
|
2267
|
+
assert.match(editor, /n new/, 'the WRITE viewer is told how to create one');
|
|
2268
|
+
assert.match(editor, /create new/, 'the create row is listed');
|
|
2269
|
+
view.handleInput('n');
|
|
2270
|
+
assert.equal(view.state.labelCreating, true, 'n starts the creation');
|
|
2271
|
+
});
|
|
2272
|
+
|
|
2273
|
+
test('r re-reads the issue detail, so a granted permission reaches the keys', async () => {
|
|
2274
|
+
let permissions = READ_ONLY;
|
|
2275
|
+
const loads = [];
|
|
2276
|
+
const rows = [issue(1)];
|
|
2277
|
+
const { view } = makeView({
|
|
2278
|
+
pages: pagesOf([rows, [], []]),
|
|
2279
|
+
issues: rows,
|
|
2280
|
+
loadIssue: (n, opts) => {
|
|
2281
|
+
loads.push(opts || null);
|
|
2282
|
+
return { ok: true, issue: issue(n, { permissions }), comments: [] };
|
|
2283
|
+
},
|
|
2284
|
+
actions: { setState: () => ({ ok: true }) },
|
|
2285
|
+
});
|
|
2286
|
+
await tick();
|
|
2287
|
+
assert.ok(!bodyOf(view, 200).includes('x close'), 'the read-only viewer has no close key');
|
|
2288
|
+
view.handleInput('x');
|
|
2289
|
+
assert.equal(view.state.mode, 'list');
|
|
2290
|
+
permissions = TRIAGE;
|
|
2291
|
+
view.handleInput('r');
|
|
2292
|
+
await tick();
|
|
2293
|
+
assert.deepEqual(loads[1], { refresh: true }, 'the refresh re-reads the detail that carries the permission');
|
|
2294
|
+
assert.match(bodyOf(view, 200), /x close/, 'the refreshed permission reaches the footer');
|
|
2295
|
+
view.handleInput('x');
|
|
2296
|
+
assert.equal(view.state.mode, 'confirm', 'and the key opens the confirmation');
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2299
|
+
test('commenting stays ungated: c opens and posts on a read-only repository', async () => {
|
|
2300
|
+
const posts = [];
|
|
2301
|
+
const rows = [issue(1)];
|
|
2302
|
+
const { view } = makeView({
|
|
2303
|
+
pages: pagesOf([rows, [], []]),
|
|
2304
|
+
issues: rows,
|
|
2305
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions: READ_ONLY }), comments: [] }),
|
|
2306
|
+
actions: { postComment: (n, text) => { posts.push([n, text]); return { ok: true }; } },
|
|
2307
|
+
});
|
|
2308
|
+
await tick();
|
|
2309
|
+
assert.match(bodyOf(view, 200), /c comment/, 'the comment key needs no repository capability');
|
|
2310
|
+
view.handleInput('c');
|
|
2311
|
+
assert.equal(view.state.mode, 'comment');
|
|
2312
|
+
for (const ch of 'hi') view.handleInput(ch);
|
|
2313
|
+
view.handleInput('ctrl+s');
|
|
2314
|
+
await tick();
|
|
2315
|
+
assert.deepEqual(posts, [[1, 'hi']], 'the comment reaches the injected action');
|
|
2316
|
+
});
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
// S3/S5/D1/D3: the widened wrapping left list and the server-page seam.
|
|
2320
|
+
describe('issues left list width, wrapping and fresh pages (S3/S5/D1/D3)', () => {
|
|
2321
|
+
// One issue carrying its server cursor, as D1 delivers it.
|
|
2322
|
+
function cursorIssue(cursor, row) {
|
|
2323
|
+
return { ...row, cursor };
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function page(issues, extra = {}) {
|
|
2327
|
+
return { ok: true, issues, total: issues.length, hasNextPage: false, ...extra };
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
test('the left pane takes the smaller 30% list share at 40, 80, 120 and 200 columns', async () => {
|
|
2331
|
+
for (const width of [40, 80, 120, 200]) {
|
|
2332
|
+
const { view } = makeView({
|
|
2333
|
+
rows: 24,
|
|
2334
|
+
loadIssuePage: () => page([cursorIssue('c1', issue(1, { title: 'short title' }))]),
|
|
2335
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
2336
|
+
});
|
|
2337
|
+
await tick();
|
|
2338
|
+
const expected = readerPanes(width).left;
|
|
2339
|
+
const bodyRow = strip(view.render(width)[5]);
|
|
2340
|
+
const chars = [...bodyRow];
|
|
2341
|
+
assert.equal(chars.indexOf('│', 2), 3 + expected, `${width}: the left pane is ${expected} cells`);
|
|
2342
|
+
// S3: the reader list is the smaller share; the READING pane owns the rest.
|
|
2343
|
+
const reading = readerPanes(width).right;
|
|
2344
|
+
assert.ok(reading > expected, `${width}: the preview is the larger pane (${expected}/${reading})`);
|
|
2345
|
+
if (width >= 100) assert.ok(expected < layout.paneWidths(layout.innerWidth(width)).left, `${width}: smaller than the worktree detail 40% list`);
|
|
2346
|
+
}
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
test('the left list shows the full wrapped title and never the issue number', async () => {
|
|
2350
|
+
const title = 'wrapped title that keeps every single word of the issue visible';
|
|
2351
|
+
const { view } = makeView({
|
|
2352
|
+
rows: 30,
|
|
2353
|
+
loadIssuePage: () => page([cursorIssue('c1', issue(7, { title }))]),
|
|
2354
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { title }), comments: [] }),
|
|
2355
|
+
});
|
|
2356
|
+
await tick();
|
|
2357
|
+
const left = leftColumn(view, 80, 30);
|
|
2358
|
+
assert.ok(!left.includes('#7'), 'the issue number is not in the left list');
|
|
2359
|
+
assert.equal(left.replace(/\s+/g, ' ').includes(title), true, 'the whole title is readable');
|
|
2360
|
+
const rows = left.split('\n').filter((row) => row.trim() !== '');
|
|
2361
|
+
assert.ok(rows.length > 1, 'a title too long for one row wraps onto the next');
|
|
2362
|
+
assert.ok(rows.slice(0, rows.length - 1).length >= 2, 'the wrap uses as many rows as it needs');
|
|
2363
|
+
});
|
|
2364
|
+
|
|
2365
|
+
test('the request limit comes from the viewport body height, never from a fixed 20', async () => {
|
|
2366
|
+
for (const [rows, expected] of [[12, 6], [14, 8], [30, 24], [40, 34]]) {
|
|
2367
|
+
const calls = [];
|
|
2368
|
+
const { view } = makeView({
|
|
2369
|
+
rows,
|
|
2370
|
+
loadIssuePage: (request) => {
|
|
2371
|
+
calls.push(request);
|
|
2372
|
+
return page([cursorIssue('c1', issue(1))]);
|
|
2373
|
+
},
|
|
2374
|
+
});
|
|
2375
|
+
await tick();
|
|
2376
|
+
assert.equal(calls.length, 1, `${rows}: one mount read`);
|
|
2377
|
+
assert.equal(calls[0].limit, expected, `${rows}: limit is the body-row upper bound`);
|
|
2378
|
+
assert.equal(calls[0].cursor, null, `${rows}: the first page starts at the head`);
|
|
2379
|
+
assert.equal(calls[0].bucketId, 'mine', `${rows}: the request names its bucket`);
|
|
2380
|
+
assert.ok(view);
|
|
2381
|
+
}
|
|
2382
|
+
});
|
|
2383
|
+
|
|
2384
|
+
test('the appended read continues after the LAST LOADED row and keeps the body budget', async () => {
|
|
2385
|
+
const calls = [];
|
|
2386
|
+
const served = [
|
|
2387
|
+
cursorIssue('ca', issue(1, { title: 'alpha beta gamma delta epsilon zeta eta theta' })),
|
|
2388
|
+
cursorIssue('cb', issue(2, { title: 'one two three four five six seven eight nine ten' })),
|
|
2389
|
+
];
|
|
2390
|
+
const { view } = makeView({
|
|
2391
|
+
rows: 12,
|
|
2392
|
+
loadIssuePage: (request) => {
|
|
2393
|
+
calls.push(request);
|
|
2394
|
+
return request.cursor === null
|
|
2395
|
+
? page(served, { hasNextPage: true })
|
|
2396
|
+
: page([cursorIssue('cc', issue(3))]);
|
|
2397
|
+
},
|
|
2398
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
2399
|
+
});
|
|
2400
|
+
await tick();
|
|
2401
|
+
const left = leftColumn(view, 40, 12);
|
|
2402
|
+
assert.match(left, /alpha/, 'the first row is painted');
|
|
2403
|
+
view.handleInput(']');
|
|
2404
|
+
assert.equal(calls.length, 2, 'scrolling to the loaded bottom asked for the next page');
|
|
2405
|
+
assert.equal(calls[1].cursor, 'cb', 'the read continues after the last LOADED row');
|
|
2406
|
+
assert.equal(calls[1].limit, 6, 'the limit follows the body budget again');
|
|
2407
|
+
assert.equal(calls[1].query, '', 'and the active query');
|
|
2408
|
+
});
|
|
2409
|
+
|
|
2410
|
+
test('r re-reads the list from the head and tab switches the bucket', async () => {
|
|
2411
|
+
const calls = [];
|
|
2412
|
+
const { view } = makeView({
|
|
2413
|
+
rows: 12,
|
|
2414
|
+
loadIssuePage: (request) => {
|
|
2415
|
+
calls.push(request);
|
|
2416
|
+
const issues = request.cursor === null
|
|
2417
|
+
? [cursorIssue('ca', issue(1)), cursorIssue('cb', issue(2))]
|
|
2418
|
+
: [cursorIssue('cc', issue(3))];
|
|
2419
|
+
return page(issues, { hasNextPage: request.cursor === null });
|
|
2420
|
+
},
|
|
2421
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
2422
|
+
});
|
|
2423
|
+
await tick();
|
|
2424
|
+
leftColumn(view, 60, 12);
|
|
2425
|
+
view.handleInput(']');
|
|
2426
|
+
assert.equal(calls.length, 2, 'scrolling reads the next page');
|
|
2427
|
+
await tick();
|
|
2428
|
+
const appended = leftColumn(view, 60, 12);
|
|
2429
|
+
assert.match(appended, /issue title 3/, 'the appended row is in the one list');
|
|
2430
|
+
view.handleInput('[');
|
|
2431
|
+
assert.equal(calls.length, 2, 'scrolling back is a window move, never a request');
|
|
2432
|
+
view.handleInput('r');
|
|
2433
|
+
assert.equal(calls.length, 3, 'r re-reads the list');
|
|
2434
|
+
assert.equal(calls[2].cursor, null, 'r re-reads from the head of the current query');
|
|
2435
|
+
assert.equal(calls[2].bucketId, 'mine');
|
|
2436
|
+
view.handleInput('tab');
|
|
2437
|
+
assert.equal(calls.length, 4, 'the new bucket is read on selection');
|
|
2438
|
+
assert.equal(calls[3].bucketId, 'assigned', 'tab moves to the next bucket');
|
|
2439
|
+
});
|
|
2440
|
+
|
|
2441
|
+
test('r refreshes the focused preview even when the page answers with the same issues', async () => {
|
|
2442
|
+
const served = [cursorIssue('ca', issue(1)), cursorIssue('cb', issue(2))];
|
|
2443
|
+
let comments = [];
|
|
2444
|
+
let loads = 0;
|
|
2445
|
+
const { view } = makeView({
|
|
2446
|
+
rows: 20,
|
|
2447
|
+
loadIssuePage: () => page(served, { hasNextPage: false }),
|
|
2448
|
+
loadIssue: (n) => {
|
|
2449
|
+
loads += 1;
|
|
2450
|
+
return { ok: true, issue: issue(n), comments: comments.slice() };
|
|
2451
|
+
},
|
|
2452
|
+
});
|
|
2453
|
+
await tick();
|
|
2454
|
+
leftColumn(view, 60, 20);
|
|
2455
|
+
view.handleInput('j');
|
|
2456
|
+
assert.equal(view.state.index, 1, 'the second issue is focused');
|
|
2457
|
+
comments = [{ author: 'ann', createdAt: new Date().toISOString(), body: 'fresh comment' }];
|
|
2458
|
+
const before = loads;
|
|
2459
|
+
view.handleInput('r');
|
|
2460
|
+
await tick();
|
|
2461
|
+
assert.ok(loads > before, 'the focused detail is refetched');
|
|
2462
|
+
const body = bodyOf(view, 60);
|
|
2463
|
+
assert.match(body, /#2 /, 'the reader stays on the same issue');
|
|
2464
|
+
assert.match(body, /fresh comment/, 'the refreshed preview shows the new comment');
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
test('a resize keeps the loaded rows, and a stale answer never paints over a newer read', async () => {
|
|
2468
|
+
const pending = [];
|
|
2469
|
+
const { view } = makeView({
|
|
2470
|
+
rows: 30,
|
|
2471
|
+
loadIssuePage: (request) => {
|
|
2472
|
+
const entry = deferred();
|
|
2473
|
+
pending.push({ request, entry });
|
|
2474
|
+
return entry.promise;
|
|
2475
|
+
},
|
|
2476
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
2477
|
+
});
|
|
2478
|
+
pending[0].entry.resolve(page([cursorIssue('c1', issue(1)), cursorIssue('c2', issue(2))], { hasNextPage: true }));
|
|
2479
|
+
await tick();
|
|
2480
|
+
bodyOf(view, 120);
|
|
2481
|
+
view.handleInput(']');
|
|
2482
|
+
assert.equal(pending[1].request.cursor, 'c2', 'the appended read continues after the loaded rows');
|
|
2483
|
+
pending[1].entry.resolve(page([cursorIssue('c3', issue(3))], { hasNextPage: true }));
|
|
2484
|
+
await tick();
|
|
2485
|
+
bodyOf(view, 120);
|
|
2486
|
+
// S3: a resize changes the window geometry only — the rows are the data and
|
|
2487
|
+
// stay exactly as loaded.
|
|
2488
|
+
bodyOf(view, 60);
|
|
2489
|
+
assert.equal(pending.length, 2, 'the resize reads nothing again');
|
|
2490
|
+
const resized = leftColumn(view, 60, 30);
|
|
2491
|
+
assert.match(resized, /issue title 1/, 'the loaded rows stay painted');
|
|
2492
|
+
assert.match(resized, /issue title 3/);
|
|
2493
|
+
view.handleInput('r');
|
|
2494
|
+
assert.equal(pending.length, 3, 'r asks again');
|
|
2495
|
+
pending[2].entry.resolve(page([cursorIssue('c4', issue(9, { title: 'newest answer' }))]));
|
|
2496
|
+
await tick();
|
|
2497
|
+
pending[1].entry.resolve(page([cursorIssue('c0', issue(8, { title: 'stale answer' }))], { hasNextPage: true }));
|
|
2498
|
+
await tick();
|
|
2499
|
+
const left = leftColumn(view, 60, 30);
|
|
2500
|
+
assert.match(left, /newest answer/, 'the newest request paints');
|
|
2501
|
+
assert.ok(!left.includes('stale answer'), 'an earlier answer never overwrites it');
|
|
2502
|
+
});
|
|
2503
|
+
|
|
2504
|
+
test('an answer for an inactive bucket paints into that bucket, never into the visible one', async () => {
|
|
2505
|
+
const pending = [];
|
|
2506
|
+
const { view } = makeView({
|
|
2507
|
+
rows: 30,
|
|
2508
|
+
loadIssuePage: (request) => {
|
|
2509
|
+
const entry = deferred();
|
|
2510
|
+
pending.push({ request, entry });
|
|
2511
|
+
return entry.promise;
|
|
2512
|
+
},
|
|
2513
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
2514
|
+
});
|
|
2515
|
+
// The first bucket is read, then the reader moves to the second one and
|
|
2516
|
+
// scrolls: that append is still in flight when the reader moves on.
|
|
2517
|
+
pending[0].entry.resolve(page([cursorIssue('a1', issue(1))], { hasNextPage: true }));
|
|
2518
|
+
await tick();
|
|
2519
|
+
bodyOf(view, 120);
|
|
2520
|
+
view.handleInput('tab');
|
|
2521
|
+
pending[1].entry.resolve(page([cursorIssue('b1', issue(2))], { hasNextPage: true }));
|
|
2522
|
+
await tick();
|
|
2523
|
+
bodyOf(view, 120);
|
|
2524
|
+
view.handleInput(']');
|
|
2525
|
+
assert.equal(pending[2].request.cursor, 'b1', 'the second bucket has an append in flight');
|
|
2526
|
+
view.handleInput('tab');
|
|
2527
|
+
pending[3].entry.resolve(page([cursorIssue('c1', issue(3))]));
|
|
2528
|
+
await tick();
|
|
2529
|
+
bodyOf(view, 120);
|
|
2530
|
+
pending[2].entry.resolve(page([cursorIssue('b2', issue(9, { title: 'appended while away' }))]));
|
|
2531
|
+
await tick();
|
|
2532
|
+
// S3: an answer belongs to ITS bucket. The visible bucket is untouched…
|
|
2533
|
+
assert.ok(!leftColumn(view, 120, 30).includes('appended while away'), 'the visible bucket is never overwritten');
|
|
2534
|
+
// …and shift+tab walks back to Assigned without reading it again.
|
|
2535
|
+
view.handleInput('shift+tab');
|
|
2536
|
+
const left = leftColumn(view, 120, 30);
|
|
2537
|
+
assert.match(left, /issue title 2/, 'the bucket keeps the rows it read');
|
|
2538
|
+
assert.match(left, /appended while away/, 'its appended page landed in its own list');
|
|
2539
|
+
assert.equal(pending.length, 4, 'the return costs no extra read');
|
|
2540
|
+
});
|
|
2541
|
+
|
|
2542
|
+
test('a failed page load is shown truthfully while the view stays mounted', async () => {
|
|
2543
|
+
const { view } = makeView({
|
|
2544
|
+
loadIssuePage: () => ({ ok: false, error: 'gh issue list failed: HTTP 403: rate limited' }),
|
|
2545
|
+
});
|
|
2546
|
+
await tick();
|
|
2547
|
+
const body = bodyOf(view, 80);
|
|
2548
|
+
assert.match(body, /failed to load issues: gh issue list failed: HTTP 403: rate limited/);
|
|
2549
|
+
assert.ok(!body.includes('issue title'), 'no rows are invented');
|
|
2550
|
+
assert.match(bodyOf(view, 160), /esc close/, 'the footer still names the close key');
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
test('static pages stay supported with no loader at all', async () => {
|
|
2554
|
+
const { view } = makeView({
|
|
2555
|
+
pages: pagesOf([[issue(1, { title: 'static row' })], [], []]),
|
|
2556
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { title: 'static row' }), comments: [] }),
|
|
2557
|
+
});
|
|
2558
|
+
await tick();
|
|
2559
|
+
assert.match(leftColumn(view, 100), /static row/, 'the eager pages still render');
|
|
2560
|
+
assert.ok(!/page/i.test(bodyOf(view, 100)), 'no page marker is printed (S3)');
|
|
2561
|
+
assert.ok(!leftColumn(view, 100).includes('#1'), 'the eager path drops the number too');
|
|
2562
|
+
});
|
|
2563
|
+
});
|
|
2564
|
+
|
|
2565
|
+
// S9/S13/S15: the fourth All bucket, a tab switch that reuses what it already
|
|
2566
|
+
// loaded, and search over the COMPLETE bucket through the server page seam.
|
|
2567
|
+
// Every request is a public input: the loader receives
|
|
2568
|
+
// {bucketId, cursor, limit, query}.
|
|
2569
|
+
describe('issues view All tab, tab cache and server search (S9/S13/S15)', () => {
|
|
2570
|
+
test('the fourth tab All lists every open issue of the repository with no dedup', async () => {
|
|
2571
|
+
const requests = [];
|
|
2572
|
+
const { view } = makeView({
|
|
2573
|
+
rows: 24,
|
|
2574
|
+
loadIssuePage: (request) => {
|
|
2575
|
+
requests.push(request);
|
|
2576
|
+
if (request.bucketId === 'all') {
|
|
2577
|
+
return pageResult([cursorRow('a1', issue(76, { title: 'belt and braces' })), cursorRow('a2', issue(77))], { total: 2 });
|
|
2578
|
+
}
|
|
2579
|
+
return pageResult([cursorRow('m1', issue(76, { title: 'belt and braces' }))], { total: 1 });
|
|
2580
|
+
},
|
|
2581
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
2582
|
+
});
|
|
2583
|
+
await tick();
|
|
2584
|
+
const tabs = strip(view.render(100)[2]);
|
|
2585
|
+
assert.match(tabs, /▸ My issues \(1\)/, 'the default bucket is still first');
|
|
2586
|
+
assert.match(tabs, /Assigned \(–\)/);
|
|
2587
|
+
assert.match(tabs, /Involved \(–\)/);
|
|
2588
|
+
assert.match(tabs, /All \(–\)/, 'an unread tab is unknown, never a false zero');
|
|
2589
|
+
assert.equal(requests.length, 1, 'only the default bucket was read');
|
|
2590
|
+
assert.equal(requests[0].bucketId, 'mine');
|
|
2591
|
+
view.handleInput('tab');
|
|
2592
|
+
view.handleInput('tab');
|
|
2593
|
+
view.handleInput('tab');
|
|
2594
|
+
await tick();
|
|
2595
|
+
assert.equal(view.state.page, 3, 'All is the fourth and last tab');
|
|
2596
|
+
assert.equal(requests[requests.length - 1].bucketId, 'all', 'All reads the whole open repository');
|
|
2597
|
+
assert.match(strip(view.render(100)[2]), /All \(2\)/, 'the All tab counts the whole open repository');
|
|
2598
|
+
const left = leftColumn(view, 100);
|
|
2599
|
+
assert.match(left, /belt and braces/, 'an issue that also lists under My issues lists here too');
|
|
2600
|
+
assert.match(left, /issue title 77/, 'the other open issue is listed');
|
|
2601
|
+
assert.equal(left.split('belt and braces').length - 1, 1, 'the list holds no duplicate row');
|
|
2602
|
+
});
|
|
2603
|
+
|
|
2604
|
+
test('returning to a visited bucket reuses its rows and window with no request and no loading flash', async () => {
|
|
2605
|
+
const requests = [];
|
|
2606
|
+
const { view } = makeView({
|
|
2607
|
+
rows: 24,
|
|
2608
|
+
loadIssuePage: (request) => {
|
|
2609
|
+
requests.push(request);
|
|
2610
|
+
return pageResult([cursorRow('p1', issue(1)), cursorRow('p2', issue(2))], { hasNextPage: true });
|
|
2611
|
+
},
|
|
2612
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
2613
|
+
});
|
|
2614
|
+
await tick();
|
|
2615
|
+
assert.equal(requests.length, 1, 'the mount read the first bucket once');
|
|
2616
|
+
leftColumn(view, 100);
|
|
2617
|
+
view.handleInput(']');
|
|
2618
|
+
await tick();
|
|
2619
|
+
assert.equal(requests.length, 2, 'scrolling reads the next page');
|
|
2620
|
+
const beforeSwitch = view.state.listStart;
|
|
2621
|
+
view.handleInput('tab');
|
|
2622
|
+
await tick();
|
|
2623
|
+
assert.equal(requests.length, 3, 'a bucket visited for the first time is read');
|
|
2624
|
+
view.handleInput('shift+tab');
|
|
2625
|
+
await tick();
|
|
2626
|
+
assert.equal(requests.length, 3, 'returning to the visited bucket reads nothing');
|
|
2627
|
+
assert.equal(view.state.loading, false, 'the switch never enters the loading state');
|
|
2628
|
+
assert.equal(view.state.listStart, beforeSwitch, 'the bucket keeps the window the reader left it on');
|
|
2629
|
+
const rendered = strip(view.render(100).join('\n'));
|
|
2630
|
+
assert.match(rendered, /issue title 2/, 'the loaded rows paint again');
|
|
2631
|
+
assert.ok(!rendered.includes('loading'), 'no loading line and no loading flash on the reused frame');
|
|
2632
|
+
});
|
|
2633
|
+
|
|
2634
|
+
test('every page request names its bucket, row budget and exact query, and a new query starts at the head', async () => {
|
|
2635
|
+
const clock = manualScheduler();
|
|
2636
|
+
const requests = [];
|
|
2637
|
+
const { view } = makeView({
|
|
2638
|
+
rows: 24,
|
|
2639
|
+
scheduleQuery: clock.schedule,
|
|
2640
|
+
cancelSchedule: clock.cancel,
|
|
2641
|
+
loadIssuePage: (request) => {
|
|
2642
|
+
requests.push(request);
|
|
2643
|
+
const head = request.cursor === null;
|
|
2644
|
+
return pageResult([cursorRow(head ? 'q1' : 'q2', issue(head ? 1 : 2))], { hasNextPage: true, total: 2 });
|
|
2645
|
+
},
|
|
2646
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
2647
|
+
});
|
|
2648
|
+
await tick();
|
|
2649
|
+
assert.deepEqual(
|
|
2650
|
+
requests[0],
|
|
2651
|
+
{ bucketId: 'mine', cursor: null, limit: 18, query: '' },
|
|
2652
|
+
'the mount read names its bucket, its body-row budget and the empty query',
|
|
2653
|
+
);
|
|
2654
|
+
view.handleInput('T');
|
|
2655
|
+
for (const ch of 'label:bug') view.handleInput(ch);
|
|
2656
|
+
assert.equal(requests.length, 1, 'typing only schedules the read');
|
|
2657
|
+
clock.fire();
|
|
2658
|
+
await tick();
|
|
2659
|
+
assert.equal(requests.length, 2, 'the settled burst is ONE request');
|
|
2660
|
+
assert.deepEqual(
|
|
2661
|
+
requests[1],
|
|
2662
|
+
{ bucketId: 'mine', cursor: null, limit: 18, query: 'label:bug' },
|
|
2663
|
+
'the read carries the whole query, at the head of its own result set',
|
|
2664
|
+
);
|
|
2665
|
+
view.handleInput('enter');
|
|
2666
|
+
leftColumn(view, 100);
|
|
2667
|
+
view.handleInput(']');
|
|
2668
|
+
await tick();
|
|
2669
|
+
assert.deepEqual(
|
|
2670
|
+
requests[2],
|
|
2671
|
+
{ bucketId: 'mine', cursor: 'q1', limit: 18, query: 'label:bug' },
|
|
2672
|
+
'the appended read continues after the last loaded row of the same query',
|
|
2673
|
+
);
|
|
2674
|
+
view.handleInput('r');
|
|
2675
|
+
await tick();
|
|
2676
|
+
assert.deepEqual(
|
|
2677
|
+
requests[3],
|
|
2678
|
+
{ bucketId: 'mine', cursor: null, limit: 18, query: 'label:bug' },
|
|
2679
|
+
'r re-reads the list from the head of the current query',
|
|
2680
|
+
);
|
|
2681
|
+
assert.equal(view.state.index, 0, 'the refreshed list starts the reader at the top');
|
|
2682
|
+
});
|
|
2683
|
+
|
|
2684
|
+
test('a stale query answer never replaces the newer query state', async () => {
|
|
2685
|
+
const clock = manualScheduler();
|
|
2686
|
+
const pending = [];
|
|
2687
|
+
const { view } = makeView({
|
|
2688
|
+
rows: 24,
|
|
2689
|
+
scheduleQuery: clock.schedule,
|
|
2690
|
+
cancelSchedule: clock.cancel,
|
|
2691
|
+
loadIssuePage: (request) => {
|
|
2692
|
+
const entry = deferred();
|
|
2693
|
+
pending.push({ request, entry });
|
|
2694
|
+
return entry.promise;
|
|
2695
|
+
},
|
|
2696
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
2697
|
+
});
|
|
2698
|
+
pending[0].entry.resolve(pageResult([cursorRow('m1', issue(1))], { total: 1 }));
|
|
2699
|
+
await tick();
|
|
2700
|
+
view.handleInput('T');
|
|
2701
|
+
view.handleInput('a');
|
|
2702
|
+
clock.fire();
|
|
2703
|
+
assert.deepEqual(pending.slice(1).map((entry) => entry.request.query), ['a'], 'the settled query is read once');
|
|
2704
|
+
view.handleInput('b');
|
|
2705
|
+
clock.fire();
|
|
2706
|
+
assert.deepEqual(pending.slice(1).map((entry) => entry.request.query), ['a', 'ab'], 'the next settled query reads its own');
|
|
2707
|
+
pending[2].entry.resolve(pageResult([cursorRow('n1', issue(2, { title: 'ab match' }))], { total: 1 }));
|
|
2708
|
+
await tick();
|
|
2709
|
+
pending[1].entry.resolve(pageResult([cursorRow('o1', issue(3, { title: 'a only match' }))], { total: 1 }));
|
|
2710
|
+
await tick();
|
|
2711
|
+
const left = leftColumn(view, 100);
|
|
2712
|
+
assert.match(left, /ab match/, 'the newest query answer paints');
|
|
2713
|
+
assert.ok(!left.includes('a only match'), 'the answer for the older query never paints');
|
|
2714
|
+
assert.equal(view.state.loading, false);
|
|
2715
|
+
});
|
|
2716
|
+
|
|
2717
|
+
test('server answers are never re-filtered as literal text for GitHub operators', async () => {
|
|
2718
|
+
const clock = manualScheduler();
|
|
2719
|
+
const { view } = makeView({
|
|
2720
|
+
rows: 24,
|
|
2721
|
+
scheduleQuery: clock.schedule,
|
|
2722
|
+
cancelSchedule: clock.cancel,
|
|
2723
|
+
loadIssuePage: () => pageResult([cursorRow('x1', issue(5, { title: 'crash on logout' }))], { total: 1 }),
|
|
2724
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
2725
|
+
});
|
|
2726
|
+
await tick();
|
|
2727
|
+
view.handleInput('T');
|
|
2728
|
+
for (const ch of 'label:bug') view.handleInput(ch);
|
|
2729
|
+
clock.fire();
|
|
2730
|
+
await tick();
|
|
2731
|
+
assert.equal(view.state.query, 'label:bug');
|
|
2732
|
+
const left = leftColumn(view, 100);
|
|
2733
|
+
assert.match(
|
|
2734
|
+
left,
|
|
2735
|
+
/crash on logout/,
|
|
2736
|
+
'a row the server matched for label:bug still lists although its title holds no operator word',
|
|
2737
|
+
);
|
|
2738
|
+
});
|
|
2739
|
+
|
|
2740
|
+
test('a search result that lands on the second server page is selectable and previews its detail', async () => {
|
|
2741
|
+
const loads = [];
|
|
2742
|
+
const { view } = makeView({
|
|
2743
|
+
rows: 24,
|
|
2744
|
+
loadIssuePage: (request) => (request.cursor === null
|
|
2745
|
+
? pageResult([cursorRow('s1', issue(41, { title: 'first hit' }))], { hasNextPage: true, total: 2 })
|
|
2746
|
+
: pageResult([cursorRow('s2', issue(42, { title: 'second hit' }))], { hasNextPage: false, total: 2 })),
|
|
2747
|
+
loadIssue: (n) => {
|
|
2748
|
+
loads.push(n);
|
|
2749
|
+
return { ok: true, issue: issue(n), comments: [] };
|
|
2750
|
+
},
|
|
2751
|
+
});
|
|
2752
|
+
await tick();
|
|
2753
|
+
view.handleInput('T');
|
|
2754
|
+
for (const ch of 'is:open') view.handleInput(ch);
|
|
2755
|
+
await tick();
|
|
2756
|
+
// The box holds the keyboard, so the filter is committed before paging.
|
|
2757
|
+
view.handleInput('enter');
|
|
2758
|
+
await tick();
|
|
2759
|
+
leftColumn(view, 100);
|
|
2760
|
+
view.handleInput(']');
|
|
2761
|
+
await tick();
|
|
2762
|
+
const left = leftColumn(view, 100);
|
|
2763
|
+
assert.match(left, /first hit/, 'the page already read stays in the list');
|
|
2764
|
+
assert.match(left, /second hit/, 'the appended page of the search answer is listed below it');
|
|
2765
|
+
assert.equal((left.match(/first hit/g) || []).length, 1, 'the first page is never repeated');
|
|
2766
|
+
// The appended rows land below the reader, so moving onto one previews it.
|
|
2767
|
+
view.handleInput('j');
|
|
2768
|
+
await tick();
|
|
2769
|
+
assert.ok(loads.includes(42), 'the appended row previews its own detail');
|
|
2770
|
+
assert.match(bodyOf(view, 100), /#42/, 'the focused row is the appended issue');
|
|
2771
|
+
});
|
|
2772
|
+
});
|
|
2773
|
+
|
|
2774
|
+
// S3: the pager and its position marker are gone. The search row carries the
|
|
2775
|
+
// widget alone, in every state, and the list spends its whole body budget on
|
|
2776
|
+
// issues.
|
|
2777
|
+
describe('issues search row without a page marker (S3)', () => {
|
|
2778
|
+
test('the search row carries no page marker, including for one row and while typing', async () => {
|
|
2779
|
+
const { view } = makeView({
|
|
2780
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
2781
|
+
issues: [issue(1)],
|
|
2782
|
+
rows: 24,
|
|
2783
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
2784
|
+
});
|
|
2785
|
+
await tick();
|
|
2786
|
+
for (const width of [80, 120]) {
|
|
2787
|
+
const lines = view.render(width).map(strip);
|
|
2788
|
+
assert.match(lines[1], /› Search/, 'the search widget keeps its row');
|
|
2789
|
+
assert.ok(!/page/i.test(lines[1]), `width ${width}: no page marker is printed`);
|
|
2790
|
+
assert.ok(!leftColumn(view, width).includes('page'), `width ${width}: the list spends no row on a marker`);
|
|
2791
|
+
assert.ok(!/page/i.test(lines[lines.length - 2]), 'the footer still carries keys only');
|
|
2792
|
+
}
|
|
2793
|
+
view.handleInput('T');
|
|
2794
|
+
view.handleInput('1');
|
|
2795
|
+
assert.ok(!/page/i.test(strip(view.render(80)[1])), 'no marker appears while the query is typed');
|
|
2796
|
+
view.handleInput('escape');
|
|
2797
|
+
assert.ok(!/page/i.test(strip(view.render(80)[1])), 'and none after the query is kept');
|
|
2798
|
+
});
|
|
2799
|
+
|
|
2800
|
+
test('a server frame prints no page marker and appends the next page instead', async () => {
|
|
2801
|
+
const calls = [];
|
|
2802
|
+
const { view } = makeView({
|
|
2803
|
+
rows: 24,
|
|
2804
|
+
loadIssuePage: (request) => {
|
|
2805
|
+
calls.push(request);
|
|
2806
|
+
return request.cursor === null
|
|
2807
|
+
? { ok: true, issues: [cursorRow('n1', issue(1))], total: 2, hasNextPage: true }
|
|
2808
|
+
: { ok: true, issues: [cursorRow('n2', issue(2))], total: 2, hasNextPage: false };
|
|
2809
|
+
},
|
|
2810
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
2811
|
+
});
|
|
2812
|
+
await tick();
|
|
2813
|
+
assert.ok(!/page/i.test(strip(view.render(100)[1])), 'a confirmed next page prints no marker');
|
|
2814
|
+
assert.equal(calls.length, 1, 'opening reads one page only');
|
|
2815
|
+
leftColumn(view, 100);
|
|
2816
|
+
view.handleInput(']');
|
|
2817
|
+
await tick();
|
|
2818
|
+
assert.equal(calls.length, 2, 'scrolling to the loaded bottom reads the next page');
|
|
2819
|
+
assert.ok(!/page/i.test(strip(view.render(100)[1])), 'still no marker');
|
|
2820
|
+
const left = leftColumn(view, 100);
|
|
2821
|
+
assert.match(left, /issue title 1/, 'the read page stays above');
|
|
2822
|
+
assert.match(left, /issue title 2/, 'the appended page extends the one list');
|
|
2823
|
+
});
|
|
2824
|
+
});
|
|
2825
|
+
|
|
2826
|
+
// S10/S11: a write owns the view from its key until its answer, and the comment
|
|
2827
|
+
// caret marks the real insertion point. Every action is a deferred public seam,
|
|
2828
|
+
// so the busy frame, the key lock and the unlock are observed without timers.
|
|
2829
|
+
describe('issues view pending writes and comment caret (S10/S11)', () => {
|
|
2830
|
+
const LOCKED_KEYS = ['ctrl+s', 'tab', 'shift+tab', 'escape', 'enter', 'y', 'j', 'k', 'down', 'up', 'T', 'q', 'x', 'r', 'right', 'left'];
|
|
2831
|
+
|
|
2832
|
+
test('a label save paints its busy frame first, locks every key, then unlocks on success', async () => {
|
|
2833
|
+
const gate = deferred();
|
|
2834
|
+
const calls = [];
|
|
2835
|
+
let saved = ['bug'];
|
|
2836
|
+
const rows = [issue(1, { labels: [{ name: 'bug' }] })];
|
|
2837
|
+
const { view, paints } = makeView({
|
|
2838
|
+
pages: pagesOf([rows, [], []]),
|
|
2839
|
+
issues: rows,
|
|
2840
|
+
labels: [{ name: 'bug' }, { name: 'docs' }],
|
|
2841
|
+
loadIssue: (n) => ({
|
|
2842
|
+
ok: true,
|
|
2843
|
+
issue: issue(n, { labels: saved.map((name) => ({ name })), permissions: EDITABLE }),
|
|
2844
|
+
comments: [],
|
|
2845
|
+
}),
|
|
2846
|
+
actions: {
|
|
2847
|
+
editLabels: (n, add, remove, created) => {
|
|
2848
|
+
calls.push([n, add, remove, created]);
|
|
2849
|
+
saved = ['bug', ...add];
|
|
2850
|
+
return gate.promise;
|
|
2851
|
+
},
|
|
2852
|
+
},
|
|
2853
|
+
});
|
|
2854
|
+
await tick();
|
|
2855
|
+
view.handleInput('L');
|
|
2856
|
+
view.handleInput('down');
|
|
2857
|
+
view.handleInput('space');
|
|
2858
|
+
const before = paints.length;
|
|
2859
|
+
view.handleInput('ctrl+s');
|
|
2860
|
+
assert.ok(paints.length > before, 'the busy state repaints before the answer arrives');
|
|
2861
|
+
assert.equal(view.state.saving, 'labels', 'the pending write is recorded at once');
|
|
2862
|
+
const busy = strip(view.render(100).join('\n'));
|
|
2863
|
+
assert.match(busy, /saving labels/, 'the busy frame names what is in flight');
|
|
2864
|
+
for (const key of LOCKED_KEYS) view.handleInput(key);
|
|
2865
|
+
assert.equal(calls.length, 1, 'no second write is sent');
|
|
2866
|
+
assert.equal(view.state.mode, 'labels', 'the editor stays the visible state');
|
|
2867
|
+
assert.equal(view.state.page, 0, 'no tab move while pending');
|
|
2868
|
+
assert.equal(view.state.index, 0, 'no list move while pending');
|
|
2869
|
+
assert.equal(view.state.searching, false, 'no typing while pending');
|
|
2870
|
+
assert.equal(strip(view.render(100).join('\n')), busy, 'the pending frame is stable while keys arrive');
|
|
2871
|
+
gate.resolve({ ok: true });
|
|
2872
|
+
await tick();
|
|
2873
|
+
assert.equal(view.state.saving, null, 'the answer unlocks the view');
|
|
2874
|
+
assert.equal(view.state.mode, 'list');
|
|
2875
|
+
const settled = bodyOf(view, 100);
|
|
2876
|
+
assert.match(settled, /labels: bug, docs/, 'the refetched preview shows the saved labels');
|
|
2877
|
+
assert.ok(!settled.includes('saving labels'), 'the busy line is gone once it settled');
|
|
2878
|
+
assert.deepEqual(calls, [[1, ['docs'], [], []]], 'exactly one apply with the toggled delta');
|
|
2879
|
+
});
|
|
2880
|
+
|
|
2881
|
+
test('a rejected label save unlocks and keeps the toggles', async () => {
|
|
2882
|
+
const gate = deferred();
|
|
2883
|
+
const rows = [issue(1, { labels: [{ name: 'bug' }] })];
|
|
2884
|
+
const { view } = makeView({
|
|
2885
|
+
pages: pagesOf([rows, [], []]),
|
|
2886
|
+
issues: rows,
|
|
2887
|
+
labels: [{ name: 'bug' }, { name: 'docs' }],
|
|
2888
|
+
loadIssue: (n) => ({
|
|
2889
|
+
ok: true,
|
|
2890
|
+
issue: issue(n, { labels: [{ name: 'bug' }], permissions: EDITABLE }),
|
|
2891
|
+
comments: [],
|
|
2892
|
+
}),
|
|
2893
|
+
actions: { editLabels: () => gate.promise },
|
|
2894
|
+
});
|
|
2895
|
+
await tick();
|
|
2896
|
+
view.handleInput('L');
|
|
2897
|
+
view.handleInput('down');
|
|
2898
|
+
view.handleInput('space');
|
|
2899
|
+
view.handleInput('ctrl+s');
|
|
2900
|
+
gate.resolve({ ok: false, error: 'permission denied by GitHub' });
|
|
2901
|
+
await tick();
|
|
2902
|
+
assert.equal(view.state.saving, null, 'a rejected save unlocks the view');
|
|
2903
|
+
assert.equal(view.state.mode, 'labels', 'the editor stays open');
|
|
2904
|
+
const body = bodyOf(view, 100);
|
|
2905
|
+
assert.match(body, /permission denied by GitHub/, 'the real error is shown');
|
|
2906
|
+
assert.match(body, /\[x\] docs/, 'the toggled label survives the rejection');
|
|
2907
|
+
assert.ok(!body.includes('saving labels'), 'the busy line is gone');
|
|
2908
|
+
});
|
|
2909
|
+
|
|
2910
|
+
test('an assignee save locks the view while pending and shows the saved state after', async () => {
|
|
2911
|
+
const gate = deferred();
|
|
2912
|
+
const calls = [];
|
|
2913
|
+
let assignees = [{ login: 'bob' }];
|
|
2914
|
+
const rows = [issue(1)];
|
|
2915
|
+
const { view } = makeView({
|
|
2916
|
+
pages: pagesOf([rows, [], []]),
|
|
2917
|
+
issues: rows,
|
|
2918
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions: EDITABLE, assignees }), comments: [] }),
|
|
2919
|
+
loadAssignees: () => ({ ok: true, users: [{ login: 'ann', name: 'Ann' }, { login: 'bob', name: '' }] }),
|
|
2920
|
+
actions: {
|
|
2921
|
+
editAssignees: (n, changes) => {
|
|
2922
|
+
calls.push([n, changes]);
|
|
2923
|
+
assignees = [{ login: 'ann' }];
|
|
2924
|
+
return gate.promise;
|
|
2925
|
+
},
|
|
2926
|
+
},
|
|
2927
|
+
});
|
|
2928
|
+
await tick();
|
|
2929
|
+
view.handleInput('a');
|
|
2930
|
+
await tick();
|
|
2931
|
+
view.handleInput('space');
|
|
2932
|
+
view.handleInput('down');
|
|
2933
|
+
view.handleInput('space');
|
|
2934
|
+
view.handleInput('ctrl+s');
|
|
2935
|
+
assert.equal(view.state.saving, 'assignees');
|
|
2936
|
+
assert.match(strip(view.render(100).join('\n')), /saving assignees/, 'the busy frame names the save');
|
|
2937
|
+
for (const key of LOCKED_KEYS) view.handleInput(key);
|
|
2938
|
+
assert.equal(calls.length, 1, 'no duplicate assignee write');
|
|
2939
|
+
assert.match(bodyOf(view, 100), /\[x\] ann/, 'the pending frame keeps the draft toggles');
|
|
2940
|
+
gate.resolve({ ok: true });
|
|
2941
|
+
await tick();
|
|
2942
|
+
assert.equal(view.state.saving, null, 'success unlocks the view');
|
|
2943
|
+
assert.equal(view.state.mode, 'list');
|
|
2944
|
+
assert.deepEqual(calls, [[1, { add: ['ann'], remove: ['bob'] }]], 'one delta write');
|
|
2945
|
+
assert.match(bodyOf(view, 100), /assignees: ann/, 'the refetched preview shows the saved assignee');
|
|
2946
|
+
});
|
|
2947
|
+
|
|
2948
|
+
test('a comment save locks typing while pending, then shows the posted comment', async () => {
|
|
2949
|
+
const gate = deferred();
|
|
2950
|
+
const calls = [];
|
|
2951
|
+
let comments = [];
|
|
2952
|
+
const rows = [issue(1)];
|
|
2953
|
+
const { view } = makeView({
|
|
2954
|
+
pages: pagesOf([rows, [], []]),
|
|
2955
|
+
issues: rows,
|
|
2956
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: comments.slice() }),
|
|
2957
|
+
actions: {
|
|
2958
|
+
postComment: (n, text) => {
|
|
2959
|
+
calls.push([n, text]);
|
|
2960
|
+
comments = [{ author: 'octo', createdAt: new Date().toISOString(), body: text }];
|
|
2961
|
+
return gate.promise;
|
|
2962
|
+
},
|
|
2963
|
+
},
|
|
2964
|
+
});
|
|
2965
|
+
await tick();
|
|
2966
|
+
view.handleInput('c');
|
|
2967
|
+
for (const ch of 'ship it') view.handleInput(ch);
|
|
2968
|
+
view.handleInput('ctrl+s');
|
|
2969
|
+
assert.equal(view.state.saving, 'comment');
|
|
2970
|
+
assert.match(strip(view.render(100).join('\n')), /posting comment/, 'the busy frame names the post in flight');
|
|
2971
|
+
for (const key of LOCKED_KEYS) view.handleInput(key);
|
|
2972
|
+
for (const ch of 'xyz') view.handleInput(ch);
|
|
2973
|
+
assert.equal(view.state.commentText, 'ship it', 'typing is locked while the post is pending');
|
|
2974
|
+
assert.equal(view.state.mode, 'comment', 'esc cannot leave a pending post');
|
|
2975
|
+
assert.equal(calls.length, 1, 'no second post');
|
|
2976
|
+
gate.resolve({ ok: true });
|
|
2977
|
+
await tick();
|
|
2978
|
+
assert.equal(view.state.saving, null, 'success unlocks the view');
|
|
2979
|
+
assert.equal(view.state.mode, 'list', 'success leaves the editor');
|
|
2980
|
+
const settled = bodyOf(view, 100);
|
|
2981
|
+
assert.match(settled, /ship it/, 'the posted comment is displayed');
|
|
2982
|
+
assert.match(settled, /comment posted/);
|
|
2983
|
+
assert.equal(view.state.commentText, '', 'the sent draft is cleared');
|
|
2984
|
+
});
|
|
2985
|
+
|
|
2986
|
+
test('a close save locks the confirmation while pending and then drops the issue', async () => {
|
|
2987
|
+
const gate = deferred();
|
|
2988
|
+
const calls = [];
|
|
2989
|
+
const rows = [issue(1), issue(2)];
|
|
2990
|
+
const { view } = makeView({
|
|
2991
|
+
pages: pagesOf([rows, [], []]),
|
|
2992
|
+
issues: rows,
|
|
2993
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions: EDITABLE }), comments: [] }),
|
|
2994
|
+
actions: { setState: (n, state) => { calls.push([n, state]); return gate.promise; } },
|
|
2995
|
+
});
|
|
2996
|
+
await tick();
|
|
2997
|
+
view.handleInput('x');
|
|
2998
|
+
view.handleInput('enter');
|
|
2999
|
+
assert.equal(view.state.saving, 'close');
|
|
3000
|
+
assert.match(strip(view.render(100).join('\n')), /closing issue/, 'the busy frame names the close');
|
|
3001
|
+
for (const key of LOCKED_KEYS) view.handleInput(key);
|
|
3002
|
+
assert.equal(calls.length, 1, 'no duplicate close');
|
|
3003
|
+
assert.equal(view.state.mode, 'confirm', 'the confirmation stays visible while pending');
|
|
3004
|
+
gate.resolve({ ok: true });
|
|
3005
|
+
await tick();
|
|
3006
|
+
assert.equal(view.state.saving, null, 'success unlocks the view');
|
|
3007
|
+
assert.equal(view.state.mode, 'list');
|
|
3008
|
+
assert.deepEqual(calls, [[1, 'closed']], 'one close write');
|
|
3009
|
+
assert.ok(!leftColumn(view, 100).includes('issue title 1'), 'the closed issue leaves the open list');
|
|
3010
|
+
assert.match(leftColumn(view, 100), /issue title 2/, 'the remaining open issue stays listed');
|
|
3011
|
+
});
|
|
3012
|
+
|
|
3013
|
+
test('a failed save keeps the draft and its error, and the view is usable again', async () => {
|
|
3014
|
+
const gate = deferred();
|
|
3015
|
+
const calls = [];
|
|
3016
|
+
const rows = [issue(1)];
|
|
3017
|
+
const { view } = makeView({
|
|
3018
|
+
pages: pagesOf([rows, [], []]),
|
|
3019
|
+
issues: rows,
|
|
3020
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3021
|
+
actions: { postComment: (n, text) => { calls.push([n, text]); return gate.promise; } },
|
|
3022
|
+
});
|
|
3023
|
+
await tick();
|
|
3024
|
+
view.handleInput('c');
|
|
3025
|
+
for (const ch of 'keep this') view.handleInput(ch);
|
|
3026
|
+
view.handleInput('ctrl+s');
|
|
3027
|
+
gate.resolve({ ok: false, error: 'rate limited' });
|
|
3028
|
+
await tick();
|
|
3029
|
+
assert.equal(view.state.saving, null, 'a rejected save unlocks the view');
|
|
3030
|
+
assert.equal(view.state.mode, 'comment', 'the editor and its draft survive the failure');
|
|
3031
|
+
const body = bodyOf(view, 100);
|
|
3032
|
+
assert.match(body, /rate limited/, 'the real error is shown');
|
|
3033
|
+
assert.match(body, /keep this/, 'the draft is preserved');
|
|
3034
|
+
assert.ok(!body.includes('posting comment'), 'the busy line is gone');
|
|
3035
|
+
assert.equal(calls.length, 1);
|
|
3036
|
+
view.handleInput('escape');
|
|
3037
|
+
assert.equal(view.state.mode, 'comment', 'esc still asks before discarding a draft');
|
|
3038
|
+
assert.match(strip(view.render(100).join('\n')), /Discard this draft/, 'the safe cancel behaviour is unchanged');
|
|
3039
|
+
});
|
|
3040
|
+
|
|
3041
|
+
test('the comment editor paints the caret at the insertion point (S11)', async () => {
|
|
3042
|
+
const { view } = makeView({
|
|
3043
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
3044
|
+
issues: [issue(1)],
|
|
3045
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3046
|
+
});
|
|
3047
|
+
await tick();
|
|
3048
|
+
view.handleInput('c');
|
|
3049
|
+
assert.match(bodyOf(view, 100), /█/, 'an empty draft shows where typing lands');
|
|
3050
|
+
for (const ch of 'hi') view.handleInput(ch);
|
|
3051
|
+
assert.match(bodyOf(view, 100), /hi█/, 'the caret follows the typed text');
|
|
3052
|
+
view.handleInput('enter');
|
|
3053
|
+
for (const ch of 'there') view.handleInput(ch);
|
|
3054
|
+
const body = bodyOf(view, 100);
|
|
3055
|
+
assert.match(body, /there█/, 'on a new line the caret sits after the last typed character');
|
|
3056
|
+
assert.ok(!/h█i/.test(body), 'the caret is never detached from the insertion point');
|
|
3057
|
+
view.handleInput('backspace');
|
|
3058
|
+
assert.match(bodyOf(view, 100), /ther█/, 'Backspace moves the caret back with the text');
|
|
3059
|
+
});
|
|
3060
|
+
|
|
3061
|
+
test('the caret lands on the real line on the first frame after opening and after enter (S11)', async () => {
|
|
3062
|
+
const { view } = makeView({
|
|
3063
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
3064
|
+
issues: [issue(1)],
|
|
3065
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3066
|
+
});
|
|
3067
|
+
await tick();
|
|
3068
|
+
view.handleInput('c');
|
|
3069
|
+
// The header owns the first body row, so the insertion point of an empty
|
|
3070
|
+
// draft is the row directly under it — never the one below that.
|
|
3071
|
+
let rows = rightRows(view, 100);
|
|
3072
|
+
assert.match(rows[0], /Leave a comment/, 'the editor header owns the first body row');
|
|
3073
|
+
assert.equal(caretRow(rows), 1, 'an empty draft puts the caret on the first line under the header');
|
|
3074
|
+
assert.equal(rows[1], '█', 'and that line holds nothing but the caret');
|
|
3075
|
+
assert.equal(rows[2], '', 'no blank line is left between the header and the caret');
|
|
3076
|
+
|
|
3077
|
+
for (const ch of 'hi') view.handleInput(ch);
|
|
3078
|
+
rows = rightRows(view, 100);
|
|
3079
|
+
assert.equal(caretRow(rows), 1, 'typing keeps the caret on the line typing continues on');
|
|
3080
|
+
assert.match(rows[1], /hi█/);
|
|
3081
|
+
|
|
3082
|
+
// Enter opens the line under the typed text, so that IS the insertion row.
|
|
3083
|
+
view.handleInput('enter');
|
|
3084
|
+
rows = rightRows(view, 100);
|
|
3085
|
+
assert.equal(rows[1], 'hi', 'enter commits the typed line above');
|
|
3086
|
+
assert.equal(caretRow(rows), 2, 'enter moves the caret onto the new line, not one below it');
|
|
3087
|
+
assert.equal(rows[2], '█', 'the new line holds nothing but the caret');
|
|
3088
|
+
assert.equal(rows[3], '', 'the caret is not detached from the line it belongs to');
|
|
3089
|
+
|
|
3090
|
+
for (const ch of 'there') view.handleInput(ch);
|
|
3091
|
+
rows = rightRows(view, 100);
|
|
3092
|
+
assert.equal(caretRow(rows), 2, 'typing after enter continues on the row the caret is on');
|
|
3093
|
+
assert.equal(rows[2], 'there█');
|
|
3094
|
+
});
|
|
3095
|
+
|
|
3096
|
+
test('a full line still pushes the caret onto the next row (S11 preserved)', async () => {
|
|
3097
|
+
const { view } = makeView({
|
|
3098
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
3099
|
+
issues: [issue(1)],
|
|
3100
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3101
|
+
});
|
|
3102
|
+
await tick();
|
|
3103
|
+
view.handleInput('c');
|
|
3104
|
+
const rightWidth = readerPanes(100).right;
|
|
3105
|
+
assert.ok(rightWidth > 0, 'the pane has room to fill');
|
|
3106
|
+
for (const ch of 'x'.repeat(rightWidth)) view.handleInput(ch);
|
|
3107
|
+
const rows = rightRows(view, 100);
|
|
3108
|
+
assert.equal(rows[1], 'x'.repeat(rightWidth), 'the typed line fills the pane exactly');
|
|
3109
|
+
assert.equal(caretRow(rows), 2, 'a line with no room left wraps the caret onto the next row');
|
|
3110
|
+
assert.equal(rows[2], '█');
|
|
3111
|
+
});
|
|
3112
|
+
});
|
|
3113
|
+
|
|
3114
|
+
// S9 (round 3, second pass): remote search is debounced. The clock is injected
|
|
3115
|
+
// (scheduleQuery/cancelSchedule), so a burst of typing, the stale answer inside
|
|
3116
|
+
// the wait and every flush point are observed without wall-clock waits.
|
|
3117
|
+
describe('issues remote search scheduling (S9)', () => {
|
|
3118
|
+
function scheduledServerView(clock, serve, overrides = {}) {
|
|
3119
|
+
const requests = [];
|
|
3120
|
+
const made = makeView({
|
|
3121
|
+
rows: 24,
|
|
3122
|
+
scheduleQuery: clock.schedule,
|
|
3123
|
+
cancelSchedule: clock.cancel,
|
|
3124
|
+
loadIssuePage: (request) => {
|
|
3125
|
+
requests.push(request);
|
|
3126
|
+
return serve(request);
|
|
3127
|
+
},
|
|
3128
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3129
|
+
...overrides,
|
|
3130
|
+
});
|
|
3131
|
+
return { ...made, requests };
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
test('a burst of typing schedules ONE read for the query it settles on', async () => {
|
|
3135
|
+
const clock = manualScheduler();
|
|
3136
|
+
const { view, requests, paints } = scheduledServerView(clock, () => pageResult([cursorRow('m1', issue(1))], { total: 1 }));
|
|
3137
|
+
await tick();
|
|
3138
|
+
assert.equal(requests.length, 1, 'the mount read is never debounced');
|
|
3139
|
+
const before = paints.length;
|
|
3140
|
+
view.handleInput('T');
|
|
3141
|
+
for (const ch of 'crash') view.handleInput(ch);
|
|
3142
|
+
assert.equal(view.state.query, 'crash', 'the query is recorded at once');
|
|
3143
|
+
assert.match(strip(view.render(100)[1]), /› crash█/, 'the box shows the query and its caret at once');
|
|
3144
|
+
assert.equal(requests.length, 1, 'no request per keystroke');
|
|
3145
|
+
assert.ok(paints.length > before, 'each keystroke repaints the box');
|
|
3146
|
+
assert.equal(view.state.searchPending, true, 'the scheduled read is visible state');
|
|
3147
|
+
const waiting = strip(view.render(100).join('\n'));
|
|
3148
|
+
assert.match(waiting, /searching/, 'the frame names the search being scheduled');
|
|
3149
|
+
assert.match(waiting, /issue title 1/, 'the rows already loaded stay on screen');
|
|
3150
|
+
assert.ok(!waiting.includes('loading issues'), 'no full-screen loader flash while typing');
|
|
3151
|
+
assert.equal(clock.live().length, 1, 'one live callback, never one per keystroke');
|
|
3152
|
+
assert.ok(clock.live()[0].delayMs >= 100 && clock.live()[0].delayMs <= 250,
|
|
3153
|
+
`a short fixed debounce delay: ${clock.live()[0].delayMs}`);
|
|
3154
|
+
clock.fire();
|
|
3155
|
+
assert.equal(requests.length, 2, 'the settled query reads once');
|
|
3156
|
+
assert.deepEqual(requests[1], { bucketId: 'mine', cursor: null, limit: 18, query: 'crash' });
|
|
3157
|
+
assert.equal(view.state.searchPending, false, 'the wait ends when the read starts');
|
|
3158
|
+
});
|
|
3159
|
+
|
|
3160
|
+
test('an answer for the previous query cannot paint while the new one waits', async () => {
|
|
3161
|
+
const clock = manualScheduler();
|
|
3162
|
+
const pending = [];
|
|
3163
|
+
const { view } = makeView({
|
|
3164
|
+
rows: 24,
|
|
3165
|
+
scheduleQuery: clock.schedule,
|
|
3166
|
+
cancelSchedule: clock.cancel,
|
|
3167
|
+
loadIssuePage: (request) => {
|
|
3168
|
+
const entry = deferred();
|
|
3169
|
+
pending.push({ request, entry });
|
|
3170
|
+
return entry.promise;
|
|
3171
|
+
},
|
|
3172
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3173
|
+
});
|
|
3174
|
+
pending[0].entry.resolve(pageResult([cursorRow('m1', issue(1, { title: 'mount row' }))], { total: 1 }));
|
|
3175
|
+
await tick();
|
|
3176
|
+
leftColumn(view, 100);
|
|
3177
|
+
view.handleInput('T');
|
|
3178
|
+
view.handleInput('a');
|
|
3179
|
+
view.handleInput('enter'); // the 'a' read is in flight
|
|
3180
|
+
assert.deepEqual(pending[1].request, { bucketId: 'mine', cursor: null, limit: 18, query: 'a' });
|
|
3181
|
+
view.handleInput('T');
|
|
3182
|
+
view.handleInput('b'); // 'ab' waits, so 'a' is already stale
|
|
3183
|
+
pending[1].entry.resolve(pageResult([cursorRow('o1', issue(9, { title: 'a only match' }))], { total: 1 }));
|
|
3184
|
+
await tick();
|
|
3185
|
+
const waiting = leftColumn(view, 100);
|
|
3186
|
+
assert.match(waiting, /mount row/, 'the rows on screen are the ones the reader last saw');
|
|
3187
|
+
assert.ok(!waiting.includes('a only match'), 'the answer for the superseded query never paints');
|
|
3188
|
+
clock.fire();
|
|
3189
|
+
pending[2].entry.resolve(pageResult([cursorRow('n1', issue(3, { title: 'ab match' }))], { total: 1 }));
|
|
3190
|
+
await tick();
|
|
3191
|
+
assert.match(leftColumn(view, 100), /ab match/, 'only the newest query answer paints');
|
|
3192
|
+
assert.equal(pending.length, 3, 'exactly three reads: mount, flushed query, settled query');
|
|
3193
|
+
});
|
|
3194
|
+
|
|
3195
|
+
test('leaving the box reads the pending query once and never twice', async () => {
|
|
3196
|
+
const clock = manualScheduler();
|
|
3197
|
+
const { view, requests } = scheduledServerView(clock, () => pageResult([cursorRow('b1', issue(2))], { total: 1 }));
|
|
3198
|
+
await tick();
|
|
3199
|
+
view.handleInput('T');
|
|
3200
|
+
for (const ch of 'bug') view.handleInput(ch);
|
|
3201
|
+
assert.equal(requests.length, 1, 'typing waits for the debounce');
|
|
3202
|
+
view.handleInput('enter');
|
|
3203
|
+
assert.equal(view.state.searching, false);
|
|
3204
|
+
assert.equal(requests.length, 2, 'leaving the box issues the read now');
|
|
3205
|
+
assert.deepEqual(requests[1], { bucketId: 'mine', cursor: null, limit: 18, query: 'bug' });
|
|
3206
|
+
assert.equal(clock.live().length, 0, 'the pending callback is cancelled, not left to fire');
|
|
3207
|
+
clock.fire();
|
|
3208
|
+
assert.equal(requests.length, 2, 'a cancelled callback never reads');
|
|
3209
|
+
view.handleInput('T');
|
|
3210
|
+
view.handleInput('x');
|
|
3211
|
+
view.handleInput('escape');
|
|
3212
|
+
assert.equal(requests.length, 3, 'escape keeps the filter and reads it once too');
|
|
3213
|
+
assert.equal(view.state.query, 'bugx', 'escape never changes the typed query');
|
|
3214
|
+
assert.equal(clock.live().length, 0);
|
|
3215
|
+
});
|
|
3216
|
+
|
|
3217
|
+
test('a refresh under a pending query reads page one of that query only once', async () => {
|
|
3218
|
+
const clock = manualScheduler();
|
|
3219
|
+
const { view, requests } = scheduledServerView(clock, () => pageResult([cursorRow('g1', issue(1))], { total: 2, hasNextPage: true }));
|
|
3220
|
+
await tick();
|
|
3221
|
+
view.handleInput('T');
|
|
3222
|
+
for (const ch of 'lab') view.handleInput(ch);
|
|
3223
|
+
view.handleInput('enter');
|
|
3224
|
+
leftColumn(view, 100);
|
|
3225
|
+
view.handleInput('r');
|
|
3226
|
+
assert.equal(requests.length, 3, 'the explicit refresh reads the current page of the current query');
|
|
3227
|
+
assert.deepEqual(requests[2], { bucketId: 'mine', cursor: null, limit: 18, query: 'lab' },
|
|
3228
|
+
'never an old cursor, never the previous query');
|
|
3229
|
+
clock.fire();
|
|
3230
|
+
assert.equal(requests.length, 3, 'no second request from a cancelled callback');
|
|
3231
|
+
});
|
|
3232
|
+
|
|
3233
|
+
test('static local search stays immediate and never schedules', async () => {
|
|
3234
|
+
const clock = manualScheduler();
|
|
3235
|
+
const rows = [issue(1, { title: 'alpha crash' }), issue(2, { title: 'beta typo' })];
|
|
3236
|
+
const { view } = makeView({
|
|
3237
|
+
pages: pagesOf([rows, [], []]),
|
|
3238
|
+
issues: rows,
|
|
3239
|
+
scheduleQuery: clock.schedule,
|
|
3240
|
+
cancelSchedule: clock.cancel,
|
|
3241
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3242
|
+
});
|
|
3243
|
+
await tick();
|
|
3244
|
+
view.handleInput('T');
|
|
3245
|
+
for (const ch of 'beta') view.handleInput(ch);
|
|
3246
|
+
assert.equal(view.state.searchPending, false, 'a local list filters without a scheduled read');
|
|
3247
|
+
assert.equal(clock.entries.length, 0, 'nothing is scheduled for local search');
|
|
3248
|
+
const body = bodyOf(view, 100);
|
|
3249
|
+
assert.match(body, /issue title 2/, 'the local filter applies at once');
|
|
3250
|
+
assert.ok(!body.includes('alpha crash'), 'the other local row is filtered out');
|
|
3251
|
+
});
|
|
3252
|
+
|
|
3253
|
+
test('a late answer after the reader closed paints nothing', async () => {
|
|
3254
|
+
const clock = manualScheduler();
|
|
3255
|
+
const gate = deferred();
|
|
3256
|
+
const paints = [];
|
|
3257
|
+
let settled = 'pending';
|
|
3258
|
+
const view = createIssuesView({
|
|
3259
|
+
rowsAvailable: () => 24,
|
|
3260
|
+
helpers,
|
|
3261
|
+
theme: plainTheme,
|
|
3262
|
+
scheduleQuery: clock.schedule,
|
|
3263
|
+
cancelSchedule: clock.cancel,
|
|
3264
|
+
requestRender: () => { paints.push(true); },
|
|
3265
|
+
done: (result) => { settled = result; },
|
|
3266
|
+
loadIssuePage: () => gate.promise,
|
|
3267
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3268
|
+
});
|
|
3269
|
+
view.handleInput('T');
|
|
3270
|
+
view.handleInput('q');
|
|
3271
|
+
view.handleInput('enter');
|
|
3272
|
+
view.handleInput('escape');
|
|
3273
|
+
assert.equal(settled, null, 'esc still closes with null');
|
|
3274
|
+
const before = paints.length;
|
|
3275
|
+
gate.resolve(pageResult([cursorRow('z1', issue(5))], { total: 1 }));
|
|
3276
|
+
await tick();
|
|
3277
|
+
assert.equal(paints.length, before, 'a late answer repaints nothing after close');
|
|
3278
|
+
});
|
|
3279
|
+
|
|
3280
|
+
// S9: rows answer ONE query. A search that fails must never leave the
|
|
3281
|
+
// previous query's rows (or its total) looking like this query's result.
|
|
3282
|
+
test('a failed search for a new query drops the previous query rows and count', async () => {
|
|
3283
|
+
const clock = manualScheduler();
|
|
3284
|
+
const { view, requests } = scheduledServerView(clock, (request) => {
|
|
3285
|
+
if (request.query === 'z') {
|
|
3286
|
+
if (request.bucketId === 'mine') {
|
|
3287
|
+
return { ok: false, error: 'GitHub issue search exceeds 1000 matches; narrow the filter', truncated: true };
|
|
3288
|
+
}
|
|
3289
|
+
return pageResult([cursorRow('a1', issue(7, { title: 'assigned match' }))], { total: 1 });
|
|
3290
|
+
}
|
|
3291
|
+
return pageResult([cursorRow('f1', issue(1)), cursorRow('f2', issue(2))], { total: 2 });
|
|
3292
|
+
});
|
|
3293
|
+
await tick();
|
|
3294
|
+
assert.match(strip(view.render(100)[2]), /▸ My issues \(2\)/, 'the unfiltered read names its own count');
|
|
3295
|
+
view.handleInput('T');
|
|
3296
|
+
view.handleInput('z');
|
|
3297
|
+
view.handleInput('enter');
|
|
3298
|
+
await tick();
|
|
3299
|
+
assert.equal(requests.length, 2);
|
|
3300
|
+
assert.deepEqual(requests[1], { bucketId: 'mine', cursor: null, limit: 18, query: 'z' });
|
|
3301
|
+
const body = bodyOf(view, 100);
|
|
3302
|
+
assert.match(body, /failed to load issues: GitHub issue search exceeds 1000 matches; narrow the filter/,
|
|
3303
|
+
'the search cap is reported honestly');
|
|
3304
|
+
assert.ok(!body.includes('issue title 1'), 'the previous query row is never presented as this query result');
|
|
3305
|
+
assert.ok(!body.includes('issue title 2'), 'no unfiltered row survives the failed search');
|
|
3306
|
+
assert.ok(!/1000\+/.test(body), 'no truncated result is claimed as the full dataset');
|
|
3307
|
+
assert.equal(view.state.loading, false, 'the failure ends the loading state');
|
|
3308
|
+
assert.equal(view.state.page, 0, 'the failure leaves the reader on the bucket that failed');
|
|
3309
|
+
// S9: the count falls back to UNKNOWN instead of reusing another query's
|
|
3310
|
+
// total, and only the bucket that failed lost its rows.
|
|
3311
|
+
view.handleInput('tab');
|
|
3312
|
+
await tick();
|
|
3313
|
+
const tabs = strip(view.render(100)[2]);
|
|
3314
|
+
assert.match(tabs, /My issues \(–\)/, 'a total from another query is unknown, never reused');
|
|
3315
|
+
assert.match(tabs, /▸ Assigned \(1\)/, 'the bucket that answered keeps its own count');
|
|
3316
|
+
const left = leftColumn(view, 100);
|
|
3317
|
+
assert.match(left, /assigned match/, 'the bucket that answered paints its rows');
|
|
3318
|
+
assert.ok(!left.includes('issue title 1'), 'the failed bucket keeps presenting no rows');
|
|
3319
|
+
});
|
|
3320
|
+
|
|
3321
|
+
// S9: the clear is per bucket. A failure on the bucket on screen never
|
|
3322
|
+
// reaches into another bucket's own rows.
|
|
3323
|
+
test('a failed query clears only the bucket that failed', async () => {
|
|
3324
|
+
const clock = manualScheduler();
|
|
3325
|
+
const assigned = deferred();
|
|
3326
|
+
const { view } = makeView({
|
|
3327
|
+
rows: 24,
|
|
3328
|
+
scheduleQuery: clock.schedule,
|
|
3329
|
+
cancelSchedule: clock.cancel,
|
|
3330
|
+
loadIssuePage: (request) => {
|
|
3331
|
+
if (request.query === 'z') {
|
|
3332
|
+
if (request.bucketId === 'assigned') return assigned.promise;
|
|
3333
|
+
return { ok: false, error: 'GitHub issue search exceeds 1000 matches; narrow the filter' };
|
|
3334
|
+
}
|
|
3335
|
+
return request.bucketId === 'assigned'
|
|
3336
|
+
? pageResult([cursorRow('a1', issue(2, { title: 'assigned row' }))], { total: 1 })
|
|
3337
|
+
: pageResult([cursorRow('m1', issue(1, { title: 'mine row' }))], { total: 1 });
|
|
3338
|
+
},
|
|
3339
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3340
|
+
});
|
|
3341
|
+
await tick();
|
|
3342
|
+
view.handleInput('tab');
|
|
3343
|
+
await tick();
|
|
3344
|
+
assert.match(leftColumn(view, 100), /assigned row/, 'the second bucket read its own rows');
|
|
3345
|
+
view.handleInput('shift+tab');
|
|
3346
|
+
await tick();
|
|
3347
|
+
view.handleInput('T');
|
|
3348
|
+
view.handleInput('z');
|
|
3349
|
+
view.handleInput('enter');
|
|
3350
|
+
await tick();
|
|
3351
|
+
assert.ok(!bodyOf(view, 100).includes('mine row'), 'the failing bucket lost its rows');
|
|
3352
|
+
view.handleInput('tab');
|
|
3353
|
+
await tick();
|
|
3354
|
+
assert.match(leftColumn(view, 100), /assigned row/,
|
|
3355
|
+
'the other bucket keeps the rows of its own query while it reads the new one');
|
|
3356
|
+
assigned.resolve(pageResult([cursorRow('a2', issue(5, { title: 'assigned match' }))], { total: 1 }));
|
|
3357
|
+
await tick();
|
|
3358
|
+
assert.match(leftColumn(view, 100), /assigned match/, 'and its own answer paints when it lands');
|
|
3359
|
+
assert.ok(!leftColumn(view, 100).includes('assigned row'), 'the bucket result is replaced, not appended');
|
|
3360
|
+
});
|
|
3361
|
+
|
|
3362
|
+
// S9: the reported blocker — All painted its unfiltered connection rows under
|
|
3363
|
+
// a failed typed search, and the same rows never came back after the reader
|
|
3364
|
+
// cleared the query.
|
|
3365
|
+
test('All drops its connection rows when a typed query fails, and reads them again after', async () => {
|
|
3366
|
+
const clock = manualScheduler();
|
|
3367
|
+
const { view, requests } = scheduledServerView(clock, (request) => {
|
|
3368
|
+
if (request.query !== '') {
|
|
3369
|
+
return { ok: false, error: 'GitHub issue search exceeds 1000 matches; narrow the filter', truncated: true };
|
|
3370
|
+
}
|
|
3371
|
+
if (request.bucketId === 'all') {
|
|
3372
|
+
return pageResult([cursorRow('a1', issue(41, { title: 'all connection row' }))], { total: 2, hasNextPage: true });
|
|
3373
|
+
}
|
|
3374
|
+
return pageResult([cursorRow('m1', issue(40, { title: 'mine row' }))], { total: 1 });
|
|
3375
|
+
});
|
|
3376
|
+
await tick();
|
|
3377
|
+
view.handleInput('tab');
|
|
3378
|
+
view.handleInput('tab');
|
|
3379
|
+
view.handleInput('tab');
|
|
3380
|
+
await tick();
|
|
3381
|
+
assert.equal(view.state.page, 3, 'All is the fourth tab');
|
|
3382
|
+
assert.match(strip(view.render(100)[2]), /▸ All \(2\+\)/, 'All names the connection total');
|
|
3383
|
+
assert.match(leftColumn(view, 100), /all connection row/, 'All paints the connection rows');
|
|
3384
|
+
view.handleInput('T');
|
|
3385
|
+
view.handleInput('z');
|
|
3386
|
+
view.handleInput('enter');
|
|
3387
|
+
await tick();
|
|
3388
|
+
assert.equal(requests.at(-1).bucketId, 'all', 'the typed query reads the bucket on screen');
|
|
3389
|
+
assert.equal(requests.at(-1).query, 'z');
|
|
3390
|
+
let body = bodyOf(view, 100);
|
|
3391
|
+
assert.match(body, /failed to load issues: GitHub issue search exceeds 1000 matches/);
|
|
3392
|
+
assert.ok(!body.includes('all connection row'),
|
|
3393
|
+
'the unfiltered connection rows are never presented as matches for the query');
|
|
3394
|
+
// Clearing the query is a new read of the connection, and it paints again.
|
|
3395
|
+
view.handleInput('T');
|
|
3396
|
+
view.handleInput('delete');
|
|
3397
|
+
clock.fire();
|
|
3398
|
+
await tick();
|
|
3399
|
+
assert.equal(requests.at(-1).bucketId, 'all');
|
|
3400
|
+
assert.equal(requests.at(-1).query, '', 'the cleared query reads the connection source again');
|
|
3401
|
+
body = bodyOf(view, 100);
|
|
3402
|
+
assert.match(body, /all connection row/, 'the connection rows come back with the query cleared');
|
|
3403
|
+
assert.match(strip(view.render(100)[2]), /▸ All \(2\+\)/, 'and its own count comes back with them');
|
|
3404
|
+
assert.ok(!body.includes('failed to load issues'), 'the failure is gone once a read answers');
|
|
3405
|
+
});
|
|
3406
|
+
|
|
3407
|
+
// S9: a continuation page is the SAME query. Its failure is reported, but the
|
|
3408
|
+
// rows already read for that query stay — they are still that query's rows.
|
|
3409
|
+
test('a failed append keeps the rows of the query they belong to', async () => {
|
|
3410
|
+
const clock = manualScheduler();
|
|
3411
|
+
const { view, requests } = scheduledServerView(clock, (request) => (request.cursor === null
|
|
3412
|
+
? pageResult([cursorRow('c1', issue(1, { title: 'first page row' }))], { total: 2, hasNextPage: true })
|
|
3413
|
+
: { ok: false, error: 'gh issue list failed: HTTP 502: bad gateway' }));
|
|
3414
|
+
await tick();
|
|
3415
|
+
assert.match(strip(view.render(100)[2]), /▸ My issues \(2\+\)/, 'the first page names its own count');
|
|
3416
|
+
view.handleInput(']');
|
|
3417
|
+
await tick();
|
|
3418
|
+
assert.equal(requests.length, 2, 'reaching the loaded bottom asks for the next page');
|
|
3419
|
+
assert.equal(requests[1].cursor, 'c1', 'the append continues after the last loaded row');
|
|
3420
|
+
const body = bodyOf(view, 100);
|
|
3421
|
+
assert.match(body, /first page row/, 'the rows of the same query stay painted');
|
|
3422
|
+
assert.equal(body.split('first page row').length - 1, 1, 'and are never duplicated');
|
|
3423
|
+
assert.match(body, /502: bad gateway/, 'the failed continuation is reported');
|
|
3424
|
+
assert.ok(!body.includes('failed to load issues'), 'a failed append is a note, not a blank list');
|
|
3425
|
+
assert.match(strip(view.render(100)[2]), /▸ My issues \(2\+\)/, 'the count still describes the rows listed');
|
|
3426
|
+
});
|
|
3427
|
+
|
|
3428
|
+
// S9: an explicit refresh reads the SAME query again. Its failure keeps the
|
|
3429
|
+
// rows the reader is on (the preview beside them survives too).
|
|
3430
|
+
test('a failed refresh of the same query keeps the rows it already showed', async () => {
|
|
3431
|
+
const clock = manualScheduler();
|
|
3432
|
+
let reads = 0;
|
|
3433
|
+
const { view } = scheduledServerView(clock, () => {
|
|
3434
|
+
reads += 1;
|
|
3435
|
+
return reads === 1
|
|
3436
|
+
? pageResult([cursorRow('r1', issue(1, { title: 'refresh row' }))], { total: 1 })
|
|
3437
|
+
: { ok: false, error: 'gh issue list failed: HTTP 500: server error' };
|
|
3438
|
+
});
|
|
3439
|
+
await tick();
|
|
3440
|
+
assert.match(leftColumn(view, 100), /refresh row/);
|
|
3441
|
+
view.handleInput('r');
|
|
3442
|
+
await tick();
|
|
3443
|
+
assert.equal(reads, 2, 'the refresh reads the query again');
|
|
3444
|
+
const body = bodyOf(view, 100);
|
|
3445
|
+
assert.match(body, /refresh row/, 'the rows of the same query stay painted');
|
|
3446
|
+
assert.match(body, /500: server error/, 'the failure is reported on the note row');
|
|
3447
|
+
assert.match(strip(view.render(100)[2]), /▸ My issues \(1\)/, 'the count still describes the rows listed');
|
|
3448
|
+
});
|
|
3449
|
+
|
|
3450
|
+
// S9: the answer that clears rows is the one for the query on screen. A
|
|
3451
|
+
// superseded query's failure is dropped exactly like its rows would be.
|
|
3452
|
+
test('a failed answer for a superseded query neither paints nor clears', async () => {
|
|
3453
|
+
const clock = manualScheduler();
|
|
3454
|
+
const pending = [];
|
|
3455
|
+
const { view } = makeView({
|
|
3456
|
+
rows: 24,
|
|
3457
|
+
scheduleQuery: clock.schedule,
|
|
3458
|
+
cancelSchedule: clock.cancel,
|
|
3459
|
+
loadIssuePage: (request) => {
|
|
3460
|
+
const entry = deferred();
|
|
3461
|
+
pending.push({ request, entry });
|
|
3462
|
+
return entry.promise;
|
|
3463
|
+
},
|
|
3464
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3465
|
+
});
|
|
3466
|
+
pending[0].entry.resolve(pageResult([cursorRow('m1', issue(1, { title: 'mount row' }))], { total: 1 }));
|
|
3467
|
+
await tick();
|
|
3468
|
+
leftColumn(view, 100);
|
|
3469
|
+
view.handleInput('T');
|
|
3470
|
+
view.handleInput('a');
|
|
3471
|
+
view.handleInput('enter');
|
|
3472
|
+
assert.deepEqual(pending[1].request, { bucketId: 'mine', cursor: null, limit: 18, query: 'a' });
|
|
3473
|
+
view.handleInput('T');
|
|
3474
|
+
view.handleInput('b');
|
|
3475
|
+
clock.fire();
|
|
3476
|
+
assert.deepEqual(pending[2].request, { bucketId: 'mine', cursor: null, limit: 18, query: 'ab' });
|
|
3477
|
+
pending[1].entry.resolve({ ok: false, error: 'GitHub issue search exceeds 1000 matches' });
|
|
3478
|
+
await tick();
|
|
3479
|
+
const waiting = leftColumn(view, 100);
|
|
3480
|
+
assert.match(waiting, /mount row/, 'the rows on screen are never cleared by a stale failure');
|
|
3481
|
+
assert.ok(!strip(view.render(100).join('\n')).includes('1000 matches'), 'and its error never paints');
|
|
3482
|
+
pending[2].entry.resolve(pageResult([cursorRow('n1', issue(3, { title: 'ab match' }))], { total: 1 }));
|
|
3483
|
+
await tick();
|
|
3484
|
+
assert.match(leftColumn(view, 100), /ab match/, 'only the newest query answer paints');
|
|
3485
|
+
});
|
|
3486
|
+
});
|
|
3487
|
+
|
|
3488
|
+
// S3: with no pager there is no position marker to reserve or clip, so an empty
|
|
3489
|
+
// result and a long query simply own the whole search row.
|
|
3490
|
+
describe('issues search row for empty and long results (S3)', () => {
|
|
3491
|
+
function rightColumn(view, width, rows = 24) {
|
|
3492
|
+
const panes = readerPanes(width);
|
|
3493
|
+
return view.render(width)
|
|
3494
|
+
.slice(4, Math.max(4, rows - 2))
|
|
3495
|
+
.map((line) => [...strip(line)].slice(5 + panes.left, 5 + panes.left + panes.right).join(''))
|
|
3496
|
+
.join('\n');
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
test('an empty result prints no page marker and keeps the empty state', async () => {
|
|
3500
|
+
const { view } = makeView({
|
|
3501
|
+
pages: pagesOf([[], [], []]),
|
|
3502
|
+
issues: [],
|
|
3503
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3504
|
+
});
|
|
3505
|
+
await tick();
|
|
3506
|
+
const lines = view.render(100).map(strip);
|
|
3507
|
+
assert.match(lines[1], /› Search/, 'the search widget keeps its row');
|
|
3508
|
+
assert.ok(!/page/i.test(lines[1]), 'the empty list prints no page marker');
|
|
3509
|
+
assert.ok(!leftColumn(view, 100).includes('page'), 'the list still holds rows only');
|
|
3510
|
+
assert.match(bodyOf(view, 100), /no issues/i);
|
|
3511
|
+
});
|
|
3512
|
+
|
|
3513
|
+
test('an empty server result says so honestly and invents no count', async () => {
|
|
3514
|
+
const clock = manualScheduler();
|
|
3515
|
+
const { view } = makeView({
|
|
3516
|
+
rows: 24,
|
|
3517
|
+
scheduleQuery: clock.schedule,
|
|
3518
|
+
cancelSchedule: clock.cancel,
|
|
3519
|
+
loadIssuePage: () => pageResult([], { total: 0 }),
|
|
3520
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3521
|
+
});
|
|
3522
|
+
await tick();
|
|
3523
|
+
assert.ok(!/page/i.test(strip(view.render(100)[1])), 'no page marker is printed');
|
|
3524
|
+
assert.match(rightColumn(view, 100), /no issues/, 'the empty pane says so honestly');
|
|
3525
|
+
});
|
|
3526
|
+
|
|
3527
|
+
test('a long query is clipped in the display only: the caret stays where typing lands', async () => {
|
|
3528
|
+
const { view } = makeView({
|
|
3529
|
+
pages: pagesOf([[issue(1)]], [], []),
|
|
3530
|
+
issues: [issue(1)],
|
|
3531
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3532
|
+
});
|
|
3533
|
+
await tick();
|
|
3534
|
+
const long = 'author:@me label:bug is:open no:assignee sort:updated-desc milestone:v1 project:roadmap';
|
|
3535
|
+
view.handleInput('T');
|
|
3536
|
+
for (const ch of long) view.handleInput(ch);
|
|
3537
|
+
assert.equal(view.state.query, long, 'the stored query keeps every character');
|
|
3538
|
+
const row = strip(view.render(80)[1]);
|
|
3539
|
+
assert.match(row, /…/, 'the displayed query is clamped');
|
|
3540
|
+
assert.match(row, /█/, 'the caret stays visible where typing lands');
|
|
3541
|
+
assert.ok(!row.includes(long), 'the display never claims to hold the whole query');
|
|
3542
|
+
});
|
|
3543
|
+
|
|
3544
|
+
test('a query the pane can hold is not touched', async () => {
|
|
3545
|
+
const { view } = makeView({
|
|
3546
|
+
pages: pagesOf([[issue(1)]], [], []),
|
|
3547
|
+
issues: [issue(1)],
|
|
3548
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3549
|
+
});
|
|
3550
|
+
await tick();
|
|
3551
|
+
view.handleInput('T');
|
|
3552
|
+
for (const ch of 'bug') view.handleInput(ch);
|
|
3553
|
+
assert.match(strip(view.render(120)[1]), /› bug█/, 'a short query renders verbatim');
|
|
3554
|
+
});
|
|
3555
|
+
});
|
|
3556
|
+
|
|
3557
|
+
// S16 (T6 hand-over): the Issues preview is the SAME formatter the worktree
|
|
3558
|
+
// detail uses — lib/issue-preview.js — so the two surfaces cannot drift.
|
|
3559
|
+
describe('issues preview shares lib/issue-preview.js (S16)', () => {
|
|
3560
|
+
const { issueDetailLines } = require('../lib/issue-preview.js');
|
|
3561
|
+
|
|
3562
|
+
function rightColumn(view, width, rows = 24) {
|
|
3563
|
+
const panes = readerPanes(width);
|
|
3564
|
+
return view.render(width)
|
|
3565
|
+
.slice(4, Math.max(4, rows - 2))
|
|
3566
|
+
.map((line) => [...strip(line)].slice(5 + panes.left, 5 + panes.left + panes.right).join(''))
|
|
3567
|
+
.join('\n');
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
const detail = issue(7, {
|
|
3571
|
+
title: 'shared preview title',
|
|
3572
|
+
labels: [{ name: 'bug' }, { name: 'urgent' }],
|
|
3573
|
+
assignees: [{ login: 'ann' }, { login: 'bob' }],
|
|
3574
|
+
projects: [{ title: 'Roadmap' }],
|
|
3575
|
+
linkedPullRequestCount: 2,
|
|
3576
|
+
reactions: { plusOne: 4 },
|
|
3577
|
+
commentsCount: 2,
|
|
3578
|
+
body: 'the body the preview renders',
|
|
3579
|
+
});
|
|
3580
|
+
const comments = [
|
|
3581
|
+
{ author: 'carol', createdAt: new Date(Date.now() - 300_000).toISOString(), body: 'first shared comment' },
|
|
3582
|
+
{ author: 'dave', createdAt: new Date(Date.now() - 60_000).toISOString(), body: 'second shared comment' },
|
|
3583
|
+
];
|
|
3584
|
+
|
|
3585
|
+
function previewView(theme = plainTheme) {
|
|
3586
|
+
return makeView({
|
|
3587
|
+
pages: pagesOf([[detail], [], []]),
|
|
3588
|
+
issues: [detail],
|
|
3589
|
+
rows: 40,
|
|
3590
|
+
theme,
|
|
3591
|
+
loadIssue: () => ({ ok: true, issue: detail, comments }),
|
|
3592
|
+
});
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
test('the ready pane is exactly what issueDetailLines renders', async () => {
|
|
3596
|
+
const { view } = previewView();
|
|
3597
|
+
await tick();
|
|
3598
|
+
const panes = readerPanes(100);
|
|
3599
|
+
const expected = issueDetailLines(detail, comments, { width: panes.right })
|
|
3600
|
+
.map((line) => line.trimEnd());
|
|
3601
|
+
const actual = rightColumn(view, 100, 40).split('\n').map((line) => line.trimEnd());
|
|
3602
|
+
// Trailing padding rows are frame, not preview.
|
|
3603
|
+
while (expected.length > 0 && expected[expected.length - 1] === '') expected.pop();
|
|
3604
|
+
while (actual.length > 0 && actual[actual.length - 1] === '') actual.pop();
|
|
3605
|
+
assert.deepEqual(actual, expected, 'every field, label, count, blank line and end marker matches the shared formatter');
|
|
3606
|
+
});
|
|
3607
|
+
|
|
3608
|
+
// T9 (S13): the right-pane issue title is a TITLE, so it takes the same role
|
|
3609
|
+
// the shared table gives the frame title and every other title — the preview
|
|
3610
|
+
// formatter asks the palette for it instead of hardcoding `accent`.
|
|
3611
|
+
test('the preview title takes the shared title role the formatter paints', async () => {
|
|
3612
|
+
const calls = [];
|
|
3613
|
+
const theme = {
|
|
3614
|
+
fg: (role, text) => { calls.push([role, text]); return String(text); },
|
|
3615
|
+
bg: (_role, text) => String(text),
|
|
3616
|
+
};
|
|
3617
|
+
const { view } = previewView(theme);
|
|
3618
|
+
await tick();
|
|
3619
|
+
view.render(100);
|
|
3620
|
+
assert.ok(
|
|
3621
|
+
calls.some(([role, text]) => role === 'customMessageLabel' && text === '#7 shared preview title'),
|
|
3622
|
+
'the title is painted with the shared title role',
|
|
3623
|
+
);
|
|
3624
|
+
assert.ok(
|
|
3625
|
+
!calls.some(([role, text]) => role === 'accent' && text === '#7 shared preview title'),
|
|
3626
|
+
'the title is not painted with the button accent',
|
|
3627
|
+
);
|
|
3628
|
+
});
|
|
3629
|
+
|
|
3630
|
+
test('the error and loading panes stay the view own status lines', async () => {
|
|
3631
|
+
const failing = makeView({
|
|
3632
|
+
pages: pagesOf([[detail], [], []]),
|
|
3633
|
+
issues: [detail],
|
|
3634
|
+
rows: 40,
|
|
3635
|
+
loadIssue: () => ({ ok: false, error: 'gh timed out' }),
|
|
3636
|
+
});
|
|
3637
|
+
await tick();
|
|
3638
|
+
assert.match(rightColumn(failing.view, 100, 40), /unreadable: gh timed out/,
|
|
3639
|
+
'a failed detail read keeps its honest status line');
|
|
3640
|
+
});
|
|
3641
|
+
});
|
|
3642
|
+
|
|
3643
|
+
|
|
3644
|
+
describe('round3 shared preview bindings and lossless comments', () => {
|
|
3645
|
+
async function scrollingView(extra = {}) {
|
|
3646
|
+
const rows = [issue(1), issue(2)];
|
|
3647
|
+
const result = makeView({
|
|
3648
|
+
pages: pagesOf([rows, [], []]), issues: rows, rows: 18,
|
|
3649
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { body: 'body line\n'.repeat(150) }), comments: [] }),
|
|
3650
|
+
...extra,
|
|
3651
|
+
});
|
|
3652
|
+
result.view.render(220);
|
|
3653
|
+
await tick();
|
|
3654
|
+
result.view.render(220);
|
|
3655
|
+
return result;
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
test('ctrl+j/k and the extended pair page before a case-folding host can select', async () => {
|
|
3659
|
+
const { view, paints } = await scrollingView({ helpers: {
|
|
3660
|
+
...helpers,
|
|
3661
|
+
matchesKey: (data, name) => name.length === 1
|
|
3662
|
+
? data.toLowerCase() === name.toLowerCase() : data === name,
|
|
3663
|
+
} });
|
|
3664
|
+
view.handleInput('pagedown');
|
|
3665
|
+
const page = view.state.scroll;
|
|
3666
|
+
assert.ok(page > 0);
|
|
3667
|
+
for (const key of ['ctrl+j', '\x1b[106;5u', '\x1b[27;5;106~', 'ctrl+shift+j', 'shift+ctrl+j', '\x1b[106;6u', '\x1b[27;6;106~', 'J']) {
|
|
3668
|
+
view.handleInput('home');
|
|
3669
|
+
view.handleInput(key);
|
|
3670
|
+
assert.equal(view.state.scroll, page, `${key} pages the document down`);
|
|
3671
|
+
assert.equal(view.state.index, 0, 'paging never selects');
|
|
3672
|
+
}
|
|
3673
|
+
for (const key of ['ctrl+k', '\x1b[107;5u', '\x1b[27;5;107~', 'ctrl+shift+k', '\x1b[107;6u', 'K']) {
|
|
3674
|
+
view.handleInput('home');
|
|
3675
|
+
view.handleInput('pagedown');
|
|
3676
|
+
const down = view.state.scroll;
|
|
3677
|
+
view.handleInput(key);
|
|
3678
|
+
assert.ok(view.state.scroll < down, `${key} pages the document up`);
|
|
3679
|
+
}
|
|
3680
|
+
view.handleInput('home');
|
|
3681
|
+
const before = paints.length;
|
|
3682
|
+
view.handleInput('\x1b[106;6:3u');
|
|
3683
|
+
assert.equal(view.state.scroll, 0);
|
|
3684
|
+
assert.equal(paints.length, before, 'release never repaints');
|
|
3685
|
+
for (const key of ['\n', '\r', '\x0b', 'enter']) {
|
|
3686
|
+
view.handleInput(key);
|
|
3687
|
+
assert.equal(view.state.scroll, 0, `${JSON.stringify(key)}: a bare newline and the legacy ctrl+k byte never scroll`);
|
|
3688
|
+
}
|
|
3689
|
+
view.handleInput('ctrl+d');
|
|
3690
|
+
assert.equal(view.state.scroll, Math.max(1, Math.floor(page / 2)), 'half-page stays distinct');
|
|
3691
|
+
view.handleInput('j');
|
|
3692
|
+
assert.equal(view.state.index, 1, 'lowercase still selects');
|
|
3693
|
+
view.handleInput('t');
|
|
3694
|
+
view.handleInput('J');
|
|
3695
|
+
assert.equal(view.state.query, 'J', 'search receives text, not preview paging');
|
|
3696
|
+
});
|
|
3697
|
+
|
|
3698
|
+
test('the legend always names ctrl+j/k scroll, never a fabricated capability', async () => {
|
|
3699
|
+
for (const active of [false, true]) {
|
|
3700
|
+
const { view } = await scrollingView({ helpers: { ...helpers, isKittyProtocolActive: () => active } });
|
|
3701
|
+
const footer = strip(view.render(220).at(-2));
|
|
3702
|
+
assert.equal(footer.split('ctrl+j/k scroll').length - 1, 1, `kitty ${active}: one labelled scroll token: ${footer}`);
|
|
3703
|
+
assert.ok(!footer.includes('ctrl+shift'), `kitty ${active}: the shifted alternate leaves the legend: ${footer}`);
|
|
3704
|
+
assert.ok(!footer.includes('shift+j/k'), `kitty ${active}: the printable alternate leaves the legend: ${footer}`);
|
|
3705
|
+
}
|
|
3706
|
+
});
|
|
3707
|
+
|
|
3708
|
+
test('a bare newline stays Enter: it confirms and never scrolls, even when the host calls it ctrl+j', async () => {
|
|
3709
|
+
// The installed matcher reports the same 0x0a byte as BOTH `ctrl+j` and
|
|
3710
|
+
// `enter` while no extended protocol is active. On that terminal Enter must
|
|
3711
|
+
// keep its confirm meaning and the byte must never page the document.
|
|
3712
|
+
const lfIsCtrlJ = {
|
|
3713
|
+
...helpers,
|
|
3714
|
+
matchesKey: (data, name) => data === name || (data === '\n' && (name === 'enter' || name === 'ctrl+j')),
|
|
3715
|
+
parseKey: (data) => (data === '\n' ? 'ctrl+j' : null),
|
|
3716
|
+
};
|
|
3717
|
+
const calls = [];
|
|
3718
|
+
const rows = [issue(1), issue(2)];
|
|
3719
|
+
const confirmView = makeView({
|
|
3720
|
+
helpers: lfIsCtrlJ,
|
|
3721
|
+
pages: pagesOf([rows, [], []]),
|
|
3722
|
+
issues: rows,
|
|
3723
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions: EDITABLE }), comments: [] }),
|
|
3724
|
+
actions: { setState: (n, state) => { calls.push([n, state]); return { ok: true }; } },
|
|
3725
|
+
}).view;
|
|
3726
|
+
confirmView.render(160);
|
|
3727
|
+
await tick();
|
|
3728
|
+
confirmView.handleInput('x');
|
|
3729
|
+
assert.equal(confirmView.state.mode, 'confirm', 'x opens the close confirmation');
|
|
3730
|
+
confirmView.handleInput('\n');
|
|
3731
|
+
await tick();
|
|
3732
|
+
assert.deepEqual(calls, [[1, 'closed']], 'the newline confirms the close instead of scrolling');
|
|
3733
|
+
assert.equal(confirmView.state.scroll, 0, 'the newline never paged the document');
|
|
3734
|
+
|
|
3735
|
+
const commentView = makeView({
|
|
3736
|
+
helpers: lfIsCtrlJ,
|
|
3737
|
+
pages: pagesOf([rows, [], []]),
|
|
3738
|
+
issues: rows,
|
|
3739
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { permissions: EDITABLE }), comments: [] }),
|
|
3740
|
+
}).view;
|
|
3741
|
+
await tick();
|
|
3742
|
+
commentView.render(160);
|
|
3743
|
+
commentView.handleInput('c');
|
|
3744
|
+
assert.equal(commentView.state.mode, 'comment');
|
|
3745
|
+
commentView.handleInput('h');
|
|
3746
|
+
commentView.handleInput('i');
|
|
3747
|
+
commentView.handleInput('\n');
|
|
3748
|
+
assert.equal(commentView.state.commentText, 'hi\n', 'the newline starts a comment line instead of scrolling');
|
|
3749
|
+
assert.equal(commentView.state.scroll, 0, 'and the document never moved');
|
|
3750
|
+
|
|
3751
|
+
const { view: scrolling } = await scrollingView({ helpers: lfIsCtrlJ });
|
|
3752
|
+
scrolling.handleInput('ctrl+j');
|
|
3753
|
+
assert.ok(scrolling.state.scroll > 0, 'the distinguishable ctrl+j still pages the document');
|
|
3754
|
+
});
|
|
3755
|
+
|
|
3756
|
+
test('the Issues preview keeps every character of an overlong comment token', async () => {
|
|
3757
|
+
const { view } = makeView({
|
|
3758
|
+
pages: pagesOf([[issue(1)], [], []]), issues: [issue(1)],
|
|
3759
|
+
loadIssue: () => ({ ok: true, issue: issue(1), comments: [{ author: 'writer', body: 'X'.repeat(2500) }] }),
|
|
3760
|
+
});
|
|
3761
|
+
view.render(110);
|
|
3762
|
+
await tick();
|
|
3763
|
+
let seen = '';
|
|
3764
|
+
let previous = null;
|
|
3765
|
+
for (let step = 0; step < 200; step += 1) {
|
|
3766
|
+
const body = view.render(110).slice(4, -2).map(strip);
|
|
3767
|
+
const scroll = view.state.scroll;
|
|
3768
|
+
const overlap = previous === null ? 0 : Math.max(0, previous + body.length - scroll);
|
|
3769
|
+
seen += body.slice(overlap).join('');
|
|
3770
|
+
if (previous === scroll) break;
|
|
3771
|
+
previous = scroll;
|
|
3772
|
+
view.handleInput('pagedown');
|
|
3773
|
+
}
|
|
3774
|
+
assert.equal((seen.match(/X/g) || []).length, 2500);
|
|
3775
|
+
assert.match(seen, /end of issue/);
|
|
3776
|
+
});
|
|
3777
|
+
});
|
|
3778
|
+
|
|
3779
|
+
// S14: the user read the extra document-scroll tokens as noise — «alguna key
|
|
3780
|
+
// generidad como el page o half estan demas (nose para que es)». The footer now
|
|
3781
|
+
// spends ONE token on the scroll interaction and says what it does
|
|
3782
|
+
// (`ctrl+j/k scroll`), while every key whose hint left the row keeps its
|
|
3783
|
+
// binding: `]`/`[` still scroll the list, they are simply not advertised
|
|
3784
|
+
// because the selection already drags the list window.
|
|
3785
|
+
describe('issues footer names each scroll target once (S14)', () => {
|
|
3786
|
+
const longBody = Array.from({ length: 100 }, (_, i) => `body line ${i + 1}`).join('\n');
|
|
3787
|
+
|
|
3788
|
+
function scrollView(extra = {}) {
|
|
3789
|
+
const rows = Array.from({ length: 12 }, (_, i) => issue(i + 1, { body: longBody }));
|
|
3790
|
+
const result = makeView({
|
|
3791
|
+
pages: pagesOf([rows, [issue(20), issue(21)], [issue(30)]]),
|
|
3792
|
+
issues: rows,
|
|
3793
|
+
rows: 16,
|
|
3794
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { body: longBody }), comments: [] }),
|
|
3795
|
+
...extra,
|
|
3796
|
+
});
|
|
3797
|
+
result.view.render(160);
|
|
3798
|
+
return result;
|
|
3799
|
+
}
|
|
3800
|
+
|
|
3801
|
+
const footerOf = (view, width) => strip(view.render(width).at(-2));
|
|
3802
|
+
|
|
3803
|
+
test('the scroll hints collapse to ONE token that says what it does', async () => {
|
|
3804
|
+
for (const active of [true, false]) {
|
|
3805
|
+
const { view } = scrollView({ helpers: { ...helpers, isKittyProtocolActive: () => active } });
|
|
3806
|
+
await tick();
|
|
3807
|
+
const footer = footerOf(view, 160);
|
|
3808
|
+
assert.equal(footer.split('ctrl+j/k scroll').length - 1, 1, `the scroll hint is named once: ${footer}`);
|
|
3809
|
+
for (const gone of ['PgUp', 'PgDn', 'PgUp/Dn', 'ctrl+d/u', 'half', 'ctrl+shift', 'shift+j/k']) {
|
|
3810
|
+
assert.ok(!footer.includes(gone), `the redundant "${gone}" token is gone: ${footer}`);
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
});
|
|
3814
|
+
|
|
3815
|
+
test('the list-scroll binding works but is not advertised', async () => {
|
|
3816
|
+
const { view } = scrollView();
|
|
3817
|
+
await tick();
|
|
3818
|
+
const footer = footerOf(view, 160);
|
|
3819
|
+
assert.ok(!footer.includes(']/['), `the list-scroll hint left the row: ${footer}`);
|
|
3820
|
+
assert.equal(footer.split('scroll').length - 1, 1, `only the one scroll token is named: ${footer}`);
|
|
3821
|
+
const start = view.state.listStart;
|
|
3822
|
+
const scroll = view.state.scroll;
|
|
3823
|
+
view.handleInput(']');
|
|
3824
|
+
assert.ok(view.state.listStart > start, '] still scrolls the list a screenful');
|
|
3825
|
+
assert.equal(view.state.scroll, scroll, '] never scrolls the document');
|
|
3826
|
+
view.handleInput('[');
|
|
3827
|
+
assert.equal(view.state.listStart, start, '[ still scrolls the list back');
|
|
3828
|
+
});
|
|
3829
|
+
|
|
3830
|
+
test('every document-scroll binding keeps working after its hint left the row', async () => {
|
|
3831
|
+
const { view } = scrollView();
|
|
3832
|
+
await tick();
|
|
3833
|
+
view.render(160);
|
|
3834
|
+
for (const key of ['pagedown', '\x1b[6~', 'ctrl+j', '\x1b[106;5u', 'ctrl+shift+j', 'J']) {
|
|
3835
|
+
view.handleInput('home');
|
|
3836
|
+
assert.equal(view.state.scroll, 0, `home returns the document to the top: ${key}`);
|
|
3837
|
+
view.handleInput(key);
|
|
3838
|
+
assert.ok(view.state.scroll > 0, `${key} still pages the document down`);
|
|
3839
|
+
assert.equal(view.state.index, 0, `${key} never moves the list selection`);
|
|
3840
|
+
}
|
|
3841
|
+
view.handleInput('home');
|
|
3842
|
+
view.handleInput('pagedown');
|
|
3843
|
+
const page = view.state.scroll;
|
|
3844
|
+
view.handleInput('home');
|
|
3845
|
+
view.handleInput('ctrl+d');
|
|
3846
|
+
const half = view.state.scroll;
|
|
3847
|
+
assert.ok(half > 0 && half < page, `ctrl+d is still half a page: ${half} of ${page}`);
|
|
3848
|
+
view.handleInput('ctrl+u');
|
|
3849
|
+
assert.equal(view.state.scroll, 0, 'ctrl+u is still half a page back up');
|
|
3850
|
+
for (const key of ['pageup', '\x1b[5~', 'ctrl+k', '\x1b[107;5u', 'ctrl+shift+k', 'K']) {
|
|
3851
|
+
view.handleInput('pagedown');
|
|
3852
|
+
const down = view.state.scroll;
|
|
3853
|
+
assert.ok(down > 0, `pagedown has somewhere to come back from: ${key}`);
|
|
3854
|
+
view.handleInput(key);
|
|
3855
|
+
assert.ok(view.state.scroll < down, `${key} still pages the document up`);
|
|
3856
|
+
}
|
|
3857
|
+
view.handleInput('pagedown');
|
|
3858
|
+
view.handleInput('home');
|
|
3859
|
+
assert.equal(view.state.scroll, 0, 'home still returns the document to the top');
|
|
3860
|
+
view.handleInput('pagedown');
|
|
3861
|
+
view.handleInput('end');
|
|
3862
|
+
assert.equal(view.state.scroll, 0, 'end still shows the last issue from its own top');
|
|
3863
|
+
assert.equal(view.state.index, 11, 'end still selects the last issue');
|
|
3864
|
+
});
|
|
3865
|
+
|
|
3866
|
+
test('the list scroll and the bucket keys keep their own bindings', async () => {
|
|
3867
|
+
const { view } = scrollView();
|
|
3868
|
+
await tick();
|
|
3869
|
+
view.render(160);
|
|
3870
|
+
assert.equal(view.state.listStart, 0);
|
|
3871
|
+
view.handleInput(']');
|
|
3872
|
+
const moved = view.state.listStart;
|
|
3873
|
+
assert.ok(moved > 0, '] still scrolls the list a screenful');
|
|
3874
|
+
view.handleInput('[');
|
|
3875
|
+
assert.ok(view.state.listStart < moved, '[ still scrolls the list back');
|
|
3876
|
+
for (const [key, bucket] of [['right', 1], ['tab', 2], ['left', 1], ['shift+tab', 0]]) {
|
|
3877
|
+
view.handleInput(key);
|
|
3878
|
+
assert.equal(view.state.page, bucket, `${key} still switches buckets`);
|
|
3879
|
+
assert.equal(view.state.scroll, 0, `${key} never scrolls the document`);
|
|
3880
|
+
}
|
|
3881
|
+
});
|
|
3882
|
+
|
|
3883
|
+
test('the collapsed legend stays exactly ONE row at narrow widths', async () => {
|
|
3884
|
+
const { view } = scrollView();
|
|
3885
|
+
await tick();
|
|
3886
|
+
for (const width of [100, 80, 60]) {
|
|
3887
|
+
const lines = view.render(width);
|
|
3888
|
+
assert.equal(lines.length, 16, `the frame keeps its 16-row budget at ${width}`);
|
|
3889
|
+
for (const line of lines) assert.equal(helpers.visibleWidth(line), width, `the frame stays ${width} wide`);
|
|
3890
|
+
const footer = strip(lines.at(-2));
|
|
3891
|
+
assert.ok(footer.startsWith('│ ') && footer.endsWith(' │'), `the legend fills one row cell-exact: ${footer}`);
|
|
3892
|
+
assert.ok(footer.includes('esc close'), `the close hint outlives the trim at ${width}: ${footer}`);
|
|
3893
|
+
assert.ok(!footer.includes('PgUp') && !footer.includes('ctrl+d/u'), `no dropped token returns at ${width}: ${footer}`);
|
|
3894
|
+
}
|
|
3895
|
+
});
|
|
3896
|
+
});
|
|
3897
|
+
|
|
3898
|
+
// S3 (T3): the reader no longer pages the list. The left pane takes the smaller
|
|
3899
|
+
// 30% share, the list is ONE continuous window that scrolls by a screenful, and
|
|
3900
|
+
// on the server seam the next cursor page APPENDS to the rows already loaded —
|
|
3901
|
+
// search still reads the complete bucket through `query`, so no local filter is
|
|
3902
|
+
// ever the source of truth.
|
|
3903
|
+
describe('issues continuous list scrolling instead of pages (S3)', () => {
|
|
3904
|
+
const readerPanes = (width) => layout.paneWidths(layout.innerWidth(width), { listShare: layout.READER_LIST_SHARE });
|
|
3905
|
+
|
|
3906
|
+
test('the left pane takes the smaller 30/70 share at 80, 120 and 200 columns', async () => {
|
|
3907
|
+
for (const width of [80, 120, 200]) {
|
|
3908
|
+
const { view } = makeView({
|
|
3909
|
+
rows: 24,
|
|
3910
|
+
loadIssuePage: () => pageResult([cursorRow('c1', issue(1, { title: 'short title' }))]),
|
|
3911
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3912
|
+
});
|
|
3913
|
+
await tick();
|
|
3914
|
+
const expected = readerPanes(width).left;
|
|
3915
|
+
const bodyRow = strip(view.render(width)[5]);
|
|
3916
|
+
assert.equal([...bodyRow].indexOf('│', 2), 3 + expected, `${width}: the left pane is ${expected} cells`);
|
|
3917
|
+
assert.ok(expected < layout.paneWidths(layout.innerWidth(width)).left, `${width}: it is narrower than the old 40% list`);
|
|
3918
|
+
}
|
|
3919
|
+
});
|
|
3920
|
+
|
|
3921
|
+
test('no page marker renders anywhere in the frame', async () => {
|
|
3922
|
+
const rows = Array.from({ length: 30 }, (_, at) => issue(at + 1));
|
|
3923
|
+
const { view } = makeView({
|
|
3924
|
+
pages: pagesOf([rows, [], []]),
|
|
3925
|
+
issues: rows,
|
|
3926
|
+
rows: 12,
|
|
3927
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3928
|
+
});
|
|
3929
|
+
await tick();
|
|
3930
|
+
const body = bodyOf(view, 100);
|
|
3931
|
+
assert.ok(!/page \d/i.test(body), 'no page indicator is printed');
|
|
3932
|
+
assert.match(strip(view.render(100)[1]), /› Search/, 'the search row carries the widget alone');
|
|
3933
|
+
});
|
|
3934
|
+
|
|
3935
|
+
test('the footer names no page key', async () => {
|
|
3936
|
+
const { view } = makeView({
|
|
3937
|
+
pages: pagesOf([[issue(1)], [], []]),
|
|
3938
|
+
issues: [issue(1)],
|
|
3939
|
+
rows: 24,
|
|
3940
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3941
|
+
});
|
|
3942
|
+
await tick();
|
|
3943
|
+
const footer = strip(view.render(200).at(-2));
|
|
3944
|
+
assert.ok(!/page/i.test(footer), `the legend drops the page hint: ${footer}`);
|
|
3945
|
+
// S10: the arrows switch buckets now, so the legend names THEM as the
|
|
3946
|
+
// bucket key, while `]`/`[` keep scrolling the list without a token.
|
|
3947
|
+
assert.match(footer, /←→ bucket/, 'the bucket switch is still named, arrows only');
|
|
3948
|
+
assert.ok(!footer.includes('tab'), 'tab keeps working but leaves the legend');
|
|
3949
|
+
// S14: the scroll hints collapse into the ONE labelled token, and the list
|
|
3950
|
+
// keys — whose hint was noise beside the selection that drags the window —
|
|
3951
|
+
// cost no cells.
|
|
3952
|
+
assert.ok(!footer.includes(']/['), `the list scroll keys name nothing: ${footer}`);
|
|
3953
|
+
assert.match(footer, /ctrl\+j\/k scroll/, 'the scroll is named once with what it does');
|
|
3954
|
+
});
|
|
3955
|
+
|
|
3956
|
+
test('a long list scrolls continuously: the window moves a screen at a time', async () => {
|
|
3957
|
+
const rows = Array.from({ length: 30 }, (_, at) => issue(at + 1));
|
|
3958
|
+
const { view } = makeView({
|
|
3959
|
+
pages: pagesOf([rows, [], []]),
|
|
3960
|
+
issues: rows,
|
|
3961
|
+
rows: 12,
|
|
3962
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3963
|
+
});
|
|
3964
|
+
await tick();
|
|
3965
|
+
// 12 rows leave 6 body rows, so the first window paints issues 1..6.
|
|
3966
|
+
const first = leftColumn(view, 100, 12);
|
|
3967
|
+
assert.match(first, /issue title 1/);
|
|
3968
|
+
assert.match(first, /issue title 6/);
|
|
3969
|
+
assert.ok(!first.includes('issue title 7'), 'the rest of the list is below the window');
|
|
3970
|
+
view.handleInput(']');
|
|
3971
|
+
const second = leftColumn(view, 100, 12).split('\n').map((row) => row.trim().replace(/^[▸ ] /, ''));
|
|
3972
|
+
assert.equal(second[0], 'issue title 7', '→ scrolls to the next screen starting after the last visible row');
|
|
3973
|
+
assert.equal(second[5], 'issue title 12');
|
|
3974
|
+
assert.ok(!second.includes('issue title 1'), 'the previous screen scrolled away');
|
|
3975
|
+
view.handleInput('[');
|
|
3976
|
+
const back = leftColumn(view, 100, 12).split('\n').map((row) => row.trim().replace(/^[▸ ] /, ''));
|
|
3977
|
+
assert.equal(back[0], 'issue title 1', '← scrolls back to the first screen');
|
|
3978
|
+
});
|
|
3979
|
+
|
|
3980
|
+
test('the selection walks past the window edge and the list follows it', async () => {
|
|
3981
|
+
const rows = Array.from({ length: 20 }, (_, at) => issue(at + 1));
|
|
3982
|
+
const { view } = makeView({
|
|
3983
|
+
pages: pagesOf([rows, [], []]),
|
|
3984
|
+
issues: rows,
|
|
3985
|
+
rows: 12,
|
|
3986
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
3987
|
+
});
|
|
3988
|
+
await tick();
|
|
3989
|
+
for (let step = 0; step < 6; step += 1) view.handleInput('down');
|
|
3990
|
+
assert.equal(view.state.index, 6, 'the seventh issue is focused');
|
|
3991
|
+
const left = leftColumn(view, 100, 12);
|
|
3992
|
+
assert.ok(!left.includes('issue title 1'), 'the window scrolled past the first row');
|
|
3993
|
+
assert.match(left, /▸ issue title 7/, 'the focused row is painted at the window edge');
|
|
3994
|
+
});
|
|
3995
|
+
|
|
3996
|
+
test('home and end move the window to the first and the last loaded row', async () => {
|
|
3997
|
+
const rows = Array.from({ length: 25 }, (_, at) => issue(at + 1));
|
|
3998
|
+
const { view } = makeView({
|
|
3999
|
+
pages: pagesOf([rows, [], []]),
|
|
4000
|
+
issues: rows,
|
|
4001
|
+
rows: 12,
|
|
4002
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
4003
|
+
});
|
|
4004
|
+
await tick();
|
|
4005
|
+
view.handleInput('end');
|
|
4006
|
+
assert.equal(view.state.index, 24);
|
|
4007
|
+
assert.match(leftColumn(view, 100, 12), /▸ issue title 25/, 'the last issue is painted');
|
|
4008
|
+
view.handleInput('home');
|
|
4009
|
+
assert.equal(view.state.index, 0);
|
|
4010
|
+
assert.match(leftColumn(view, 100, 12), /▸ issue title 1/, 'the first issue is painted again');
|
|
4011
|
+
});
|
|
4012
|
+
|
|
4013
|
+
test('scrolling to the bottom of the server list appends the next cursor page', async () => {
|
|
4014
|
+
const calls = [];
|
|
4015
|
+
const { view } = makeView({
|
|
4016
|
+
rows: 12,
|
|
4017
|
+
loadIssuePage: (request) => {
|
|
4018
|
+
calls.push(request);
|
|
4019
|
+
if (request.cursor === null) {
|
|
4020
|
+
return pageResult([
|
|
4021
|
+
cursorRow('c1', issue(1)),
|
|
4022
|
+
cursorRow('c2', issue(2)),
|
|
4023
|
+
cursorRow('c3', issue(3)),
|
|
4024
|
+
], { hasNextPage: true });
|
|
4025
|
+
}
|
|
4026
|
+
return pageResult([cursorRow('c4', issue(4)), cursorRow('c5', issue(5))]);
|
|
4027
|
+
},
|
|
4028
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
4029
|
+
});
|
|
4030
|
+
await tick();
|
|
4031
|
+
assert.equal(calls.length, 1, 'opening reads one page, never the whole bucket');
|
|
4032
|
+
leftColumn(view, 60, 12);
|
|
4033
|
+
view.handleInput(']');
|
|
4034
|
+
assert.equal(calls.length, 2, 'scrolling to the loaded bottom asks for the next page');
|
|
4035
|
+
assert.equal(calls[1].cursor, 'c3', 'the appended read starts after the last loaded row');
|
|
4036
|
+
assert.equal(calls[1].bucketId, 'mine', 'the appended read stays in the same bucket');
|
|
4037
|
+
await tick();
|
|
4038
|
+
const left = leftColumn(view, 60, 12);
|
|
4039
|
+
assert.match(left, /issue title 1/, 'the first page stays in the list above the appended rows');
|
|
4040
|
+
assert.match(left, /issue title 5/, 'the appended page extends the list instead of replacing it');
|
|
4041
|
+
});
|
|
4042
|
+
|
|
4043
|
+
test('the appended read repeats the active query instead of the page text', async () => {
|
|
4044
|
+
const calls = [];
|
|
4045
|
+
const clock = manualScheduler();
|
|
4046
|
+
const { view } = makeView({
|
|
4047
|
+
rows: 12,
|
|
4048
|
+
scheduleQuery: clock.schedule,
|
|
4049
|
+
cancelSchedule: clock.cancel,
|
|
4050
|
+
loadIssuePage: (request) => {
|
|
4051
|
+
calls.push(request);
|
|
4052
|
+
return pageResult([cursorRow(`c${calls.length}`, issue(calls.length))], { hasNextPage: true });
|
|
4053
|
+
},
|
|
4054
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
4055
|
+
});
|
|
4056
|
+
await tick();
|
|
4057
|
+
leftColumn(view, 60, 12);
|
|
4058
|
+
view.handleInput('T');
|
|
4059
|
+
for (const char of 'label:bug') view.handleInput(char);
|
|
4060
|
+
view.handleInput('enter');
|
|
4061
|
+
clock.fire();
|
|
4062
|
+
await tick();
|
|
4063
|
+
const afterQuery = calls.at(-1);
|
|
4064
|
+
assert.equal(afterQuery.query, 'label:bug', 'the query rides the request');
|
|
4065
|
+
assert.equal(afterQuery.cursor, null, 'a new query starts at the head of its own result set');
|
|
4066
|
+
leftColumn(view, 60, 12);
|
|
4067
|
+
view.handleInput(']');
|
|
4068
|
+
await tick();
|
|
4069
|
+
const appended = calls.at(-1);
|
|
4070
|
+
assert.equal(appended.query, 'label:bug', 'the appended page searches the same query');
|
|
4071
|
+
assert.ok(appended.cursor !== null, 'the appended page continues from the loaded rows');
|
|
4072
|
+
});
|
|
4073
|
+
|
|
4074
|
+
test('a bucket with no next page stops asking for more', async () => {
|
|
4075
|
+
const calls = [];
|
|
4076
|
+
const { view } = makeView({
|
|
4077
|
+
rows: 12,
|
|
4078
|
+
loadIssuePage: (request) => {
|
|
4079
|
+
calls.push(request);
|
|
4080
|
+
return pageResult([cursorRow('c1', issue(1)), cursorRow('c2', issue(2))]);
|
|
4081
|
+
},
|
|
4082
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
4083
|
+
});
|
|
4084
|
+
await tick();
|
|
4085
|
+
leftColumn(view, 60, 12);
|
|
4086
|
+
for (let step = 0; step < 3; step += 1) view.handleInput(']');
|
|
4087
|
+
assert.equal(calls.length, 1, 'a complete bucket is never re-read by scrolling');
|
|
4088
|
+
});
|
|
4089
|
+
|
|
4090
|
+
test('a resize keeps the loaded rows instead of re-reading page one', async () => {
|
|
4091
|
+
const calls = [];
|
|
4092
|
+
const { view } = makeView({
|
|
4093
|
+
rows: 30,
|
|
4094
|
+
loadIssuePage: (request) => {
|
|
4095
|
+
calls.push(request);
|
|
4096
|
+
return pageResult([cursorRow('c1', issue(1)), cursorRow('c2', issue(2))], { hasNextPage: true });
|
|
4097
|
+
},
|
|
4098
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
4099
|
+
});
|
|
4100
|
+
await tick();
|
|
4101
|
+
bodyOf(view, 120);
|
|
4102
|
+
bodyOf(view, 60);
|
|
4103
|
+
assert.equal(calls.length, 1, 'the window geometry changed, not the data');
|
|
4104
|
+
const left = leftColumn(view, 60, 30);
|
|
4105
|
+
assert.match(left, /issue title 1/, 'the rows stay painted after the resize');
|
|
4106
|
+
assert.match(left, /issue title 2/);
|
|
4107
|
+
});
|
|
4108
|
+
|
|
4109
|
+
test('the All tab still lists the repository and keeps the query', async () => {
|
|
4110
|
+
const calls = [];
|
|
4111
|
+
const clock = manualScheduler();
|
|
4112
|
+
const { view } = makeView({
|
|
4113
|
+
rows: 20,
|
|
4114
|
+
scheduleQuery: clock.schedule,
|
|
4115
|
+
cancelSchedule: clock.cancel,
|
|
4116
|
+
loadIssuePage: (request) => {
|
|
4117
|
+
calls.push(request);
|
|
4118
|
+
return pageResult([cursorRow('a1', issue(41, { title: 'all tab hit' }))]);
|
|
4119
|
+
},
|
|
4120
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
4121
|
+
});
|
|
4122
|
+
await tick();
|
|
4123
|
+
const tabs = strip(view.render(120)[2]);
|
|
4124
|
+
assert.match(tabs, /All/, 'the fourth tab survives the layout change');
|
|
4125
|
+
view.handleInput('tab');
|
|
4126
|
+
view.handleInput('tab');
|
|
4127
|
+
view.handleInput('tab');
|
|
4128
|
+
assert.equal(view.state.page, 3, 'tab reaches the All bucket');
|
|
4129
|
+
await tick();
|
|
4130
|
+
assert.equal(calls.at(-1).bucketId, 'all', 'All reads its own bucket');
|
|
4131
|
+
assert.match(leftColumn(view, 120, 20), /all tab hit/);
|
|
4132
|
+
view.handleInput('T');
|
|
4133
|
+
for (const char of 'bug') view.handleInput(char);
|
|
4134
|
+
clock.fire();
|
|
4135
|
+
await tick();
|
|
4136
|
+
assert.equal(calls.at(-1).query, 'bug', 'search still rides the All bucket read');
|
|
4137
|
+
assert.equal(calls.at(-1).bucketId, 'all');
|
|
4138
|
+
});
|
|
4139
|
+
});
|
|
4140
|
+
|
|
4141
|
+
// ---------------------------------------------------------------------------
|
|
4142
|
+
// S10/S11: the arrows switch buckets exactly like `tab` (the list keeps its
|
|
4143
|
+
// other scroll keys), and every footer/header token paints through the injected
|
|
4144
|
+
// theme with a role that distinguishes a shortcut KEY from its description, a
|
|
4145
|
+
// title from its count, a label from a live value.
|
|
4146
|
+
describe('issues view bucket arrows and palette (S10/S11)', () => {
|
|
4147
|
+
const rows = Array.from({ length: 10 }, (_, at) => issue(at + 1));
|
|
4148
|
+
|
|
4149
|
+
function twoBucketView(extra = {}) {
|
|
4150
|
+
return makeView({
|
|
4151
|
+
pages: [
|
|
4152
|
+
{ id: 'p0', label: 'My issues', issues: rows, total: rows.length },
|
|
4153
|
+
{ id: 'p1', label: 'Assigned', issues: [issue(99)], total: 1 },
|
|
4154
|
+
],
|
|
4155
|
+
issues: rows,
|
|
4156
|
+
rows: 12,
|
|
4157
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n), comments: [] }),
|
|
4158
|
+
...extra,
|
|
4159
|
+
});
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
test('←/→ switch buckets like tab and never scroll the list (S10)', async () => {
|
|
4163
|
+
const { view } = twoBucketView();
|
|
4164
|
+
await tick();
|
|
4165
|
+
bodyOf(view, 100);
|
|
4166
|
+
// The list scroll keys are the OTHER bindings and keep their behaviour.
|
|
4167
|
+
view.handleInput(']');
|
|
4168
|
+
assert.equal(view.state.listStart, 4, '] still scrolls the list one screenful');
|
|
4169
|
+
assert.equal(view.state.page, 0, '] never switches buckets');
|
|
4170
|
+
view.handleInput('[');
|
|
4171
|
+
assert.equal(view.state.listStart, 0, '[ still scrolls the list back');
|
|
4172
|
+
view.handleInput('right');
|
|
4173
|
+
assert.equal(view.state.page, 1, '→ switches to the next bucket like tab');
|
|
4174
|
+
assert.equal(view.state.listStart, 0, '→ scrolled no list');
|
|
4175
|
+
assert.match(bodyOf(view, 100), /issue title 99/, 'the next bucket paints after →');
|
|
4176
|
+
view.handleInput('left');
|
|
4177
|
+
assert.equal(view.state.page, 0, '← switches back a bucket');
|
|
4178
|
+
view.handleInput('tab');
|
|
4179
|
+
assert.equal(view.state.page, 1, 'tab still switches forward');
|
|
4180
|
+
view.handleInput('shift+tab');
|
|
4181
|
+
assert.equal(view.state.page, 0, 'shift+tab still switches back');
|
|
4182
|
+
});
|
|
4183
|
+
|
|
4184
|
+
test('the footer names the bucket arrows and no longer names the list scroll (S10)', async () => {
|
|
4185
|
+
const { view } = twoBucketView({ rows: 24 });
|
|
4186
|
+
await tick();
|
|
4187
|
+
const footer = strip(view.render(200).at(-2));
|
|
4188
|
+
assert.match(footer, /←→ bucket/, 'the arrows are named as the bucket switch');
|
|
4189
|
+
assert.ok(!footer.includes('tab'), 'tab keeps working but leaves the legend');
|
|
4190
|
+
assert.ok(!footer.includes(']/['), `the list scroll hint left the row: ${footer}`);
|
|
4191
|
+
assert.equal(footer.split('scroll').length - 1, 1, `the one scroll token is the surviving one: ${footer}`);
|
|
4192
|
+
assert.ok(!/←\/→ scroll/.test(footer), 'the old arrow-scroll hint is gone');
|
|
4193
|
+
});
|
|
4194
|
+
|
|
4195
|
+
test('the preview scroll keys keep paging the issue pane (S10 preserve)', async () => {
|
|
4196
|
+
const longBody = Array.from({ length: 100 }, (_, at) => `body line ${at + 1}`).join('\n');
|
|
4197
|
+
const { view } = makeView({
|
|
4198
|
+
pages: pagesOf([[issue(1, { body: longBody })], [], []]),
|
|
4199
|
+
issues: [issue(1, { body: longBody })],
|
|
4200
|
+
rows: 14,
|
|
4201
|
+
loadIssue: (n) => ({ ok: true, issue: issue(n, { body: longBody }), comments: [] }),
|
|
4202
|
+
});
|
|
4203
|
+
await tick();
|
|
4204
|
+
view.handleInput('pagedown');
|
|
4205
|
+
assert.ok(view.state.scroll > 0, 'PageDown still scrolls the preview');
|
|
4206
|
+
view.handleInput('home');
|
|
4207
|
+
assert.equal(view.state.scroll, 0, 'home still returns to the top');
|
|
4208
|
+
view.handleInput('ctrl+d');
|
|
4209
|
+
assert.ok(view.state.scroll > 0, 'ctrl+d still scrolls half a page');
|
|
4210
|
+
});
|
|
4211
|
+
|
|
4212
|
+
test('the footer paints keys accent+bold and descriptions muted (S11)', async () => {
|
|
4213
|
+
const theme = recordingTheme();
|
|
4214
|
+
const { view } = twoBucketView({ rows: 24, theme });
|
|
4215
|
+
await tick();
|
|
4216
|
+
const footer = view.render(200).at(-2);
|
|
4217
|
+
assert.match(footer, /\x1b\[1m\x1b\[96m↑↓\x1b\[0m\x1b\[22m/, 'the key token is painted accent+bold');
|
|
4218
|
+
assert.match(footer, /\x1b\[90m move/, 'its description is painted muted');
|
|
4219
|
+
assert.match(footer, /\x1b\[1m\x1b\[96m←→\x1b\[0m\x1b\[22m/, 'a compound key token is ONE key run');
|
|
4220
|
+
assert.match(footer, /\x1b\[90m scroll/, 'the scroll hint description is muted, not accent');
|
|
4221
|
+
assert.match(footer, /\x1b\[1m\x1b\[96mctrl\+j\/k\x1b\[0m\x1b\[22m\x1b\[90m scroll/, 'the collapsed scroll hint paints key and description apart, as ONE key run');
|
|
4222
|
+
assert.ok(theme.calls.some((call) => call.role === 'accent' && call.text === '↑↓'),
|
|
4223
|
+
'the accent role is requested from the injected theme');
|
|
4224
|
+
assert.ok(theme.calls.some((call) => call.role === 'bold' && call.text.includes('↑↓')),
|
|
4225
|
+
'the key is bolded through the theme, never by a literal escape');
|
|
4226
|
+
assert.ok(theme.calls.some((call) => call.role === 'muted' && call.text.includes('move')),
|
|
4227
|
+
'the description asks the injected theme for the muted role');
|
|
4228
|
+
});
|
|
4229
|
+
|
|
4230
|
+
// T9 (S13): the frame title and the active tab are TITLES, so they take the
|
|
4231
|
+
// Agents view's own title role; the footer keys keep the primary accent. The
|
|
4232
|
+
// preview's own heading now asks the same shared table for its title role.
|
|
4233
|
+
test('the header palette: secondary title, secondary active tab, muted counts (S11, S13)', async () => {
|
|
4234
|
+
const theme = recordingTheme();
|
|
4235
|
+
const { view } = twoBucketView({ rows: 24, theme });
|
|
4236
|
+
await tick();
|
|
4237
|
+
const lines = view.render(120);
|
|
4238
|
+
assert.ok(lines[0].includes(`${TITLE_PAINT}◈ Issues${CLOSE_PAINT}`),
|
|
4239
|
+
'the frame title is the shared title role, bold');
|
|
4240
|
+
assert.ok(theme.calls.some((call) => call.role === 'customMessageLabel' && call.text === '◈ Issues'),
|
|
4241
|
+
'the title asks the injected theme for the title role');
|
|
4242
|
+
assert.ok(!theme.calls.some((call) => call.role === 'accent' && call.text === '◈ Issues'),
|
|
4243
|
+
'the title never takes the button accent');
|
|
4244
|
+
const tabs = lines[2];
|
|
4245
|
+
assert.ok(tabs.includes(`${TITLE_PAINT}▸ My issues${CLOSE_PAINT}`),
|
|
4246
|
+
'the active tab label is a title too');
|
|
4247
|
+
assert.match(tabs, /\x1b\[90m \(10\)/, 'the active tab count is muted');
|
|
4248
|
+
assert.match(tabs, /\x1b\[90mAssigned \(1\)\x1b\[0m/, 'an inactive tab label and its count are both muted');
|
|
4249
|
+
assert.ok(lines.join('\n').includes(`${TITLE_PAINT}#1 issue title 1`),
|
|
4250
|
+
'the right-pane document title shares the frame title role (S13)');
|
|
4251
|
+
});
|
|
4252
|
+
|
|
4253
|
+
test('a hostile theme never breaks the frame and keeps the keys and focus readable (S11)', async () => {
|
|
4254
|
+
const hostile = {
|
|
4255
|
+
fg() { throw new Error('no colour support'); },
|
|
4256
|
+
bold() { throw new Error('no bold support'); },
|
|
4257
|
+
bg() { throw new Error('no background support'); },
|
|
4258
|
+
};
|
|
4259
|
+
const { view } = twoBucketView({ rows: 24, theme: hostile });
|
|
4260
|
+
await tick();
|
|
4261
|
+
const body = strip(view.render(120).join('\n'));
|
|
4262
|
+
assert.match(body, /◈ Issues/, 'the frame title still renders without colour');
|
|
4263
|
+
assert.match(body, /↑↓ move/, 'the footer still names the key and its description');
|
|
4264
|
+
assert.match(body, /▸ My issues/, 'the active tab keeps its mark, so focus survives monochrome');
|
|
4265
|
+
assert.match(body, /› Search/, 'the search widget still renders');
|
|
4266
|
+
});
|
|
4267
|
+
|
|
4268
|
+
test('the search row paints its label dim and its live value as ordinary text (S11, S13)', async () => {
|
|
4269
|
+
const theme = recordingTheme();
|
|
4270
|
+
const { view } = twoBucketView({ rows: 24, theme });
|
|
4271
|
+
await tick();
|
|
4272
|
+
const idle = view.render(120)[1];
|
|
4273
|
+
assert.match(idle, /\x1b\[2m› \x1b\[0m\x1b\[2mSearch/, 'the idle search widget is dim, never accent');
|
|
4274
|
+
view.handleInput('T');
|
|
4275
|
+
for (const char of 'bug') view.handleInput(char);
|
|
4276
|
+
const live = view.render(120)[1];
|
|
4277
|
+
assert.match(live, /\x1b\[2m› \x1b\[0m/, 'the prompt keeps the dim label role while typing');
|
|
4278
|
+
assert.match(live, /\x1b\[39mbug/, 'the live query value is the theme text role');
|
|
4279
|
+
assert.ok(!/\x1b\[96mbug/.test(live), 'the typed value is never the button accent');
|
|
4280
|
+
});
|
|
4281
|
+
});
|
|
4282
|
+
|
|
4283
|
+
// S12: «em issues, la preview igual hagamoslo como un md estilizado». The
|
|
4284
|
+
// preview's FREE TEXT — the issue body and every comment body — renders through
|
|
4285
|
+
// `lib/odd-markdown.js`, the same renderer the ODD document pane uses, so a
|
|
4286
|
+
// report written in markdown reads as headings, inline formatting and code
|
|
4287
|
+
// instead of raw syntax. The metadata rows (number, title, state, labels,
|
|
4288
|
+
// counts) stay the shared formatter's own (S16), and a control sequence in a
|
|
4289
|
+
// body is display data: it can neither paint nor move the cursor.
|
|
4290
|
+
describe('issues preview renders styled markdown (S12)', () => {
|
|
4291
|
+
const PANE_TOP = 4;
|
|
4292
|
+
function rightPane(view, width, rows = 40) {
|
|
4293
|
+
const panes = readerPanes(width);
|
|
4294
|
+
return view.render(width)
|
|
4295
|
+
.slice(PANE_TOP, Math.max(PANE_TOP, rows - 2))
|
|
4296
|
+
.map((line) => [...strip(line)].slice(5 + panes.left, 5 + panes.left + panes.right).join(''))
|
|
4297
|
+
.join('\n');
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
async function viewFor(body, { comments = [], theme = plainTheme } = {}) {
|
|
4301
|
+
const detail = issue(9, { body, title: 'markdown body' });
|
|
4302
|
+
const built = makeView({
|
|
4303
|
+
pages: pagesOf([[detail], [], []]),
|
|
4304
|
+
issues: [detail],
|
|
4305
|
+
rows: 40,
|
|
4306
|
+
theme,
|
|
4307
|
+
loadIssue: () => ({ ok: true, issue: detail, comments }),
|
|
4308
|
+
});
|
|
4309
|
+
built.view.render(100);
|
|
4310
|
+
await tick();
|
|
4311
|
+
built.view.render(100);
|
|
4312
|
+
return built.view;
|
|
4313
|
+
}
|
|
4314
|
+
|
|
4315
|
+
test('a heading in the body renders as a heading, not as raw syntax', async () => {
|
|
4316
|
+
const view = await viewFor('## Steps\n\nrun the tool');
|
|
4317
|
+
const text = rightPane(view, 100);
|
|
4318
|
+
assert.ok(!text.includes('## Steps'), 'the raw heading marker never reaches the pane');
|
|
4319
|
+
assert.match(text, /▌ Steps/, 'the heading keeps its mark');
|
|
4320
|
+
assert.match(text, /run the tool/, 'the body below the heading survives');
|
|
4321
|
+
});
|
|
4322
|
+
|
|
4323
|
+
test('inline code and bold lose their markers and keep their words', async () => {
|
|
4324
|
+
const view = await viewFor('use `gh auth` and **now** please');
|
|
4325
|
+
const text = rightPane(view, 100);
|
|
4326
|
+
assert.ok(!text.includes('`gh auth`'), 'the code marker is gone');
|
|
4327
|
+
assert.ok(!text.includes('**now**'), 'the bold marker is gone');
|
|
4328
|
+
assert.match(text, /gh auth/);
|
|
4329
|
+
assert.match(text, /now please/);
|
|
4330
|
+
});
|
|
4331
|
+
|
|
4332
|
+
test('a fenced block renders its body without the fence markers', async () => {
|
|
4333
|
+
const view = await viewFor('before\n\n```sh\ngit status\n```\n\nafter');
|
|
4334
|
+
const text = rightPane(view, 100);
|
|
4335
|
+
assert.ok(!text.includes('```'), 'no fence marker reaches the pane');
|
|
4336
|
+
assert.match(text, /git status/);
|
|
4337
|
+
assert.match(text, /before/);
|
|
4338
|
+
assert.match(text, /after/);
|
|
4339
|
+
});
|
|
4340
|
+
|
|
4341
|
+
test('the markdown roles come from the theme, like the ODD document pane', async () => {
|
|
4342
|
+
const theme = recordingTheme();
|
|
4343
|
+
const view = await viewFor('## Steps\n\nuse `gh` now', { theme });
|
|
4344
|
+
assert.ok(theme.calls.some((call) => call.role === 'mdHeading' && call.text.includes('Steps')),
|
|
4345
|
+
'a heading is painted mdHeading');
|
|
4346
|
+
assert.ok(theme.calls.some((call) => call.role === 'dim' && call.text.includes('gh')),
|
|
4347
|
+
'inline code is painted dim');
|
|
4348
|
+
assert.ok(theme.calls.some((call) => call.role === 'bold' && call.text.includes('#9 markdown body')),
|
|
4349
|
+
'the issue title keeps the shared formatter styling');
|
|
4350
|
+
});
|
|
4351
|
+
|
|
4352
|
+
test('a comment body is markdown too, and keeps its author heading', async () => {
|
|
4353
|
+
const view = await viewFor('issue body', {
|
|
4354
|
+
comments: [{ author: 'ann', createdAt: new Date().toISOString(), body: '- first point\n- second point' }],
|
|
4355
|
+
});
|
|
4356
|
+
const text = rightPane(view, 100);
|
|
4357
|
+
assert.match(text, /ann/, 'the comment author row survives');
|
|
4358
|
+
assert.ok(!text.includes('- first point'), 'the bullet marker is replaced by the renderer mark');
|
|
4359
|
+
assert.match(text, /• first point/);
|
|
4360
|
+
assert.match(text, /second point/);
|
|
4361
|
+
});
|
|
4362
|
+
|
|
4363
|
+
test('control sequences in a body cannot paint or move the cursor', async () => {
|
|
4364
|
+
const view = await viewFor('plain \x1b[31mred\x1b[0m \x1b]0;owned\x07text');
|
|
4365
|
+
const panes = readerPanes(100);
|
|
4366
|
+
const raw = view.render(100)
|
|
4367
|
+
.slice(PANE_TOP, 38)
|
|
4368
|
+
.map((line) => [...line].slice(5 + panes.left, 5 + panes.left + panes.right).join(''))
|
|
4369
|
+
.join('\n');
|
|
4370
|
+
assert.ok(!raw.includes('\x1b'), 'no escape sequence from the issue body reaches the pane');
|
|
4371
|
+
assert.ok(!raw.includes('\x07'), 'no BEL from the issue body reaches the pane');
|
|
4372
|
+
assert.match(strip(raw), /plain red text/);
|
|
4373
|
+
});
|
|
4374
|
+
|
|
4375
|
+
test('a markdown body keeps the reader scroll, the width, and every word', async () => {
|
|
4376
|
+
const items = Array.from({ length: 40 }, (_, at) => `- item ${at + 1}`);
|
|
4377
|
+
const view = await viewFor(['## Checklist', '', ...items].join('\n'));
|
|
4378
|
+
const pane = readerPanes(100);
|
|
4379
|
+
const first = rightPane(view, 100).split('\n')[0];
|
|
4380
|
+
view.handleInput('pagedown');
|
|
4381
|
+
view.render(100);
|
|
4382
|
+
const scroll = view.state.scroll;
|
|
4383
|
+
assert.ok(scroll > 0, 'the page key moved the document');
|
|
4384
|
+
const moved = rightPane(view, 100).split('\n')[0];
|
|
4385
|
+
assert.notEqual(moved, first, 'the pane actually moved');
|
|
4386
|
+
view.render(100);
|
|
4387
|
+
assert.equal(view.state.scroll, scroll, 'a repaint keeps the scroll position');
|
|
4388
|
+
assert.equal(rightPane(view, 100).split('\n')[0], moved, 'and the same first row');
|
|
4389
|
+
let seen = '';
|
|
4390
|
+
view.handleInput('home');
|
|
4391
|
+
for (let step = 0; step < 40; step += 1) {
|
|
4392
|
+
seen += `${rightPane(view, 100)}\n`;
|
|
4393
|
+
if (seen.includes('(end of issue)')) break;
|
|
4394
|
+
view.handleInput('pagedown');
|
|
4395
|
+
}
|
|
4396
|
+
for (let at = 1; at <= 40; at += 1) assert.match(seen, new RegExp(`item ${at}\\b`), `lost item ${at}`);
|
|
4397
|
+
assert.match(seen, /\(end of issue\)/, 'the end marker is still reachable by paging');
|
|
4398
|
+
assert.ok(!seen.includes('…'), 'no markdown row is wider than the pane');
|
|
4399
|
+
assert.equal(readerPanes(100).right, pane.right);
|
|
4400
|
+
});
|
|
4401
|
+
});
|