mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Three small pure modules the phase-2 verbs are built on: - tui-composer: the single-line editor behind `p` and `/`, holding text as code points so a cursor can never split a surrogate pair, with the scroll window derived from the width rather than remembered. - tui-approvals: what an approvals-inbox item's card says, which keys are live for it (a digit answers only when the server parsed that option, and an idle prompt answers to none of them), and which ids the bell has not rung for yet. - tui-digest: the away digest as compact lines, counts first and one line per entry, with a capped tail per section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
144 lines
5.0 KiB
TypeScript
144 lines
5.0 KiB
TypeScript
/**
|
|
* @fileoverview Unit tests for reading an approvals-inbox item.
|
|
*
|
|
* The key matrix is the part worth pinning: a digit the server did not parse
|
|
* off the pane must NOT produce an answer (it would be typed at a dialog that
|
|
* has no such option), and an idle prompt must produce none at all, since there
|
|
* is no dialog on screen and every keystroke would land in the composer.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
approvalAnswerForKey,
|
|
approvalCard,
|
|
approvalDenyOption,
|
|
approvalTone,
|
|
newApprovalIds,
|
|
} from '../../src/tui/tui-approvals.js';
|
|
import type { ApprovalItem } from '../../src/web/approval-inbox.js';
|
|
|
|
const NOW = 1_700_000_000_000;
|
|
|
|
function item(overrides: Partial<ApprovalItem> = {}): ApprovalItem {
|
|
return {
|
|
id: 'sess:1',
|
|
sessionId: 'sess',
|
|
sessionName: 'w4-api',
|
|
kind: 'permission',
|
|
createdAt: NOW,
|
|
toolName: 'Bash',
|
|
toolSummary: 'Bash(git push origin main)',
|
|
options: [
|
|
{ n: 1, label: 'Yes' },
|
|
{ n: 2, label: "Yes, don't ask again" },
|
|
{ n: 3, label: 'No, tell Claude what to do (esc)' },
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('approvalCard', () => {
|
|
it('leads a permission prompt with the tool it wants to run', () => {
|
|
const card = approvalCard(item());
|
|
expect(card.tone).toBe('err');
|
|
expect(card.title).toContain('Bash(git push origin main)');
|
|
expect(card.options).toHaveLength(3);
|
|
expect(card.hint).toContain('y approve');
|
|
expect(card.hint).toContain('digit');
|
|
});
|
|
|
|
it('leads a question with its message', () => {
|
|
const card = approvalCard(item({ kind: 'question', message: 'Which color?', toolSummary: undefined }));
|
|
expect(card.title).toBe('Which color?');
|
|
expect(card.tone).toBe('err');
|
|
});
|
|
|
|
it('says an idle prompt is answered by typing, not by approving', () => {
|
|
const card = approvalCard(item({ kind: 'idle', message: 'waiting for input', options: undefined }));
|
|
expect(card.tone).toBe('warn');
|
|
expect(card.options).toEqual([]);
|
|
expect(card.hint).toBe('p to reply');
|
|
});
|
|
|
|
it('drops the approve/deny-only hint when the frame did not parse', () => {
|
|
const card = approvalCard(item({ options: undefined }));
|
|
expect(card.options).toEqual([]);
|
|
expect(card.hint).toBe('y approve · n deny');
|
|
});
|
|
|
|
it('keeps the message as detail when it says more than the tool line', () => {
|
|
expect(approvalCard(item({ message: 'about to force-push' })).detail).toEqual(['about to force-push']);
|
|
expect(approvalCard(item({ message: 'Bash(git push origin main)' })).detail).toEqual([]);
|
|
});
|
|
|
|
it('collapses whitespace so a wrapped hook field cannot break the card', () => {
|
|
expect(approvalCard(item({ toolSummary: 'Bash(git\n push)' })).title).toBe('requests: Bash(git push)');
|
|
});
|
|
});
|
|
|
|
describe('approvalTone', () => {
|
|
it('is red for a dialog and yellow for a waiting prompt', () => {
|
|
expect(approvalTone(item())).toBe('err');
|
|
expect(approvalTone(item({ kind: 'question' }))).toBe('err');
|
|
expect(approvalTone(item({ kind: 'idle' }))).toBe('warn');
|
|
});
|
|
});
|
|
|
|
describe('approvalAnswerForKey', () => {
|
|
it('approves with y', () => {
|
|
expect(approvalAnswerForKey(item(), 'y')).toEqual({ action: 'approve' });
|
|
});
|
|
|
|
it('denies with the parsed No option when there is one', () => {
|
|
expect(approvalDenyOption(item())).toBe(3);
|
|
expect(approvalAnswerForKey(item(), 'n')).toEqual({ action: 'option', option: 3 });
|
|
});
|
|
|
|
it('falls back to Esc semantics when no No option parsed', () => {
|
|
expect(approvalDenyOption(item({ options: undefined }))).toBeNull();
|
|
expect(approvalAnswerForKey(item({ options: undefined }), 'n')).toEqual({ action: 'deny' });
|
|
expect(
|
|
approvalAnswerForKey(
|
|
item({
|
|
options: [
|
|
{ n: 1, label: 'Red' },
|
|
{ n: 2, label: 'Blue' },
|
|
],
|
|
}),
|
|
'n'
|
|
)
|
|
).toEqual({
|
|
action: 'deny',
|
|
});
|
|
});
|
|
|
|
it('answers with a digit only when the server parsed that option', () => {
|
|
expect(approvalAnswerForKey(item(), '2')).toEqual({ action: 'option', option: 2 });
|
|
expect(approvalAnswerForKey(item(), '4')).toBeNull();
|
|
expect(approvalAnswerForKey(item({ options: undefined }), '1')).toBeNull();
|
|
});
|
|
|
|
it('makes no key an answer for an idle prompt', () => {
|
|
const idle = item({ kind: 'idle', options: undefined });
|
|
for (const key of ['y', 'n', '1', '2', '9']) expect(approvalAnswerForKey(idle, key)).toBeNull();
|
|
});
|
|
|
|
it('leaves every other key to the list', () => {
|
|
for (const key of ['j', 'k', 'q', 'x', 'p', '/', 'g', '0']) {
|
|
expect(approvalAnswerForKey(item(), key)).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('newApprovalIds', () => {
|
|
it('reports only ids the set has not seen', () => {
|
|
const seen = new Set(['sess:1']);
|
|
expect(newApprovalIds(seen, [item(), item({ id: 'other:7', sessionId: 'other' })])).toEqual(['other:7']);
|
|
expect(newApprovalIds(seen, [item()])).toEqual([]);
|
|
expect(newApprovalIds(new Set(), [])).toEqual([]);
|
|
});
|
|
|
|
it('reports one id once even when it arrives twice', () => {
|
|
expect(newApprovalIds(new Set(), [item(), item()])).toEqual(['sess:1']);
|
|
});
|
|
});
|