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,2328 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// T3/T4 (odd-viewer S3,S4,S6) tests: lib/odd-view.js — the two-pane odd
|
|
3
|
+
// viewer component (list on the left, styled document on the right).
|
|
4
|
+
//
|
|
5
|
+
// Contract under test, as observable output only: the component renders
|
|
6
|
+
// exactly the requested rows at the requested width; the left pane lists the
|
|
7
|
+
// display names without extension or path; moving the selection swaps the
|
|
8
|
+
// right pane and resets its scroll; the right pane scrolls to the last line
|
|
9
|
+
// of a long document; an empty checkout, an unreadable document and an empty
|
|
10
|
+
// document each render their own state instead of fake content; `esc` closes
|
|
11
|
+
// with a null result and mutates nothing; `r` re-lists.
|
|
12
|
+
|
|
13
|
+
const { test, describe } = require('node:test');
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
|
|
16
|
+
const { createOddView } = require('../lib/odd-view.js');
|
|
17
|
+
const layout = require('../lib/view-layout.js');
|
|
18
|
+
|
|
19
|
+
function strip(value) {
|
|
20
|
+
return String(value).replace(/\x1b\[[0-9;]*m/g, '');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const helpers = {
|
|
24
|
+
visibleWidth: (text) => [...strip(text)].length,
|
|
25
|
+
truncateToWidth: (text, width) => {
|
|
26
|
+
const chars = [...String(text)];
|
|
27
|
+
return chars.length > width ? chars.slice(0, Math.max(0, width - 1)).join('') + '…' : String(text);
|
|
28
|
+
},
|
|
29
|
+
matchesKey: (key, name) => key === name,
|
|
30
|
+
isKeyRelease: (key) => key === 'release',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Optional real host helpers: the extension injects the pi-tui module
|
|
34
|
+
// namespace. CI installs no dependencies, so the byte-level assertions run
|
|
35
|
+
// only when it resolves; every raw-byte assertion works without it.
|
|
36
|
+
let realTui = null;
|
|
37
|
+
try {
|
|
38
|
+
realTui = require('@earendil-works/pi-tui');
|
|
39
|
+
} catch {
|
|
40
|
+
realTui = null;
|
|
41
|
+
}
|
|
42
|
+
const cellWidth = (line) => (realTui && typeof realTui.visibleWidth === 'function'
|
|
43
|
+
? realTui.visibleWidth(line)
|
|
44
|
+
: [...strip(line)].length);
|
|
45
|
+
|
|
46
|
+
const plainTheme = { fg: (_role, text) => String(text), bg: (_role, text) => String(text) };
|
|
47
|
+
|
|
48
|
+
// S11/T9: a theme that RECORDS which role it was asked to paint each string
|
|
49
|
+
// with and paints every role with its own SGR code, so a rendered line can be
|
|
50
|
+
// read for the ROLE behind a token instead of trusting an eyeballed colour.
|
|
51
|
+
// T9 (S13): the title role is `customMessageLabel`, the one the Pi Agents view
|
|
52
|
+
// paints its own frame title with (`gentle-pi/lib/agents-view.ts:42,437`), which
|
|
53
|
+
// the recording theme paints with SGR 33; `text` is normal body text and
|
|
54
|
+
// `accent` the buttons.
|
|
55
|
+
const ROLE_SGR = { accent: 96, customMessageLabel: 33, muted: 90, dim: 2, border: 34, error: 91, text: 39, mdHeading: 35 };
|
|
56
|
+
|
|
57
|
+
function recordingTheme() {
|
|
58
|
+
const calls = [];
|
|
59
|
+
const code = (role) => ROLE_SGR[role] ?? 95;
|
|
60
|
+
return {
|
|
61
|
+
calls,
|
|
62
|
+
fg(role, text) {
|
|
63
|
+
const value = String(text);
|
|
64
|
+
calls.push({ role, text: value });
|
|
65
|
+
return `\x1b[${code(role)}m${value}\x1b[0m`;
|
|
66
|
+
},
|
|
67
|
+
bold(text) {
|
|
68
|
+
const value = String(text);
|
|
69
|
+
calls.push({ role: 'bold', text: value });
|
|
70
|
+
return `\x1b[1m${value}\x1b[22m`;
|
|
71
|
+
},
|
|
72
|
+
bg(role, text) {
|
|
73
|
+
calls.push({ role: `bg:${role}`, text: String(text) });
|
|
74
|
+
return String(text);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function doc(name) {
|
|
80
|
+
return { name, file: `${name}.md`, path: `/checkout/odd/tasks/${name}.md` };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function makeView({ docs, load, reloadDocs, rows = 20 } = {}) {
|
|
84
|
+
const paints = [];
|
|
85
|
+
let settled = 'pending';
|
|
86
|
+
const view = createOddView({
|
|
87
|
+
docs,
|
|
88
|
+
loadDoc: load,
|
|
89
|
+
reloadDocs,
|
|
90
|
+
helpers,
|
|
91
|
+
theme: plainTheme,
|
|
92
|
+
rowsAvailable: () => rows,
|
|
93
|
+
requestRender: () => { paints.push(true); },
|
|
94
|
+
done: (result) => { settled = result; },
|
|
95
|
+
});
|
|
96
|
+
return { view, done: () => settled, paints };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const longDoc = (lines) => Array.from({ length: lines }, (_, index) => `line ${index + 1}`).join('\n');
|
|
100
|
+
|
|
101
|
+
describe('createOddView rendering', () => {
|
|
102
|
+
test('renders a framed two-pane view at the requested size with the document names on the left', async () => {
|
|
103
|
+
const { view } = makeView({
|
|
104
|
+
docs: [doc('alpha'), doc('beta')],
|
|
105
|
+
load: () => ({ ok: true, text: '# alpha\n\n## Specs\nS1. first rule\n' }),
|
|
106
|
+
});
|
|
107
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
108
|
+
const lines = view.render(80);
|
|
109
|
+
assert.equal(lines.length, 20, 'the view fills the available rows');
|
|
110
|
+
for (const line of lines) assert.equal(helpers.visibleWidth(line), 80, `not 80 wide: "${strip(line)}"`);
|
|
111
|
+
const body = strip(lines.join('\n'));
|
|
112
|
+
assert.match(body, /✎ Odd · 2 documents/, 'the title carries the document count');
|
|
113
|
+
assert.match(body, /alpha/, 'the selected document name is listed');
|
|
114
|
+
assert.match(body, /beta/, 'the other document name is listed');
|
|
115
|
+
assert.ok(!body.includes('alpha.md'), 'the extension is not shown in the list');
|
|
116
|
+
assert.ok(!body.includes('/checkout/odd/tasks'), 'no path is shown in the list');
|
|
117
|
+
assert.match(body, /S1\. first rule/, 'the right pane shows the selected document content');
|
|
118
|
+
assert.match(body, /esc close/, 'the footer names the close key');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('moving the selection swaps the document and resets its scroll', async () => {
|
|
122
|
+
const contents = { alpha: `# alpha\n${longDoc(80)}`, beta: '# beta\n\nonly beta body\n' };
|
|
123
|
+
const { view } = makeView({
|
|
124
|
+
docs: [doc('alpha'), doc('beta')],
|
|
125
|
+
load: (entry) => ({ ok: true, text: contents[entry.name] }),
|
|
126
|
+
});
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
128
|
+
view.handleInput('J');
|
|
129
|
+
const scrolled = strip(view.render(80).join('\n'));
|
|
130
|
+
assert.ok(view.state.scroll > 0, 'shift+j scrolls the right pane');
|
|
131
|
+
assert.match(scrolled, /line 1\b/, 'the first line is still on screen right after scrolling one row');
|
|
132
|
+
view.handleInput('down');
|
|
133
|
+
const swapped = strip(view.render(80).join('\n'));
|
|
134
|
+
assert.equal(view.state.index, 1, 'down selects the next document');
|
|
135
|
+
assert.equal(view.state.scroll, 0, 'the scroll resets on the new document');
|
|
136
|
+
assert.match(swapped, /only beta body/, 'the right pane shows the newly selected document');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('scrolls to the last line of a long document and back to the top', async () => {
|
|
140
|
+
const total = 120;
|
|
141
|
+
const { view } = makeView({
|
|
142
|
+
docs: [doc('alpha')],
|
|
143
|
+
load: () => ({ ok: true, text: longDoc(total) }),
|
|
144
|
+
});
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
146
|
+
view.handleInput('end');
|
|
147
|
+
const bottom = strip(view.render(80).join('\n'));
|
|
148
|
+
assert.match(bottom, /line 120/, 'the last source line is reachable');
|
|
149
|
+
view.handleInput('home');
|
|
150
|
+
assert.equal(view.state.scroll, 0, 'home returns to the top');
|
|
151
|
+
assert.match(strip(view.render(80).join('\n')), /line 1\b/);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('createOddView states', () => {
|
|
156
|
+
test('a checkout without documents renders its empty state, not an empty frame', async () => {
|
|
157
|
+
const { view } = makeView({ docs: [], load: () => ({ ok: true, text: '' }) });
|
|
158
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
159
|
+
const body = strip(view.render(80).join('\n'));
|
|
160
|
+
assert.match(body, /✎ Odd · 0 documents/);
|
|
161
|
+
assert.match(body, /no odd documents/i);
|
|
162
|
+
assert.match(body, /odd\/tasks/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('an unreadable document renders its error instead of empty content', async () => {
|
|
166
|
+
const { view } = makeView({
|
|
167
|
+
docs: [doc('alpha')],
|
|
168
|
+
load: () => ({ ok: false, error: 'EACCES: permission denied' }),
|
|
169
|
+
});
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
171
|
+
const body = strip(view.render(80).join('\n'));
|
|
172
|
+
assert.match(body, /EACCES: permission denied/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('esc closes with a null result and mutates nothing', async () => {
|
|
176
|
+
const { view, done } = makeView({ docs: [doc('alpha')], load: () => ({ ok: true, text: '# alpha\n' }) });
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
178
|
+
assert.equal(done(), 'pending');
|
|
179
|
+
view.handleInput('escape');
|
|
180
|
+
assert.equal(done(), null);
|
|
181
|
+
assert.equal(view.state.index, 0);
|
|
182
|
+
assert.equal(view.state.scroll, 0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('r re-lists through reloadDocs, so a document written while open appears', async () => {
|
|
186
|
+
let current = [doc('alpha')];
|
|
187
|
+
const { view } = makeView({
|
|
188
|
+
docs: current,
|
|
189
|
+
load: (entry) => ({ ok: true, text: `# ${entry.name}\n` }),
|
|
190
|
+
reloadDocs: () => ({ ok: true, docs: current }),
|
|
191
|
+
});
|
|
192
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
193
|
+
assert.match(strip(view.render(80).join('\n')), /1 documents/);
|
|
194
|
+
current = [doc('alpha'), doc('gamma')];
|
|
195
|
+
view.handleInput('r');
|
|
196
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
197
|
+
const body = strip(view.render(80).join('\n'));
|
|
198
|
+
assert.match(body, /2 documents/);
|
|
199
|
+
assert.match(body, /gamma/);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// S10b: the odd viewer is read-only with respect to the checkout↔document
|
|
204
|
+
// association. The options the existing callers still pass (`assigned`,
|
|
205
|
+
// `saveAssignment`, `assignmentEnabled`) are accepted and ignored: no mark,
|
|
206
|
+
// no toggle, no save, no unsaved claim — and no key that writes.
|
|
207
|
+
describe('createOddView read-only association (S10b)', () => {
|
|
208
|
+
function makeInertView({ docs, rows = 20, ...rest } = {}) {
|
|
209
|
+
const calls = [];
|
|
210
|
+
let settled = 'pending';
|
|
211
|
+
const view = createOddView({
|
|
212
|
+
docs,
|
|
213
|
+
loadDoc: (entry) => ({ ok: true, text: `# ${entry.name}\n` }),
|
|
214
|
+
helpers,
|
|
215
|
+
theme: plainTheme,
|
|
216
|
+
rowsAvailable: () => rows,
|
|
217
|
+
requestRender: () => {},
|
|
218
|
+
done: (result) => { settled = result; },
|
|
219
|
+
...rest,
|
|
220
|
+
saveAssignment: (...args) => { calls.push(args); return { ok: true }; },
|
|
221
|
+
});
|
|
222
|
+
return { view, calls, done: () => settled };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
226
|
+
const ASSIGNED = ['odd/tasks/alpha.md'];
|
|
227
|
+
// The list rows are the cells between the left border and the divider; the
|
|
228
|
+
// title icon (`✎`) and the legend separators (`·`) live outside them.
|
|
229
|
+
const listRows = (body) => body.split('\n').filter((line) => /│ [▸ ] (alpha|beta)( |$)/.test(line));
|
|
230
|
+
|
|
231
|
+
test('no association mark renders and the status row carries no assigned or unsaved fact', async () => {
|
|
232
|
+
const { view } = makeInertView({
|
|
233
|
+
docs: [doc('alpha'), doc('beta')],
|
|
234
|
+
assigned: ASSIGNED,
|
|
235
|
+
assignmentEnabled: true,
|
|
236
|
+
checkoutName: 'demo-checkout',
|
|
237
|
+
});
|
|
238
|
+
await tick();
|
|
239
|
+
const body = strip(view.render(80).join('\n'));
|
|
240
|
+
const rows = listRows(body);
|
|
241
|
+
assert.equal(rows.length, 2, 'both documents still list');
|
|
242
|
+
assert.ok(rows.every((line) => !/[✓✎]/.test(line)), 'no association mark on any row');
|
|
243
|
+
assert.match(rows[0], /▸ alpha/, 'the focused row renders unmarked');
|
|
244
|
+
assert.ok(!body.includes('assigned'), 'no assigned count is claimed');
|
|
245
|
+
assert.ok(!body.includes('unsaved'), 'no unsaved indicator exists');
|
|
246
|
+
assert.ok(!body.includes('space toggle'), 'the space toggle leaves the legend');
|
|
247
|
+
assert.ok(!body.includes('ctrl+s'), 'the save key leaves the legend');
|
|
248
|
+
assert.match(body, /ctrl\+j\/k scroll/, 'the reading keys stay in the legend, naming the default pair and what it does (S14)');
|
|
249
|
+
assert.match(body, /esc close/, 'the close key stays in the legend');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('space and ctrl+s change nothing: no save call, no state change, no repaint difference', async () => {
|
|
253
|
+
const { view, calls } = makeInertView({ docs: [doc('alpha'), doc('beta')], assigned: ASSIGNED });
|
|
254
|
+
await tick();
|
|
255
|
+
const snapshot = JSON.stringify(view.state);
|
|
256
|
+
const rendered = view.render(80).join('\n');
|
|
257
|
+
for (const key of ['space', ' ', 'ctrl+s', '\x13', 'space', 'ctrl+s']) view.handleInput(key);
|
|
258
|
+
await tick();
|
|
259
|
+
assert.deepEqual(calls, [], 'the injected save callback is never reached');
|
|
260
|
+
assert.equal(JSON.stringify(view.state), snapshot, 'no state changed');
|
|
261
|
+
assert.equal(view.render(80).join('\n'), rendered, 'nothing repainted differently');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('the association options are inert: the frame is identical with, without and with the gate off', async () => {
|
|
265
|
+
const docs = [doc('alpha'), doc('beta')];
|
|
266
|
+
const plain = makeInertView({ docs });
|
|
267
|
+
const passed = makeInertView({ docs, assigned: ASSIGNED, assignmentEnabled: true, checkoutName: 'demo-checkout' });
|
|
268
|
+
const gatedOff = makeInertView({ docs, assigned: ASSIGNED, assignmentEnabled: false });
|
|
269
|
+
await tick();
|
|
270
|
+
const expected = plain.view.render(80).join('\n');
|
|
271
|
+
assert.equal(passed.view.render(80).join('\n'), expected, 'assigned/saveAssignment/assignmentEnabled change no cell');
|
|
272
|
+
assert.equal(gatedOff.view.render(80).join('\n'), expected, 'assignmentEnabled false changes no cell either');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('the viewer still opens, navigates, filters, scrolls, reloads and closes with the options passed', async () => {
|
|
276
|
+
const long = Array.from({ length: 80 }, (_, index) => `line ${index + 1}`).join('\n');
|
|
277
|
+
let current = [doc('alpha'), doc('beta')];
|
|
278
|
+
const { view, done } = makeInertView({
|
|
279
|
+
docs: current,
|
|
280
|
+
assigned: ASSIGNED,
|
|
281
|
+
assignmentEnabled: false,
|
|
282
|
+
loadDoc: () => ({ ok: true, text: long }),
|
|
283
|
+
reloadDocs: () => ({ ok: true, docs: current }),
|
|
284
|
+
});
|
|
285
|
+
await tick();
|
|
286
|
+
view.handleInput('down');
|
|
287
|
+
assert.equal(view.state.index, 1, 'navigation still works');
|
|
288
|
+
view.handleInput('up');
|
|
289
|
+
assert.equal(view.state.index, 0, 'navigation still works both ways');
|
|
290
|
+
view.handleInput('T');
|
|
291
|
+
view.handleInput('b');
|
|
292
|
+
view.handleInput('enter');
|
|
293
|
+
const narrowed = strip(view.render(80).join('\n')).split('\n');
|
|
294
|
+
assert.ok(!narrowed.some((line) => /│ [▸ ] alpha *│/.test(line)), 'the filter still narrows the list');
|
|
295
|
+
view.handleInput('/');
|
|
296
|
+
view.handleInput('\x7f');
|
|
297
|
+
view.handleInput('enter');
|
|
298
|
+
const cleared = strip(view.render(80).join('\n')).split('\n');
|
|
299
|
+
assert.ok(cleared.some((line) => /│ [▸ ] alpha *│/.test(line)), 'the filter still clears');
|
|
300
|
+
view.handleInput('J');
|
|
301
|
+
assert.ok(view.state.scroll > 0, 'the preview still scrolls');
|
|
302
|
+
current = [doc('alpha'), doc('beta'), doc('gamma')];
|
|
303
|
+
view.handleInput('r');
|
|
304
|
+
await tick();
|
|
305
|
+
assert.match(strip(view.render(80).join('\n')), /3 documents/, 'reload still re-lists');
|
|
306
|
+
view.handleInput('escape');
|
|
307
|
+
assert.equal(done(), null, 'esc still closes with a null result');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('createOddView key consistency (K3)', () => {
|
|
313
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
314
|
+
|
|
315
|
+
function makeKeyView({ docs, openImpl } = {}) {
|
|
316
|
+
const opened = [];
|
|
317
|
+
let settled = 'pending';
|
|
318
|
+
const open = (...args) => {
|
|
319
|
+
opened.push(args);
|
|
320
|
+
if (typeof openImpl === 'function') return openImpl(...args);
|
|
321
|
+
return { ok: true };
|
|
322
|
+
};
|
|
323
|
+
const view = createOddView({
|
|
324
|
+
docs: docs || [doc('alpha'), doc('beta'), doc('gamma')],
|
|
325
|
+
loadDoc: (entry) => ({ ok: true, text: `# ${entry.name}\n` }),
|
|
326
|
+
openExternal: open,
|
|
327
|
+
helpers,
|
|
328
|
+
theme: plainTheme,
|
|
329
|
+
rowsAvailable: () => 20,
|
|
330
|
+
requestRender: () => {},
|
|
331
|
+
done: (result) => { settled = result; },
|
|
332
|
+
});
|
|
333
|
+
return { view, opened, done: () => settled };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
test('? is inert: no key-help overlay, and esc closes the view directly', async () => {
|
|
337
|
+
const { view, done } = makeKeyView();
|
|
338
|
+
await tick();
|
|
339
|
+
assert.ok(!strip(view.render(80).join('\n')).includes('Keys'), 'no help before ?');
|
|
340
|
+
view.handleInput('?');
|
|
341
|
+
const body = strip(view.render(80).join('\n'));
|
|
342
|
+
assert.ok(!body.includes('Keys'), '? does not open a help overlay');
|
|
343
|
+
assert.ok(!body.includes('? help'), 'the footer does not hint a help key');
|
|
344
|
+
view.handleInput('escape');
|
|
345
|
+
assert.equal(done(), null, 'esc with no overlay closes the view');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('T filters the document list by name, live as you type, and leaving the box keeps the filter', async () => {
|
|
349
|
+
const { view } = makeKeyView();
|
|
350
|
+
await tick();
|
|
351
|
+
view.handleInput('T');
|
|
352
|
+
view.handleInput('a');
|
|
353
|
+
view.handleInput('l');
|
|
354
|
+
const body = strip(view.render(80).join('\n'));
|
|
355
|
+
assert.match(body, /alpha/, 'the matching document stays');
|
|
356
|
+
assert.ok(!body.includes('beta') || body.includes('filter'), 'non-matching rows leave the list');
|
|
357
|
+
const rows = body.split('\n').filter((line) => /│ [▸ ] /.test(line));
|
|
358
|
+
assert.ok(rows.every((line) => !line.includes('beta') && !line.includes('gamma')), 'only the match is listed');
|
|
359
|
+
assert.match(body, / › al/, 'the filter is visible while typing');
|
|
360
|
+
view.handleInput('enter');
|
|
361
|
+
const kept = strip(view.render(80).join('\n'));
|
|
362
|
+
assert.match(kept, /› al( |$)/m, 'leaving the box keeps the filter');
|
|
363
|
+
assert.match(kept, /alpha/);
|
|
364
|
+
const keptRows = kept.split('\n').filter((line) => /[▸ ] [✓·✎] /.test(line));
|
|
365
|
+
assert.ok(keptRows.every((line) => !line.includes('beta') && !line.includes('gamma')), 'the filter still applies after leaving the box');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('/ also opens the filter box', async () => {
|
|
369
|
+
const { view } = makeKeyView();
|
|
370
|
+
await tick();
|
|
371
|
+
view.handleInput('/');
|
|
372
|
+
view.handleInput('g');
|
|
373
|
+
view.handleInput('a');
|
|
374
|
+
view.handleInput('m');
|
|
375
|
+
view.handleInput('enter');
|
|
376
|
+
const body = strip(view.render(80).join('\n'));
|
|
377
|
+
assert.match(body, /› gam( |$)/m, 'the / filter is kept like the T filter');
|
|
378
|
+
assert.match(body, /gamma/);
|
|
379
|
+
assert.ok(!body.split('\n').filter((line) => /│ [▸ ] /.test(line)).some((line) => line.includes('alpha')), 'alpha is filtered out');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('a filter matching nothing renders its empty state instead of a blank frame', async () => {
|
|
383
|
+
const { view } = makeKeyView();
|
|
384
|
+
await tick();
|
|
385
|
+
view.handleInput('T');
|
|
386
|
+
view.handleInput('z');
|
|
387
|
+
view.handleInput('z');
|
|
388
|
+
view.handleInput('z');
|
|
389
|
+
const body = strip(view.render(80).join('\n'));
|
|
390
|
+
assert.match(body, /no matches/, 'the empty filter names itself');
|
|
391
|
+
assert.match(body, /› zzz/, 'the query stays visible');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('o opens the focused document through the injected opener', async () => {
|
|
395
|
+
const { view, opened } = makeKeyView();
|
|
396
|
+
await tick();
|
|
397
|
+
view.handleInput('down');
|
|
398
|
+
view.handleInput('o');
|
|
399
|
+
await tick();
|
|
400
|
+
assert.equal(opened.length, 1, 'one external open for one o');
|
|
401
|
+
assert.equal(opened[0][0], '/checkout/odd/tasks/beta.md', 'the opener receives the focused absolute path');
|
|
402
|
+
const body = strip(view.render(80).join('\n'));
|
|
403
|
+
assert.ok(!body.includes('open failed'), 'a successful open shows no error');
|
|
404
|
+
assert.equal(view.state.index, 1, 'the selection is untouched');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('a failing opener reports the error honestly and keeps the selection', async () => {
|
|
408
|
+
const { view, opened } = makeKeyView({ openImpl: () => ({ ok: false, error: 'spawn ed ENOENT' }) });
|
|
409
|
+
await tick();
|
|
410
|
+
view.handleInput('o');
|
|
411
|
+
await tick();
|
|
412
|
+
assert.equal(opened.length, 1);
|
|
413
|
+
const body = strip(view.render(80).join('\n'));
|
|
414
|
+
assert.match(body, /open failed: spawn ed ENOENT/, 'the failure is shown, never silent');
|
|
415
|
+
assert.equal(view.state.index, 0, 'the selection is untouched');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('o with an empty document list does nothing', async () => {
|
|
419
|
+
const { view, opened } = makeKeyView({ docs: [] });
|
|
420
|
+
await tick();
|
|
421
|
+
view.handleInput('o');
|
|
422
|
+
await tick();
|
|
423
|
+
assert.deepEqual(opened, [], 'nothing to open, nothing called');
|
|
424
|
+
const body = strip(view.render(80).join('\n'));
|
|
425
|
+
assert.match(body, /no odd documents/, 'the empty state survives');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('the footer names every key the view gained', async () => {
|
|
429
|
+
const { view } = makeKeyView();
|
|
430
|
+
await tick();
|
|
431
|
+
const body = strip(view.render(80).join('\n'));
|
|
432
|
+
assert.match(body, /› Search/, 'the search row is the search widget');
|
|
433
|
+
assert.match(body, /t search|(^|· )t( ·|$)/, 'the footer names the filter key (S4)');
|
|
434
|
+
assert.match(body, /o open/, 'the footer names the external-open key');
|
|
435
|
+
assert.ok(!body.includes('? help'), 'the footer no longer hints the removed help key');
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// warp-usability T4: S13 (scroll), S14 (no help), S23-S27 (search row,
|
|
441
|
+
// concise title, frame cells, no top-right esc) and S29 (assignment gate).
|
|
442
|
+
// These feed the public render/handleInput/handleMouse surface with the raw
|
|
443
|
+
// terminal bytes and wheel events the host emits.
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
describe('createOddView warp usability (S13,S14,S23-S27,S29)', () => {
|
|
446
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
447
|
+
const skipReal = realTui ? false : 'requires @earendil-works/pi-tui';
|
|
448
|
+
const body200 = longDoc(200);
|
|
449
|
+
|
|
450
|
+
function build({ docs = [doc('alpha'), doc('beta')], rows = 20, load, ...rest } = {}) {
|
|
451
|
+
const paints = [];
|
|
452
|
+
let settled = 'pending';
|
|
453
|
+
const view = createOddView({
|
|
454
|
+
docs,
|
|
455
|
+
loadDoc: load || (() => ({ ok: true, text: body200 })),
|
|
456
|
+
helpers: realTui || helpers,
|
|
457
|
+
theme: plainTheme,
|
|
458
|
+
rowsAvailable: () => rows,
|
|
459
|
+
requestRender: () => { paints.push(true); },
|
|
460
|
+
done: (result) => { settled = result; },
|
|
461
|
+
...rest,
|
|
462
|
+
});
|
|
463
|
+
return { view, paints, done: () => settled };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
test('the mouse wheel scrolls the preview without touching the selection', { skip: skipReal }, async () => {
|
|
467
|
+
const { view } = build();
|
|
468
|
+
view.render(80);
|
|
469
|
+
assert.equal(typeof view.handleMouse, 'function', 'the view accepts host mouse events');
|
|
470
|
+
assert.equal(view.state.scroll, 0);
|
|
471
|
+
view.handleMouse({ type: 'wheel', button: 'none', x: 60, y: 10, screenX: 60, screenY: 10, width: 80, height: 20, shift: false, alt: false, ctrl: false, wheelDelta: 4 });
|
|
472
|
+
assert.equal(view.state.scroll, 4, 'a down wheel moves the preview down');
|
|
473
|
+
assert.equal(view.state.index, 0, 'the wheel never moves the list selection');
|
|
474
|
+
view.handleMouse({ type: 'wheel', wheelDelta: -2 });
|
|
475
|
+
assert.equal(view.state.scroll, 2, 'an up wheel moves the preview back up');
|
|
476
|
+
view.handleMouse({ type: 'wheel', wheelDelta: -50 });
|
|
477
|
+
assert.equal(view.state.scroll, 0, 'the top bound clamps');
|
|
478
|
+
view.handleMouse({ type: 'wheel', wheelDelta: 1e6 });
|
|
479
|
+
const bottom = view.state.scroll;
|
|
480
|
+
assert.ok(bottom > 0, 'a huge down wheel reaches the bottom page');
|
|
481
|
+
view.handleMouse({ type: 'wheel', wheelDelta: 1e6 });
|
|
482
|
+
assert.equal(view.state.scroll, bottom, 'the bottom bound clamps');
|
|
483
|
+
view.handleMouse({ type: 'wheel', wheelDelta: -1 });
|
|
484
|
+
assert.equal(view.state.scroll, bottom - 1, 'the preview scrolls back from the bottom');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test('SGR wheel bytes scroll the preview through handleInput', () => {
|
|
488
|
+
const { view } = build();
|
|
489
|
+
view.render(80);
|
|
490
|
+
view.handleInput('\x1b[<65;60;10M');
|
|
491
|
+
assert.equal(view.state.scroll, 1, 'SGR wheel down scrolls the preview');
|
|
492
|
+
view.handleInput('\x1b[<65;60;10M');
|
|
493
|
+
assert.equal(view.state.scroll, 2);
|
|
494
|
+
view.handleInput('\x1b[<64;60;10M');
|
|
495
|
+
assert.equal(view.state.scroll, 1, 'SGR wheel up scrolls back');
|
|
496
|
+
assert.equal(view.state.index, 0, 'wheel bytes never move the list selection');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('raw scroll bytes drive the preview with no helpers (never a dead preview)', () => {
|
|
500
|
+
const { view } = build({ helpers: null });
|
|
501
|
+
view.render(80);
|
|
502
|
+
view.handleInput('\n'); // Enter, and the legacy ctrl+j byte
|
|
503
|
+
assert.equal(view.state.scroll, 0, 'bare LF never scrolls: it is Enter');
|
|
504
|
+
view.handleInput('\x0b'); // legacy ctrl+k byte
|
|
505
|
+
assert.equal(view.state.scroll, 0, 'the old ctrl+k byte no longer scrolls');
|
|
506
|
+
view.handleInput('\x1b[106;6u'); // CSI-u ctrl+shift+j
|
|
507
|
+
assert.ok(view.state.scroll > 0, 'raw ctrl+shift+j scrolls down');
|
|
508
|
+
const pressed = view.state.scroll;
|
|
509
|
+
view.handleInput('\x1b[106;6:3u'); // the release of the same key
|
|
510
|
+
assert.equal(view.state.scroll, pressed, 'a key release never scrolls');
|
|
511
|
+
view.handleInput('\x1b[107;6u'); // CSI-u ctrl+shift+k
|
|
512
|
+
assert.equal(view.state.scroll, 0, 'raw ctrl+shift+k scrolls back up');
|
|
513
|
+
view.handleInput('\x1b[6~'); // PageDown
|
|
514
|
+
const paged = view.state.scroll;
|
|
515
|
+
assert.ok(paged > 1, 'raw PageDown scrolls a page');
|
|
516
|
+
view.handleInput('\x1b[5~'); // PageUp
|
|
517
|
+
assert.equal(view.state.scroll, 0, 'raw PageUp scrolls the page back');
|
|
518
|
+
view.handleInput('\x1b[F'); // End
|
|
519
|
+
assert.ok(view.state.scroll > 0, 'raw End reaches the bottom');
|
|
520
|
+
view.handleInput('\x1b[H'); // Home
|
|
521
|
+
assert.equal(view.state.scroll, 0, 'raw Home returns to the top');
|
|
522
|
+
assert.equal(view.state.index, 0, 'scroll keys never move the list selection');
|
|
523
|
+
view.handleInput('\x1b[B'); // Down moves the list, not the preview
|
|
524
|
+
assert.equal(view.state.index, 1, 'raw Down moves the selection');
|
|
525
|
+
assert.equal(view.state.scroll, 0, 'the scroll resets on the new document');
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('the real host keyboard bytes scroll the preview and keep the selection separate', { skip: skipReal }, () => {
|
|
529
|
+
const { view } = build();
|
|
530
|
+
view.render(80);
|
|
531
|
+
view.handleInput('\n'); // a newline: Enter, and the legacy ctrl+j byte
|
|
532
|
+
assert.equal(view.state.scroll, 0, 'the host calls this byte enter, so it never scrolls');
|
|
533
|
+
view.handleInput('\x0b'); // the legacy ctrl+k byte
|
|
534
|
+
assert.equal(view.state.scroll, 0, 'the host calls this byte ctrl+k, not a scroll key');
|
|
535
|
+
view.handleInput('\x1b[106;6u'); // Kitty ctrl+shift+j
|
|
536
|
+
assert.ok(view.state.scroll > 0, 'ctrl+shift+j scrolls through the real matcher');
|
|
537
|
+
view.handleInput('\x1b[6~'); // PageDown
|
|
538
|
+
const paged = view.state.scroll;
|
|
539
|
+
assert.ok(paged > 1, 'PageDown scrolls a page');
|
|
540
|
+
view.handleInput('\x1b[5~'); // PageUp
|
|
541
|
+
assert.ok(view.state.scroll < paged, 'PageUp scrolls the page back');
|
|
542
|
+
view.handleInput('\x1b[F'); // End
|
|
543
|
+
assert.ok(view.state.scroll > 0, 'End reaches the bottom');
|
|
544
|
+
view.handleInput('\x1b[H'); // Home
|
|
545
|
+
assert.equal(view.state.scroll, 0, 'Home returns to the top');
|
|
546
|
+
assert.equal(view.state.index, 0, 'no scroll key moved the list selection');
|
|
547
|
+
view.handleInput('\x1b[B');
|
|
548
|
+
assert.equal(view.state.index, 1, 'Down still moves the list selection');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// S1: the full-page pair is the shifted one the user asked for — ctrl+shift+
|
|
552
|
+
// j/k through the extended protocol, and the printable J/K fallback on a
|
|
553
|
+
// legacy terminal — while ctrl+d/ctrl+u stay the distinct half-page shortcut.
|
|
554
|
+
test('S1: ctrl+shift+j/k and shifted J/K page the preview, j/k still move the list', { skip: skipReal }, () => {
|
|
555
|
+
const { view } = build();
|
|
556
|
+
view.render(80);
|
|
557
|
+
view.handleInput('\x1b[6~'); // PageDown: the full-page step
|
|
558
|
+
const paged = view.state.scroll;
|
|
559
|
+
view.handleInput('\x1b[H');
|
|
560
|
+
assert.equal(view.state.scroll, 0, 'home returns to the top first');
|
|
561
|
+
view.handleInput('\x04'); // ctrl+d
|
|
562
|
+
const half = view.state.scroll;
|
|
563
|
+
assert.ok(half > 0, 'raw ctrl+d scrolls the preview down');
|
|
564
|
+
assert.ok(half < paged, 'ctrl+d moves half a page, less than PageDown');
|
|
565
|
+
assert.equal(view.state.index, 0, 'ctrl+d never moves the list selection');
|
|
566
|
+
view.handleInput('\x15'); // ctrl+u
|
|
567
|
+
assert.equal(view.state.scroll, 0, 'raw ctrl+u scrolls the preview back up');
|
|
568
|
+
view.handleInput('\x1b[100;5u'); // Kitty ctrl+d
|
|
569
|
+
assert.ok(view.state.scroll > 0, 'Kitty ctrl+d scrolls the preview down');
|
|
570
|
+
view.handleInput('\x1b[117;5u'); // Kitty ctrl+u
|
|
571
|
+
assert.equal(view.state.scroll, 0, 'Kitty ctrl+u scrolls the preview back up');
|
|
572
|
+
view.handleInput('K'); // shifted K pages up
|
|
573
|
+
view.handleInput('\x04');
|
|
574
|
+
view.handleInput('K');
|
|
575
|
+
assert.equal(view.state.scroll, 0, 'K pages the preview back up');
|
|
576
|
+
view.handleInput('J'); // shifted J pages down
|
|
577
|
+
assert.equal(view.state.scroll, paged, 'J pages exactly what PageDown pages');
|
|
578
|
+
assert.equal(view.state.index, 0, 'J never moves the list selection');
|
|
579
|
+
view.handleInput('\x1b[H');
|
|
580
|
+
view.handleInput('\x1b[106;6u'); // Kitty ctrl+shift+j
|
|
581
|
+
assert.equal(view.state.scroll, paged, 'ctrl+shift+j pages exactly what PageDown pages');
|
|
582
|
+
view.handleInput('\x1b[107;6u'); // Kitty ctrl+shift+k
|
|
583
|
+
assert.equal(view.state.scroll, 0, 'ctrl+shift+k pages back up');
|
|
584
|
+
view.handleInput('j');
|
|
585
|
+
assert.equal(view.state.index, 1, 'j still moves the list selection');
|
|
586
|
+
assert.equal(view.state.scroll, 0, 'j never scrolls the preview');
|
|
587
|
+
view.handleInput('k');
|
|
588
|
+
assert.equal(view.state.index, 0, 'k still moves the list selection');
|
|
589
|
+
view.handleInput('\x1b[6~');
|
|
590
|
+
assert.equal(view.state.scroll, paged, 'PageDown still scrolls a full page');
|
|
591
|
+
view.handleInput('\x1b[5~');
|
|
592
|
+
assert.equal(view.state.scroll, 0, 'PageUp still scrolls the page back');
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test('S15: the half-page and fallback keys also work with no helpers injected', () => {
|
|
596
|
+
const { view } = build({ helpers: null });
|
|
597
|
+
view.render(80);
|
|
598
|
+
view.handleInput('\x04');
|
|
599
|
+
const half = view.state.scroll;
|
|
600
|
+
assert.ok(half > 0, 'raw ctrl+d scrolls with no helpers');
|
|
601
|
+
view.handleInput('\x15');
|
|
602
|
+
assert.equal(view.state.scroll, 0, 'raw ctrl+u scrolls back with no helpers');
|
|
603
|
+
view.handleInput('\x04');
|
|
604
|
+
view.handleInput('K');
|
|
605
|
+
assert.equal(view.state.scroll, 0, 'K pages back up with no helpers');
|
|
606
|
+
view.handleInput('J');
|
|
607
|
+
assert.ok(view.state.scroll > half, 'J pages a full page with no helpers, more than ctrl+d');
|
|
608
|
+
assert.equal(view.state.index, 0, 'J never moves the list selection without helpers');
|
|
609
|
+
view.handleInput('\x1b[100;5u'); // Kitty ctrl+d
|
|
610
|
+
const kittyDown = view.state.scroll;
|
|
611
|
+
assert.ok(kittyDown > half, 'Kitty ctrl+d scrolls further down with no helpers');
|
|
612
|
+
view.handleInput('\x1b[117;5u'); // Kitty ctrl+u
|
|
613
|
+
assert.ok(view.state.scroll < kittyDown, 'Kitty ctrl+u scrolls back with no helpers');
|
|
614
|
+
view.handleInput('\x1b[H');
|
|
615
|
+
view.handleInput('j');
|
|
616
|
+
assert.equal(view.state.index, 1, 'j still moves the list selection without helpers');
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test('the scroll keys repaint without writing anything', { skip: skipReal }, async () => {
|
|
620
|
+
const calls = [];
|
|
621
|
+
const { view, paints } = build({ saveAssignment: (...args) => { calls.push(args); return { ok: true }; } });
|
|
622
|
+
view.render(80);
|
|
623
|
+
const before = paints.length;
|
|
624
|
+
view.handleInput('\x1b[6~');
|
|
625
|
+
assert.ok(paints.length > before, 'scrolling requests a repaint');
|
|
626
|
+
assert.deepEqual(calls, [], 'scrolling never calls the save callback');
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test('? no longer opens a key-help overlay and the footer never hints it', () => {
|
|
630
|
+
const { view } = build();
|
|
631
|
+
view.handleInput('?');
|
|
632
|
+
const body = strip(view.render(80).join('\n'));
|
|
633
|
+
assert.ok(!body.includes('Keys'), 'no help overlay');
|
|
634
|
+
assert.ok(!body.includes('? help'), 'no ? hint in the footer');
|
|
635
|
+
assert.ok(!/\?/.test(body), 'no bare ? anywhere in the frame');
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test('the title carries only the viewer name and the document count', () => {
|
|
639
|
+
const { view } = build({ docs: [doc('alpha'), doc('beta')], checkoutName: 'demo-checkout' });
|
|
640
|
+
const lines = strip(view.render(80).join('\n')).split('\n');
|
|
641
|
+
assert.match(strip(lines[0]), /✎ Odd · 2 documents/);
|
|
642
|
+
assert.ok(!lines[0].includes('demo-checkout'), 'no checkout/worktree name in the title');
|
|
643
|
+
assert.ok(!lines[0].includes('alpha'), 'no selected document in the title');
|
|
644
|
+
assert.ok(!lines[0].includes('esc'), 'no top-right esc in the frame');
|
|
645
|
+
assert.ok(!strip(lines.join('\n')).includes('demo-checkout'), 'the checkout name is gone from the whole frame');
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test('the search row sits directly below the title and carries the query and the position marks', () => {
|
|
649
|
+
const { view } = build();
|
|
650
|
+
view.handleInput('T');
|
|
651
|
+
view.handleInput('a');
|
|
652
|
+
view.handleInput('l');
|
|
653
|
+
let lines = strip(view.render(80).join('\n')).split('\n');
|
|
654
|
+
assert.match(strip(lines[1]), /› al█/, 'typing shows the live query on its own row');
|
|
655
|
+
assert.ok(!strip(lines[0]).includes('1/2'), 'the count is not buried in the title');
|
|
656
|
+
view.handleInput('enter');
|
|
657
|
+
lines = strip(view.render(80).join('\n')).split('\n');
|
|
658
|
+
assert.match(strip(lines[1]), /› al( |$)/m, 'leaving the box keeps the query visible');
|
|
659
|
+
view.handleInput('/');
|
|
660
|
+
lines = strip(view.render(80).join('\n')).split('\n');
|
|
661
|
+
assert.match(strip(lines[1]), /› al█/, '/ edits the same row');
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test('the frame fills every cell with aligned corners and no top-right esc at 80x24 and 160x40', () => {
|
|
665
|
+
for (const [width, rows] of [[80, 24], [160, 40]]) {
|
|
666
|
+
const { view } = build({ rows });
|
|
667
|
+
const lines = view.render(width);
|
|
668
|
+
assert.equal(lines.length, rows, `${width}x${rows}: the view fills the rows`);
|
|
669
|
+
lines.forEach((line, at) => {
|
|
670
|
+
assert.equal(cellWidth(line), width, `${width}x${rows} row ${at} is ${width} cells: "${line}"`);
|
|
671
|
+
});
|
|
672
|
+
const plain = strip(lines.join('\n')).split('\n');
|
|
673
|
+
assert.equal(plain[0][0], '╭', 'top-left corner');
|
|
674
|
+
assert.equal(plain[0][width - 1], '╮', 'top-right corner in the last cell');
|
|
675
|
+
assert.equal(plain[rows - 1][0], '╰', 'bottom-left corner');
|
|
676
|
+
assert.equal(plain[rows - 1][width - 1], '╯', 'bottom-right corner in the last cell');
|
|
677
|
+
for (let at = 1; at < rows - 1; at += 1) {
|
|
678
|
+
assert.equal(plain[at][0], '│', `row ${at} left border`);
|
|
679
|
+
assert.equal(plain[at][width - 1], '│', `row ${at} right border in the last cell`);
|
|
680
|
+
}
|
|
681
|
+
assert.ok(!plain.slice(0, 4).join('').includes('esc'), 'no top-right esc text');
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test('the preview shows the start, a middle page and the end at 80x24 and 160x40 with the query row visible', () => {
|
|
686
|
+
for (const [width, rows] of [[80, 24], [160, 40]]) {
|
|
687
|
+
const { view } = build({ rows });
|
|
688
|
+
let body = strip(view.render(width).join('\n'));
|
|
689
|
+
assert.equal(view.state.scroll, 0, `${width}x${rows}: the start is the top`);
|
|
690
|
+
assert.match(body, /line 1\b/, `${width}x${rows}: the first source line is visible at the start`);
|
|
691
|
+
assert.match(body.split('\n')[1], /› Search/, `${width}x${rows}: the query row is visible`);
|
|
692
|
+
view.handleInput('\x1b[6~');
|
|
693
|
+
body = strip(view.render(width).join('\n'));
|
|
694
|
+
assert.ok(view.state.scroll > 0, `${width}x${rows}: PageDown reaches a middle window`);
|
|
695
|
+
assert.ok(!/\bline 1\b/.test(body), `${width}x${rows}: the start left the middle window`);
|
|
696
|
+
assert.ok(!/\bline 200\b/.test(body), `${width}x${rows}: the end is not yet visible in the middle window`);
|
|
697
|
+
view.handleInput('\x1b[F');
|
|
698
|
+
body = strip(view.render(width).join('\n'));
|
|
699
|
+
assert.match(body, /\bline 200\b/, `${width}x${rows}: End reaches the last source line`);
|
|
700
|
+
assert.ok(!/\bline 1\b/.test(body), `${width}x${rows}: the start left the end window`);
|
|
701
|
+
view.handleInput('\x1b[H');
|
|
702
|
+
body = strip(view.render(width).join('\n'));
|
|
703
|
+
assert.equal(view.state.scroll, 0, `${width}x${rows}: Home returns to the top`);
|
|
704
|
+
assert.match(body, /line 1\b/, `${width}x${rows}: the start is visible again`);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test('the inherited name filter stays case-insensitive and keeps the selection inside the visible list', () => {
|
|
709
|
+
const { view } = build({ docs: [doc('alpha'), doc('beta'), doc('gamma')] });
|
|
710
|
+
view.handleInput('T');
|
|
711
|
+
view.handleInput('G');
|
|
712
|
+
view.handleInput('a');
|
|
713
|
+
let body = strip(view.render(80).join('\n'));
|
|
714
|
+
const filterRows = (text) => text.split('\n').filter((line) => /│ [▸ ] (alpha|beta|gamma)( |$)/.test(line));
|
|
715
|
+
const rows = filterRows(body);
|
|
716
|
+
assert.equal(rows.length, 1, 'an uppercase query still matches the lowercase name');
|
|
717
|
+
assert.match(rows[0], /gamma/, 'only the case-insensitive match is listed');
|
|
718
|
+
assert.ok(!body.split('\n').some((line) => /│ [▸ ] (alpha|beta) *│/.test(line)), 'the filter hides the other rows');
|
|
719
|
+
assert.ok(body.split('\n').some((line) => /│ ▸ gamma *│/.test(line)), 'the only match stays focused');
|
|
720
|
+
view.handleInput('enter');
|
|
721
|
+
view.handleInput('down');
|
|
722
|
+
assert.equal(view.state.index, 0, 'one match has nowhere to move');
|
|
723
|
+
view.handleInput('/');
|
|
724
|
+
view.handleInput('\x7f');
|
|
725
|
+
body = strip(view.render(80).join('\n'));
|
|
726
|
+
assert.equal(filterRows(body).length, 1, 'Backspace edits the kept query');
|
|
727
|
+
view.handleInput('\x7f');
|
|
728
|
+
view.handleInput('enter');
|
|
729
|
+
body = strip(view.render(80).join('\n'));
|
|
730
|
+
const cleared = filterRows(body);
|
|
731
|
+
assert.equal(cleared.length, 3, 'clearing the query restores every document');
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test('the raw DEL byte edits the kept query even with no host helpers injected', () => {
|
|
735
|
+
const { view } = build({ helpers: null, docs: [doc('alpha'), doc('beta'), doc('gamma')] });
|
|
736
|
+
view.handleInput('T');
|
|
737
|
+
view.handleInput('G');
|
|
738
|
+
view.handleInput('a');
|
|
739
|
+
view.handleInput('enter');
|
|
740
|
+
view.handleInput('down');
|
|
741
|
+
view.handleInput('/');
|
|
742
|
+
const narrowedRows = (text) => text.split('\n').filter((line) => /│ [▸ ] (alpha|beta|gamma) *│/.test(line));
|
|
743
|
+
let body = strip(view.render(80).join('\n'));
|
|
744
|
+
assert.equal(narrowedRows(body).length, 1, 'the kept query still filters to the single match');
|
|
745
|
+
view.handleInput('\x7f');
|
|
746
|
+
body = strip(view.render(80).join('\n'));
|
|
747
|
+
assert.equal(narrowedRows(body).length, 1, 'DEL edits the kept query without host helpers');
|
|
748
|
+
assert.ok(!/\x7f/i.test(body), 'the raw DEL byte never becomes visible query text');
|
|
749
|
+
view.handleInput('\x7f');
|
|
750
|
+
view.handleInput('\x7f');
|
|
751
|
+
body = strip(view.render(80).join('\n'));
|
|
752
|
+
assert.equal(narrowedRows(body).length, 3, 'emptying the query restores every document');
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test('S10b: the association options are inert in the rendered frame', async () => {
|
|
756
|
+
const calls = [];
|
|
757
|
+
const opened = [];
|
|
758
|
+
const plain = build();
|
|
759
|
+
const gatedOff = build({
|
|
760
|
+
assigned: ['odd/tasks/alpha.md'],
|
|
761
|
+
assignmentEnabled: false,
|
|
762
|
+
saveAssignment: (...args) => { calls.push(args); return { ok: true }; },
|
|
763
|
+
openExternal: (target) => { opened.push(target); return { ok: true }; },
|
|
764
|
+
});
|
|
765
|
+
const expected = plain.view.render(80).join('\n');
|
|
766
|
+
gatedOff.view.render(80);
|
|
767
|
+
assert.equal(gatedOff.view.render(80).join('\n'), expected, 'no cell differs when the options are passed');
|
|
768
|
+
const body = strip(gatedOff.view.render(80).join('\n'));
|
|
769
|
+
const rows = body.split('\n').filter((line) => /│ [▸ ] (alpha|beta)( |$)/.test(line));
|
|
770
|
+
assert.equal(rows.length, 2, 'both documents still list');
|
|
771
|
+
assert.ok(rows.every((line) => !/[✓✎]/.test(line)), 'no assignment marks on the rows');
|
|
772
|
+
assert.ok(!body.includes('unsaved'), 'no unsaved indicator');
|
|
773
|
+
assert.ok(!body.includes('space'), 'no space hint');
|
|
774
|
+
assert.ok(!body.includes('ctrl+s'), 'no save hint');
|
|
775
|
+
assert.ok(!body.includes('assigned'), 'no assigned count');
|
|
776
|
+
assert.match(body, /ctrl\+j\/k scroll/, 'the scroll hint stays, naming the default pair');
|
|
777
|
+
assert.match(body, /› Search/, 'the search row stays, without naming its key');
|
|
778
|
+
assert.match(body, /o open/, 'the open hint stays');
|
|
779
|
+
assert.match(body, /esc close/, 'the close hint stays');
|
|
780
|
+
gatedOff.view.handleInput(' ');
|
|
781
|
+
gatedOff.view.handleInput('\x13'); // ctrl+s
|
|
782
|
+
await tick();
|
|
783
|
+
assert.deepEqual(calls, [], 'Space and ctrl+s bytes never call the save callback');
|
|
784
|
+
assert.ok(!strip(gatedOff.view.render(80).join('\n')).split('\n').some((line) => /✎ (alpha|beta)/.test(line)), 'no pending mark appears');
|
|
785
|
+
gatedOff.view.handleInput('J');
|
|
786
|
+
assert.ok(gatedOff.view.state.scroll > 0, 'scrolling still works with the options passed');
|
|
787
|
+
gatedOff.view.handleInput('T');
|
|
788
|
+
gatedOff.view.handleInput('a');
|
|
789
|
+
gatedOff.view.handleInput('l');
|
|
790
|
+
gatedOff.view.handleInput('enter');
|
|
791
|
+
assert.match(strip(gatedOff.view.render(80).join('\n')).split('\n')[1], /› al( |$)/m, 'search still works');
|
|
792
|
+
gatedOff.view.handleInput('o');
|
|
793
|
+
await tick();
|
|
794
|
+
assert.equal(opened.length, 1, 'the external open still works');
|
|
795
|
+
gatedOff.view.handleInput('escape');
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// S2: the key legend is ONE terminal row — the keys the view gained (`T`
|
|
800
|
+
// filter, `o` open) ride the same row as the rest instead of a second one.
|
|
801
|
+
describe('S2: one-row key legends', () => {
|
|
802
|
+
const WIDTHS = [40, 60, 80, 120];
|
|
803
|
+
const tickOnce = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
804
|
+
|
|
805
|
+
test('the footer legend owns exactly one row at every width', async () => {
|
|
806
|
+
for (const width of WIDTHS) {
|
|
807
|
+
const { view } = makeView({
|
|
808
|
+
docs: [doc('alpha'), doc('beta')],
|
|
809
|
+
load: () => ({ ok: true, text: '# alpha\n\nbody of alpha\n' }),
|
|
810
|
+
});
|
|
811
|
+
await tickOnce();
|
|
812
|
+
const lines = view.render(width).map(strip);
|
|
813
|
+
assert.equal(lines.length, 20, `width ${width}: the frame fills the available rows`);
|
|
814
|
+
for (const line of lines) assert.equal(helpers.visibleWidth(line), width, `width ${width}: row is not ${width} wide`);
|
|
815
|
+
const legend = lines.filter((line) => /esc close|o open/.test(line));
|
|
816
|
+
assert.equal(legend.length, 1, `width ${width}: exactly one legend row, got ${legend.length}`);
|
|
817
|
+
assert.ok(!legend[0].includes('…'), `width ${width}: compacted, never ellipsis-clipped: ${legend[0]}`);
|
|
818
|
+
assert.match(legend[0], /↑↓/, `width ${width}: the movement keys survive`);
|
|
819
|
+
assert.match(legend[0], /esc close/, `width ${width}: the close key survives`);
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
// S13: the frame says each thing once. The document total lives in the title
|
|
825
|
+
// only, no chrome row repeats the focused document, the search key is named by
|
|
826
|
+
// the search row alone, and the escape hint belongs to the interaction the
|
|
827
|
+
// keyboard is in — while the box is open the footer names its keys once
|
|
828
|
+
// instead of the box and the footer both naming them.
|
|
829
|
+
describe('S13: the odd frame repeats nothing', () => {
|
|
830
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
831
|
+
const footerOf = (lines) => lines[lines.length - 2];
|
|
832
|
+
const tokensOf = (footer) => footer.split('·').map((token) => token.trim());
|
|
833
|
+
|
|
834
|
+
function quietView({ docs = [doc('alpha'), doc('beta')], text = 'body line\n', rows = 20 } = {}) {
|
|
835
|
+
return makeView({ docs, load: () => ({ ok: true, text }), rows }).view;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
test('the document total renders once and no chrome row repeats the focused name', async () => {
|
|
839
|
+
const view = quietView();
|
|
840
|
+
await tick();
|
|
841
|
+
const lines = strip(view.render(80).join('\n')).split('\n');
|
|
842
|
+
const body = lines.join('\n');
|
|
843
|
+
assert.equal((body.match(/documents?\b/g) || []).length, 1, `the total renders once: ${body}`);
|
|
844
|
+
assert.equal((body.match(/alpha/g) || []).length, 1, 'the focused name appears in the list row only');
|
|
845
|
+
assert.match(lines[0], /✎ Odd · 2 documents/, 'the title carries the one total');
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
test('the search key is named by the footer alone (S4 supersedes the earlier split)', async () => {
|
|
849
|
+
const view = quietView();
|
|
850
|
+
await tick();
|
|
851
|
+
const lines = strip(view.render(120).join('\n')).split('\n');
|
|
852
|
+
const footer = footerOf(lines);
|
|
853
|
+
assert.match(lines[1], /› Search/, 'the search row carries the query only');
|
|
854
|
+
assert.ok(!lines[1].includes('(T)'), 'no parenthetical key above the footer');
|
|
855
|
+
assert.ok(tokensOf(footer).some((token) => /^t/.test(token)), `the footer names t: ${footer}`);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test('while searching the footer names the search keys once and the input carries only the query', async () => {
|
|
859
|
+
const view = quietView();
|
|
860
|
+
await tick();
|
|
861
|
+
view.handleInput('T');
|
|
862
|
+
view.handleInput('a');
|
|
863
|
+
const lines = strip(view.render(80).join('\n')).split('\n');
|
|
864
|
+
const body = lines.join('\n');
|
|
865
|
+
assert.match(lines[1], /› a█/, 'the input row carries the live query');
|
|
866
|
+
assert.ok(!lines[1].includes('enter'), 'the input row repeats no key hint');
|
|
867
|
+
assert.ok(!lines[1].includes('esc'), 'the input row names no escape key');
|
|
868
|
+
const footer = footerOf(lines);
|
|
869
|
+
assert.match(footer, /enter\/esc keep/, 'the footer names the search keys of this interaction');
|
|
870
|
+
assert.ok(!footer.includes('esc close'), 'the close hint belongs to the reading interaction only');
|
|
871
|
+
assert.ok(!footer.includes('ctrl+j/k'), 'the removed binding is never printed');
|
|
872
|
+
assert.ok(!footer.includes('shift+j/k') && !footer.includes('ctrl+shift'), 'the scroll hints leave the footer while typing');
|
|
873
|
+
assert.equal((body.match(/esc/g) || []).length, 1, `one escape hint for the current interaction: ${body}`);
|
|
874
|
+
assert.equal((body.match(/enter/g) || []).length, 1, 'one enter hint for the current interaction');
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test('leaving the box swaps the footer back and keeps a single escape hint', async () => {
|
|
878
|
+
const view = quietView();
|
|
879
|
+
await tick();
|
|
880
|
+
view.handleInput('T');
|
|
881
|
+
view.handleInput('a');
|
|
882
|
+
view.handleInput('enter');
|
|
883
|
+
const lines = strip(view.render(80).join('\n')).split('\n');
|
|
884
|
+
const footer = footerOf(lines);
|
|
885
|
+
assert.match(footer, /esc close/, 'the reading legend returns');
|
|
886
|
+
assert.ok(!footer.includes('enter/esc keep'), 'the search keys leave with the box');
|
|
887
|
+
assert.equal((lines.join('\n').match(/esc/g) || []).length, 1, 'still one escape hint');
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test('the scroll position shares the search row instead of owning a second chrome row', async () => {
|
|
891
|
+
const view = quietView({ docs: [doc('alpha')], text: longDoc(200) });
|
|
892
|
+
await tick();
|
|
893
|
+
view.handleInput('J');
|
|
894
|
+
const lines = strip(view.render(80).join('\n')).split('\n');
|
|
895
|
+
assert.equal(lines.length, 20, 'the frame still fills every row');
|
|
896
|
+
assert.match(lines[1], /› Search/, 'the search widget rides the search row');
|
|
897
|
+
assert.ok(!/page/i.test(lines[1]), 'the page marker is gone with the pager (S3)');
|
|
898
|
+
assert.match(lines[1], / \d+\/\d+/, 'the reading position keeps the right edge');
|
|
899
|
+
assert.ok(!/\d+-\d+\//.test(lines[1]), 'S10: no redundant start-of-window range');
|
|
900
|
+
assert.equal(lines.length - 3, 20 - 3, 'the panes keep the rows the status row used to take');
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
// worktree-ui-feedback T2/T5: S1 (real host input), S3/D3 (wrapped names and
|
|
906
|
+
// viewport-driven rows), S4 (every hint on the footer, Delete clears), S5
|
|
907
|
+
// ("para odd igual, si son muchas igual se pagina").
|
|
908
|
+
// ---------------------------------------------------------------------------
|
|
909
|
+
describe('createOddView feedback round (S1,S3,S4,S5,D3)', () => {
|
|
910
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
911
|
+
|
|
912
|
+
function build(opts = {}) {
|
|
913
|
+
const paints = [];
|
|
914
|
+
const view = createOddView({
|
|
915
|
+
docs: opts.docs || [doc('alpha'), doc('beta')],
|
|
916
|
+
loadDoc: opts.loadDoc || (() => ({ ok: true, text: longDoc(200) })),
|
|
917
|
+
helpers: opts.helpers === undefined ? (realTui || helpers) : opts.helpers,
|
|
918
|
+
theme: plainTheme,
|
|
919
|
+
rowsAvailable: () => (opts.rows === undefined ? 20 : opts.rows),
|
|
920
|
+
requestRender: () => { paints.push(true); },
|
|
921
|
+
done: () => {},
|
|
922
|
+
...opts.rest,
|
|
923
|
+
});
|
|
924
|
+
return { view, paints };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const footerOf = (lines) => strip(lines[lines.length - 2]);
|
|
928
|
+
const searchRowOf = (lines) => strip(lines[1]);
|
|
929
|
+
const leftRows = (lines) => lines
|
|
930
|
+
.slice(2, -2)
|
|
931
|
+
.map((line) => strip(line).split('│')[1] || '');
|
|
932
|
+
|
|
933
|
+
test('S1: ctrl+j/k scroll the preview, ctrl+shift+j/k stay an alternative, and the legacy ctrl+j byte does not', { skip: realTui ? false : 'requires @earendil-works/pi-tui' }, () => {
|
|
934
|
+
// The installed 1.4 matcher reports the same 0x0a as BOTH `ctrl+j` and
|
|
935
|
+
// `enter` while the Kitty protocol is inactive, which is the terminal the
|
|
936
|
+
// user reported. That byte must stay Enter; only a payload the host can
|
|
937
|
+
// tell apart from Enter pages.
|
|
938
|
+
assert.equal(realTui.matchesKey('\n', 'ctrl+j'), true, 'the host byte is ctrl+j');
|
|
939
|
+
assert.equal(realTui.matchesKey('\n', 'enter'), true, 'and the host also calls it enter');
|
|
940
|
+
assert.equal(realTui.matchesKey('j', 'J'), true, 'the host matcher folds letter case, so the helper never asks it about a bare letter');
|
|
941
|
+
const { view } = build();
|
|
942
|
+
view.render(80);
|
|
943
|
+
view.handleInput('\n');
|
|
944
|
+
assert.equal(view.state.scroll, 0, 'the legacy ctrl+j byte never scrolls');
|
|
945
|
+
view.handleInput('\x1b[106;5u'); // Kitty ctrl+j press
|
|
946
|
+
assert.ok(view.state.scroll > 0, 'an extended ctrl+j scrolls where the host reports it');
|
|
947
|
+
view.handleInput('\x1b[107;5u'); // Kitty ctrl+k press
|
|
948
|
+
assert.equal(view.state.scroll, 0, 'the extended ctrl+k scrolls back');
|
|
949
|
+
view.handleInput('\x1b[106;6u'); // Kitty ctrl+shift+j press
|
|
950
|
+
assert.ok(view.state.scroll > 0, 'ctrl+shift+j scrolls the preview through the real matcher');
|
|
951
|
+
view.handleInput('\x1b[107;6u'); // Kitty ctrl+shift+k press
|
|
952
|
+
assert.equal(view.state.scroll, 0, 'ctrl+shift+k scrolls back');
|
|
953
|
+
view.handleInput('\x0b');
|
|
954
|
+
assert.equal(view.state.scroll, 0, 'the legacy control byte never scrolls');
|
|
955
|
+
view.handleInput('\x1b[106;6:2u'); // Kitty ctrl+shift+j repeat
|
|
956
|
+
assert.ok(view.state.scroll > 0, 'a Kitty key repeat also scrolls');
|
|
957
|
+
view.handleInput('\x1b[106;6:3u'); // and its release does not
|
|
958
|
+
const repeated = view.state.scroll;
|
|
959
|
+
view.handleInput('\x1b[106;6:3u');
|
|
960
|
+
assert.equal(view.state.scroll, repeated, 'a release never scrolls');
|
|
961
|
+
assert.equal(view.state.index, 0, 'no scroll key moved the list selection');
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test('S3: a long document list scrolls by the body budget, and ←/→ move the window', async () => {
|
|
965
|
+
const docs = Array.from({ length: 14 }, (_, at) => doc(`document-${String(at + 1).padStart(2, '0')}`));
|
|
966
|
+
const { view } = build({ docs, rows: 12 });
|
|
967
|
+
await tick();
|
|
968
|
+
let lines = view.render(80);
|
|
969
|
+
// rows 12 -> top rule + search row + footer + bottom rule = 8 body rows
|
|
970
|
+
const firstWindow = leftRows(lines).filter((row) => row.trim() !== '');
|
|
971
|
+
assert.equal(firstWindow.length, 8, 'every body row carries a document, no fixed page size');
|
|
972
|
+
assert.match(firstWindow[0], /document-01/);
|
|
973
|
+
assert.ok(!/page/i.test(searchRowOf(lines)), 'no page marker is printed');
|
|
974
|
+
|
|
975
|
+
view.handleInput('right');
|
|
976
|
+
lines = view.render(80);
|
|
977
|
+
const secondWindow = leftRows(lines).filter((row) => row.trim() !== '');
|
|
978
|
+
assert.equal(secondWindow.length, 8, 'the last window is kept full instead of half empty');
|
|
979
|
+
assert.match(secondWindow[0], /document-07/, '→ starts the next screen after the last painted document');
|
|
980
|
+
assert.equal(view.state.index, 6, 'the selection follows into the new screen');
|
|
981
|
+
|
|
982
|
+
view.handleInput('left');
|
|
983
|
+
assert.equal(view.state.listStart, 0, '← returns to the first document of the previous screen');
|
|
984
|
+
assert.match(leftRows(view.render(80)).join(' '), /document-01/, 'the first screen paints again');
|
|
985
|
+
view.handleInput('right');
|
|
986
|
+
view.handleInput('right');
|
|
987
|
+
assert.equal(view.state.listStart, 6, '→ stops at the last full window');
|
|
988
|
+
view.handleInput('left');
|
|
989
|
+
view.handleInput('left');
|
|
990
|
+
assert.equal(view.state.listStart, 0, '← never scrolls before the first document');
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test('S3: ↓ walks every document across window boundaries, skipping none', async () => {
|
|
994
|
+
const docs = Array.from({ length: 14 }, (_, at) => doc(`document-${String(at + 1).padStart(2, '0')}`));
|
|
995
|
+
const { view } = build({ docs, rows: 12 });
|
|
996
|
+
await tick();
|
|
997
|
+
const seen = [];
|
|
998
|
+
const windows = [];
|
|
999
|
+
for (let step = 0; step < docs.length; step += 1) {
|
|
1000
|
+
const rendered = view.render(80);
|
|
1001
|
+
const rows = leftRows(rendered);
|
|
1002
|
+
const focused = rows.find((row) => row.includes('▸')) || '';
|
|
1003
|
+
seen.push((focused.match(/document-\d+/) || [''])[0]);
|
|
1004
|
+
windows.push(view.state.listStart);
|
|
1005
|
+
view.handleInput('down');
|
|
1006
|
+
}
|
|
1007
|
+
assert.deepEqual(seen, docs.map((entry) => entry.name.replace(/\.md$/, '')), 'every document is reachable in order');
|
|
1008
|
+
assert.equal(windows[0], 0, 'the first document starts the walk at the top of the list');
|
|
1009
|
+
assert.equal(windows.at(-1), 6, 'the last document ends the walk in the last full window');
|
|
1010
|
+
assert.deepEqual(windows, [...windows].sort((a, b) => a - b), 'the continuous window never jumps backwards');
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
test('S3: a long document name wraps over the rows it needs instead of truncating', async () => {
|
|
1014
|
+
const name = 'a-document-name-that-is-far-wider-than-the-list-pane';
|
|
1015
|
+
const { view } = build({ docs: [doc(name), doc('beta')], rows: 20, loadDoc: () => ({ ok: true, text: 'body\n' }) });
|
|
1016
|
+
await tick();
|
|
1017
|
+
const rows = leftRows(view.render(80)).map((row) => row.trim());
|
|
1018
|
+
const rendered = rows.join(' ');
|
|
1019
|
+
assert.ok(!rendered.includes('…'), `no ellipsis truncation: ${rendered}`);
|
|
1020
|
+
assert.ok(rendered.includes('wider'), 'the middle of the name is visible');
|
|
1021
|
+
assert.ok(rendered.includes('pane'), 'the end of the name is visible');
|
|
1022
|
+
assert.match(searchRowOf(view.render(80)), /› Search\b/, 'the search row still reads as the search widget');
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
test('S4: t is named by the footer only, and both cases open the box', async () => {
|
|
1026
|
+
const { view } = build();
|
|
1027
|
+
await tick();
|
|
1028
|
+
// S12 added one legend token, so the FULL spelling needs the wider row;
|
|
1029
|
+
// S14 replaced the page/half aliases with the ONE labelled document token,
|
|
1030
|
+
// so the compact tier now starts below 120.
|
|
1031
|
+
const lines = view.render(160);
|
|
1032
|
+
assert.equal(searchRowOf(lines).includes('(T)'), false, 'the search row names no key');
|
|
1033
|
+
assert.equal(searchRowOf(lines).includes('(t)'), false, 'in either case');
|
|
1034
|
+
assert.ok(!searchRowOf(lines).includes('T to edit'), 'leaving the box never re-adds the parenthetical key');
|
|
1035
|
+
assert.match(footerOf(lines), /t search/, 'the footer names the search key in lowercase');
|
|
1036
|
+
assert.match(footerOf(view.render(80)), /(^|· )t( ·|$)/, 'the compact legend still names the search key');
|
|
1037
|
+
view.handleInput('t');
|
|
1038
|
+
assert.equal(view.state.searching, true, 'lowercase t opens the search box');
|
|
1039
|
+
view.handleInput('a');
|
|
1040
|
+
const typing = view.render(80);
|
|
1041
|
+
assert.match(searchRowOf(typing), /› a█/, 'the input row carries the query only');
|
|
1042
|
+
assert.match(footerOf(typing), /del clear/, 'the footer advertises Delete while the box is open');
|
|
1043
|
+
assert.match(footerOf(typing), /bksp/, 'and Backspace for one character');
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
test('uppercase T keeps opening the search', async () => {
|
|
1047
|
+
const { view } = build();
|
|
1048
|
+
await tick();
|
|
1049
|
+
view.handleInput('T');
|
|
1050
|
+
assert.equal(view.state.searching, true, 'T still opens the box');
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
test('S4: Delete clears the whole query, Backspace deletes one character', async () => {
|
|
1054
|
+
const { view } = build({ docs: [doc('alpha'), doc('beta'), doc('gamma')] });
|
|
1055
|
+
await tick();
|
|
1056
|
+
view.handleInput('T');
|
|
1057
|
+
for (const char of 'gam') view.handleInput(char);
|
|
1058
|
+
assert.equal(view.state.query, 'gam');
|
|
1059
|
+
view.handleInput('\x7f');
|
|
1060
|
+
assert.equal(view.state.query, 'ga', 'Backspace removes exactly one character');
|
|
1061
|
+
for (const char of 'mma') view.handleInput(char);
|
|
1062
|
+
assert.equal(leftRows(view.render(80)).filter((row) => row.includes('gamma')).length, 1, 'the query filters');
|
|
1063
|
+
view.handleInput('\x1b[3~'); // Delete
|
|
1064
|
+
assert.equal(view.state.query, '', 'Delete clears the entire query');
|
|
1065
|
+
assert.equal(view.state.searching, true, 'and leaves the box open for a new query');
|
|
1066
|
+
assert.equal(leftRows(view.render(80)).filter((row) => /document|alpha|beta|gamma/.test(row)).length, 3, 'every document lists again');
|
|
1067
|
+
for (const char of 'beta') view.handleInput(char);
|
|
1068
|
+
view.handleInput('delete'); // the host name for the same key
|
|
1069
|
+
assert.equal(view.state.query, '', 'the host key name clears it too');
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test('D3: Tab stays out of the list; only ←/→ page and ↑↓/j/k select', async () => {
|
|
1073
|
+
const docs = Array.from({ length: 10 }, (_, at) => doc(`document-${at + 1}`));
|
|
1074
|
+
const { view } = build({ docs, rows: 12 });
|
|
1075
|
+
await tick();
|
|
1076
|
+
view.handleInput('tab');
|
|
1077
|
+
assert.equal(view.state.index, 0, 'Tab changes no selection in a single-dataset reader');
|
|
1078
|
+
assert.equal(view.state.scroll, 0);
|
|
1079
|
+
view.handleInput('down');
|
|
1080
|
+
assert.equal(view.state.index, 1, 'bare ↓ still moves the selection');
|
|
1081
|
+
view.handleInput('j');
|
|
1082
|
+
assert.equal(view.state.index, 2, 'bare j still moves the selection');
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
// ---------------------------------------------------------------------------
|
|
1087
|
+
// ui-consistency-round3 T3: S5 (shared 40/60 geometry, full titles), S8 (page
|
|
1088
|
+
// capacity from the real rendered body and the wrapped row cost), S14 (page
|
|
1089
|
+
// marker fixed next to Search on every page count) and S17 (hyphen-aware
|
|
1090
|
+
// label wrapping). Every case drives the public render/handleInput surface
|
|
1091
|
+
// only: no internal state is read except the documented `view.state` index.
|
|
1092
|
+
// ---------------------------------------------------------------------------
|
|
1093
|
+
describe('createOddView round-3 geometry, capacity and page marker (S5,S8,S14,S17)', () => {
|
|
1094
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
1095
|
+
const inner = (width) => layout.innerWidth(width);
|
|
1096
|
+
// S3: the ODD reader asks view-layout for the smaller 30% list share.
|
|
1097
|
+
const panes = (width) => layout.paneWidths(inner(width), { listShare: layout.READER_LIST_SHARE });
|
|
1098
|
+
const searchRowOf = (lines) => strip(lines[1]);
|
|
1099
|
+
const listCells = (lines) => lines.slice(2, -2).map((line) => strip(line).split('│')[1] || '');
|
|
1100
|
+
const filled = (lines) => listCells(lines).filter((row) => row.trim() !== '');
|
|
1101
|
+
|
|
1102
|
+
function build({ docs, rows = 20, load } = {}) {
|
|
1103
|
+
const view = createOddView({
|
|
1104
|
+
docs,
|
|
1105
|
+
loadDoc: load || (() => ({ ok: true, text: 'body\n' })),
|
|
1106
|
+
helpers,
|
|
1107
|
+
theme: plainTheme,
|
|
1108
|
+
rowsAvailable: () => rows,
|
|
1109
|
+
requestRender: () => {},
|
|
1110
|
+
done: () => {},
|
|
1111
|
+
});
|
|
1112
|
+
return { view };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const manyShort = (count) => Array.from({ length: count }, (_unused, at) => doc(`doc-${String(at + 1).padStart(2, '0')}`));
|
|
1116
|
+
const manyLong = (count) => Array.from({ length: count }, (_unused, at) => doc(`long-wrapped-document-name-number-${String(at + 1).padStart(2, '0')}`));
|
|
1117
|
+
|
|
1118
|
+
test('S3: the two panes take the smaller 30/70 reader geometry at 200 and 80 columns', async () => {
|
|
1119
|
+
const { view } = build({ docs: [doc('alpha'), doc('beta')] });
|
|
1120
|
+
await tick();
|
|
1121
|
+
for (const width of [200, 80]) {
|
|
1122
|
+
const lines = view.render(width);
|
|
1123
|
+
const shared = panes(width);
|
|
1124
|
+
assert.ok(shared.right > shared.left, `${width}: the preview is wider than the list`);
|
|
1125
|
+
assert.equal(shared.left + shared.right + 3, inner(width), `${width}: the panes fill the inner width`);
|
|
1126
|
+
const row = strip(lines[2]);
|
|
1127
|
+
assert.equal([...row].indexOf('│', 2), 3 + shared.left, `${width}: the divider sits where the shared rule puts it`);
|
|
1128
|
+
for (const line of lines) assert.equal(helpers.visibleWidth(line), width, `${width}: the frame fills the terminal`);
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
test('S5: a full document name is reconstructible from its wrapped rows at 200 columns', async () => {
|
|
1133
|
+
const name = 'document-name-that-is-long-enough-to-need-several-wrapped-rows-in-the-list-pane';
|
|
1134
|
+
const { view } = build({ docs: [doc(name), doc('beta')] });
|
|
1135
|
+
await tick();
|
|
1136
|
+
const lines = view.render(200);
|
|
1137
|
+
const cost = layout.rowCost(helpers, name, panes(200).left, { first: '▸ ' });
|
|
1138
|
+
assert.equal(cost, 2, 'the name needs two rows in the widened list');
|
|
1139
|
+
const rows = listCells(lines).slice(0, cost).map((row) => row.slice(1).replace(/^[▸ ]{2}/, '').replace(/\s+$/, ''));
|
|
1140
|
+
assert.equal(rows.join(''), name, 'every character of the title survives the wrap');
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
test('S8: capacity is the real body rows divided by the rows each name needs, at 200 columns', async () => {
|
|
1144
|
+
const { view } = build({ docs: manyShort(30), rows: 20 });
|
|
1145
|
+
await tick();
|
|
1146
|
+
const first = view.render(200);
|
|
1147
|
+
assert.equal(listCells(first).length, 16, 'the body keeps every row the fixed chrome leaves');
|
|
1148
|
+
assert.equal(filled(first).length, 16, '16 one-row names fill the window at 200 columns');
|
|
1149
|
+
assert.ok(!/page/i.test(searchRowOf(first)), 'no page marker is printed (S3)');
|
|
1150
|
+
assert.match(filled(first)[0], /doc-01/);
|
|
1151
|
+
view.handleInput('right');
|
|
1152
|
+
const second = view.render(200);
|
|
1153
|
+
assert.equal(filled(second).length, 16, 'the next window is kept full');
|
|
1154
|
+
assert.match(filled(second)[0], /doc-15/, 'the next window starts after the last painted name');
|
|
1155
|
+
assert.equal(view.state.listStart, 14, 'a forward scroll stops at the last full window');
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
test('S8: taller wrapped names spend more of the same body budget', async () => {
|
|
1159
|
+
const docs = manyLong(12);
|
|
1160
|
+
const { view } = build({ docs, rows: 20 });
|
|
1161
|
+
await tick();
|
|
1162
|
+
const lines = view.render(64);
|
|
1163
|
+
const cost = layout.rowCost(helpers, docs[0].name, panes(64).left, { first: '▸ ' });
|
|
1164
|
+
assert.ok(cost >= 2, `each name wraps over several rows at this width, got ${cost}`);
|
|
1165
|
+
assert.equal(listCells(lines).length, 16, 'the body row budget does not depend on the wrap');
|
|
1166
|
+
assert.equal(filled(lines).length, Math.floor(16 / cost) * cost, 'a full window of wrapped names spends every body row');
|
|
1167
|
+
assert.equal(filled(lines).length / cost, Math.floor(16 / cost), 'the window carries whole names, never a fixed 20');
|
|
1168
|
+
assert.ok(!/page/i.test(searchRowOf(lines)), 'no page marker is printed (S3)');
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
test('S8: walking the window covers each label once, mixed one-row and two-row names included', async () => {
|
|
1172
|
+
const docs = Array.from({ length: 18 }, (_unused, at) => (at % 2 === 0
|
|
1173
|
+
? doc(`short-name-${String(at + 1).padStart(2, '0')}`)
|
|
1174
|
+
: doc(`long-wrapped-document-name-number-${String(at + 1).padStart(2, '0')}`)));
|
|
1175
|
+
const { view } = build({ docs, rows: 20 });
|
|
1176
|
+
await tick();
|
|
1177
|
+
const walked = new Set();
|
|
1178
|
+
for (let guard = 0; guard < 40; guard += 1) {
|
|
1179
|
+
const rows = filled(view.render(64));
|
|
1180
|
+
assert.ok(rows.length <= 16, `window ${guard + 1}: never more rows than the body holds`);
|
|
1181
|
+
// Rows of one wrapped name are concatenated, so a name broken across rows
|
|
1182
|
+
// is still read whole.
|
|
1183
|
+
for (const token of rows.map((row) => row.trim()).join('').match(/name-(?:number-)?\d\d/g) || []) {
|
|
1184
|
+
walked.add(token.replace(/^name-(?:number-)?/, ''));
|
|
1185
|
+
}
|
|
1186
|
+
const before = view.state.listStart;
|
|
1187
|
+
view.handleInput('right');
|
|
1188
|
+
if (view.state.listStart === before) break;
|
|
1189
|
+
}
|
|
1190
|
+
assert.deepEqual(
|
|
1191
|
+
[...walked].sort(),
|
|
1192
|
+
Array.from({ length: 18 }, (_unused, at) => String(at + 1).padStart(2, '0')),
|
|
1193
|
+
'every document is reachable by scrolling the one window',
|
|
1194
|
+
);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
test('S8: resizing re-windows around the selection and keeps it visible', async () => {
|
|
1198
|
+
const { view } = build({ docs: manyLong(40), rows: 20 });
|
|
1199
|
+
await tick();
|
|
1200
|
+
for (let step = 0; step < 20; step += 1) view.handleInput('down');
|
|
1201
|
+
assert.equal(view.state.index, 20);
|
|
1202
|
+
const wide = view.render(200);
|
|
1203
|
+
assert.ok(!/page/i.test(searchRowOf(wide)), 'one-row names at 200 columns print no page marker');
|
|
1204
|
+
assert.match(strip(filled(wide).find((row) => row.includes('▸'))), /number-21/, 'the selection is in the wide window');
|
|
1205
|
+
const narrow = view.render(64);
|
|
1206
|
+
const focused = filled(narrow).find((row) => row.includes('▸'));
|
|
1207
|
+
assert.ok(focused, 'the selection stays in the window after the resize');
|
|
1208
|
+
assert.match(strip(focused), /▸ long-wrapped-/, 'the focused name starts on the focused row');
|
|
1209
|
+
assert.ok(filled(narrow).join(' ').replace(/\s+/g, '').includes('name-number-21'), 'the focused document renders');
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
test('S8: a search that narrows the list keeps the focused row on the visible page', async () => {
|
|
1213
|
+
const { view } = build({ docs: manyShort(30), rows: 12 });
|
|
1214
|
+
await tick();
|
|
1215
|
+
for (let step = 0; step < 19; step += 1) view.handleInput('down');
|
|
1216
|
+
view.handleInput('T');
|
|
1217
|
+
view.handleInput('2');
|
|
1218
|
+
view.handleInput('enter');
|
|
1219
|
+
const lines = view.render(80);
|
|
1220
|
+
assert.match(searchRowOf(lines), /› 2/, 'the narrowed list keeps its query on the search row');
|
|
1221
|
+
assert.ok(!/page/i.test(searchRowOf(lines)), 'no page marker is printed (S3)');
|
|
1222
|
+
assert.equal(view.state.listStart, 0, 'the narrowed list starts at its own top');
|
|
1223
|
+
assert.match(strip(filled(lines).find((row) => row.includes('▸'))), /doc-02/, 'the selection restarts in the visible window');
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
test('S3: no page marker renders beside Search in any state', async () => {
|
|
1227
|
+
const single = build({ docs: [doc('alpha'), doc('beta')] });
|
|
1228
|
+
await tick();
|
|
1229
|
+
assert.match(searchRowOf(single.view.render(120)), /› Search/, 'the widget keeps the row');
|
|
1230
|
+
assert.ok(!/page/i.test(searchRowOf(single.view.render(120))), 'a short list prints no marker');
|
|
1231
|
+
const empty = build({ docs: [] });
|
|
1232
|
+
await tick();
|
|
1233
|
+
assert.ok(!/page/i.test(searchRowOf(empty.view.render(120))), 'the empty list prints no marker');
|
|
1234
|
+
const noMatch = build({ docs: [doc('alpha'), doc('beta')] });
|
|
1235
|
+
await tick();
|
|
1236
|
+
noMatch.view.handleInput('T');
|
|
1237
|
+
noMatch.view.handleInput('z');
|
|
1238
|
+
assert.match(searchRowOf(noMatch.view.render(120)), /› z█/, 'the query is still displayed');
|
|
1239
|
+
assert.ok(!/page/i.test(searchRowOf(noMatch.view.render(120))), 'a no-match query prints no marker');
|
|
1240
|
+
const narrow = build({ docs: [doc('alpha'), doc('beta')], rows: 12 });
|
|
1241
|
+
await tick();
|
|
1242
|
+
assert.ok(!/page/i.test(searchRowOf(narrow.view.render(24))), 'even the narrowest frame prints no marker');
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
test('S3: the frame carries no page marker at all, and the window moves instead', async () => {
|
|
1246
|
+
const docs = Array.from({ length: 14 }, (_unused, at) => doc(`document-${String(at + 1).padStart(2, '0')}`));
|
|
1247
|
+
const { view } = build({ docs, rows: 12 });
|
|
1248
|
+
await tick();
|
|
1249
|
+
assert.ok(!/page \d+\/\d+/.test(strip(view.render(80).join('\n'))), 'nothing in the frame claims a page');
|
|
1250
|
+
assert.match(filled(view.render(80))[0], /document-01/);
|
|
1251
|
+
view.handleInput('right');
|
|
1252
|
+
assert.match(filled(view.render(80))[0], /document-07/, 'the window moved instead');
|
|
1253
|
+
assert.ok(!listCells(view.render(80)).some((row) => /page \d/.test(row)), 'the list content carries no page marker');
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
test('S17: the list breaks a hyphenated name at its hyphens, never inside a segment', async () => {
|
|
1257
|
+
const name = 'a-document-name-that-is-far-wider-than-the-list-pane';
|
|
1258
|
+
const { view } = build({ docs: [doc(name), doc('beta')] });
|
|
1259
|
+
await tick();
|
|
1260
|
+
const lines = view.render(80);
|
|
1261
|
+
const width = panes(80).left;
|
|
1262
|
+
const expected = layout.wrapLabel(helpers, name, width, { first: '▸ ', rest: ' ' });
|
|
1263
|
+
assert.ok(expected.length > 1, 'the name wraps over several rows');
|
|
1264
|
+
for (const row of expected) assert.ok(helpers.visibleWidth(row) <= width, `row too wide: ${row}`);
|
|
1265
|
+
assert.deepEqual(
|
|
1266
|
+
listCells(lines).slice(0, expected.length).map((row) => row.slice(1).replace(/\s+$/, '')),
|
|
1267
|
+
expected.map((row) => row.replace(/\s+$/, '')),
|
|
1268
|
+
'the rendered rows are the hyphen-aware wrap',
|
|
1269
|
+
);
|
|
1270
|
+
assert.ok(expected.some((row) => row.trimEnd().endsWith('-')), 'a break lands on a hyphen');
|
|
1271
|
+
assert.equal(expected.map((row) => row.replace(/^[▸ ]{2}/, '')).join(''), name, 'every character survives');
|
|
1272
|
+
});
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
// ---------------------------------------------------------------------------
|
|
1276
|
+
// round-3 T3 S1: the preview scroll bindings ODD shares through
|
|
1277
|
+
// lib/preview-scroll-keys.js. The default pair is ctrl+j/k — the one the user
|
|
1278
|
+
// asked for, printed whatever the terminal reports — while ctrl+shift+j/k and
|
|
1279
|
+
// the printable J/K stay working alternates whose hints leave the row. A bare
|
|
1280
|
+
// LF byte is Enter on a legacy terminal and never pages.
|
|
1281
|
+
// ---------------------------------------------------------------------------
|
|
1282
|
+
describe('createOddView preview scroll bindings (S1)', () => {
|
|
1283
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
1284
|
+
const body200 = longDoc(200);
|
|
1285
|
+
const searchRowOf = (lines) => strip(lines[1]);
|
|
1286
|
+
const footerOf = (lines) => strip(lines[lines.length - 2]);
|
|
1287
|
+
|
|
1288
|
+
// A host whose matcher/parser is a table of the payloads these tests send, so
|
|
1289
|
+
// each case states what the terminal reported instead of what the installed
|
|
1290
|
+
// module happens to decode.
|
|
1291
|
+
const TABLE = {
|
|
1292
|
+
'\x1b[6~': 'pageDown',
|
|
1293
|
+
'\x1b[5~': 'pageUp',
|
|
1294
|
+
'\x1b[106;6u': 'shift+ctrl+j',
|
|
1295
|
+
'\x1b[107;6u': 'shift+ctrl+k',
|
|
1296
|
+
'\x1b[27;6;106~': 'shift+ctrl+j',
|
|
1297
|
+
'\x1b[106;5u': 'ctrl+j',
|
|
1298
|
+
'\x1b[27;5;106~': 'ctrl+j',
|
|
1299
|
+
J: 'J',
|
|
1300
|
+
K: 'K',
|
|
1301
|
+
j: 'j',
|
|
1302
|
+
k: 'k',
|
|
1303
|
+
'\n': 'enter',
|
|
1304
|
+
'\x0b': 'ctrl+k',
|
|
1305
|
+
'\x04': 'ctrl+d',
|
|
1306
|
+
};
|
|
1307
|
+
const namesOf = (data) => (TABLE[data] ? [TABLE[data]] : []);
|
|
1308
|
+
const sameName = (a, b) => (a.length === 1 || b.length === 1 ? a === b : a.toLowerCase() === b.toLowerCase());
|
|
1309
|
+
function host({ kitty = false, partial = false } = {}) {
|
|
1310
|
+
const helpers = partial
|
|
1311
|
+
? { matchesKey: (data, name) => namesOf(data).some((entry) => sameName(entry, name)) }
|
|
1312
|
+
: {
|
|
1313
|
+
isKittyProtocolActive: () => kitty,
|
|
1314
|
+
matchesKey: (data, name) => namesOf(data).some((entry) => sameName(entry, name)),
|
|
1315
|
+
parseKey: (data) => namesOf(data)[0] ?? null,
|
|
1316
|
+
isKeyRelease: (data) => /:3u$/.test(String(data)),
|
|
1317
|
+
};
|
|
1318
|
+
return helpers;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function build(kitchen = {}) {
|
|
1322
|
+
const paints = [];
|
|
1323
|
+
const view = createOddView({
|
|
1324
|
+
docs: [doc('alpha'), doc('beta')],
|
|
1325
|
+
loadDoc: () => ({ ok: true, text: body200 }),
|
|
1326
|
+
helpers: kitchen.helpers === undefined ? host() : kitchen.helpers,
|
|
1327
|
+
theme: plainTheme,
|
|
1328
|
+
rowsAvailable: () => 20,
|
|
1329
|
+
requestRender: () => { paints.push(true); },
|
|
1330
|
+
done: () => {},
|
|
1331
|
+
});
|
|
1332
|
+
return { view, paints };
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
test('the footer always names ctrl+j/k scroll', async () => {
|
|
1336
|
+
for (const kitty of [true, false]) {
|
|
1337
|
+
const { view } = build({ helpers: host({ kitty }) });
|
|
1338
|
+
await tick();
|
|
1339
|
+
const footer = footerOf(view.render(120));
|
|
1340
|
+
assert.match(footer, /ctrl\+j\/k scroll/, `kitty ${kitty}: the default pair is named: ${footer}`);
|
|
1341
|
+
for (const gone of ['ctrl+shift', 'shift+j/k']) {
|
|
1342
|
+
assert.ok(!footer.includes(gone), `kitty ${kitty}: the alternate "${gone}" leaves the legend: ${footer}`);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
const cases = [
|
|
1346
|
+
['unknown namespace', {}],
|
|
1347
|
+
['no helpers', null],
|
|
1348
|
+
['throwing getter', { isKittyProtocolActive: () => { throw new Error('no tty'); } }],
|
|
1349
|
+
];
|
|
1350
|
+
for (const [label, helpers] of cases) {
|
|
1351
|
+
const { view } = build({ helpers });
|
|
1352
|
+
await tick();
|
|
1353
|
+
const footer = footerOf(view.render(120));
|
|
1354
|
+
assert.match(footer, /ctrl\+j\/k scroll/, `${label}: the default pair is named: ${footer}`);
|
|
1355
|
+
assert.ok(!footer.includes('ctrl+shift'), `${label}: no fabricated extended support: ${footer}`);
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
test('PgUp/PgDn still page although S14 drops their generic token', async () => {
|
|
1360
|
+
// S14 supersedes the earlier legend: «alguna key generidad como el page o
|
|
1361
|
+
// half estan demas». The pair leaves the row because the labelled document
|
|
1362
|
+
// token already names what it moves — the BINDINGS are untouched.
|
|
1363
|
+
for (const kitty of [true, false]) {
|
|
1364
|
+
const { view } = build({ helpers: host({ kitty }) });
|
|
1365
|
+
await tick();
|
|
1366
|
+
const footer = footerOf(view.render(120));
|
|
1367
|
+
assert.ok(!/(PgUp|PgDn|PgUp\/Dn)/.test(footer), `kitty ${kitty}: no generic page token: ${footer}`);
|
|
1368
|
+
view.render(80);
|
|
1369
|
+
view.handleInput('\x1b[6~');
|
|
1370
|
+
const paged = view.state.scroll;
|
|
1371
|
+
assert.ok(paged > 1, `kitty ${kitty}: PageDown still pages the preview`);
|
|
1372
|
+
view.handleInput('\x1b[5~');
|
|
1373
|
+
assert.equal(view.state.scroll, 0, `kitty ${kitty}: PageUp still pages back`);
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
test('ctrl+j/k page the preview where the host reports them, and a bare LF never does', async () => {
|
|
1378
|
+
const { view } = build({ helpers: host({ kitty: true }) });
|
|
1379
|
+
await tick();
|
|
1380
|
+
view.render(80);
|
|
1381
|
+
view.handleInput('\x1b[106;5u');
|
|
1382
|
+
const paged = view.state.scroll;
|
|
1383
|
+
assert.ok(paged > 1, 'ctrl+j pages the preview');
|
|
1384
|
+
view.handleInput('\x1b[107;5u');
|
|
1385
|
+
assert.equal(view.state.scroll, 0, 'ctrl+k pages back up');
|
|
1386
|
+
view.handleInput('\x1b[27;5;106~');
|
|
1387
|
+
assert.equal(view.state.scroll, paged, 'the xterm ctrl+j sequence pages too');
|
|
1388
|
+
view.handleInput('\x1b[27;5;107~');
|
|
1389
|
+
assert.equal(view.state.scroll, 0, 'and the xterm ctrl+k pages back');
|
|
1390
|
+
view.handleInput('\x1b[106;6u');
|
|
1391
|
+
assert.ok(view.state.scroll > 1, 'ctrl+shift+j stays a working alternative');
|
|
1392
|
+
view.handleInput('\x1b[107;6u');
|
|
1393
|
+
assert.equal(view.state.scroll, 0, 'ctrl+shift+k stays a working alternative');
|
|
1394
|
+
for (const data of ['\n', '\x0b']) {
|
|
1395
|
+
view.render(80);
|
|
1396
|
+
view.handleInput(data);
|
|
1397
|
+
assert.equal(view.state.scroll, 0, `${JSON.stringify(data)}: the legacy byte stays Enter, never a scroll`);
|
|
1398
|
+
assert.equal(view.state.index, 0, `${JSON.stringify(data)}: no selection movement either`);
|
|
1399
|
+
}
|
|
1400
|
+
assert.equal(view.state.index, 0);
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
test('the xterm modifyOtherKeys sequence pages and its release is ignored', async () => {
|
|
1404
|
+
const { view, paints } = build({ helpers: host({ kitty: true }) });
|
|
1405
|
+
await tick();
|
|
1406
|
+
view.render(80);
|
|
1407
|
+
view.handleInput('\x1b[27;6;106~');
|
|
1408
|
+
const paged = view.state.scroll;
|
|
1409
|
+
assert.ok(paged > 1, 'the xterm ctrl+shift+j sequence pages the preview');
|
|
1410
|
+
const before = paints.length;
|
|
1411
|
+
view.handleInput('\x1b[27;6;106;3~');
|
|
1412
|
+
assert.equal(view.state.scroll, paged, 'the release suffix never pages');
|
|
1413
|
+
assert.equal(paints.length, before, 'a release does not even repaint');
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
test('a repeat pages, a release never does', async () => {
|
|
1417
|
+
const { view } = build({ helpers: host({ kitty: true }) });
|
|
1418
|
+
await tick();
|
|
1419
|
+
view.render(80);
|
|
1420
|
+
view.handleInput('\x1b[106;6:2u');
|
|
1421
|
+
const repeated = view.state.scroll;
|
|
1422
|
+
assert.ok(repeated > 1, 'a key repeat pages the preview');
|
|
1423
|
+
view.handleInput('\x1b[106;6:3u');
|
|
1424
|
+
assert.equal(view.state.scroll, repeated, 'the release of that key is ignored');
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
test('Enter and the LF byte keep their meaning even when the host calls the byte ctrl+j', async () => {
|
|
1428
|
+
const lfIsCtrlJ = {
|
|
1429
|
+
...helpers,
|
|
1430
|
+
matchesKey: (data, name) => data === name || (data === '\n' && (name === 'enter' || name === 'ctrl+j')),
|
|
1431
|
+
parseKey: (data) => (data === '\n' ? 'ctrl+j' : null),
|
|
1432
|
+
};
|
|
1433
|
+
const { view, paints } = build({ helpers: lfIsCtrlJ });
|
|
1434
|
+
await tick();
|
|
1435
|
+
view.render(80);
|
|
1436
|
+
const before = paints.length;
|
|
1437
|
+
for (const data of ['\n', '\r', 'enter']) {
|
|
1438
|
+
view.handleInput(data);
|
|
1439
|
+
assert.equal(view.state.scroll, 0, `${JSON.stringify(data)}: Enter never scrolls`);
|
|
1440
|
+
assert.equal(view.state.index, 0, `${JSON.stringify(data)}: Enter never moves the selection`);
|
|
1441
|
+
}
|
|
1442
|
+
assert.equal(paints.length, before, 'Enter requests no repaint: nothing changed');
|
|
1443
|
+
view.handleInput('ctrl+j');
|
|
1444
|
+
assert.ok(view.state.scroll > 0, 'the distinguishable ctrl+j still pages the preview');
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
test('a search session keeps the scroll keys out of the preview', async () => {
|
|
1448
|
+
const { view } = build({ helpers: host({ kitty: true }) });
|
|
1449
|
+
await tick();
|
|
1450
|
+
view.handleInput('T');
|
|
1451
|
+
view.handleInput('J');
|
|
1452
|
+
view.handleInput('\x1b[106;6u');
|
|
1453
|
+
assert.equal(view.state.query, 'J', 'the printable byte types into the query, it does not page');
|
|
1454
|
+
assert.equal(view.state.scroll, 0, 'no preview scroll while the box holds the keyboard');
|
|
1455
|
+
const lines = view.render(120);
|
|
1456
|
+
assert.match(searchRowOf(lines), /› J█/, 'the query row carries the typed letter');
|
|
1457
|
+
assert.match(footerOf(lines), /enter\/esc keep/, 'the footer names the search keys of this interaction');
|
|
1458
|
+
assert.ok(!footerOf(lines).includes('ctrl+shift'), 'the scroll hint leaves the footer while typing');
|
|
1459
|
+
view.handleInput('enter');
|
|
1460
|
+
assert.equal(view.state.query, 'J', 'leaving the box keeps the query');
|
|
1461
|
+
assert.equal(view.state.scroll, 0, 'still no scroll');
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
test('a partial helper namespace still pages through the shared fallback', async () => {
|
|
1465
|
+
const { view } = build({ helpers: host({ partial: true }) });
|
|
1466
|
+
await tick();
|
|
1467
|
+
view.render(80);
|
|
1468
|
+
view.handleInput('\x1b[6~');
|
|
1469
|
+
const paged = view.state.scroll;
|
|
1470
|
+
assert.ok(paged > 1, 'PageDown works with a matcher-only host');
|
|
1471
|
+
view.handleInput('K');
|
|
1472
|
+
assert.equal(view.state.scroll, 0, 'the printable shift fallback works with a matcher-only host');
|
|
1473
|
+
view.handleInput('J');
|
|
1474
|
+
assert.equal(view.state.scroll, paged, 'and pages exactly what PageDown pages');
|
|
1475
|
+
view.handleInput('\x1b[106;6u');
|
|
1476
|
+
assert.ok(view.state.scroll > paged, 'the extended pair still resolves through the matcher');
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
test('the mouse wheel and the frame geometry are untouched by the binding change', async () => {
|
|
1480
|
+
const { view } = build({ helpers: host({ kitty: true }) });
|
|
1481
|
+
await tick();
|
|
1482
|
+
const geometry = layout.paneWidths(layout.innerWidth(120), { listShare: layout.READER_LIST_SHARE });
|
|
1483
|
+
const lines = view.render(120);
|
|
1484
|
+
assert.equal(
|
|
1485
|
+
layout.paneWidths(layout.innerWidth(120), { listShare: layout.READER_LIST_SHARE }).left,
|
|
1486
|
+
geometry.left,
|
|
1487
|
+
'the 30/70 reader rule owns the panes',
|
|
1488
|
+
);
|
|
1489
|
+
assert.equal([...strip(lines[2])].indexOf('│', 2), 3 + geometry.left, 'the divider still sits at the shared column');
|
|
1490
|
+
view.handleMouse({ type: 'wheel', wheelDelta: 2 });
|
|
1491
|
+
assert.equal(view.state.scroll, 2, 'the wheel still scrolls the preview');
|
|
1492
|
+
assert.equal(view.state.index, 0, 'the wheel never moves the selection');
|
|
1493
|
+
});
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
// ---------------------------------------------------------------------------
|
|
1497
|
+
// S14: «alguna key generidad como el page o half estan demas (nose para que
|
|
1498
|
+
// es)» — the ODD footer names the scroll interaction once, exactly like the
|
|
1499
|
+
// issues reader: the document pane's page, half-page and printable hints
|
|
1500
|
+
// collapse into the ONE labelled token the reader is always shown, and every
|
|
1501
|
+
// binding still answers in the handler even after its hint leaves the row.
|
|
1502
|
+
// ---------------------------------------------------------------------------
|
|
1503
|
+
describe('createOddView footer scroll targets (S14)', () => {
|
|
1504
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
1505
|
+
const footerOf = (lines) => strip(lines[lines.length - 2]);
|
|
1506
|
+
const tokensOf = (footer) => footer.split('·').map((token) => token.trim()).filter((token) => token !== '');
|
|
1507
|
+
const body200 = longDoc(200);
|
|
1508
|
+
// The generic tokens the user reported as noise: a page pair and a half-page
|
|
1509
|
+
// pair nobody could place, next to a bare unlabelled `scroll`.
|
|
1510
|
+
const GENERIC = /(pgup|pgdn|half|ctrl\+d\/u)/i;
|
|
1511
|
+
|
|
1512
|
+
// A host whose matcher is the payload table these tests drive, so every
|
|
1513
|
+
// binding assertion states what the terminal reported.
|
|
1514
|
+
function host(kitty) {
|
|
1515
|
+
return {
|
|
1516
|
+
isKittyProtocolActive: () => kitty,
|
|
1517
|
+
matchesKey: (data, name) => data === name,
|
|
1518
|
+
parseKey: (data) => data,
|
|
1519
|
+
isKeyRelease: () => false,
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function build({ kitty = true, theme = plainTheme, rows = 20, docs = [doc('alpha'), doc('beta')] } = {}) {
|
|
1524
|
+
return createOddView({
|
|
1525
|
+
docs,
|
|
1526
|
+
loadDoc: () => ({ ok: true, text: body200 }),
|
|
1527
|
+
helpers: host(kitty),
|
|
1528
|
+
theme,
|
|
1529
|
+
rowsAvailable: () => rows,
|
|
1530
|
+
requestRender: () => {},
|
|
1531
|
+
done: () => {},
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
test('the generic page and half tokens leave the footer in both tiers', async () => {
|
|
1536
|
+
for (const kitty of [true, false]) {
|
|
1537
|
+
const view = build({ kitty });
|
|
1538
|
+
await tick();
|
|
1539
|
+
for (const width of [80, 120]) {
|
|
1540
|
+
const footer = footerOf(view.render(width));
|
|
1541
|
+
assert.ok(!GENERIC.test(footer), `kitty ${kitty}/${width}: no generic page or half token: ${footer}`);
|
|
1542
|
+
// `scroll` is the label of the one surviving token, so it may only ever
|
|
1543
|
+
// appear inside it: an unlabelled word would be the ambiguity S14 fixed.
|
|
1544
|
+
const scrollTokens = tokensOf(footer).filter((token) => /\bscroll\b/.test(token));
|
|
1545
|
+
assert.deepEqual(scrollTokens, ['ctrl+j/k scroll'], `kitty ${kitty}/${width}: the one scroll token is labelled: ${footer}`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
test('the scroll interaction is named once, by the default ctrl+j/k pair', async () => {
|
|
1551
|
+
const extended = build({ kitty: true });
|
|
1552
|
+
const printable = build({ kitty: false });
|
|
1553
|
+
await tick();
|
|
1554
|
+
const extendedFooter = footerOf(extended.render(120));
|
|
1555
|
+
const printableFooter = footerOf(printable.render(120));
|
|
1556
|
+
assert.match(extendedFooter, /ctrl\+j\/k scroll/, `the default pair is named: ${extendedFooter}`);
|
|
1557
|
+
assert.match(printableFooter, /ctrl\+j\/k scroll/, `the same pair on a legacy terminal: ${printableFooter}`);
|
|
1558
|
+
for (const [label, footer] of [['extended', extendedFooter], ['fallback', printableFooter]]) {
|
|
1559
|
+
const scrollTokens = tokensOf(footer).filter((token) => /(^|\s)scroll$/.test(token));
|
|
1560
|
+
assert.equal(scrollTokens.length, 1, `${label}: exactly one labelled scroll token: ${footer}`);
|
|
1561
|
+
}
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
test('S8: the list scroll pair has no footer token, and the scroll token keeps its label', async () => {
|
|
1565
|
+
const view = build();
|
|
1566
|
+
await tick();
|
|
1567
|
+
const footer = footerOf(view.render(120));
|
|
1568
|
+
assert.ok(!footer.includes('←/→'), `the redundant list scroll hint is gone: ${footer}`);
|
|
1569
|
+
assert.match(footer, /ctrl\+j\/k scroll/, 'the scroll keeps its labelled token');
|
|
1570
|
+
assert.match(footer, /s sort: recent/, 'the sort token stays beside the remaining scroll hint');
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
test('every document scroll binding still answers with its hint gone', async () => {
|
|
1574
|
+
const view = build({ kitty: true });
|
|
1575
|
+
await tick();
|
|
1576
|
+
view.render(80);
|
|
1577
|
+
view.handleInput('\x1b[6~');
|
|
1578
|
+
const paged = view.state.scroll;
|
|
1579
|
+
assert.ok(paged > 1, 'PgDn still pages the document');
|
|
1580
|
+
view.handleInput('\x1b[5~');
|
|
1581
|
+
assert.equal(view.state.scroll, 0, 'PgUp still pages back');
|
|
1582
|
+
view.handleInput('J');
|
|
1583
|
+
assert.equal(view.state.scroll, paged, 'J pages exactly what PgDn pages');
|
|
1584
|
+
view.handleInput('K');
|
|
1585
|
+
assert.equal(view.state.scroll, 0, 'K pages back');
|
|
1586
|
+
view.handleInput('\x04');
|
|
1587
|
+
const half = view.state.scroll;
|
|
1588
|
+
assert.ok(half > 0 && half < paged, `ctrl+d keeps the half-page step: ${half} of ${paged}`);
|
|
1589
|
+
view.handleInput('\x15');
|
|
1590
|
+
assert.equal(view.state.scroll, 0, 'ctrl+u returns half a page up');
|
|
1591
|
+
view.handleInput('\x1b[106;6u');
|
|
1592
|
+
assert.equal(view.state.scroll, paged, 'ctrl+shift+j pages the document');
|
|
1593
|
+
view.handleInput('\x1b[107;6u');
|
|
1594
|
+
assert.equal(view.state.scroll, 0, 'ctrl+shift+k pages back');
|
|
1595
|
+
view.handleInput('end');
|
|
1596
|
+
assert.ok(view.state.scroll > 0, 'end still jumps to the bottom');
|
|
1597
|
+
view.handleInput('home');
|
|
1598
|
+
assert.equal(view.state.scroll, 0, 'home still returns to the top');
|
|
1599
|
+
assert.equal(view.state.index, 0, 'no document scroll key ever moved the list');
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
test('the list scroll keys keep working with no footer token', async () => {
|
|
1603
|
+
const names = Array.from({ length: 30 }, (_unused, at) => doc(`document-${String(at + 1).padStart(2, '0')}`));
|
|
1604
|
+
const view = build({ docs: names, rows: 12 });
|
|
1605
|
+
await tick();
|
|
1606
|
+
const firstCells = (lines) => lines.slice(2, -2).map((line) => strip(line).split('│')[1] || '').filter((cell) => cell.trim() !== '');
|
|
1607
|
+
const first = () => firstCells(view.render(80))[0];
|
|
1608
|
+
assert.match(first(), /document-01/, 'the window starts at the first document');
|
|
1609
|
+
view.handleInput(']');
|
|
1610
|
+
assert.match(first(), /document-09/, '] moves the window a screen forward');
|
|
1611
|
+
view.handleInput('[');
|
|
1612
|
+
assert.match(first(), /document-01/, '[ moves it back');
|
|
1613
|
+
view.handleInput('right');
|
|
1614
|
+
assert.match(first(), /document-09/, '→ scrolls the list like ]');
|
|
1615
|
+
view.handleInput('left');
|
|
1616
|
+
assert.match(first(), /document-01/, '← scrolls the list like [');
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
test('the one-row legend keeps the key/description roles at every width', async () => {
|
|
1620
|
+
for (const width of [40, 60, 80, 120]) {
|
|
1621
|
+
const view = build({ theme: recordingTheme() });
|
|
1622
|
+
await tick();
|
|
1623
|
+
const lines = view.render(width);
|
|
1624
|
+
const legend = lines.map(strip).filter((line) => /esc close|o open/.test(line));
|
|
1625
|
+
assert.equal(legend.length, 1, `${width}: exactly one legend row, got ${legend.length}`);
|
|
1626
|
+
assert.ok(!legend[0].includes('…'), `${width}: compacted, never ellipsis-clipped: ${legend[0]}`);
|
|
1627
|
+
assert.match(legend[0], /esc close/, `${width}: the close key survives`);
|
|
1628
|
+
for (const line of lines) assert.equal(helpers.visibleWidth(line), width, `${width}: rows stay ${width} wide`);
|
|
1629
|
+
}
|
|
1630
|
+
const view = build({ theme: recordingTheme(), kitty: true });
|
|
1631
|
+
await tick();
|
|
1632
|
+
const footer = view.render(160).at(-2);
|
|
1633
|
+
assert.match(footer, /\x1b\[1m\x1b\[96mctrl\+j\/k\x1b\[0m\x1b\[22m\x1b\[90m scroll/,
|
|
1634
|
+
'the scroll hint paints its key accent+bold, as ONE key run, and its description muted');
|
|
1635
|
+
assert.match(footer, /\x1b\[1m\x1b\[96mr\x1b\[0m\x1b\[22m\x1b\[90m reload/,
|
|
1636
|
+
'the reload hint keeps the key/description split');
|
|
1637
|
+
assert.ok(!strip(footer).includes('←/→'), 'S8: the list scroll pair is not painted at all');
|
|
1638
|
+
});
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
// S3 (T3): the ODD reader drops the page marker and becomes a continuous list.
|
|
1642
|
+
// The left pane is the smaller 30% share, ←/→ scroll the list a screenful at a
|
|
1643
|
+
// time, and the focused document always stays inside the painted window — the
|
|
1644
|
+
// filter keeps working over the whole list and no document is skipped.
|
|
1645
|
+
describe('createOddView continuous list scrolling instead of pages (S3)', () => {
|
|
1646
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
1647
|
+
const readerPanes = (width) => layout.paneWidths(layout.innerWidth(width), { listShare: layout.READER_LIST_SHARE });
|
|
1648
|
+
const searchRowOf = (lines) => strip(lines[1]);
|
|
1649
|
+
const footerOf = (lines) => strip(lines[lines.length - 2]);
|
|
1650
|
+
const listCells = (lines) => lines.slice(2, -2).map((line) => strip(line).split('│')[1] || '');
|
|
1651
|
+
const filled = (lines) => listCells(lines).filter((row) => row.trim() !== '');
|
|
1652
|
+
const names = (count) => Array.from({ length: count }, (_unused, at) => doc(`document-${String(at + 1).padStart(2, '0')}`));
|
|
1653
|
+
|
|
1654
|
+
function build({ docs = names(2), rows = 20 } = {}) {
|
|
1655
|
+
const view = createOddView({
|
|
1656
|
+
docs,
|
|
1657
|
+
loadDoc: () => ({ ok: true, text: 'body\n' }),
|
|
1658
|
+
helpers,
|
|
1659
|
+
theme: plainTheme,
|
|
1660
|
+
rowsAvailable: () => rows,
|
|
1661
|
+
requestRender: () => {},
|
|
1662
|
+
done: () => {},
|
|
1663
|
+
});
|
|
1664
|
+
return view;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
test('the left pane takes the smaller 30/70 share', async () => {
|
|
1668
|
+
const view = build({ docs: [doc('alpha'), doc('beta')] });
|
|
1669
|
+
await tick();
|
|
1670
|
+
for (const width of [80, 120, 200]) {
|
|
1671
|
+
const lines = view.render(width);
|
|
1672
|
+
const expected = readerPanes(width).left;
|
|
1673
|
+
assert.equal([...strip(lines[2])].indexOf('│', 2), 3 + expected, `${width}: the list is ${expected} cells`);
|
|
1674
|
+
assert.ok(expected < layout.paneWidths(layout.innerWidth(width)).left, `${width}: narrower than the old 40% list`);
|
|
1675
|
+
for (const line of lines) assert.equal(helpers.visibleWidth(line), width, `${width}: the frame fills the terminal`);
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
test('no page marker renders on the search row in any state', async () => {
|
|
1680
|
+
const many = build({ docs: names(30), rows: 12 });
|
|
1681
|
+
await tick();
|
|
1682
|
+
assert.ok(!/page/i.test(searchRowOf(many.render(80))), 'a long list prints no page marker');
|
|
1683
|
+
const single = build({ docs: [doc('alpha')] });
|
|
1684
|
+
await tick();
|
|
1685
|
+
assert.ok(!/page/i.test(searchRowOf(single.render(120))), 'a one-document list prints no marker');
|
|
1686
|
+
const empty = build({ docs: [] });
|
|
1687
|
+
await tick();
|
|
1688
|
+
assert.ok(!/page/i.test(searchRowOf(empty.render(120))), 'the empty state prints no marker');
|
|
1689
|
+
const narrow = build({ docs: names(30), rows: 12 });
|
|
1690
|
+
await tick();
|
|
1691
|
+
assert.ok(!/page/i.test(searchRowOf(narrow.render(24))), 'the narrowest frame prints no marker');
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
test('the footer names no page key', async () => {
|
|
1695
|
+
const view = build({ docs: names(30), rows: 12 });
|
|
1696
|
+
await tick();
|
|
1697
|
+
const footer = footerOf(view.render(200));
|
|
1698
|
+
assert.ok(!/page/i.test(footer), `the legend drops the page hint: ${footer}`);
|
|
1699
|
+
assert.ok(!footer.includes('←/→'), `S8: the list scroll pair costs no cells: ${footer}`);
|
|
1700
|
+
assert.match(footer, /ctrl\+j\/k scroll/, 'the scroll is named once with what it does');
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
test('a long list scrolls continuously: one screen at a time', async () => {
|
|
1704
|
+
const view = build({ docs: names(30), rows: 12 });
|
|
1705
|
+
await tick();
|
|
1706
|
+
// 12 rows leave 8 body rows, so the first window paints documents 1..8.
|
|
1707
|
+
const first = filled(view.render(80));
|
|
1708
|
+
assert.equal(first.length, 8, 'every body row carries a document');
|
|
1709
|
+
assert.match(first[0], /document-01/);
|
|
1710
|
+
assert.ok(!first.join(' ').includes('document-09'), 'the rest of the list is below the window');
|
|
1711
|
+
view.handleInput('right');
|
|
1712
|
+
const second = filled(view.render(80));
|
|
1713
|
+
assert.match(second[0], /document-09/, '→ starts the next screen after the last painted document');
|
|
1714
|
+
assert.ok(!second.join(' ').includes('document-01'), 'the previous screen scrolled away');
|
|
1715
|
+
view.handleInput('left');
|
|
1716
|
+
assert.match(filled(view.render(80))[0], /document-01/, '← scrolls back to the first screen');
|
|
1717
|
+
view.handleInput('left');
|
|
1718
|
+
assert.match(filled(view.render(80))[0], /document-01/, '← never scrolls before the first document');
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
test('the selection walks past the window edge and the list follows it', async () => {
|
|
1722
|
+
const view = build({ docs: names(20), rows: 12 });
|
|
1723
|
+
await tick();
|
|
1724
|
+
for (let step = 0; step < 8; step += 1) view.handleInput('down');
|
|
1725
|
+
assert.equal(view.state.index, 8, 'the ninth document is focused');
|
|
1726
|
+
const rows = filled(view.render(80));
|
|
1727
|
+
assert.ok(!rows.join(' ').includes('document-01'), 'the window scrolled past the first document');
|
|
1728
|
+
assert.ok(rows.some((row) => /▸ document-09/.test(row)), 'the focused document is painted at the window edge');
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
test('every document stays reachable in order across the window moves', async () => {
|
|
1732
|
+
const docs = names(18);
|
|
1733
|
+
const view = build({ docs, rows: 12 });
|
|
1734
|
+
await tick();
|
|
1735
|
+
const seen = [];
|
|
1736
|
+
for (let step = 0; step < docs.length; step += 1) {
|
|
1737
|
+
const rows = filled(view.render(80));
|
|
1738
|
+
const focused = rows.find((row) => row.includes('▸')) || '';
|
|
1739
|
+
seen.push((focused.match(/document-\d+/) || [''])[0]);
|
|
1740
|
+
view.handleInput('down');
|
|
1741
|
+
}
|
|
1742
|
+
assert.deepEqual(seen, docs.map((entry) => entry.name), 'every document is focused once, in order');
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
test('the filter still narrows the whole list and keeps the focused document visible', async () => {
|
|
1746
|
+
const view = build({ docs: names(30), rows: 12 });
|
|
1747
|
+
await tick();
|
|
1748
|
+
for (let step = 0; step < 19; step += 1) view.handleInput('down');
|
|
1749
|
+
view.handleInput('T');
|
|
1750
|
+
view.handleInput('2');
|
|
1751
|
+
view.handleInput('enter');
|
|
1752
|
+
const rows = filled(view.render(80));
|
|
1753
|
+
assert.match(searchRowOf(view.render(80)), /› 2/, 'the query is kept on the search row');
|
|
1754
|
+
assert.match(rows.find((row) => row.includes('▸')) || '', /document-02/, 'the selection restarts on a visible document');
|
|
1755
|
+
assert.ok(rows.length > 0, 'the narrowed list paints its rows');
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
test('the preview scroll position still rides the right edge of the search row', async () => {
|
|
1759
|
+
const view = createOddView({
|
|
1760
|
+
docs: [doc('alpha')],
|
|
1761
|
+
loadDoc: () => ({ ok: true, text: longDoc(200) }),
|
|
1762
|
+
helpers,
|
|
1763
|
+
theme: plainTheme,
|
|
1764
|
+
rowsAvailable: () => 20,
|
|
1765
|
+
requestRender: () => {},
|
|
1766
|
+
done: () => {},
|
|
1767
|
+
});
|
|
1768
|
+
await tick();
|
|
1769
|
+
view.handleInput('J');
|
|
1770
|
+
const row = searchRowOf(view.render(80));
|
|
1771
|
+
assert.match(row, /› Search/, 'the search widget owns the left of the row');
|
|
1772
|
+
assert.match(row, / \d+\/\d+/, 'the reading position keeps the right edge');
|
|
1773
|
+
assert.ok(!/\d+-\d+\//.test(row), 'S10: the reading position carries no range prefix');
|
|
1774
|
+
});
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
// ---------------------------------------------------------------------------
|
|
1778
|
+
// S11: every footer token paints through the injected theme with a role that
|
|
1779
|
+
// distinguishes a shortcut KEY from its description, and the header paints its
|
|
1780
|
+
// title accent+bold, its separator dim and its count muted.
|
|
1781
|
+
describe('odd view palette (S11)', () => {
|
|
1782
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
1783
|
+
|
|
1784
|
+
function paletteView(theme, docs = [doc('alpha'), doc('beta')]) {
|
|
1785
|
+
return createOddView({
|
|
1786
|
+
docs,
|
|
1787
|
+
loadDoc: (entry) => ({ ok: true, text: `# ${entry.name}\n\n## Specs\nS1. first rule\n` }),
|
|
1788
|
+
helpers,
|
|
1789
|
+
theme,
|
|
1790
|
+
rowsAvailable: () => 20,
|
|
1791
|
+
requestRender: () => {},
|
|
1792
|
+
done: () => {},
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
test('the footer paints every shortcut key accent+bold and its description muted', async () => {
|
|
1797
|
+
const theme = recordingTheme();
|
|
1798
|
+
const view = paletteView(theme);
|
|
1799
|
+
await tick();
|
|
1800
|
+
const footer = view.render(160).at(-2);
|
|
1801
|
+
assert.match(footer, /\x1b\[1m\x1b\[96m↑↓\x1b\[0m\x1b\[22m/, 'the key token is painted accent+bold');
|
|
1802
|
+
assert.match(footer, /\x1b\[90m select/, 'its description is painted muted');
|
|
1803
|
+
assert.match(footer, /\x1b\[1m\x1b\[96mctrl\+j\/k\x1b\[0m\x1b\[22m/, 'the document scroll key is a key run');
|
|
1804
|
+
assert.match(footer, /\x1b\[1m\x1b\[96mt\x1b\[0m\x1b\[22m/, 'a single-letter key is painted the same way');
|
|
1805
|
+
assert.ok(theme.calls.some((call) => call.role === 'accent' && call.text === '↑↓'),
|
|
1806
|
+
'the accent role comes from the injected theme');
|
|
1807
|
+
assert.ok(theme.calls.some((call) => call.role === 'bold' && call.text.includes('↑↓')),
|
|
1808
|
+
'the key is bolded through the theme, never by a literal escape');
|
|
1809
|
+
assert.ok(theme.calls.some((call) => call.role === 'muted' && call.text.includes('select')),
|
|
1810
|
+
'the description asks the injected theme for the muted role');
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
test('the search legend uses the same key/description split', async () => {
|
|
1814
|
+
const theme = recordingTheme();
|
|
1815
|
+
const view = paletteView(theme);
|
|
1816
|
+
await tick();
|
|
1817
|
+
view.handleInput('T');
|
|
1818
|
+
const footer = view.render(160).at(-2);
|
|
1819
|
+
assert.match(footer, /\x1b\[1m\x1b\[96mtype\x1b\[0m\x1b\[22m\x1b\[90m to filter/,
|
|
1820
|
+
'the typing hint paints its key accent+bold and its description muted');
|
|
1821
|
+
assert.match(footer, /\x1b\[1m\x1b\[96mbksp\x1b\[0m\x1b\[22m/, 'backspace keeps the same pair');
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
// T9 (S13): the frame title takes `customMessageLabel`, the role the Pi Agents
|
|
1825
|
+
// view paints its own frame title with, while the footer's keys
|
|
1826
|
+
// keep the PRIMARY accent — so a title and a button never read as the same
|
|
1827
|
+
// control. The document's markdown heading keeps its own `mdHeading` role.
|
|
1828
|
+
test('the header paints the title with the reference dim title role, its separator dim and the count muted', async () => {
|
|
1829
|
+
const theme = recordingTheme();
|
|
1830
|
+
const view = paletteView(theme);
|
|
1831
|
+
await tick();
|
|
1832
|
+
const lines = view.render(120);
|
|
1833
|
+
assert.match(lines[0], /\x1b\[1m\x1b\[33m✎ Odd\x1b\[0m\x1b\[22m/,
|
|
1834
|
+
'the frame title is the dim role, bold');
|
|
1835
|
+
assert.ok(theme.calls.some((call) => call.role === 'customMessageLabel' && call.text === '✎ Odd'),
|
|
1836
|
+
'the title asks the injected theme for the title role');
|
|
1837
|
+
assert.ok(!theme.calls.some((call) => call.role === 'accent' && call.text === '✎ Odd'),
|
|
1838
|
+
'the title never competes with the primary accent a button uses');
|
|
1839
|
+
assert.match(lines[0], /\x1b\[2m · \x1b\[0m/, 'the separator is the dim label role');
|
|
1840
|
+
assert.match(lines[0], /\x1b\[90m2 documents\x1b\[0m/, 'the document count is muted, never accent');
|
|
1841
|
+
// The right-pane document title is the markdown heading, painted through the
|
|
1842
|
+
// injected theme with its own heading role plus bold — never a hardcoded SGR.
|
|
1843
|
+
assert.ok(theme.calls.some((call) => call.role === 'mdHeading'),
|
|
1844
|
+
'the document title keeps its own heading role');
|
|
1845
|
+
const titleCall = theme.calls.find((call) => call.role === 'mdHeading');
|
|
1846
|
+
assert.match(titleCall.text, /^\x1b\[1m/, 'the document title is bolded through the theme');
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
test('a hostile theme never breaks the frame and keeps the keys and focus readable', async () => {
|
|
1850
|
+
const hostile = {
|
|
1851
|
+
fg() { throw new Error('no colour support'); },
|
|
1852
|
+
bold() { throw new Error('no bold support'); },
|
|
1853
|
+
bg() { throw new Error('no background support'); },
|
|
1854
|
+
};
|
|
1855
|
+
const view = paletteView(hostile);
|
|
1856
|
+
await tick();
|
|
1857
|
+
// 160 keeps the full-spelling legend tier (S12 added one token).
|
|
1858
|
+
const body = strip(view.render(160).join('\n'));
|
|
1859
|
+
assert.match(body, /✎ Odd · 2 documents/, 'the header still renders without colour');
|
|
1860
|
+
assert.match(body, /↑↓ select/, 'the footer still names the key and its description');
|
|
1861
|
+
assert.match(body, /▸ alpha/, 'the focused document keeps its mark, so focus survives monochrome');
|
|
1862
|
+
assert.match(body, /› Search/, 'the search widget still renders');
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
// T9 (S13): text the reader typed is ordinary text, not the accent a button
|
|
1866
|
+
// owns — the selected row stays told apart by its own band instead.
|
|
1867
|
+
test('the search row paints its label dim and its live value as ordinary text', async () => {
|
|
1868
|
+
const theme = recordingTheme();
|
|
1869
|
+
const view = paletteView(theme);
|
|
1870
|
+
await tick();
|
|
1871
|
+
const idle = view.render(120)[1];
|
|
1872
|
+
assert.match(idle, /\x1b\[2m› \x1b\[0m\x1b\[2mSearch/, 'the idle search widget is dim, never accent');
|
|
1873
|
+
view.handleInput('T');
|
|
1874
|
+
for (const char of 'alp') view.handleInput(char);
|
|
1875
|
+
const live = view.render(120)[1];
|
|
1876
|
+
assert.match(live, /\x1b\[2m› \x1b\[0m/, 'the prompt keeps the dim label role while typing');
|
|
1877
|
+
assert.match(live, /\x1b\[39malp/, 'the live query value is the theme text role');
|
|
1878
|
+
assert.ok(!/\x1b\[96malp/.test(live), 'the typed value is never the button accent');
|
|
1879
|
+
});
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
// ---------------------------------------------------------------------------
|
|
1883
|
+
// S12: the list shows the most recently modified document first — the order
|
|
1884
|
+
// lib/odd-docs.js already returns — and ONE shortcut alternates it with a
|
|
1885
|
+
// case-insensitive alphabetical order. The focused document survives the
|
|
1886
|
+
// toggle, the visible window re-anchors on it, the name filter composes with
|
|
1887
|
+
// both orders, and the ONE footer legend names the key and the active order.
|
|
1888
|
+
describe('createOddView list order (S12)', () => {
|
|
1889
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
1890
|
+
const listCells = (lines) => lines.slice(2, -2).map((line) => strip(line).split('│')[1] || '');
|
|
1891
|
+
const filled = (lines) => listCells(lines).filter((cell) => cell.trim() !== '');
|
|
1892
|
+
const rowName = (cell) => cell.trim().replace(/^▸\s*/, '');
|
|
1893
|
+
const namesOf = (lines) => filled(lines).map(rowName);
|
|
1894
|
+
const focusedName = (lines) => {
|
|
1895
|
+
const cell = filled(lines).find((entry) => entry.includes('▸'));
|
|
1896
|
+
return cell === undefined ? null : rowName(cell);
|
|
1897
|
+
};
|
|
1898
|
+
const footerOf = (lines) => strip(lines[lines.length - 2]);
|
|
1899
|
+
const searchRowOf = (lines) => strip(lines[1]);
|
|
1900
|
+
|
|
1901
|
+
// The loader's own order: most recently modified first, never alphabetical.
|
|
1902
|
+
const RECENT = [doc('mike'), doc('alpha'), doc('Zulu'), doc('bravo')];
|
|
1903
|
+
|
|
1904
|
+
function build({ docs = RECENT, rows = 20, theme = plainTheme, reloadDocs } = {}) {
|
|
1905
|
+
const paints = [];
|
|
1906
|
+
const view = createOddView({
|
|
1907
|
+
docs,
|
|
1908
|
+
loadDoc: (entry) => ({ ok: true, text: `# ${entry.name}\n\nbody of ${entry.name}\n` }),
|
|
1909
|
+
reloadDocs,
|
|
1910
|
+
helpers,
|
|
1911
|
+
theme,
|
|
1912
|
+
rowsAvailable: () => rows,
|
|
1913
|
+
requestRender: () => { paints.push(true); },
|
|
1914
|
+
done: () => {},
|
|
1915
|
+
});
|
|
1916
|
+
return { view, paints };
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
test('the list defaults to the loader order, newest modified first', async () => {
|
|
1920
|
+
const { view } = build();
|
|
1921
|
+
await tick();
|
|
1922
|
+
const lines = view.render(80);
|
|
1923
|
+
assert.deepEqual(namesOf(lines), ['mike', 'alpha', 'Zulu', 'bravo'], 'the loader order is kept, never re-sorted');
|
|
1924
|
+
assert.equal(focusedName(lines), 'mike', 'the first (most recent) document is focused');
|
|
1925
|
+
assert.match(footerOf(view.render(160)), /s sort: recent/, 'the footer names the order key and the active order');
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
test('s alternates between newest-first and case-insensitive alphabetical order', async () => {
|
|
1929
|
+
const { view, paints } = build();
|
|
1930
|
+
await tick();
|
|
1931
|
+
const before = paints.length;
|
|
1932
|
+
view.handleInput('s');
|
|
1933
|
+
assert.ok(paints.length > before, 'the toggle requests a repaint');
|
|
1934
|
+
let lines = view.render(80);
|
|
1935
|
+
assert.deepEqual(namesOf(lines), ['alpha', 'bravo', 'mike', 'Zulu'], 'alphabetical and case-insensitive');
|
|
1936
|
+
assert.equal(focusedName(lines), 'mike', 'the focused document rides the toggle');
|
|
1937
|
+
assert.match(footerOf(view.render(160)), /s sort: name/, 'the footer names the alphabetical order once it is active');
|
|
1938
|
+
view.handleInput('s');
|
|
1939
|
+
lines = view.render(80);
|
|
1940
|
+
assert.deepEqual(namesOf(lines), ['mike', 'alpha', 'Zulu', 'bravo'], 'the second toggle returns the loader order');
|
|
1941
|
+
assert.equal(focusedName(lines), 'mike');
|
|
1942
|
+
assert.match(footerOf(view.render(160)), /s sort: recent/);
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
test('the focused document stays focused across the toggle, in both directions', async () => {
|
|
1946
|
+
const { view } = build();
|
|
1947
|
+
await tick();
|
|
1948
|
+
view.handleInput('down');
|
|
1949
|
+
assert.equal(focusedName(view.render(80)), 'alpha', 'alpha is focused in the loader order');
|
|
1950
|
+
view.handleInput('s');
|
|
1951
|
+
let lines = view.render(80);
|
|
1952
|
+
assert.equal(focusedName(lines), 'alpha', 'the same document is focused in the alphabetical order');
|
|
1953
|
+
assert.equal(view.state.index, 0, 'its new position is the focused index');
|
|
1954
|
+
assert.match(strip(lines.join('\n')), /body of alpha/, 'the reading pane still shows the same document');
|
|
1955
|
+
view.handleInput('s');
|
|
1956
|
+
lines = view.render(80);
|
|
1957
|
+
assert.equal(focusedName(lines), 'alpha', 'and again on the way back');
|
|
1958
|
+
assert.equal(view.state.index, 1, 'back in its loader position');
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
test('a toggle re-anchors the visible window on the focused document', async () => {
|
|
1962
|
+
// The loader order is the reverse of the alphabetical one.
|
|
1963
|
+
const docs = Array.from({ length: 20 }, (_unused, at) => doc(`item-${String(20 - at).padStart(2, '0')}`));
|
|
1964
|
+
const { view } = build({ docs, rows: 12 });
|
|
1965
|
+
await tick();
|
|
1966
|
+
let lines = view.render(80);
|
|
1967
|
+
assert.equal(namesOf(lines)[0], 'item-20', 'the most recent document starts the window');
|
|
1968
|
+
assert.equal(focusedName(lines), 'item-20');
|
|
1969
|
+
view.handleInput('s');
|
|
1970
|
+
lines = view.render(80);
|
|
1971
|
+
assert.equal(focusedName(lines), 'item-20', 'the focused document stays focused');
|
|
1972
|
+
assert.equal(namesOf(lines).at(-1), 'item-20', 'the window moved to its alphabetical position at the end of the list');
|
|
1973
|
+
assert.ok(!namesOf(lines).includes('item-01'), 'the window no longer starts at the top of the list');
|
|
1974
|
+
assert.equal(filled(lines).length, 8, 'the window is kept full');
|
|
1975
|
+
view.handleInput('s');
|
|
1976
|
+
lines = view.render(80);
|
|
1977
|
+
assert.equal(namesOf(lines)[0], 'item-20', 'the window returns to the top with the loader order');
|
|
1978
|
+
assert.equal(focusedName(lines), 'item-20');
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
test('the order toggle composes with the name filter', async () => {
|
|
1982
|
+
const { view } = build({ docs: [doc('maple'), doc('alpha'), doc('bravo'), doc('cherry')] });
|
|
1983
|
+
await tick();
|
|
1984
|
+
view.handleInput('T');
|
|
1985
|
+
view.handleInput('a');
|
|
1986
|
+
view.handleInput('enter');
|
|
1987
|
+
let lines = view.render(80);
|
|
1988
|
+
assert.match(searchRowOf(lines), /› a( |$)/m, 'the query stays on the search row');
|
|
1989
|
+
assert.deepEqual(namesOf(lines), ['maple', 'alpha', 'bravo'], 'the filter keeps the loader order of its matches');
|
|
1990
|
+
view.handleInput('s');
|
|
1991
|
+
lines = view.render(80);
|
|
1992
|
+
assert.deepEqual(namesOf(lines), ['alpha', 'bravo', 'maple'], 'the filtered matches sort alphabetically');
|
|
1993
|
+
assert.match(searchRowOf(lines), /› a( |$)/m, 'the toggle keeps the filter');
|
|
1994
|
+
assert.equal(focusedName(lines), 'maple', 'the focused match stays focused');
|
|
1995
|
+
view.handleInput('s');
|
|
1996
|
+
assert.deepEqual(namesOf(view.render(80)), ['maple', 'alpha', 'bravo'], 'and back to the loader order of the matches');
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
test('s types into the open filter box instead of toggling the order', async () => {
|
|
2000
|
+
const { view } = build({ docs: [doc('maple'), doc('alpha'), doc('bravo')] });
|
|
2001
|
+
await tick();
|
|
2002
|
+
view.handleInput('T');
|
|
2003
|
+
view.handleInput('s');
|
|
2004
|
+
assert.match(searchRowOf(view.render(80)), /› s█/, 'the letter goes into the query');
|
|
2005
|
+
view.handleInput('\x7f');
|
|
2006
|
+
view.handleInput('enter');
|
|
2007
|
+
assert.deepEqual(namesOf(view.render(80)), ['maple', 'alpha', 'bravo'], 'the order never toggled while typing');
|
|
2008
|
+
assert.match(footerOf(view.render(160)), /s sort: recent/);
|
|
2009
|
+
});
|
|
2010
|
+
|
|
2011
|
+
test('a reload keeps the chosen order', async () => {
|
|
2012
|
+
let current = [doc('mike'), doc('alpha'), doc('Zulu')];
|
|
2013
|
+
const { view } = build({ docs: current, reloadDocs: () => ({ ok: true, docs: current }) });
|
|
2014
|
+
await tick();
|
|
2015
|
+
view.handleInput('s');
|
|
2016
|
+
current = [doc('mike'), doc('alpha'), doc('Zulu'), doc('bravo')];
|
|
2017
|
+
view.handleInput('r');
|
|
2018
|
+
await tick();
|
|
2019
|
+
assert.deepEqual(namesOf(view.render(80)), ['alpha', 'bravo', 'mike', 'Zulu'], 'the reloaded list keeps the alphabetical order');
|
|
2020
|
+
assert.match(strip(view.render(80).join('\n')), /4 documents/);
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
test('the footer names the sort key with the key/description roles, on ONE row', async () => {
|
|
2024
|
+
const { view } = build({ theme: recordingTheme() });
|
|
2025
|
+
await tick();
|
|
2026
|
+
let footer = view.render(160).at(-1 - 1);
|
|
2027
|
+
assert.match(footer, /\x1b\[1m\x1b\[96ms\x1b\[0m\x1b\[22m\x1b\[90m sort: recent/, 'the key is accent+bold, its description muted');
|
|
2028
|
+
view.handleInput('s');
|
|
2029
|
+
footer = view.render(160).at(-2);
|
|
2030
|
+
assert.match(footer, /\x1b\[1m\x1b\[96ms\x1b\[0m\x1b\[22m\x1b\[90m sort: name/, 'the description names the new order');
|
|
2031
|
+
|
|
2032
|
+
const plain = build();
|
|
2033
|
+
await tick();
|
|
2034
|
+
for (const width of [40, 60, 80, 120]) {
|
|
2035
|
+
const lines = plain.view.render(width);
|
|
2036
|
+
const legend = lines.map(strip).filter((line) => /esc close|o open/.test(line));
|
|
2037
|
+
assert.equal(legend.length, 1, `${width}: the legend owns exactly one row`);
|
|
2038
|
+
assert.ok(!legend[0].includes('…'), `${width}: compacted, never ellipsis-clipped`);
|
|
2039
|
+
// S14: the labelled document token is four cells longer than the bare
|
|
2040
|
+
// pair it replaced, so at the narrowest width the sort key is the entry
|
|
2041
|
+
// the one-row budget drops first. From 60 up both spellings survive: the
|
|
2042
|
+
// compact `s` while the row still compacts, the full one once it fits.
|
|
2043
|
+
if (width >= 60 && width < 120) assert.match(legend[0], /(^|· )s( ·|$)/, `${width}: the sort key survives the compaction`);
|
|
2044
|
+
if (width >= 120) assert.match(legend[0], /s sort: recent/, `${width}: the full spelling names the sort key and the order`);
|
|
2045
|
+
assert.equal(lines.length, 20, `${width}: the frame still fills the rows`);
|
|
2046
|
+
for (const line of lines) assert.equal(helpers.visibleWidth(line), width, `${width}: rows stay ${width} wide`);
|
|
2047
|
+
}
|
|
2048
|
+
});
|
|
2049
|
+
});
|
|
2050
|
+
|
|
2051
|
+
// ---------------------------------------------------------------------------
|
|
2052
|
+
// worktree-issues-reliability T6 (S8,S9,S10), all through the public
|
|
2053
|
+
// render/handleInput surface:
|
|
2054
|
+
// S8 «salen flecha izq/der ... esta bien el atajo pero demas marcarlo» — the
|
|
2055
|
+
// list scroll pair keeps working and leaves the footer;
|
|
2056
|
+
// S9 «el r en odd no hace nada pareceria» — a reload that changes no row must
|
|
2057
|
+
// still say it ran, re-read the open document, and name a failure;
|
|
2058
|
+
// S10 «pagina/linea ... sale el 1-, esta demas» — the reading position keeps
|
|
2059
|
+
// the useful numbers and loses the range prefix.
|
|
2060
|
+
// ---------------------------------------------------------------------------
|
|
2061
|
+
describe('createOddView shortcut round (S8,S9,S10)', () => {
|
|
2062
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
2063
|
+
const footerOf = (lines) => strip(lines[lines.length - 2]);
|
|
2064
|
+
const searchRowOf = (lines) => strip(lines[1]);
|
|
2065
|
+
const bodyOf = (lines) => strip(lines.join('\n'));
|
|
2066
|
+
const listCells = (lines) => lines.slice(2, -2).map((line) => strip(line).split('│')[1] || '');
|
|
2067
|
+
const filled = (lines) => listCells(lines).filter((cell) => cell.trim() !== '');
|
|
2068
|
+
const names = (count) => Array.from({ length: count }, (_unused, at) => doc(`document-${String(at + 1).padStart(2, '0')}`));
|
|
2069
|
+
|
|
2070
|
+
function build({ docs = [doc('alpha'), doc('beta')], rows = 20, load, reloadDocs } = {}) {
|
|
2071
|
+
return createOddView({
|
|
2072
|
+
docs,
|
|
2073
|
+
loadDoc: load || (() => ({ ok: true, text: longDoc(200) })),
|
|
2074
|
+
reloadDocs,
|
|
2075
|
+
helpers,
|
|
2076
|
+
theme: plainTheme,
|
|
2077
|
+
rowsAvailable: () => rows,
|
|
2078
|
+
requestRender: () => {},
|
|
2079
|
+
done: () => {},
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
test('S8: the footer no longer marks the list scroll pair, and ←/→ keep scrolling the list', async () => {
|
|
2084
|
+
const view = build({ docs: names(14), rows: 12 });
|
|
2085
|
+
await tick();
|
|
2086
|
+
const footer = footerOf(view.render(80));
|
|
2087
|
+
assert.ok(!footer.includes('←/→'), `the redundant list hint is gone: ${footer}`);
|
|
2088
|
+
assert.ok(!/→\s*list/.test(footer), 'no list scroll token anywhere in the footer');
|
|
2089
|
+
assert.match(footer, /esc close/, 'the rest of the legend survives');
|
|
2090
|
+
const first = () => filled(view.render(80))[0];
|
|
2091
|
+
assert.match(first(), /document-01/, 'the window starts at the first document');
|
|
2092
|
+
view.handleInput('right');
|
|
2093
|
+
assert.match(first(), /document-07/, '→ still moves the list one screenful');
|
|
2094
|
+
view.handleInput('left');
|
|
2095
|
+
assert.match(first(), /document-01/, '← still moves it back');
|
|
2096
|
+
view.handleInput(']');
|
|
2097
|
+
assert.match(first(), /document-07/, '] still moves it forward');
|
|
2098
|
+
view.handleInput('[');
|
|
2099
|
+
assert.match(first(), /document-01/, '[ still moves it back');
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
test('S9: r names its outcome on the footer, so a reload that changes nothing is not silent', async () => {
|
|
2103
|
+
const same = [doc('alpha'), doc('beta')];
|
|
2104
|
+
const view = build({ reloadDocs: () => ({ ok: true, docs: same }) });
|
|
2105
|
+
await tick();
|
|
2106
|
+
const before = footerOf(view.render(80));
|
|
2107
|
+
assert.match(before, / r( |$)/, 'the idle footer still names the key');
|
|
2108
|
+
view.handleInput('r');
|
|
2109
|
+
await tick();
|
|
2110
|
+
const after = footerOf(view.render(80));
|
|
2111
|
+
assert.match(after, /r reloaded/, `the footer says the reload happened: ${after}`);
|
|
2112
|
+
assert.notEqual(after, before, 'the reload is visible even when nothing else changed');
|
|
2113
|
+
});
|
|
2114
|
+
|
|
2115
|
+
test('S9: r re-reads the focused document, so an edit on disk reaches the pane', async () => {
|
|
2116
|
+
let text = '# alpha\n\nfirst body\n';
|
|
2117
|
+
const view = build({
|
|
2118
|
+
docs: [doc('alpha')],
|
|
2119
|
+
load: () => ({ ok: true, text }),
|
|
2120
|
+
reloadDocs: () => ({ ok: true, docs: [doc('alpha')] }),
|
|
2121
|
+
});
|
|
2122
|
+
await tick();
|
|
2123
|
+
assert.match(bodyOf(view.render(80)), /first body/);
|
|
2124
|
+
text = '# alpha\n\nsecond body\n';
|
|
2125
|
+
view.handleInput('r');
|
|
2126
|
+
await tick();
|
|
2127
|
+
const body = bodyOf(view.render(80));
|
|
2128
|
+
assert.match(body, /second body/, 'the reloaded pane shows the edited document');
|
|
2129
|
+
assert.ok(!body.includes('first body'), 'the stale body is gone');
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
test('S9: a failing reload keeps the list and reports the failure instead of doing nothing', async () => {
|
|
2133
|
+
const view = build({ reloadDocs: () => ({ ok: false, reason: 'no-checkout', docs: [] }) });
|
|
2134
|
+
await tick();
|
|
2135
|
+
view.handleInput('r');
|
|
2136
|
+
await tick();
|
|
2137
|
+
const lines = view.render(80);
|
|
2138
|
+
assert.match(bodyOf(lines), /alpha/, 'the documents stay listed');
|
|
2139
|
+
assert.match(footerOf(lines), /(^|· )r failed( ·|$)/, `the failure is named in the footer: ${footerOf(lines)}`);
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
// S9 at the reported width: the reader who saw `r` in the compact legend and
|
|
2143
|
+
// then reloaded is on a ~60-column terminal. The idle token fits there, so the
|
|
2144
|
+
// outcome must fit too instead of being pruned away as if nothing happened.
|
|
2145
|
+
test('S9: the reload outcome stays in the compact legend at 60 columns, where the idle r fits', async () => {
|
|
2146
|
+
for (const kitty of [null, true, false]) {
|
|
2147
|
+
const label = kitty === null ? 'fallback helpers' : `kitty ${kitty}`;
|
|
2148
|
+
const same = [doc('alpha'), doc('beta')];
|
|
2149
|
+
const make = (reloadDocs) => {
|
|
2150
|
+
const localHelpers = kitty === null
|
|
2151
|
+
? helpers
|
|
2152
|
+
: { ...helpers, isKittyProtocolActive: () => kitty };
|
|
2153
|
+
return createOddView({
|
|
2154
|
+
docs: [doc('alpha'), doc('beta'), ...names(8)],
|
|
2155
|
+
loadDoc: () => ({ ok: true, text: longDoc(120) }),
|
|
2156
|
+
reloadDocs,
|
|
2157
|
+
helpers: localHelpers,
|
|
2158
|
+
theme: plainTheme,
|
|
2159
|
+
rowsAvailable: () => 12,
|
|
2160
|
+
requestRender: () => {},
|
|
2161
|
+
done: () => {},
|
|
2162
|
+
});
|
|
2163
|
+
};
|
|
2164
|
+
const idle = make(() => ({ ok: true, docs: same }));
|
|
2165
|
+
const footer = footerOf(idle.render(60));
|
|
2166
|
+
assert.match(footer, /(^|· )r( ·|$)/, `${label}: the premise — an idle r token fits at 60: ${footer}`);
|
|
2167
|
+
assert.match(footer, /esc close/, `${label}: close survives at 60`);
|
|
2168
|
+
|
|
2169
|
+
const ok = make(() => ({ ok: true, docs: same }));
|
|
2170
|
+
ok.handleInput('r');
|
|
2171
|
+
await tick();
|
|
2172
|
+
const after = footerOf(ok.render(60));
|
|
2173
|
+
assert.match(after, /(^|· )r reloaded( ·|$)/, `${label}: the successful outcome is visible at 60: ${after}`);
|
|
2174
|
+
assert.match(after, /esc close/, `${label}: close survives the larger token: ${after}`);
|
|
2175
|
+
|
|
2176
|
+
const failing = make(() => ({ ok: false }));
|
|
2177
|
+
failing.handleInput('r');
|
|
2178
|
+
await tick();
|
|
2179
|
+
const failedFooter = footerOf(failing.render(60));
|
|
2180
|
+
assert.match(failedFooter, /(^|· )r failed( ·|$)/, `${label}: the failure is visible at 60: ${failedFooter}`);
|
|
2181
|
+
assert.match(failedFooter, /esc close/, `${label}: close survives the failure token: ${failedFooter}`);
|
|
2182
|
+
}
|
|
2183
|
+
});
|
|
2184
|
+
|
|
2185
|
+
test('S9: the compact legend keeps the keys working once the outcome token is painted', async () => {
|
|
2186
|
+
const localHelpers = { ...helpers, isKittyProtocolActive: () => true };
|
|
2187
|
+
let closed = 'pending';
|
|
2188
|
+
const view = createOddView({
|
|
2189
|
+
docs: names(14),
|
|
2190
|
+
loadDoc: () => ({ ok: true, text: longDoc(40) }),
|
|
2191
|
+
reloadDocs: () => ({ ok: true, docs: names(14) }),
|
|
2192
|
+
helpers: localHelpers,
|
|
2193
|
+
theme: plainTheme,
|
|
2194
|
+
rowsAvailable: () => 12,
|
|
2195
|
+
requestRender: () => {},
|
|
2196
|
+
done: (result) => { closed = result; },
|
|
2197
|
+
});
|
|
2198
|
+
view.handleInput('r');
|
|
2199
|
+
await tick();
|
|
2200
|
+
assert.match(footerOf(view.render(60)), /r reloaded/, 'the preview frame is the reported 60-column one');
|
|
2201
|
+
const first = () => filled(view.render(60))[0];
|
|
2202
|
+
assert.match(first(), /document-01/, 'the window starts at the first document');
|
|
2203
|
+
view.handleInput('right');
|
|
2204
|
+
assert.match(first(), /document-07/, '→ still moves the list a screen under the outcome token');
|
|
2205
|
+
view.handleInput('left');
|
|
2206
|
+
assert.match(first(), /document-01/, '← still moves it back');
|
|
2207
|
+
view.handleInput('escape');
|
|
2208
|
+
assert.equal(closed, null, 'esc still closes the view');
|
|
2209
|
+
});
|
|
2210
|
+
|
|
2211
|
+
test('S10: the reading position names the visible-through line and the total, with no 1- prefix', async () => {
|
|
2212
|
+
const view = build({ docs: [doc('alpha')] });
|
|
2213
|
+
await tick();
|
|
2214
|
+
const top = searchRowOf(view.render(80));
|
|
2215
|
+
assert.match(top, / 16\/399( |$)/, `the row shows the visible-through line and the total: ${top}`);
|
|
2216
|
+
assert.ok(!/\d+-\d+\//.test(top), `no redundant range prefix: ${top}`);
|
|
2217
|
+
view.handleInput('J');
|
|
2218
|
+
const paged = searchRowOf(view.render(80));
|
|
2219
|
+
assert.match(paged, / 31\/399( |$)/, `the position moves with the document: ${paged}`);
|
|
2220
|
+
assert.ok(!/\d+-\d+\//.test(paged), 'and the range never comes back');
|
|
2221
|
+
});
|
|
2222
|
+
});
|
|
2223
|
+
|
|
2224
|
+
// ---------------------------------------------------------------------------
|
|
2225
|
+
// S21 («se traba»): the /odd view can be handed its list in TWO steps — the
|
|
2226
|
+
// interactive open paints the cheap local scan at once, and the git-ranked
|
|
2227
|
+
// order lands when the checkout's history walk answers. `applyDocs` is that
|
|
2228
|
+
// second step, and it has to behave like the `s` order toggle rather than like
|
|
2229
|
+
// a reload: the focused document survives by IDENTITY (the reader keeps
|
|
2230
|
+
// reading the same file while the list settles), the window re-anchors on its
|
|
2231
|
+
// new position, and no document is re-read because only the order changed.
|
|
2232
|
+
// ---------------------------------------------------------------------------
|
|
2233
|
+
describe('createOddView two-step listing (S21)', () => {
|
|
2234
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
2235
|
+
const listCells = (lines) => lines.slice(2, -2).map((line) => strip(line).split('│')[1] || '');
|
|
2236
|
+
const filled = (lines) => listCells(lines).filter((cell) => cell.trim() !== '');
|
|
2237
|
+
const rowName = (cell) => cell.trim().replace(/^▸\s*/, '');
|
|
2238
|
+
const namesOf = (lines) => filled(lines).map(rowName);
|
|
2239
|
+
const focusedName = (lines) => {
|
|
2240
|
+
const cell = filled(lines).find((entry) => entry.includes('▸'));
|
|
2241
|
+
return cell === undefined ? null : rowName(cell);
|
|
2242
|
+
};
|
|
2243
|
+
const bodyOf = (lines) => strip(lines.join('\n'));
|
|
2244
|
+
|
|
2245
|
+
function build({ docs, rows = 20, load } = {}) {
|
|
2246
|
+
const reads = [];
|
|
2247
|
+
const paints = [];
|
|
2248
|
+
const view = createOddView({
|
|
2249
|
+
docs,
|
|
2250
|
+
loadDoc: (entry) => {
|
|
2251
|
+
reads.push(entry.name);
|
|
2252
|
+
return { ok: true, text: `# ${entry.name}\n\nbody of ${entry.name}\n` };
|
|
2253
|
+
},
|
|
2254
|
+
helpers,
|
|
2255
|
+
theme: plainTheme,
|
|
2256
|
+
rowsAvailable: () => rows,
|
|
2257
|
+
requestRender: () => { paints.push(true); },
|
|
2258
|
+
done: () => {},
|
|
2259
|
+
});
|
|
2260
|
+
return { view, reads, paints };
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
test('a re-ranked list keeps the focused document and its already-rendered pane', async () => {
|
|
2264
|
+
const { view, reads, paints } = build({ docs: [doc('bravo'), doc('alpha'), doc('mike')] });
|
|
2265
|
+
await tick();
|
|
2266
|
+
view.render(80);
|
|
2267
|
+
view.handleInput('down');
|
|
2268
|
+
view.handleInput('down');
|
|
2269
|
+
const before = view.render(80);
|
|
2270
|
+
assert.equal(focusedName(before), 'mike', 'the premise: mike is the focused document');
|
|
2271
|
+
const readsBefore = reads.slice();
|
|
2272
|
+
|
|
2273
|
+
const paintsBefore = paints.length;
|
|
2274
|
+
view.applyDocs([doc('mike'), doc('alpha'), doc('bravo')]);
|
|
2275
|
+
assert.ok(paints.length > paintsBefore, 'applying the settled order requests a repaint');
|
|
2276
|
+
|
|
2277
|
+
const after = view.render(80);
|
|
2278
|
+
assert.deepEqual(namesOf(after), ['mike', 'alpha', 'bravo'], 'the list takes the new sequence');
|
|
2279
|
+
assert.equal(focusedName(after), 'mike', 'the reader keeps the SAME document, not the same index');
|
|
2280
|
+
assert.match(bodyOf(after), /body of mike/, 'the pane still shows the document being read');
|
|
2281
|
+
assert.deepEqual(reads, readsBefore, 'only the order moved, so no document is read again');
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
test('the applied order keeps the selection inside the list when a document disappears', async () => {
|
|
2285
|
+
const { view } = build({ docs: [doc('alpha'), doc('bravo'), doc('charlie')] });
|
|
2286
|
+
await tick();
|
|
2287
|
+
view.handleInput('down');
|
|
2288
|
+
view.handleInput('down');
|
|
2289
|
+
assert.equal(focusedName(view.render(80)), 'charlie');
|
|
2290
|
+
// The checkout changed under the reader: charlie is gone from the settled
|
|
2291
|
+
// listing, so the selection clamps instead of pointing past the end.
|
|
2292
|
+
view.applyDocs([doc('alpha'), doc('bravo')]);
|
|
2293
|
+
const lines = view.render(80);
|
|
2294
|
+
assert.deepEqual(namesOf(lines), ['alpha', 'bravo']);
|
|
2295
|
+
assert.ok(view.state.index <= 1, `the selection stays inside the list: ${view.state.index}`);
|
|
2296
|
+
assert.match(bodyOf(lines), /body of bravo/, 'and it lands on the last document instead of nothing');
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2299
|
+
test('an apply that carries nothing usable leaves the open list exactly as it was', async () => {
|
|
2300
|
+
const { view } = build({ docs: [doc('alpha'), doc('bravo')] });
|
|
2301
|
+
await tick();
|
|
2302
|
+
view.handleInput('down');
|
|
2303
|
+
const before = view.render(80);
|
|
2304
|
+
view.applyDocs(undefined);
|
|
2305
|
+
view.applyDocs(null);
|
|
2306
|
+
const after = view.render(80);
|
|
2307
|
+
assert.deepEqual(namesOf(after), namesOf(before), 'an absent answer never empties the list');
|
|
2308
|
+
assert.equal(focusedName(after), focusedName(before), 'and never moves the reader');
|
|
2309
|
+
});
|
|
2310
|
+
|
|
2311
|
+
test('a two-step open still shows the whole first list and closes on esc', async () => {
|
|
2312
|
+
let closed = 'pending';
|
|
2313
|
+
const view = createOddView({
|
|
2314
|
+
docs: [doc('alpha'), doc('beta')],
|
|
2315
|
+
loadDoc: () => ({ ok: true, text: '# alpha\n' }),
|
|
2316
|
+
helpers,
|
|
2317
|
+
theme: plainTheme,
|
|
2318
|
+
rowsAvailable: () => 20,
|
|
2319
|
+
requestRender: () => {},
|
|
2320
|
+
done: (result) => { closed = result; },
|
|
2321
|
+
});
|
|
2322
|
+
const first = view.render(80);
|
|
2323
|
+
assert.match(bodyOf(first), /✎ Odd · 2 documents/, 'the cheap first frame already counts the documents');
|
|
2324
|
+
assert.deepEqual(namesOf(first), ['alpha', 'beta'], 'and already lists them');
|
|
2325
|
+
view.handleInput('escape');
|
|
2326
|
+
assert.equal(closed, null, 'esc still closes the view');
|
|
2327
|
+
});
|
|
2328
|
+
});
|