pi-odd-worktree 0.0.0-stage → 0.1.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 +223 -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,1357 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Lib unit tests for lib/warp-create-picker.js (pure Node, no subprocess, no
|
|
3
|
+
// prompt): pagination, the reusable multi-select component driven with
|
|
4
|
+
// synthetic keys, the single-select prompt and the inline name input.
|
|
5
|
+
//
|
|
6
|
+
// S1: the ISSUE and ODD selection plumbing is gone with the association
|
|
7
|
+
// itself — no `gh` fetch shape, no open-issue matcher, no `odd/tasks` listing,
|
|
8
|
+
// no pick-item adapters and no issue/ODD creation steps. The tests that pinned
|
|
9
|
+
// them were removed WITH the code (they asserted the removed contract), and
|
|
10
|
+
// the absence is pinned below so the datasets cannot quietly come back.
|
|
11
|
+
|
|
12
|
+
const { test, describe, after } = require('node:test');
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const picker = require('../lib/warp-create-picker.js');
|
|
19
|
+
|
|
20
|
+
// Optional real dependency: the host's pi-tui package. CI installs NO
|
|
21
|
+
// dependencies, so resolve it once here and SKIP the tests that genuinely
|
|
22
|
+
// need the real module instead of failing (the
|
|
23
|
+
// worktree-warp-real-session pattern). Every test that only needs the
|
|
24
|
+
// raw-byte fallback keeps running everywhere, with or without the package.
|
|
25
|
+
let TUI_AVAILABLE = false;
|
|
26
|
+
try {
|
|
27
|
+
require.resolve('@earendil-works/pi-tui');
|
|
28
|
+
TUI_AVAILABLE = true;
|
|
29
|
+
} catch {
|
|
30
|
+
TUI_AVAILABLE = false;
|
|
31
|
+
}
|
|
32
|
+
if (!TUI_AVAILABLE) {
|
|
33
|
+
console.error(
|
|
34
|
+
'warp-create-picker: SKIPPING real-matcher tests (no @earendil-works/pi-tui found; '
|
|
35
|
+
+ 'install the package, e.g. npm install @earendil-works/pi-tui, to run them)',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const roots = [];
|
|
40
|
+
after(() => {
|
|
41
|
+
for (const dir of roots) {
|
|
42
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function track(p) {
|
|
47
|
+
roots.push(p);
|
|
48
|
+
return p;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function scratchDir(tag) {
|
|
52
|
+
return track(fs.mkdtempSync(path.join(os.tmpdir(), `wt-pick-${tag}-`)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// S1: worktrees receive no issue or ODD association, so the module carries no
|
|
56
|
+
// dataset plumbing for them any more. Pinned as an ABSENCE: the names are the
|
|
57
|
+
// removed contract, and a future re-add would reintroduce an association
|
|
58
|
+
// surface the create flow must not have.
|
|
59
|
+
describe('S1: no issue or ODD selection plumbing survives', { concurrency: false }, () => {
|
|
60
|
+
test('the module exports no issue/ODD fetch, listing, matcher or pick-item adapter', () => {
|
|
61
|
+
for (const name of [
|
|
62
|
+
'ghFetchArgv', 'resolveGhBin', 'fetchOpenIssues', 'fetchOpenIssuesAsync',
|
|
63
|
+
'listOddTaskDocs', 'filterIssueItems', 'filterOddItems', 'issuePickItems', 'oddPickItems',
|
|
64
|
+
'GH_FETCH_LIMIT_DEFAULT',
|
|
65
|
+
]) {
|
|
66
|
+
assert.equal(Object.prototype.hasOwnProperty.call(picker, name), false, `lib/warp-create-picker.js no longer exports ${name}`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('the code runs no `gh` issue query and reads no odd/tasks directory', () => {
|
|
71
|
+
const code = fs.readFileSync(path.resolve(__dirname, '../lib/warp-create-picker.js'), 'utf8')
|
|
72
|
+
.split('\n')
|
|
73
|
+
.filter((line) => !line.trimStart().startsWith('//'))
|
|
74
|
+
.join('\n');
|
|
75
|
+
assert.ok(!code.includes("'issue', 'list'"), 'no gh issue list argv');
|
|
76
|
+
assert.ok(!code.includes('odd/tasks'), 'no odd/tasks listing');
|
|
77
|
+
assert.ok(!/fetchOpenIssues|issuePickItems|oddPickItems|listOddTaskDocs|filterIssueItems|filterOddItems/.test(code), 'no association data helper survives');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('pagination', { concurrency: false }, () => {
|
|
82
|
+
test('pages clamp and slice', () => {
|
|
83
|
+
const items = Array.from({ length: 12 }, (_, i) => i);
|
|
84
|
+
assert.deepEqual(picker.paginate(items, 0, 5), { items: [0, 1, 2, 3, 4], page: 0, pageCount: 3, total: 12 });
|
|
85
|
+
assert.deepEqual(picker.paginate(items, 2, 5).items, [10, 11], 'the last page holds the rest');
|
|
86
|
+
assert.equal(picker.paginate(items, 9, 5).page, 2, 'a far page clamps to the last one');
|
|
87
|
+
assert.equal(picker.paginate(items, -3, 5).page, 0, 'a negative page clamps to the first one');
|
|
88
|
+
assert.deepEqual(picker.paginate([], 0, 5), { items: [], page: 0, pageCount: 1, total: 0 });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('the reusable multi-select component', { concurrency: false }, () => {
|
|
93
|
+
const items = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, label: `row ${i + 1}` }));
|
|
94
|
+
const identityFilter = (rows, q) => {
|
|
95
|
+
const query = String(q || '').trim().toLowerCase();
|
|
96
|
+
if (!query) return rows;
|
|
97
|
+
return rows.filter((r) => r.label.toLowerCase().includes(query));
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function open(overrides = {}) {
|
|
101
|
+
const finished = [];
|
|
102
|
+
const view = picker.createMultiSelect({
|
|
103
|
+
title: 'Select rows (optional)',
|
|
104
|
+
items,
|
|
105
|
+
filterFn: identityFilter,
|
|
106
|
+
pageSize: 5,
|
|
107
|
+
helpers: null,
|
|
108
|
+
hostTui: null,
|
|
109
|
+
theme: null,
|
|
110
|
+
done: (out) => { finished.push(out); },
|
|
111
|
+
...overrides,
|
|
112
|
+
});
|
|
113
|
+
return { view, finished };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function textOf(view, width = 90) {
|
|
117
|
+
return view.render(width).join('\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
test('it renders rules, prompt, search box, DATA rows only and a key footer', () => {
|
|
121
|
+
const { view, finished } = open();
|
|
122
|
+
const lines = view.render(90);
|
|
123
|
+
const text = lines.join('\n');
|
|
124
|
+
assert.ok(/^─+$/.test(lines[0]), 'a thin accent rule opens the component');
|
|
125
|
+
assert.ok(/^─+$/.test(lines[lines.length - 1]), 'a thin accent rule closes it');
|
|
126
|
+
assert.ok(text.includes('Select rows (optional)'), 'the prompt travels');
|
|
127
|
+
assert.ok(text.includes('Search:'), 'the search box sits under the prompt');
|
|
128
|
+
assert.ok(text.includes('row 1'), 'the first page shows');
|
|
129
|
+
assert.ok(!text.includes('row 6'), 'the second page stays off-screen');
|
|
130
|
+
assert.match(text, /page 1\/3/i, 'the footer names the page');
|
|
131
|
+
assert.match(text, /enter.*confirm/i, 'the footer names Enter as the confirm key');
|
|
132
|
+
assert.match(text, /space.*toggle/i, 'the footer names Space as the toggle key');
|
|
133
|
+
assert.match(text, /←\/→ pages/i, 'the footer names LEFT/RIGHT as the page keys');
|
|
134
|
+
assert.match(text, /tab.*back/i, 'the footer reserves Tab for Back');
|
|
135
|
+
assert.match(text, /t search/i, 'the footer names T as the search key');
|
|
136
|
+
assert.ok(!text.includes('← Back'), 'no selectable Back row renders anywhere');
|
|
137
|
+
assert.ok(!text.includes('Back'), 'no Back row label renders anywhere');
|
|
138
|
+
assert.ok(!text.includes('Confirm'), 'there is no Confirm row: Enter confirms');
|
|
139
|
+
// Raw Tab is the only Back affordance: it returns without completing.
|
|
140
|
+
view.handleInput('\t');
|
|
141
|
+
assert.deepEqual(finished, [{ back: true, selected: [], uiState: { query: '', page: 0, cursor: 0 } }], 'raw Tab goes Back with no selectable row');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('panels are standalone: no cross-field chips, tabs or step counters leak into the render', () => {
|
|
145
|
+
const { view } = open({
|
|
146
|
+
title: 'Select issues (optional)',
|
|
147
|
+
});
|
|
148
|
+
const text = textOf(view);
|
|
149
|
+
assert.ok(!/\[\d+\/\d+\]/.test(text), 'no [n/m] step counter renders');
|
|
150
|
+
assert.ok(!text.includes('▸'), 'no chip strip renders');
|
|
151
|
+
assert.ok(!text.includes('✓'), 'no answered-step mark renders');
|
|
152
|
+
const { view: second } = open({ title: 'Select odd documents (optional)' });
|
|
153
|
+
const again = textOf(second);
|
|
154
|
+
assert.ok(!/\[\d+\/\d+\]/.test(again), 'the second panel carries no counter either');
|
|
155
|
+
assert.ok(!again.includes('Issues') || !again.includes('Odd'), 'no joint Type/Name/Issues/Odd header groups the panels');
|
|
156
|
+
assert.ok(again.includes('Select odd documents (optional)'), 'each panel keeps only its own title');
|
|
157
|
+
// Pagination of THIS list is fine: it is not a wizard step indicator.
|
|
158
|
+
assert.match(text, /page 1\/3/i, 'the page counter of the current list stays');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('focused rows carry the distinct chevron and checked boxes align', () => {
|
|
162
|
+
const { view } = open({ pageSize: 12 });
|
|
163
|
+
view.handleInput('space');
|
|
164
|
+
const lines = view.render(90);
|
|
165
|
+
const row = lines.find((line) => line.includes('row 1'));
|
|
166
|
+
assert.ok(row.includes('[x]'), 'the toggled box reads checked');
|
|
167
|
+
assert.ok(row.includes('❯'), 'the focused row carries the chevron');
|
|
168
|
+
const other = lines.find((line) => line.includes('row 2'));
|
|
169
|
+
assert.ok(other.includes('[ ]'), 'the untoggled box reads empty');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('moving pages shows the later rows', () => {
|
|
173
|
+
const { view } = open();
|
|
174
|
+
view.handleInput('pagedown');
|
|
175
|
+
const text = textOf(view);
|
|
176
|
+
assert.ok(text.includes('row 6'), 'the second page shows after pagedown');
|
|
177
|
+
assert.ok(!text.includes('row 1\n') && !text.includes('row 1 '), 'the first page is gone');
|
|
178
|
+
assert.match(text, /page 2\/3/i);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('space toggles, enter confirms, tab goes back preserving values', () => {
|
|
182
|
+
const { view, finished } = open();
|
|
183
|
+
view.handleInput('down');
|
|
184
|
+
assert.equal(view.state.focus, 'list', 'arrowing focuses the list');
|
|
185
|
+
view.handleInput('space');
|
|
186
|
+
assert.deepEqual(view.state.selected, [2], 'Space toggles the focused checkbox');
|
|
187
|
+
assert.ok(textOf(view).includes('1 selected'), 'the footer counts the selection');
|
|
188
|
+
view.handleInput('space');
|
|
189
|
+
assert.deepEqual(view.state.selected, [], 'Space toggles it back off');
|
|
190
|
+
assert.deepEqual(finished, [], 'toggling never completes');
|
|
191
|
+
view.handleInput('space');
|
|
192
|
+
view.handleInput('\t');
|
|
193
|
+
assert.deepEqual(finished, [{ back: true, selected: [2], uiState: { query: '', page: 0, cursor: 1 } }], 'Tab alone returns with selections and list position echoed');
|
|
194
|
+
assert.deepEqual(view.state.selected, [2], 'the selections are kept for the return trip');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('multi-word title queries keep working from the T search field', () => {
|
|
198
|
+
const { view, finished } = open({ pageSize: 12 });
|
|
199
|
+
view.handleInput('x');
|
|
200
|
+
assert.equal(view.state.query, '', 'stray typing in list mode never filters');
|
|
201
|
+
view.handleInput('t');
|
|
202
|
+
assert.equal(view.state.mode, 'search', 'T opens the dedicated search field');
|
|
203
|
+
for (const ch of 'row 1') view.handleInput(ch);
|
|
204
|
+
assert.equal(view.state.query, 'row 1', 'the space typed into the query');
|
|
205
|
+
const text = textOf(view);
|
|
206
|
+
assert.ok(text.includes('row 1'), 'the filter narrows to row 1');
|
|
207
|
+
assert.ok(!text.includes('row 2'), 'row 2 is filtered out');
|
|
208
|
+
assert.deepEqual(view.state.selected, [], 'filtering never selects');
|
|
209
|
+
view.handleInput('enter');
|
|
210
|
+
assert.equal(view.state.mode, 'list', 'Enter leaves search for the list');
|
|
211
|
+
assert.deepEqual(finished, [], 'leaving search never confirms');
|
|
212
|
+
assert.equal(view.state.query, 'row 1', 'the filter is retained');
|
|
213
|
+
view.handleInput('enter');
|
|
214
|
+
assert.deepEqual(finished, [{ selected: [], uiState: { query: 'row 1', page: 0, cursor: 0 } }], 'Enter in list confirms, echoing the kept filter');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('confirming with nothing selected is valid and carries an empty list', () => {
|
|
218
|
+
const { view, finished } = open();
|
|
219
|
+
// No Confirm row exists: Enter on the list confirms immediately.
|
|
220
|
+
view.handleInput('enter');
|
|
221
|
+
assert.deepEqual(finished, [{ selected: [], uiState: { query: '', page: 0, cursor: 0 } }]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('typing filters the rows and keeps working after a multi-selection', () => {
|
|
225
|
+
const { view, finished } = open({ pageSize: 12 });
|
|
226
|
+
view.handleInput('down');
|
|
227
|
+
view.handleInput('space');
|
|
228
|
+
view.handleInput('down');
|
|
229
|
+
view.handleInput('space');
|
|
230
|
+
assert.deepEqual(view.state.selected, [2, 3], 'two rows selected');
|
|
231
|
+
view.handleInput('t');
|
|
232
|
+
for (const ch of 'row 1') view.handleInput(ch);
|
|
233
|
+
assert.equal(view.state.query, 'row 1');
|
|
234
|
+
const text = textOf(view);
|
|
235
|
+
assert.ok(text.includes('row 1'), 'the filter narrows to row 1');
|
|
236
|
+
assert.ok(!text.includes('row 2'), 'row 2 is filtered out');
|
|
237
|
+
assert.deepEqual(view.state.selected, [2, 3], 'the selection survives filtering');
|
|
238
|
+
view.handleInput('enter');
|
|
239
|
+
assert.equal(view.state.mode, 'list', 'Enter leaves search first');
|
|
240
|
+
assert.deepEqual(finished, [], 'leaving search never confirms');
|
|
241
|
+
view.handleInput('enter');
|
|
242
|
+
assert.deepEqual(finished, [{ selected: [2, 3], uiState: { query: 'row 1', page: 0, cursor: 0 } }], 'Enter in list confirms the multi-selection, echoing the kept filter');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('backspace edits the query', () => {
|
|
246
|
+
const { view } = open({ pageSize: 12 });
|
|
247
|
+
view.handleInput('t');
|
|
248
|
+
view.handleInput('x');
|
|
249
|
+
assert.equal(view.state.query, 'x');
|
|
250
|
+
view.handleInput('backspace');
|
|
251
|
+
assert.equal(view.state.query, '');
|
|
252
|
+
assert.equal(view.state.mode, 'search', 'editing stays in the field');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// S4/S12: while the search field is active, Delete clears what was typed in
|
|
256
|
+
// one keystroke instead of erasing it character by character. Backspace
|
|
257
|
+
// keeps its one-character meaning, and neither key leaves the field.
|
|
258
|
+
test('Delete clears the whole active query while Backspace removes one character', () => {
|
|
259
|
+
const { view, finished } = open({ pageSize: 12 });
|
|
260
|
+
view.handleInput('t');
|
|
261
|
+
for (const char of 'row 1') view.handleInput(char);
|
|
262
|
+
assert.equal(view.state.query, 'row 1');
|
|
263
|
+
view.handleInput('\x1b[3~'); // the raw Delete sequence
|
|
264
|
+
assert.equal(view.state.query, '', 'one Delete clears everything typed');
|
|
265
|
+
assert.equal(view.state.mode, 'search', 'the search field stays active');
|
|
266
|
+
assert.equal(view.state.page, 0);
|
|
267
|
+
assert.ok(textOf(view).includes('row 6'), 'the full list is visible again');
|
|
268
|
+
assert.deepEqual(finished, [], 'clearing the query never completes the step');
|
|
269
|
+
|
|
270
|
+
for (const char of 'row 2') view.handleInput(char);
|
|
271
|
+
view.handleInput('backspace');
|
|
272
|
+
assert.equal(view.state.query, 'row ', 'Backspace still removes exactly one character');
|
|
273
|
+
view.handleInput('delete');
|
|
274
|
+
assert.equal(view.state.query, '', 'the installed matcher spelling clears it too');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('the search footer advertises the clear key on its one row', () => {
|
|
278
|
+
for (const width of [40, 80, 120]) {
|
|
279
|
+
const { view } = open({ pageSize: 12 });
|
|
280
|
+
view.handleInput('t');
|
|
281
|
+
const lines = view.render(width).map((line) => String(line).replace(/\x1b\[[0-9;]*m/g, ''));
|
|
282
|
+
const legend = lines.filter((line) => /filter kept|back to list|tab|clear|esc cancel/i.test(line));
|
|
283
|
+
assert.equal(legend.length, 1, `width ${width}: exactly one legend row, got ${legend.length}`);
|
|
284
|
+
assert.match(legend[0], /clear/i, `width ${width}: the active search names the clear key: ${legend[0]}`);
|
|
285
|
+
assert.ok(!legend[0].includes('…'), `width ${width}: compacted, never ellipsis-clipped: ${legend[0]}`);
|
|
286
|
+
view.handleInput('\x1b'); // leave the field for the list
|
|
287
|
+
const listLines = view.render(width).map((line) => String(line).replace(/\x1b\[[0-9;]*m/g, ''));
|
|
288
|
+
assert.equal(listLines.filter((line) => /clear/i.test(line)).length, 0, `width ${width}: no clear hint while no search is active`);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('escape cancels the whole step', () => {
|
|
293
|
+
const { view, finished } = open();
|
|
294
|
+
view.handleInput('down');
|
|
295
|
+
view.handleInput('space');
|
|
296
|
+
view.handleInput('escape');
|
|
297
|
+
assert.deepEqual(finished, [null], 'Esc finishes with null: cancel, not confirm');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('a key release changes nothing', () => {
|
|
301
|
+
const { view } = open({
|
|
302
|
+
helpers: { isKeyRelease: () => true },
|
|
303
|
+
});
|
|
304
|
+
view.handleInput('down');
|
|
305
|
+
view.handleInput('x');
|
|
306
|
+
view.handleInput('enter');
|
|
307
|
+
assert.equal(view.state.query, '', 'no typing leaked through the release guard');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('it never crashes on hostile dimensions', () => {
|
|
311
|
+
const { view } = open();
|
|
312
|
+
assert.deepEqual(view.render(0), []);
|
|
313
|
+
assert.deepEqual(view.render(-4), []);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('freeze/cancel/key regression (S52: real matcher + raw fallback)', { concurrency: false }, () => {
|
|
318
|
+
const items = [
|
|
319
|
+
{ id: 11, label: '#11 First' },
|
|
320
|
+
{ id: 12, label: '#12 Second' },
|
|
321
|
+
];
|
|
322
|
+
const identityFilter = (rows, q) => {
|
|
323
|
+
const query = String(q || '').trim().toLowerCase();
|
|
324
|
+
if (!query) return rows;
|
|
325
|
+
return rows.filter((r) => r.label.toLowerCase().includes(query));
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
function open(overrides = {}) {
|
|
329
|
+
const finished = [];
|
|
330
|
+
const view = picker.createMultiSelect({
|
|
331
|
+
title: 'Select issues (optional)',
|
|
332
|
+
items,
|
|
333
|
+
filterFn: identityFilter,
|
|
334
|
+
pageSize: 8,
|
|
335
|
+
helpers: null,
|
|
336
|
+
hostTui: null,
|
|
337
|
+
theme: null,
|
|
338
|
+
done: (out) => { finished.push(out); },
|
|
339
|
+
...overrides,
|
|
340
|
+
});
|
|
341
|
+
return { view, finished };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
test("the installed matcher accepts the ids the picker passes ('escape', 'enter', 'tab')", { skip: !TUI_AVAILABLE }, async () => {
|
|
345
|
+
const tui = await import('@earendil-works/pi-tui');
|
|
346
|
+
assert.equal(typeof tui.matchesKey, 'function', 'the host exposes matchesKey');
|
|
347
|
+
// The explorer claim that 'escape' is invalid never executed: the
|
|
348
|
+
// installed keys.js matches BOTH spellings, plus raw sequences.
|
|
349
|
+
assert.equal(tui.matchesKey('\x1b', 'escape'), true, 'raw Esc matches escape');
|
|
350
|
+
assert.equal(tui.matchesKey('\x1b', 'esc'), true, 'raw Esc matches esc');
|
|
351
|
+
assert.equal(tui.matchesKey('\r', 'enter'), true, 'raw CR matches enter');
|
|
352
|
+
assert.equal(tui.matchesKey('\r', 'return'), true, 'raw CR matches return');
|
|
353
|
+
assert.equal(tui.matchesKey('\t', 'tab'), true, 'raw Tab matches tab');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('with the real helpers, raw terminal keys drive the picker', { skip: !TUI_AVAILABLE }, async () => {
|
|
357
|
+
const tui = await import('@earendil-works/pi-tui');
|
|
358
|
+
const helpers = { matchesKey: tui.matchesKey, isKeyRelease: tui.isKeyRelease };
|
|
359
|
+
const first = open({ helpers });
|
|
360
|
+
first.view.handleInput('\x1b');
|
|
361
|
+
assert.deepEqual(first.finished, [null], 'raw Esc cancels');
|
|
362
|
+
const second = open({ helpers });
|
|
363
|
+
second.view.handleInput('\x1b[B');
|
|
364
|
+
second.view.handleInput(' ');
|
|
365
|
+
assert.deepEqual(second.view.state.selected, [12], 'raw arrow moved, raw Space toggled');
|
|
366
|
+
second.view.handleInput('\r');
|
|
367
|
+
assert.deepEqual(second.finished, [{ selected: [12], uiState: { query: '', page: 0, cursor: 1 } }], 'raw CR confirms');
|
|
368
|
+
const third = open({ helpers });
|
|
369
|
+
third.view.handleInput('\t');
|
|
370
|
+
assert.deepEqual(third.finished, [{ back: true, selected: [], uiState: { query: '', page: 0, cursor: 0 } }], 'raw Tab goes Back with selections and position echoed');
|
|
371
|
+
const fourth = open({ helpers });
|
|
372
|
+
fourth.view.handleInput('\x1b[C');
|
|
373
|
+
assert.equal(fourth.view.state.page, 0, 'one page: RIGHT clamps');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('without helpers, raw control sequences still work (never a dead picker)', () => {
|
|
377
|
+
const esc = open();
|
|
378
|
+
esc.view.handleInput('\x1b');
|
|
379
|
+
assert.deepEqual(esc.finished, [null], 'raw Esc cancels with no helpers');
|
|
380
|
+
const enter = open();
|
|
381
|
+
enter.view.handleInput('\r');
|
|
382
|
+
assert.deepEqual(enter.finished, [{ selected: [], uiState: { query: '', page: 0, cursor: 0 } }], 'raw CR confirms empty with no helpers');
|
|
383
|
+
const space = open();
|
|
384
|
+
space.view.handleInput(' ');
|
|
385
|
+
assert.deepEqual(space.view.state.selected, [11], 'raw Space toggles the focused row');
|
|
386
|
+
const tab = open();
|
|
387
|
+
tab.view.handleInput('\t');
|
|
388
|
+
assert.deepEqual(tab.finished, [{ back: true, selected: [], uiState: { query: '', page: 0, cursor: 0 } }], 'raw Tab goes Back, never toggles');
|
|
389
|
+
const arrows = open();
|
|
390
|
+
arrows.view.handleInput('\x1b[B');
|
|
391
|
+
arrows.view.handleInput(' ');
|
|
392
|
+
assert.deepEqual(arrows.view.state.selected, [12], 'raw arrow moves before toggling');
|
|
393
|
+
const bs = open({ pageSize: 12 });
|
|
394
|
+
bs.view.handleInput('t');
|
|
395
|
+
bs.view.handleInput('x');
|
|
396
|
+
bs.view.handleInput('\x7f');
|
|
397
|
+
assert.equal(bs.view.state.query, '', 'raw DEL edits the query');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('completion fires exactly once under an input storm', () => {
|
|
401
|
+
const { view, finished } = open();
|
|
402
|
+
view.handleInput('escape');
|
|
403
|
+
view.handleInput('escape');
|
|
404
|
+
view.handleInput('enter');
|
|
405
|
+
view.handleInput('\x1b');
|
|
406
|
+
view.handleInput('\r');
|
|
407
|
+
view.handleInput('tab');
|
|
408
|
+
view.handleInput(' ');
|
|
409
|
+
assert.deepEqual(finished, [null], 'one cancel, never a second resolution');
|
|
410
|
+
const confirm = open();
|
|
411
|
+
confirm.view.handleInput('enter');
|
|
412
|
+
confirm.view.handleInput('enter');
|
|
413
|
+
confirm.view.handleInput('escape');
|
|
414
|
+
confirm.view.handleInput('tab');
|
|
415
|
+
assert.equal(confirm.finished.length, 1, 'one confirm, never a second resolution');
|
|
416
|
+
assert.deepEqual(confirm.finished[0], { selected: [], uiState: { query: '', page: 0, cursor: 0 } });
|
|
417
|
+
const back = open();
|
|
418
|
+
back.view.handleInput('tab');
|
|
419
|
+
back.view.handleInput('tab');
|
|
420
|
+
back.view.handleInput('enter');
|
|
421
|
+
assert.deepEqual(back.finished, [{ back: true, selected: [], uiState: { query: '', page: 0, cursor: 0 } }], 'one back, never a second resolution');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test('Enter confirms the step (empty valid); Space toggles; no Confirm row, no Back row', () => {
|
|
425
|
+
const { view, finished } = open();
|
|
426
|
+
const text = view.render(90).join('\n');
|
|
427
|
+
assert.ok(!text.includes('Confirm'), 'no Confirm row renders: Enter confirms');
|
|
428
|
+
assert.ok(!text.includes('Back'), 'no selectable Back row renders: Tab goes back');
|
|
429
|
+
// No phantom focus row after the last data item: the cursor roams
|
|
430
|
+
// data rows only, so Enter on the last row still confirms.
|
|
431
|
+
view.handleInput('down');
|
|
432
|
+
view.handleInput('down');
|
|
433
|
+
view.handleInput('enter');
|
|
434
|
+
assert.deepEqual(finished, [{ selected: [], uiState: { query: '', page: 0, cursor: 1 } }], 'Enter on the last data row confirms, never goes back');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('Space toggles the focused checkbox without completing', () => {
|
|
438
|
+
const { view, finished } = open();
|
|
439
|
+
view.handleInput(' ');
|
|
440
|
+
assert.deepEqual(view.state.selected, [11], 'Space toggles on');
|
|
441
|
+
assert.deepEqual(finished, [], 'toggling never completes');
|
|
442
|
+
view.handleInput(' ');
|
|
443
|
+
assert.deepEqual(view.state.selected, [], 'Space toggles off');
|
|
444
|
+
view.handleInput('down');
|
|
445
|
+
view.handleInput(' ');
|
|
446
|
+
view.handleInput('enter');
|
|
447
|
+
assert.deepEqual(finished, [{ selected: [12], uiState: { query: '', page: 0, cursor: 1 } }], 'Enter confirms the toggled selection');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('Tab alone returns to the previous step; Enter always confirms, Space always toggles', () => {
|
|
451
|
+
const { view, finished } = open();
|
|
452
|
+
view.handleInput('down');
|
|
453
|
+
view.handleInput(' ');
|
|
454
|
+
assert.deepEqual(view.state.selected, [12], 'the second item is selected');
|
|
455
|
+
// No selectable Back row exists, so moving past the last data item
|
|
456
|
+
// clamps there and Enter still confirms the selection.
|
|
457
|
+
view.handleInput('down');
|
|
458
|
+
view.handleInput('down');
|
|
459
|
+
view.handleInput('enter');
|
|
460
|
+
assert.deepEqual(finished, [{ selected: [12], uiState: { query: '', page: 0, cursor: 1 } }], 'Enter past the last row confirms, never a Back row');
|
|
461
|
+
const viaSpace = open();
|
|
462
|
+
viaSpace.view.handleInput('down');
|
|
463
|
+
viaSpace.view.handleInput('down');
|
|
464
|
+
viaSpace.view.handleInput('down');
|
|
465
|
+
viaSpace.view.handleInput(' ');
|
|
466
|
+
assert.deepEqual(viaSpace.view.state.selected, [12], 'Space past the last row toggles data, never a Back row');
|
|
467
|
+
assert.deepEqual(viaSpace.finished, [], 'toggling never completes');
|
|
468
|
+
const viaTab = open();
|
|
469
|
+
viaTab.view.handleInput('down');
|
|
470
|
+
viaTab.view.handleInput(' ');
|
|
471
|
+
viaTab.view.handleInput('\t');
|
|
472
|
+
assert.deepEqual(viaTab.finished, [{ back: true, selected: [12], uiState: { query: '', page: 0, cursor: 1 } }], 'raw Tab alone goes back with values and position kept');
|
|
473
|
+
assert.deepEqual(viaTab.view.state.selected, [12], 'the selections survive for the return trip');
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('initialSelected restores the previous pass selections', () => {
|
|
477
|
+
const { view, finished } = open({ initialSelected: [12] });
|
|
478
|
+
assert.deepEqual(view.state.selected, [12], 'the earlier selection survives the round trip');
|
|
479
|
+
view.handleInput('enter');
|
|
480
|
+
assert.deepEqual(finished, [{ selected: [12], uiState: { query: '', page: 0, cursor: 0 } }], 'Enter confirms the restored selection');
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test('an empty filter renders no selectable rows; raw Tab still goes back', () => {
|
|
484
|
+
const { view, finished } = open();
|
|
485
|
+
view.handleInput('t');
|
|
486
|
+
for (const ch of 'zzz-no-match') view.handleInput(ch);
|
|
487
|
+
const text = view.render(90).join('\n');
|
|
488
|
+
assert.ok(text.includes('(no matches)'), 'items filter out');
|
|
489
|
+
assert.ok(!text.includes('Confirm'), 'no Confirm row appears on an empty filter either');
|
|
490
|
+
assert.ok(!text.includes('Back'), 'no selectable Back row appears on an empty filter either');
|
|
491
|
+
view.handleInput('enter');
|
|
492
|
+
assert.equal(view.state.mode, 'list', 'Enter leaves search for the list');
|
|
493
|
+
view.handleInput('\t');
|
|
494
|
+
assert.deepEqual(finished, [{ back: true, selected: [], uiState: { query: 'zzz-no-match', page: 0, cursor: 0 } }], 'raw Tab goes Back even with an empty filter');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('T reopens the kept query; clearing it restores every row', () => {
|
|
498
|
+
const { view, finished } = open({ pageSize: 12 });
|
|
499
|
+
view.handleInput('t');
|
|
500
|
+
for (const ch of 'zzz') view.handleInput(ch);
|
|
501
|
+
assert.ok(view.render(120).join('\n').includes('(no matches)'), 'the live filter hides every row');
|
|
502
|
+
view.handleInput('enter');
|
|
503
|
+
assert.equal(view.state.mode, 'list');
|
|
504
|
+
view.handleInput('t');
|
|
505
|
+
assert.equal(view.state.mode, 'search', 'T reopens the field');
|
|
506
|
+
assert.equal(view.state.query, 'zzz', 'the kept query is there for editing');
|
|
507
|
+
for (let i = 0; i < 3; i += 1) view.handleInput('backspace');
|
|
508
|
+
assert.equal(view.state.query, '', 'clearing empties the query');
|
|
509
|
+
view.handleInput('enter');
|
|
510
|
+
const text = view.render(120).join('\n');
|
|
511
|
+
assert.ok(text.includes('#11 First') && text.includes('#12 Second'), 'clearing restores every row');
|
|
512
|
+
assert.deepEqual(finished, [], 'nothing completed along the way');
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('Esc in search exits to the list without cancelling', () => {
|
|
516
|
+
const { view, finished } = open();
|
|
517
|
+
view.handleInput('t');
|
|
518
|
+
view.handleInput('1');
|
|
519
|
+
view.handleInput('escape');
|
|
520
|
+
assert.equal(view.state.mode, 'list', 'Esc leaves search for the list');
|
|
521
|
+
assert.equal(view.state.query, '1', 'the filter is retained');
|
|
522
|
+
assert.deepEqual(finished, [], 'Esc in search never cancels the step');
|
|
523
|
+
view.handleInput('escape');
|
|
524
|
+
assert.deepEqual(finished, [null], 'Esc in list still cancels');
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test('T and Space in search are text; arrows never page there', () => {
|
|
528
|
+
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, label: `row ${i + 1}` }));
|
|
529
|
+
const view = picker.createMultiSelect({
|
|
530
|
+
title: 'Paged', items: rows, pageSize: 5, helpers: null, hostTui: null, theme: null, done: () => {},
|
|
531
|
+
});
|
|
532
|
+
const textOf = () => view.render(120).join('\n');
|
|
533
|
+
view.handleInput('t');
|
|
534
|
+
view.handleInput('t');
|
|
535
|
+
view.handleInput(' ');
|
|
536
|
+
view.handleInput('t');
|
|
537
|
+
assert.equal(view.state.query, 't t', 'T and Space type inside the field');
|
|
538
|
+
view.handleInput('left');
|
|
539
|
+
view.handleInput('right');
|
|
540
|
+
assert.match(textOf(), /page 1\/3/i, 'LEFT/RIGHT in search never page');
|
|
541
|
+
assert.equal(view.state.mode, 'search', 'the field stays open');
|
|
542
|
+
const footer = textOf();
|
|
543
|
+
assert.match(footer, /back to list/i, 'the search footer says how to leave');
|
|
544
|
+
assert.match(footer, /filter kept/i, 'the search footer promises the retained filter');
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test('selections persist across filters and pages', () => {
|
|
548
|
+
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, label: `row ${i + 1}` }));
|
|
549
|
+
const finished = [];
|
|
550
|
+
const view = picker.createMultiSelect({
|
|
551
|
+
title: 'Paged', items: rows, pageSize: 5, helpers: null, hostTui: null, theme: null,
|
|
552
|
+
filterFn: (list, q) => {
|
|
553
|
+
const query = String(q || '').trim().toLowerCase();
|
|
554
|
+
if (!query) return list;
|
|
555
|
+
return list.filter((r) => r.label.toLowerCase().includes(query));
|
|
556
|
+
},
|
|
557
|
+
done: (out) => { finished.push(out); },
|
|
558
|
+
});
|
|
559
|
+
view.handleInput(' ');
|
|
560
|
+
view.handleInput('right');
|
|
561
|
+
view.handleInput(' ');
|
|
562
|
+
assert.deepEqual(view.state.selected, [1, 6], 'one per page');
|
|
563
|
+
view.handleInput('t');
|
|
564
|
+
for (const ch of 'row 1') view.handleInput(ch);
|
|
565
|
+
assert.deepEqual(view.state.selected, [1, 6], 'filtering keeps selections');
|
|
566
|
+
view.handleInput('enter');
|
|
567
|
+
view.handleInput('enter');
|
|
568
|
+
assert.deepEqual(finished, [{ selected: [1, 6], uiState: { query: 'row 1', page: 0, cursor: 0 } }], 'confirm carries selections across filters and pages');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('LEFT/RIGHT turn item pages; selections and search survive', () => {
|
|
572
|
+
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, label: `row ${i + 1}` }));
|
|
573
|
+
const finished = [];
|
|
574
|
+
const view = picker.createMultiSelect({
|
|
575
|
+
title: 'Paged', items: rows, pageSize: 5, helpers: null, hostTui: null, theme: null,
|
|
576
|
+
done: (out) => { finished.push(out); },
|
|
577
|
+
});
|
|
578
|
+
const textOf = () => view.render(90).join('\n');
|
|
579
|
+
assert.match(textOf(), /page 1\/3/i);
|
|
580
|
+
view.handleInput('right');
|
|
581
|
+
assert.match(textOf(), /page 2\/3/i, 'RIGHT turns to the next page');
|
|
582
|
+
view.handleInput(' ');
|
|
583
|
+
assert.deepEqual(view.state.selected, [6], 'Space toggles on the new page');
|
|
584
|
+
view.handleInput('left');
|
|
585
|
+
assert.match(textOf(), /page 1\/3/i, 'LEFT turns back');
|
|
586
|
+
view.handleInput('right');
|
|
587
|
+
assert.deepEqual(view.state.selected, [6], 'the selection survives page changes');
|
|
588
|
+
view.handleInput('right');
|
|
589
|
+
view.handleInput('right');
|
|
590
|
+
assert.match(textOf(), /page 3\/3/i, 'the last page clamps');
|
|
591
|
+
view.handleInput('left');
|
|
592
|
+
view.handleInput('left');
|
|
593
|
+
view.handleInput('left');
|
|
594
|
+
assert.match(textOf(), /page 1\/3/i, 'the first page clamps');
|
|
595
|
+
view.handleInput('enter');
|
|
596
|
+
assert.deepEqual(finished, [{ selected: [6], uiState: { query: '', page: 0, cursor: 0 } }], 'Enter confirms across pages');
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('raw LEFT/RIGHT sequences page; raw arrows respect search mode', () => {
|
|
600
|
+
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, label: `row ${i + 1}` }));
|
|
601
|
+
const view = picker.createMultiSelect({
|
|
602
|
+
title: 'Paged', items: rows, pageSize: 5, helpers: null, hostTui: null, theme: null, done: () => {},
|
|
603
|
+
filterFn: (list, q) => {
|
|
604
|
+
const query = String(q || '').trim().toLowerCase();
|
|
605
|
+
if (!query) return list;
|
|
606
|
+
return list.filter((r) => r.label.toLowerCase().includes(query));
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
const textOf = () => view.render(120).join('\n');
|
|
610
|
+
view.handleInput('\x1b[C');
|
|
611
|
+
assert.match(textOf(), /page 2\/3/i, 'raw RIGHT turns the page');
|
|
612
|
+
view.handleInput('\x1b[D');
|
|
613
|
+
assert.match(textOf(), /page 1\/3/i, 'raw LEFT turns back');
|
|
614
|
+
view.handleInput('t');
|
|
615
|
+
assert.equal(view.state.mode, 'search', 'T opens the search field');
|
|
616
|
+
view.handleInput('q');
|
|
617
|
+
assert.equal(view.state.query, 'q', 'typing filters inside the field');
|
|
618
|
+
assert.equal(view.state.page, 0, 'typing resets to the first page');
|
|
619
|
+
view.handleInput('\x1b[C');
|
|
620
|
+
assert.match(textOf(), /page 1\/1/i, 'raw RIGHT in search never pages');
|
|
621
|
+
assert.equal(view.state.mode, 'search', 'search mode is kept');
|
|
622
|
+
view.handleInput('\x1b[B');
|
|
623
|
+
assert.equal(view.state.mode, 'list', 'raw DOWN moves from the search into the rows');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
describe('the inline name input (S52 Back on every field)', { concurrency: false }, () => {
|
|
629
|
+
function open(overrides = {}) {
|
|
630
|
+
const finished = [];
|
|
631
|
+
const view = picker.createTextInput({
|
|
632
|
+
title: 'New worktree change name',
|
|
633
|
+
placeholder: 'name-change',
|
|
634
|
+
initial: '',
|
|
635
|
+
helpers: null,
|
|
636
|
+
hostTui: null,
|
|
637
|
+
theme: null,
|
|
638
|
+
done: (out) => { finished.push(out); },
|
|
639
|
+
...overrides,
|
|
640
|
+
});
|
|
641
|
+
return { view, finished };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
test('it renders the questionnaire frame with a write footer and no cross-field header', () => {
|
|
645
|
+
const { view } = open();
|
|
646
|
+
const lines = view.render(90);
|
|
647
|
+
const text = lines.join('\n');
|
|
648
|
+
assert.ok(/^─+$/.test(lines[0]), 'a thin accent rule opens');
|
|
649
|
+
assert.ok(/^─+$/.test(lines[lines.length - 1]), 'a thin accent rule closes');
|
|
650
|
+
assert.ok(!/\[\d+\/\d+\]/.test(text), 'no [n/m] step counter renders');
|
|
651
|
+
assert.ok(!text.includes('Type') || !text.includes('Issues'), 'no joint Type Name Issues Odd header groups the panels');
|
|
652
|
+
assert.ok(!text.includes('Odd'), 'the name panel leaks no Odd tab');
|
|
653
|
+
assert.ok(text.includes('New worktree change name'), 'the panel keeps only its own title');
|
|
654
|
+
assert.match(text, /enter.*confirm/i, 'Enter confirms');
|
|
655
|
+
assert.match(text, /tab.*back/i, 'Tab goes back');
|
|
656
|
+
assert.match(text, /esc.*cancel/i, 'Esc cancels');
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test('the name field starts truly empty: no example text in the rendered input', () => {
|
|
660
|
+
const { view } = open();
|
|
661
|
+
assert.equal(view.state.value, '', 'the initial value is empty, not prefilled');
|
|
662
|
+
const text = view.render(90).join('\n');
|
|
663
|
+
assert.ok(!text.includes('name-change'), 'no example placeholder renders inside the input');
|
|
664
|
+
assert.ok(!text.includes('picked'), 'no stale value renders either');
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test('first typing lands as the actual value with the cursor after it', { skip: !TUI_AVAILABLE }, async () => {
|
|
668
|
+
const tui = await import('@earendil-works/pi-tui');
|
|
669
|
+
const { view } = open();
|
|
670
|
+
view.handleInput('a');
|
|
671
|
+
assert.equal(view.state.value, 'a', 'the first keystroke is the actual value');
|
|
672
|
+
const line = view.render(90).join('\n').split('\n').find((l) => l.includes('❯'));
|
|
673
|
+
assert.ok(line, 'the input line renders');
|
|
674
|
+
assert.ok(line.includes('a'), 'the typed value renders in the input line');
|
|
675
|
+
const marker = tui.CURSOR_MARKER;
|
|
676
|
+
const at = line.indexOf(marker);
|
|
677
|
+
assert.ok(at >= 0, 'the real Pi cursor marker renders in the input line');
|
|
678
|
+
assert.equal(line[at + marker.length], '▊', 'the visual cursor sits immediately after the marker');
|
|
679
|
+
assert.ok(line.indexOf('a') < at, 'the marker (and cursor) sit after the actual value, never after a placeholder');
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test('the cursor marker is zero-width: Unicode/ANSI width stays exact', { skip: !TUI_AVAILABLE }, async () => {
|
|
683
|
+
const tui = await import('@earendil-works/pi-tui');
|
|
684
|
+
const { view } = open();
|
|
685
|
+
for (const ch of 'caf\u00e9 \u4e2d') view.handleInput(ch);
|
|
686
|
+
const line = view.render(120).join('\n').split('\n').find((l) => l.includes('caf'));
|
|
687
|
+
assert.ok(line, 'the unicode value renders');
|
|
688
|
+
const stripped = String(line).replace(/\x1b\[[0-9;]*m/g, '');
|
|
689
|
+
const withMarker = tui.visibleWidth(stripped);
|
|
690
|
+
const withoutMarker = tui.visibleWidth(stripped.split(tui.CURSOR_MARKER).join(''));
|
|
691
|
+
assert.equal(withMarker, withoutMarker - 1 + 1, 'marker is zero-width, cursor cell counts one');
|
|
692
|
+
assert.ok(withoutMarker > [...'caf\u00e9 \u4e2d'].length, 'wide characters count wider than codepoints');
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test('Back restores only the actually typed prior value, still with no example text', () => {
|
|
696
|
+
const { view, finished } = open({ initial: 'picked' });
|
|
697
|
+
assert.equal(view.state.value, 'picked', 'the actually typed value is restored');
|
|
698
|
+
const text = view.render(90).join('\n');
|
|
699
|
+
assert.ok(text.includes('picked'), 'the restored value renders');
|
|
700
|
+
assert.ok(!text.includes('name-change'), 'the example never renders, even on return');
|
|
701
|
+
view.handleInput('tab');
|
|
702
|
+
assert.deepEqual(finished, [{ back: true, value: 'picked' }], 'Tab alone goes back carrying the typed value');
|
|
703
|
+
assert.equal(view.state.value, 'picked', 'the typed value is kept for the return trip');
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test('typing appends (spaces included), Enter submits, Esc cancels, Tab goes back', () => {
|
|
707
|
+
const { view, finished } = open();
|
|
708
|
+
for (const ch of 'my change') view.handleInput(ch);
|
|
709
|
+
assert.equal(view.state.value, 'my change', 'spaces type in a text input');
|
|
710
|
+
view.handleInput('backspace');
|
|
711
|
+
assert.equal(view.state.value, 'my chang');
|
|
712
|
+
view.handleInput('enter');
|
|
713
|
+
assert.deepEqual(finished, ['my chang'], 'Enter submits the typed value');
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
test('Tab returns {back:true} with the typed value and Esc returns null, exactly once', () => {
|
|
717
|
+
const tab = open();
|
|
718
|
+
tab.view.handleInput('t');
|
|
719
|
+
tab.view.handleInput('tab');
|
|
720
|
+
assert.deepEqual(tab.finished, [{ back: true, value: 't' }], 'Tab alone goes back carrying the typed value');
|
|
721
|
+
assert.equal(tab.view.state.value, 't', 'the typed value is kept for the return trip');
|
|
722
|
+
tab.view.handleInput('tab');
|
|
723
|
+
tab.view.handleInput('escape');
|
|
724
|
+
assert.deepEqual(tab.finished, [{ back: true, value: 't' }], 'one back, never a second resolution');
|
|
725
|
+
const esc = open();
|
|
726
|
+
esc.view.handleInput('escape');
|
|
727
|
+
esc.view.handleInput('enter');
|
|
728
|
+
assert.deepEqual(esc.finished, [null], 'one cancel, never a second resolution');
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test('the initial value is kept for editing', () => {
|
|
732
|
+
const { view, finished } = open({ initial: 'picked' });
|
|
733
|
+
assert.equal(view.state.value, 'picked', 'Back from a later step restores the kept name');
|
|
734
|
+
view.handleInput('enter');
|
|
735
|
+
assert.deepEqual(finished, ['picked'], 'submitting unchanged keeps it');
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test('raw terminal keys drive it with real helpers', { skip: !TUI_AVAILABLE }, async () => {
|
|
739
|
+
const tui = await import('@earendil-works/pi-tui');
|
|
740
|
+
const helpers = { matchesKey: tui.matchesKey, isKeyRelease: tui.isKeyRelease };
|
|
741
|
+
const first = open({ helpers });
|
|
742
|
+
first.view.handleInput('a');
|
|
743
|
+
first.view.handleInput('\x7f');
|
|
744
|
+
first.view.handleInput('b');
|
|
745
|
+
first.view.handleInput('\t');
|
|
746
|
+
assert.deepEqual(first.finished, [{ back: true, value: 'b' }], 'raw Tab goes back carrying the typed value with real helpers');
|
|
747
|
+
});
|
|
748
|
+
test('raw terminal keys drive it with the raw fallback (no helpers)', () => {
|
|
749
|
+
const second = open();
|
|
750
|
+
second.view.handleInput('x');
|
|
751
|
+
second.view.handleInput('\x1b');
|
|
752
|
+
assert.deepEqual(second.finished, [null], 'raw Esc cancels with no helpers');
|
|
753
|
+
const third = open();
|
|
754
|
+
third.view.handleInput('y');
|
|
755
|
+
third.view.handleInput('\r');
|
|
756
|
+
assert.deepEqual(third.finished, ['y'], 'raw CR submits with no helpers');
|
|
757
|
+
const fourth = open();
|
|
758
|
+
fourth.view.handleInput(' ');
|
|
759
|
+
assert.equal(fourth.view.state.value, ' ', 'raw Space types with no helpers');
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
describe('the inline type single-select (Tab Back, no selectable rows)', { concurrency: false }, () => {
|
|
764
|
+
const TYPE_ITEMS = [
|
|
765
|
+
'Feature (feat)', 'Bug fix (fix)', 'Chore (chore)', 'Docs (docs)', 'Style (style)',
|
|
766
|
+
'Refactor (refactor)', 'Performance (perf)', 'Test (test)', 'Build (build)', 'CI (ci)', 'Revert (revert)',
|
|
767
|
+
];
|
|
768
|
+
function openType(overrides = {}) {
|
|
769
|
+
const finished = [];
|
|
770
|
+
const view = picker.createSingleSelect({
|
|
771
|
+
title: 'New worktree change type',
|
|
772
|
+
items: TYPE_ITEMS,
|
|
773
|
+
helpers: null,
|
|
774
|
+
hostTui: null,
|
|
775
|
+
theme: null,
|
|
776
|
+
done: (out) => { finished.push(out); },
|
|
777
|
+
...overrides,
|
|
778
|
+
});
|
|
779
|
+
return { view, finished };
|
|
780
|
+
}
|
|
781
|
+
test('it renders eleven data rows only with a Tab-back footer', () => {
|
|
782
|
+
const { view, finished } = openType();
|
|
783
|
+
const text = view.render(90).join('\n');
|
|
784
|
+
assert.equal(TYPE_ITEMS.length, 11, 'eleven conventional types');
|
|
785
|
+
for (const item of TYPE_ITEMS) assert.ok(text.includes(item), `renders ${item}`);
|
|
786
|
+
assert.ok(!text.includes('Back'), 'no selectable Back row anywhere');
|
|
787
|
+
assert.ok(!text.includes('Confirm'), 'no selectable Confirm row anywhere');
|
|
788
|
+
assert.ok(!/\[\d+\/\d+\]/.test(text), 'no [n/m] step counter renders');
|
|
789
|
+
assert.ok(!text.includes('Name') && !text.includes('Issues') && !text.includes('Odd'), 'the type panel leaks no other-field tabs');
|
|
790
|
+
assert.ok(text.includes('New worktree change type'), 'the panel keeps only its own title');
|
|
791
|
+
assert.match(text, /enter.*confirm/i, 'the footer names Enter as confirm');
|
|
792
|
+
assert.match(text, /tab.*back/i, 'the footer names Tab as back');
|
|
793
|
+
view.handleInput('\t');
|
|
794
|
+
assert.deepEqual(finished, [{ back: true }], 'raw Tab returns Back (single-select carries no list state)');
|
|
795
|
+
});
|
|
796
|
+
test('raw keys with the real matcher: arrows move, Enter confirms, Tab goes back', { skip: !TUI_AVAILABLE }, async () => {
|
|
797
|
+
const tui = await import('@earendil-works/pi-tui');
|
|
798
|
+
const helpers = { matchesKey: tui.matchesKey, isKeyRelease: tui.isKeyRelease };
|
|
799
|
+
const first = openType({ helpers });
|
|
800
|
+
first.view.handleInput('\x1b[B');
|
|
801
|
+
first.view.handleInput('\r');
|
|
802
|
+
assert.deepEqual(first.finished, ['Bug fix (fix)'], 'raw arrow moved, raw CR confirmed the data row');
|
|
803
|
+
const second = openType({ helpers });
|
|
804
|
+
second.view.handleInput('\t');
|
|
805
|
+
assert.deepEqual(second.finished, [{ back: true }], 'raw Tab goes Back with the real matcher');
|
|
806
|
+
const third = openType({ helpers });
|
|
807
|
+
third.view.handleInput('\x1b');
|
|
808
|
+
assert.deepEqual(third.finished, [null], 'raw Esc cancels');
|
|
809
|
+
});
|
|
810
|
+
test('Enter on the last data row confirms; no phantom row after it', () => {
|
|
811
|
+
const { view, finished } = openType();
|
|
812
|
+
for (let i = 0; i < 20; i += 1) view.handleInput('down');
|
|
813
|
+
view.handleInput('\r');
|
|
814
|
+
assert.deepEqual(finished, ['Revert (revert)'], 'clamped on the last data row, Enter confirms it');
|
|
815
|
+
});
|
|
816
|
+
test('prior selection on return restores the cursor', () => {
|
|
817
|
+
const { view, finished } = openType({ initialSelected: 'Bug fix (fix)' });
|
|
818
|
+
view.handleInput('\r');
|
|
819
|
+
assert.deepEqual(finished, ['Bug fix (fix)'], 'the kept type confirms directly');
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
describe('multi-select revisit state (query, page, cursor, selections)', { concurrency: false }, () => {
|
|
824
|
+
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, label: `row ${i + 1}` }));
|
|
825
|
+
const filterFn = (list, q) => {
|
|
826
|
+
const query = String(q || '').trim().toLowerCase();
|
|
827
|
+
if (!query) return list;
|
|
828
|
+
return list.filter((r) => r.label.toLowerCase().includes(query));
|
|
829
|
+
};
|
|
830
|
+
function openRevisit(overrides = {}) {
|
|
831
|
+
const finished = [];
|
|
832
|
+
const view = picker.createMultiSelect({
|
|
833
|
+
title: 'Select rows (optional)',
|
|
834
|
+
items: rows,
|
|
835
|
+
filterFn,
|
|
836
|
+
pageSize: 5,
|
|
837
|
+
helpers: null,
|
|
838
|
+
hostTui: null,
|
|
839
|
+
theme: null,
|
|
840
|
+
done: (out) => { finished.push(out); },
|
|
841
|
+
...overrides,
|
|
842
|
+
});
|
|
843
|
+
return { view, finished };
|
|
844
|
+
}
|
|
845
|
+
test('initialQuery/initialPage/initialCursor restore the previous pass', () => {
|
|
846
|
+
const { view } = openRevisit({ initialQuery: 'row 1', initialPage: 0, initialCursor: 2 });
|
|
847
|
+
assert.equal(view.state.query, 'row 1', 'the kept filter is restored');
|
|
848
|
+
assert.equal(view.state.cursor, 2, 'the kept cursor is restored');
|
|
849
|
+
const text = view.render(120).join('\n');
|
|
850
|
+
assert.ok(text.includes('row 12'), 'the filter still narrows (row 1, 10, 11, 12)');
|
|
851
|
+
assert.ok(!text.includes('row 3'), 'unmatched rows stay filtered out');
|
|
852
|
+
assert.match(text, /page 1\/1/i, 'the restored page fits the filtered list');
|
|
853
|
+
});
|
|
854
|
+
test('Tab Back echoes selections and uiState with a raw Tab', () => {
|
|
855
|
+
const { view, finished } = openRevisit();
|
|
856
|
+
view.handleInput('down');
|
|
857
|
+
view.handleInput(' ');
|
|
858
|
+
assert.deepEqual(view.state.selected, [2], 'the second row is toggled');
|
|
859
|
+
view.handleInput('t');
|
|
860
|
+
view.handleInput('1');
|
|
861
|
+
view.handleInput('\t');
|
|
862
|
+
assert.deepEqual(finished, [{ back: true, selected: [2], uiState: { query: '1', page: 0, cursor: 0 } }], 'raw Tab carries selections plus filter/page/cursor');
|
|
863
|
+
});
|
|
864
|
+
test('Enter confirm echoes uiState alongside the selection', () => {
|
|
865
|
+
const { view, finished } = openRevisit();
|
|
866
|
+
view.handleInput(' ');
|
|
867
|
+
view.handleInput('\r');
|
|
868
|
+
assert.deepEqual(finished, [{ selected: [1], uiState: { query: '', page: 0, cursor: 0 } }], 'raw CR confirms with the list position echoed');
|
|
869
|
+
});
|
|
870
|
+
test('a revisit round-trips filter, page and cursor through the echo', () => {
|
|
871
|
+
const first = openRevisit();
|
|
872
|
+
first.view.handleInput('right');
|
|
873
|
+
first.view.handleInput('t');
|
|
874
|
+
for (const ch of 'row 1') first.view.handleInput(ch);
|
|
875
|
+
first.view.handleInput('\t');
|
|
876
|
+
assert.equal(first.finished.length, 1, 'one back resolution');
|
|
877
|
+
const echo = first.finished[0].uiState;
|
|
878
|
+
assert.equal(echo.query, 'row 1', 'the filter travels in the echo');
|
|
879
|
+
const second = openRevisit({
|
|
880
|
+
initialQuery: echo.query,
|
|
881
|
+
initialPage: echo.page,
|
|
882
|
+
initialCursor: echo.cursor,
|
|
883
|
+
initialSelected: first.finished[0].selected,
|
|
884
|
+
});
|
|
885
|
+
assert.equal(second.view.state.query, 'row 1', 'the revisit restores the filter');
|
|
886
|
+
const text = second.view.render(120).join('\n');
|
|
887
|
+
assert.ok(text.includes('row 1'), 'filtered rows render on revisit');
|
|
888
|
+
second.view.handleInput('\r');
|
|
889
|
+
assert.deepEqual(second.finished, [{ selected: [], uiState: { query: 'row 1', page: 0, cursor: 0 } }], 'confirm on revisit carries the restored filter');
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
describe('warp selector Ctrl+D delete (single-select extension)', { concurrency: false }, () => {
|
|
894
|
+
const ITEMS = ['principal — /tmp/principal', 'feat-one — /tmp/one', 'Create new worktree…'];
|
|
895
|
+
// Eligible: every checkout row except the principal (index 0) and the
|
|
896
|
+
// create row (last). Mirrors the warp selector rule: principal and
|
|
897
|
+
// create are never deletable.
|
|
898
|
+
const canDelete = (index) => index === 1;
|
|
899
|
+
function openWarp(overrides = {}) {
|
|
900
|
+
const finished = [];
|
|
901
|
+
const view = picker.createSingleSelect({
|
|
902
|
+
title: 'Warp session · current: principal /tmp/principal',
|
|
903
|
+
items: ITEMS,
|
|
904
|
+
helpers: null,
|
|
905
|
+
hostTui: null,
|
|
906
|
+
theme: null,
|
|
907
|
+
canDelete,
|
|
908
|
+
confirmLabel: 'warp',
|
|
909
|
+
showTab: false,
|
|
910
|
+
done: (out) => { finished.push(out); },
|
|
911
|
+
...overrides,
|
|
912
|
+
});
|
|
913
|
+
return { view, finished };
|
|
914
|
+
}
|
|
915
|
+
test('raw Ctrl+D (\\x04) via the raw fallback invokes delete on the eligible row', () => {
|
|
916
|
+
const { view, finished } = openWarp();
|
|
917
|
+
view.handleInput('down');
|
|
918
|
+
assert.equal(view.state.cursor, 1, 'focus on the eligible worktree');
|
|
919
|
+
view.handleInput('\x04');
|
|
920
|
+
assert.deepEqual(finished, [{ delete: true, index: 1, value: ITEMS[1] }], 'raw Ctrl+D deletes the focused eligible row');
|
|
921
|
+
});
|
|
922
|
+
test('raw Ctrl+D via the installed matcher invokes delete on the eligible row', { skip: !TUI_AVAILABLE }, async () => {
|
|
923
|
+
const tui = await import('@earendil-works/pi-tui');
|
|
924
|
+
assert.equal(tui.matchesKey('\x04', 'ctrl+d'), true, 'precondition: the installed matcher owns Ctrl+D');
|
|
925
|
+
const { view, finished } = openWarp({ helpers: { matchesKey: tui.matchesKey, isKeyRelease: tui.isKeyRelease } });
|
|
926
|
+
view.handleInput('down');
|
|
927
|
+
view.handleInput('\x04');
|
|
928
|
+
assert.deepEqual(finished, [{ delete: true, index: 1, value: ITEMS[1] }], 'installed matcher Ctrl+D deletes');
|
|
929
|
+
});
|
|
930
|
+
test('Ctrl+D on principal and create rows is a safe no-op', () => {
|
|
931
|
+
const { view, finished } = openWarp();
|
|
932
|
+
assert.equal(view.state.cursor, 0, 'starts on the principal');
|
|
933
|
+
view.handleInput('\x04');
|
|
934
|
+
assert.deepEqual(finished, [], 'principal never deletes');
|
|
935
|
+
view.handleInput('down');
|
|
936
|
+
view.handleInput('down');
|
|
937
|
+
assert.equal(view.state.cursor, 2, 'focus on the create row');
|
|
938
|
+
view.handleInput('\x04');
|
|
939
|
+
assert.deepEqual(finished, [], 'create never deletes');
|
|
940
|
+
view.handleInput('\r');
|
|
941
|
+
assert.deepEqual(finished, [ITEMS[2]], 'Enter still confirms the create row');
|
|
942
|
+
});
|
|
943
|
+
test('footer shows Ctrl+D only on the eligible row with the actual key', () => {
|
|
944
|
+
const { view } = openWarp();
|
|
945
|
+
const principalFooter = view.render(90).join('\n');
|
|
946
|
+
assert.match(principalFooter, /enter.*warp/i, 'footer names Enter warp');
|
|
947
|
+
assert.ok(!principalFooter.includes('Ctrl+D'), 'no delete hint on the principal row');
|
|
948
|
+
assert.ok(!principalFooter.includes('Tab'), 'no Tab hint when showTab is false');
|
|
949
|
+
view.handleInput('down');
|
|
950
|
+
const eligibleFooter = view.render(90).join('\n');
|
|
951
|
+
assert.ok(eligibleFooter.includes('Ctrl+D delete'), 'the actual Ctrl+D key shows only on the eligible row');
|
|
952
|
+
view.handleInput('down');
|
|
953
|
+
const createFooter = view.render(90).join('\n');
|
|
954
|
+
assert.ok(!createFooter.includes('Ctrl+D'), 'no delete hint on the create row');
|
|
955
|
+
});
|
|
956
|
+
test('Enter still warps and Esc still cancels with the warp footer', () => {
|
|
957
|
+
const first = openWarp();
|
|
958
|
+
first.view.handleInput('down');
|
|
959
|
+
first.view.handleInput('\r');
|
|
960
|
+
assert.deepEqual(first.finished, [ITEMS[1]], 'Enter confirms the focused checkout');
|
|
961
|
+
const second = openWarp();
|
|
962
|
+
second.view.handleInput('\x1b');
|
|
963
|
+
assert.deepEqual(second.finished, [null], 'Esc cancels');
|
|
964
|
+
});
|
|
965
|
+
test('without canDelete the footer and keys stay byte-identical to before', () => {
|
|
966
|
+
const finished = [];
|
|
967
|
+
const view = picker.createSingleSelect({
|
|
968
|
+
title: 'New worktree change type',
|
|
969
|
+
items: ['Feature (feat)', 'Bug fix (fix)'],
|
|
970
|
+
helpers: null,
|
|
971
|
+
hostTui: null,
|
|
972
|
+
theme: null,
|
|
973
|
+
done: (out) => { finished.push(out); },
|
|
974
|
+
});
|
|
975
|
+
const text = view.render(90).join('\n');
|
|
976
|
+
assert.match(text, /enter.*confirm/i, 'default confirm verb unchanged');
|
|
977
|
+
assert.match(text, /tab.*back/i, 'default Tab hint unchanged');
|
|
978
|
+
assert.ok(!text.includes('Ctrl+D'), 'no delete hint without canDelete');
|
|
979
|
+
view.handleInput('\x04');
|
|
980
|
+
assert.deepEqual(finished, [], 'Ctrl+D without canDelete does nothing');
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
describe('S64 footer totals (T50, test-first RED)', () => {
|
|
985
|
+
function openTotals(overrides = {}) {
|
|
986
|
+
const finished = [];
|
|
987
|
+
const view = picker.createMultiSelect({
|
|
988
|
+
title: 'Select issues (optional)',
|
|
989
|
+
items: Array.from({ length: 12 }, (_, i) => ({ id: i + 1, label: `row ${i + 1}` })),
|
|
990
|
+
filterFn: (rows, q) => {
|
|
991
|
+
const raw = String(q ?? '').trim().toLowerCase();
|
|
992
|
+
if (raw === '') return rows.slice();
|
|
993
|
+
return rows.filter((r) => String(r.label).toLowerCase().includes(raw));
|
|
994
|
+
},
|
|
995
|
+
pageSize: 5,
|
|
996
|
+
helpers: null,
|
|
997
|
+
hostTui: null,
|
|
998
|
+
theme: null,
|
|
999
|
+
done: (out) => { finished.push(out); },
|
|
1000
|
+
...overrides,
|
|
1001
|
+
});
|
|
1002
|
+
return { view, finished };
|
|
1003
|
+
}
|
|
1004
|
+
function textOf(view, width = 90) {
|
|
1005
|
+
return view.render(width).join('\n');
|
|
1006
|
+
}
|
|
1007
|
+
test('unfiltered footer names the TOTAL available count', () => {
|
|
1008
|
+
const { view } = openTotals();
|
|
1009
|
+
const text = textOf(view);
|
|
1010
|
+
assert.match(text, /12\s*total/i, 'the original loaded length travels as TOTAL');
|
|
1011
|
+
assert.match(text, /0\s*selected/i, 'selection count stays');
|
|
1012
|
+
assert.match(text, /page 1\/3/i, 'page stays');
|
|
1013
|
+
});
|
|
1014
|
+
test('filtered footer distinguishes filtered-available from TOTAL', () => {
|
|
1015
|
+
const { view } = openTotals({ pageSize: 12 });
|
|
1016
|
+
view.handleInput('t');
|
|
1017
|
+
for (const ch of 'row 1') view.handleInput(ch);
|
|
1018
|
+
view.handleInput('enter');
|
|
1019
|
+
const text = textOf(view);
|
|
1020
|
+
assert.match(text, /4\s*of\s*12/i, 'filtered 4 of TOTAL 12, never calling the filtered count the total');
|
|
1021
|
+
assert.match(text, /selected/i, 'selection count stays');
|
|
1022
|
+
assert.match(text, /page 1\/1/i, 'page stays');
|
|
1023
|
+
});
|
|
1024
|
+
test('empty list keeps an exact zero TOTAL and a single page', () => {
|
|
1025
|
+
const finished = [];
|
|
1026
|
+
const view = picker.createMultiSelect({
|
|
1027
|
+
title: 'Select issues (optional)',
|
|
1028
|
+
items: [],
|
|
1029
|
+
filterFn: (rows) => rows.slice(),
|
|
1030
|
+
pageSize: 5,
|
|
1031
|
+
helpers: null,
|
|
1032
|
+
hostTui: null,
|
|
1033
|
+
theme: null,
|
|
1034
|
+
done: (out) => { finished.push(out); },
|
|
1035
|
+
});
|
|
1036
|
+
const text = view.render(90).join('\n');
|
|
1037
|
+
assert.match(text, /0\s*total/i, 'empty TOTAL is exact');
|
|
1038
|
+
assert.match(text, /page 1\/1/i, 'empty list still yields one page');
|
|
1039
|
+
});
|
|
1040
|
+
test('paging keeps TOTAL stable across pages with a selection', () => {
|
|
1041
|
+
const { view } = openTotals();
|
|
1042
|
+
view.handleInput('down');
|
|
1043
|
+
view.handleInput('space');
|
|
1044
|
+
view.handleInput('pagedown');
|
|
1045
|
+
const text = textOf(view);
|
|
1046
|
+
assert.match(text, /12\s*total/i, 'TOTAL survives paging');
|
|
1047
|
+
assert.match(text, /1\s*selected/i, 'selection survives paging');
|
|
1048
|
+
assert.match(text, /page 2\/3/i, 'page advances');
|
|
1049
|
+
});
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
// S2: every picker footer is ONE terminal row; a footer wider than the panel
|
|
1053
|
+
// is compacted (short tokens, then the lowest-priority keys dropped), never
|
|
1054
|
+
// ellipsis-clipped and never wrapped.
|
|
1055
|
+
describe('S2: one-row key legends', { concurrency: false }, () => {
|
|
1056
|
+
const WIDTHS = [40, 60, 80, 120];
|
|
1057
|
+
const rows = Array.from({ length: 6 }, (_, i) => ({ id: i + 1, label: `row ${i + 1}` }));
|
|
1058
|
+
const base = { helpers: null, hostTui: null, theme: null, done: () => {} };
|
|
1059
|
+
|
|
1060
|
+
function legendRows(lines, marker) {
|
|
1061
|
+
return lines.filter((line) => marker.test(line));
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
test('the write prompt footer owns exactly one row at every width', () => {
|
|
1065
|
+
for (const width of WIDTHS) {
|
|
1066
|
+
const view = picker.createTextInput({ ...base, title: 'Name', placeholder: 'name-change', initial: '' });
|
|
1067
|
+
const lines = view.render(width);
|
|
1068
|
+
const legend = legendRows(lines, /esc cancel/i);
|
|
1069
|
+
assert.equal(legend.length, 1, `width ${width}: exactly one legend row, got ${legend.length}`);
|
|
1070
|
+
assert.ok(!legend[0].includes('…'), `width ${width}: compacted, never ellipsis-clipped: ${legend[0]}`);
|
|
1071
|
+
assert.match(legend[0], /esc cancel/i, `width ${width}: the cancel key survives`);
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
test('the single-select footer owns exactly one row at every width', () => {
|
|
1076
|
+
for (const width of WIDTHS) {
|
|
1077
|
+
const view = picker.createSingleSelect({ ...base, title: 'Type', items: ['feat', 'fix', 'docs'] });
|
|
1078
|
+
const lines = view.render(width);
|
|
1079
|
+
const legend = legendRows(lines, /esc cancel/i);
|
|
1080
|
+
assert.equal(legend.length, 1, `width ${width}: exactly one legend row, got ${legend.length}`);
|
|
1081
|
+
assert.ok(!legend[0].includes('…'), `width ${width}: compacted, never ellipsis-clipped: ${legend[0]}`);
|
|
1082
|
+
assert.match(legend[0], /esc cancel/i, `width ${width}: the cancel key survives`);
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
test('the multi-select list and search footers own exactly one row at every width', () => {
|
|
1087
|
+
for (const width of WIDTHS) {
|
|
1088
|
+
const view = picker.createMultiSelect({
|
|
1089
|
+
...base,
|
|
1090
|
+
title: 'Select rows (optional)',
|
|
1091
|
+
items: rows,
|
|
1092
|
+
pageSize: 5,
|
|
1093
|
+
filterFn: (list, q) => list.filter((item) => item.label.includes(String(q || ''))),
|
|
1094
|
+
});
|
|
1095
|
+
const listLines = view.render(width);
|
|
1096
|
+
const listLegend = legendRows(listLines, /esc cancel/i);
|
|
1097
|
+
assert.equal(listLegend.length, 1, `width ${width}: exactly one list legend row, got ${listLegend.length}`);
|
|
1098
|
+
assert.ok(!listLegend[0].includes('…'), `width ${width}: compacted, never ellipsis-clipped: ${listLegend[0]}`);
|
|
1099
|
+
assert.match(listLegend[0], /esc cancel/i, `width ${width}: the cancel key survives`);
|
|
1100
|
+
view.handleInput('t');
|
|
1101
|
+
const searchLines = view.render(width);
|
|
1102
|
+
const searchLegend = legendRows(searchLines, /filter kept|back to list|Enter\/Esc keep|Tab back|tab/);
|
|
1103
|
+
assert.equal(searchLegend.length, 1, `width ${width}: exactly one search legend row, got ${searchLegend.length}`);
|
|
1104
|
+
assert.ok(!searchLegend[0].includes('…'), `width ${width}: compacted, never ellipsis-clipped: ${searchLegend[0]}`);
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
// S11/T9: every picker footer and header paints through the ONE shared palette
|
|
1110
|
+
// (lib/tui-paint.js) with the injected theme — a shortcut KEY (a button) is
|
|
1111
|
+
// accent+bold and its description muted, a TITLE takes the reference UI's `dim`
|
|
1112
|
+
// title role and stays bold, a static label is dim, a live value (the typed
|
|
1113
|
+
// query, the typed name) is the normal text role, a count is muted, and a rule
|
|
1114
|
+
// takes the border role. A hostile or absent theme degrades to plain text, and
|
|
1115
|
+
// the one-row compaction keeps the key role at every width.
|
|
1116
|
+
// This module renders no error text: S1 removed the dataset fetches together
|
|
1117
|
+
// with their skip messages, so the palette's error role has no call site here.
|
|
1118
|
+
describe('S11: the shared palette', { concurrency: false }, () => {
|
|
1119
|
+
const ROLE_SGR = { accent: 96, customMessageLabel: 33, muted: 90, dim: 2, border: 34, error: 91, text: 39, mdHeading: 35 };
|
|
1120
|
+
// T9 (S13): the title role is the reference UI's `dim` (its Agents title:
|
|
1121
|
+
// ~/.config/herdr-app/mods/agents/main.js:413), asserted LITERALLY — the test
|
|
1122
|
+
// pins the decision instead of reading it back out of the table.
|
|
1123
|
+
const TITLE_PAINT = '\x1b[1m\x1b[33m';
|
|
1124
|
+
const VALUE_PAINT = '\x1b[39m';
|
|
1125
|
+
|
|
1126
|
+
// A theme that RECORDS which role painted each string and paints every role
|
|
1127
|
+
// with its own SGR code, so a rendered line can be read for the ROLE behind a
|
|
1128
|
+
// token instead of trusting an eyeballed colour.
|
|
1129
|
+
function recordingTheme() {
|
|
1130
|
+
const calls = [];
|
|
1131
|
+
const code = (role) => ROLE_SGR[role] ?? 95;
|
|
1132
|
+
return {
|
|
1133
|
+
calls,
|
|
1134
|
+
fg(role, text) {
|
|
1135
|
+
const value = String(text);
|
|
1136
|
+
calls.push({ role, text: value });
|
|
1137
|
+
return `\x1b[${code(role)}m${value}\x1b[0m`;
|
|
1138
|
+
},
|
|
1139
|
+
bold(text) {
|
|
1140
|
+
const value = String(text);
|
|
1141
|
+
calls.push({ role: 'bold', text: value });
|
|
1142
|
+
return `\x1b[1m${value}\x1b[22m`;
|
|
1143
|
+
},
|
|
1144
|
+
bg(role, text) {
|
|
1145
|
+
calls.push({ role: `bg:${role}`, text: String(text) });
|
|
1146
|
+
return String(text);
|
|
1147
|
+
},
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function stripAnsi(text) {
|
|
1152
|
+
return String(text)
|
|
1153
|
+
.replace(/\x1b\[[0-9;]*m/g, '')
|
|
1154
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
1155
|
+
.replace(/\x1b_[^\x07\x1b]*\x07/g, '');
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// The raw (still painted) line carrying `needle`.
|
|
1159
|
+
function findRaw(lines, needle) {
|
|
1160
|
+
const line = lines.find((entry) => stripAnsi(entry).includes(needle));
|
|
1161
|
+
assert.ok(line !== undefined, `no line contains ${JSON.stringify(needle)}\n${lines.map(stripAnsi).join('\n')}`);
|
|
1162
|
+
return line;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, label: `row ${i + 1}` }));
|
|
1166
|
+
const narrow = (list, q) => {
|
|
1167
|
+
const query = String(q ?? '').trim().toLowerCase();
|
|
1168
|
+
if (query === '') return list.slice();
|
|
1169
|
+
return list.filter((item) => String(item.label).toLowerCase().includes(query));
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
function openMulti(overrides = {}) {
|
|
1173
|
+
const finished = [];
|
|
1174
|
+
const view = picker.createMultiSelect({
|
|
1175
|
+
title: 'Select rows',
|
|
1176
|
+
items: rows,
|
|
1177
|
+
filterFn: narrow,
|
|
1178
|
+
pageSize: 5,
|
|
1179
|
+
searchPlaceholder: 'type to filter',
|
|
1180
|
+
helpers: null,
|
|
1181
|
+
hostTui: null,
|
|
1182
|
+
theme: null,
|
|
1183
|
+
done: (out) => { finished.push(out); },
|
|
1184
|
+
...overrides,
|
|
1185
|
+
});
|
|
1186
|
+
return { view, finished };
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function openSingle(overrides = {}) {
|
|
1190
|
+
const finished = [];
|
|
1191
|
+
const view = picker.createSingleSelect({
|
|
1192
|
+
title: 'Type',
|
|
1193
|
+
items: ['feat', 'fix'],
|
|
1194
|
+
helpers: null,
|
|
1195
|
+
hostTui: null,
|
|
1196
|
+
theme: null,
|
|
1197
|
+
done: (out) => { finished.push(out); },
|
|
1198
|
+
...overrides,
|
|
1199
|
+
});
|
|
1200
|
+
return { view, finished };
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function openWrite(overrides = {}) {
|
|
1204
|
+
const finished = [];
|
|
1205
|
+
const view = picker.createTextInput({
|
|
1206
|
+
title: 'Name',
|
|
1207
|
+
placeholder: 'name-change',
|
|
1208
|
+
initial: '',
|
|
1209
|
+
helpers: null,
|
|
1210
|
+
hostTui: null,
|
|
1211
|
+
theme: null,
|
|
1212
|
+
done: (out) => { finished.push(out); },
|
|
1213
|
+
...overrides,
|
|
1214
|
+
});
|
|
1215
|
+
return { view, finished };
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
test('the multi-select footer paints every key accent+bold and its description muted', () => {
|
|
1219
|
+
const theme = recordingTheme();
|
|
1220
|
+
const { view } = openMulti({ theme });
|
|
1221
|
+
const legend = findRaw(view.render(100), 'Esc cancel');
|
|
1222
|
+
assert.match(legend, /\x1b\[1m\x1b\[96m↑↓\x1b\[0m\x1b\[22m\x1b\[90m move/,
|
|
1223
|
+
'the move key is accent+bold and its words muted');
|
|
1224
|
+
assert.match(legend, /\x1b\[1m\x1b\[96m←\/→\x1b\[0m\x1b\[22m\x1b\[90m pages/, 'so is the page key');
|
|
1225
|
+
assert.match(legend, /\x1b\[1m\x1b\[96mT\x1b\[0m\x1b\[22m\x1b\[90m search/, 'and the search key');
|
|
1226
|
+
assert.match(legend, /\x1b\[1m\x1b\[96mSpace\x1b\[0m\x1b\[22m\x1b\[90m toggle/, 'and the toggle key');
|
|
1227
|
+
assert.match(legend, /\x1b\[1m\x1b\[96mEnter\x1b\[0m\x1b\[22m\x1b\[90m confirm/, 'and the confirm key');
|
|
1228
|
+
assert.match(legend, /\x1b\[1m\x1b\[96mEsc\x1b\[0m\x1b\[22m\x1b\[90m cancel/, 'and the cancel key');
|
|
1229
|
+
assert.match(legend, /\x1b\[34m · \x1b\[0m/, 'the entries are joined by the border role');
|
|
1230
|
+
assert.ok(theme.calls.some((call) => call.role === 'bold' && call.text.includes('Enter')),
|
|
1231
|
+
'the key is bolded through the theme, never by a literal escape');
|
|
1232
|
+
assert.ok(theme.calls.some((call) => call.role === 'muted' && call.text === ' confirm'),
|
|
1233
|
+
'the description asks the injected theme for the muted role');
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
test('the search footer paints its keys and its descriptions', () => {
|
|
1237
|
+
const theme = recordingTheme();
|
|
1238
|
+
const { view } = openMulti({ theme });
|
|
1239
|
+
view.handleInput('t');
|
|
1240
|
+
const legend = findRaw(view.render(100), 'back to list');
|
|
1241
|
+
assert.match(legend, /\x1b\[1m\x1b\[96mEnter\/Esc\x1b\[0m\x1b\[22m\x1b\[90m back to list \(filter kept\)/,
|
|
1242
|
+
'the leave-search key is accent+bold with a muted description');
|
|
1243
|
+
assert.match(legend, /\x1b\[1m\x1b\[96mdelete\x1b\[0m\x1b\[22m\x1b\[90m clear/, 'so is the clear key');
|
|
1244
|
+
assert.match(legend, /\x1b\[1m\x1b\[96mTab\x1b\[0m\x1b\[22m\x1b\[90m back/, 'and the back key');
|
|
1245
|
+
assert.match(legend, /\x1b\[90mSearch\x1b\[0m/, 'the field name is words, never a key');
|
|
1246
|
+
assert.match(legend, /\x1b\[90mtype to filter\x1b\[0m/, 'so is the typing hint');
|
|
1247
|
+
assert.ok(!/\x1b\[1m\x1b\[96mSearch/.test(legend), 'no prose token borrows the key role');
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
test('the single-select and write-prompt footers paint keys and descriptions', () => {
|
|
1251
|
+
const single = recordingTheme();
|
|
1252
|
+
const singleLegend = findRaw(openSingle({ theme: single }).view.render(100), 'Esc cancel');
|
|
1253
|
+
assert.match(singleLegend, /\x1b\[1m\x1b\[96mEnter\x1b\[0m\x1b\[22m\x1b\[90m confirm/, 'the confirm key is accent+bold');
|
|
1254
|
+
assert.match(singleLegend, /\x1b\[1m\x1b\[96mEsc\x1b\[0m\x1b\[22m\x1b\[90m cancel/, 'so is the cancel key');
|
|
1255
|
+
assert.match(singleLegend, /\x1b\[1m\x1b\[96mTab\x1b\[0m\x1b\[22m\x1b\[90m back/, 'and the back key');
|
|
1256
|
+
|
|
1257
|
+
const write = recordingTheme();
|
|
1258
|
+
const writeLegend = findRaw(openWrite({ theme: write }).view.render(100), 'Esc cancel');
|
|
1259
|
+
assert.match(writeLegend, /\x1b\[1m\x1b\[96mEnter\x1b\[0m\x1b\[22m\x1b\[90m confirm/, 'the write prompt names its confirm key');
|
|
1260
|
+
assert.match(writeLegend, /\x1b\[1m\x1b\[96mEsc\x1b\[0m\x1b\[22m\x1b\[90m cancel/, 'and its cancel key');
|
|
1261
|
+
assert.match(writeLegend, /\x1b\[90mtype to write\x1b\[0m/, 'typing is prose, never a key');
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
// T9 (S13): the picker's title is a TITLE — the theme's secondary heading
|
|
1265
|
+
// role, bold — while the rules keep the border role and the keys stay the
|
|
1266
|
+
// primary accent a button owns.
|
|
1267
|
+
test('titles take the shared title role+bold and rules take the border role', () => {
|
|
1268
|
+
const theme = recordingTheme();
|
|
1269
|
+
const opened = [openMulti({ theme }), openSingle({ theme }), openWrite({ theme })];
|
|
1270
|
+
for (const { view } of opened) {
|
|
1271
|
+
const lines = view.render(100);
|
|
1272
|
+
assert.match(lines[0], /^\x1b\[34m─+\x1b\[0m$/, 'the opening rule is the border role');
|
|
1273
|
+
assert.match(lines[lines.length - 1], /^\x1b\[34m─+\x1b\[0m$/, 'the closing rule is the border role');
|
|
1274
|
+
assert.ok(lines[1].startsWith(TITLE_PAINT), 'the title is the shared title role, bold');
|
|
1275
|
+
assert.ok(lines[1].endsWith('\x1b[0m\x1b[22m'), 'the emphasis closes through the theme');
|
|
1276
|
+
}
|
|
1277
|
+
for (const title of ['Select rows', 'Type', 'Name']) {
|
|
1278
|
+
assert.ok(theme.calls.some((call) => call.role === 'customMessageLabel' && call.text === title),
|
|
1279
|
+
`the ${title} title asks the injected theme for the title role`);
|
|
1280
|
+
assert.ok(!theme.calls.some((call) => call.role === 'accent' && call.text === title),
|
|
1281
|
+
`the ${title} title never borrows the button accent`);
|
|
1282
|
+
}
|
|
1283
|
+
assert.ok(!theme.calls.some((call) => call.role === 'error'), 'no error role is painted on a healthy render');
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
// T9 (S13): a typed query and a typed name are ordinary text, so they stay
|
|
1287
|
+
// out of the accent a button owns; counts and the empty-list note stay muted.
|
|
1288
|
+
test('counts are muted, the search label dim and the live query ordinary text', () => {
|
|
1289
|
+
const theme = recordingTheme();
|
|
1290
|
+
const { view } = openMulti({ theme });
|
|
1291
|
+
assert.match(findRaw(view.render(100), 'total'),
|
|
1292
|
+
/^\x1b\[90m12 total · 0 selected · page 1\/3\x1b\[0m$/, 'the counts row is the muted role');
|
|
1293
|
+
const idle = findRaw(view.render(100), 'Search:');
|
|
1294
|
+
assert.match(idle, /\x1b\[2mSearch: \x1b\[0m/, 'the box label is the dim role');
|
|
1295
|
+
assert.match(idle, /\x1b\[90mtype to filter\x1b\[0m/, 'the idle placeholder is muted, never a value');
|
|
1296
|
+
view.handleInput('t');
|
|
1297
|
+
for (const ch of 'ro') view.handleInput(ch);
|
|
1298
|
+
const live = findRaw(view.render(100), 'Search:');
|
|
1299
|
+
assert.ok(live.includes(`${VALUE_PAINT}ro\x1b[0m`), 'the typed query is the normal text role');
|
|
1300
|
+
assert.ok(!/\x1b\[96mro\x1b\[0m/.test(live), 'the typed query is never the button accent');
|
|
1301
|
+
for (const ch of 'w zzz') view.handleInput(ch);
|
|
1302
|
+
assert.match(findRaw(view.render(100), '(no matches)'),
|
|
1303
|
+
/\x1b\[90m\(no matches\)\x1b\[0m/, 'the empty-list note is muted, never an error');
|
|
1304
|
+
|
|
1305
|
+
const typed = recordingTheme();
|
|
1306
|
+
const write = openWrite({ theme: typed, initial: 'typed' });
|
|
1307
|
+
assert.ok(findRaw(write.view.render(100), 'typed').includes(`${VALUE_PAINT}typed\x1b[0m`),
|
|
1308
|
+
'the typed name is ordinary text');
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
test('a hostile theme never breaks a render and keeps every key readable', () => {
|
|
1312
|
+
const hostile = {
|
|
1313
|
+
fg() { throw new Error('no colour support'); },
|
|
1314
|
+
bold() { throw new Error('no bold support'); },
|
|
1315
|
+
bg() { throw new Error('no background support'); },
|
|
1316
|
+
};
|
|
1317
|
+
const { view } = openMulti({ theme: hostile });
|
|
1318
|
+
const lines = view.render(90);
|
|
1319
|
+
const text = lines.map(stripAnsi).join('\n');
|
|
1320
|
+
assert.ok(lines.every((line) => !line.includes('\x1b')), 'a throwing theme paints nothing');
|
|
1321
|
+
assert.ok(text.includes('Select rows'), 'the title still renders');
|
|
1322
|
+
assert.ok(text.includes('12 total'), 'the counts still render');
|
|
1323
|
+
assert.ok(text.includes('type to filter'), 'the placeholder still renders');
|
|
1324
|
+
assert.ok(text.includes('↑↓ move · ←/→ pages · T search · Space toggle · Enter confirm · Tab back · Esc cancel'),
|
|
1325
|
+
'the legend still spells every key');
|
|
1326
|
+
assert.equal(lines.filter((line) => stripAnsi(line).includes('Esc cancel')).length, 1, 'and stays on ONE row');
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
test('one-row compaction keeps the key role at every width', () => {
|
|
1330
|
+
for (const width of [40, 60, 80, 120]) {
|
|
1331
|
+
const theme = recordingTheme();
|
|
1332
|
+
const { view } = openMulti({ theme });
|
|
1333
|
+
const listLegend = findRaw(view.render(width), 'Esc cancel');
|
|
1334
|
+
assert.match(listLegend, /\x1b\[1m\x1b\[96mEsc\x1b\[0m\x1b\[22m\x1b\[90m cancel/,
|
|
1335
|
+
`width ${width}: the compacted cancel key keeps accent+bold`);
|
|
1336
|
+
assert.ok(!listLegend.includes('…'), `width ${width}: compacted, never ellipsis-clipped`);
|
|
1337
|
+
assert.ok([...stripAnsi(listLegend)].length <= width,
|
|
1338
|
+
`width ${width}: the painted legend still fits the panel`);
|
|
1339
|
+
view.handleInput('t');
|
|
1340
|
+
const searchLegend = findRaw(view.render(width), 'Enter/Esc');
|
|
1341
|
+
assert.match(searchLegend, /\x1b\[1m\x1b\[96mEnter\/Esc\x1b\[0m\x1b\[22m\x1b\[90m (keep|back to list)/,
|
|
1342
|
+
`width ${width}: the compact spelling keeps its key role`);
|
|
1343
|
+
assert.ok([...stripAnsi(searchLegend)].length <= width,
|
|
1344
|
+
`width ${width}: the painted search legend still fits the panel`);
|
|
1345
|
+
}
|
|
1346
|
+
for (const width of [40, 80]) {
|
|
1347
|
+
const single = recordingTheme();
|
|
1348
|
+
const singleLegend = findRaw(openSingle({ theme: single }).view.render(width), 'Esc cancel');
|
|
1349
|
+
assert.match(singleLegend, /\x1b\[1m\x1b\[96mEsc\x1b\[0m\x1b\[22m\x1b\[90m cancel/, `width ${width}: the single-select cancel key keeps its role`);
|
|
1350
|
+
assert.ok([...stripAnsi(singleLegend)].length <= width, `width ${width}: the painted single-select legend fits`);
|
|
1351
|
+
const write = recordingTheme();
|
|
1352
|
+
const writeLegend = findRaw(openWrite({ theme: write }).view.render(width), 'Esc cancel');
|
|
1353
|
+
assert.match(writeLegend, /\x1b\[1m\x1b\[96mEsc\x1b\[0m\x1b\[22m\x1b\[90m cancel/, `width ${width}: the write-prompt cancel key keeps its role`);
|
|
1354
|
+
assert.ok([...stripAnsi(writeLegend)].length <= width, `width ${width}: the painted write legend fits`);
|
|
1355
|
+
}
|
|
1356
|
+
});
|
|
1357
|
+
});
|