takt/src/__tests__/prompt.test.ts

345 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tests for prompt module (cursor-based interactive menu)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import chalk from 'chalk';
import type { SelectOptionItem, KeyInputResult } from '../prompt/index.js';
import { renderMenu, countRenderedLines, handleKeyInput } from '../prompt/index.js';
// Disable chalk colors for predictable test output
chalk.level = 0;
describe('prompt', () => {
describe('renderMenu', () => {
const basicOptions: SelectOptionItem<string>[] = [
{ label: 'Option A', value: 'a' },
{ label: 'Option B', value: 'b' },
{ label: 'Option C', value: 'c' },
];
it('should render all options with cursor on selected item', () => {
const lines = renderMenu(basicOptions, 0, false);
// 3 options = 3 lines
expect(lines).toHaveLength(3);
// First item selected - contains cursor marker
expect(lines[0]).toContain('');
expect(lines[0]).toContain('Option A');
// Other items should not have cursor
expect(lines[1]).not.toContain('');
expect(lines[2]).not.toContain('');
});
it('should move cursor to second item when selectedIndex is 1', () => {
const lines = renderMenu(basicOptions, 1, false);
expect(lines[0]).not.toContain('');
expect(lines[1]).toContain('');
expect(lines[1]).toContain('Option B');
expect(lines[2]).not.toContain('');
});
it('should move cursor to last item', () => {
const lines = renderMenu(basicOptions, 2, false);
expect(lines[0]).not.toContain('');
expect(lines[1]).not.toContain('');
expect(lines[2]).toContain('');
expect(lines[2]).toContain('Option C');
});
it('should include Cancel option when hasCancelOption is true', () => {
const lines = renderMenu(basicOptions, 0, true);
// 3 options + 1 cancel = 4 lines
expect(lines).toHaveLength(4);
expect(lines[3]).toContain('Cancel');
});
it('should highlight Cancel when it is selected', () => {
const lines = renderMenu(basicOptions, 3, true);
// Cancel is at index 3 (options.length)
expect(lines[3]).toContain('');
expect(lines[3]).toContain('Cancel');
// Other items should not have cursor
expect(lines[0]).not.toContain('');
expect(lines[1]).not.toContain('');
expect(lines[2]).not.toContain('');
});
it('should render description lines', () => {
const optionsWithDesc: SelectOptionItem<string>[] = [
{ label: 'Option A', value: 'a', description: 'Description for A' },
{ label: 'Option B', value: 'b' },
];
const lines = renderMenu(optionsWithDesc, 0, false);
// Option A has label + description = 2 lines, Option B = 1 line
expect(lines).toHaveLength(3);
expect(lines[1]).toContain('Description for A');
});
it('should render detail lines', () => {
const optionsWithDetails: SelectOptionItem<string>[] = [
{
label: 'Option A',
value: 'a',
description: 'Desc A',
details: ['Detail 1', 'Detail 2'],
},
{ label: 'Option B', value: 'b' },
];
const lines = renderMenu(optionsWithDetails, 0, false);
// Option A: label + description + 2 details = 4 lines, Option B = 1 line
expect(lines).toHaveLength(5);
expect(lines[2]).toContain('Detail 1');
expect(lines[3]).toContain('Detail 2');
});
it('should handle empty options array', () => {
const lines = renderMenu([], 0, false);
expect(lines).toHaveLength(0);
});
it('should handle empty options with cancel', () => {
const lines = renderMenu([], 0, true);
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('Cancel');
});
});
describe('countRenderedLines', () => {
it('should count basic options (1 line each)', () => {
const options: SelectOptionItem<string>[] = [
{ label: 'A', value: 'a' },
{ label: 'B', value: 'b' },
{ label: 'C', value: 'c' },
];
expect(countRenderedLines(options, false)).toBe(3);
});
it('should add 1 for cancel option', () => {
const options: SelectOptionItem<string>[] = [
{ label: 'A', value: 'a' },
{ label: 'B', value: 'b' },
];
expect(countRenderedLines(options, true)).toBe(3);
});
it('should count description lines', () => {
const options: SelectOptionItem<string>[] = [
{ label: 'A', value: 'a', description: 'Desc A' },
{ label: 'B', value: 'b' },
];
// A: label + desc = 2, B: label = 1, total = 3
expect(countRenderedLines(options, false)).toBe(3);
});
it('should count detail lines', () => {
const options: SelectOptionItem<string>[] = [
{
label: 'A',
value: 'a',
description: 'Desc',
details: ['D1', 'D2', 'D3'],
},
];
// label + desc + 3 details = 5
expect(countRenderedLines(options, false)).toBe(5);
});
it('should count combined description and details with cancel', () => {
const options: SelectOptionItem<string>[] = [
{
label: 'A',
value: 'a',
description: 'Desc A',
details: ['D1'],
},
{ label: 'B', value: 'b', description: 'Desc B' },
];
// A: 1 + 1 + 1 = 3, B: 1 + 1 = 2, cancel: 1, total = 6
expect(countRenderedLines(options, true)).toBe(6);
});
it('should return 0 for empty options without cancel', () => {
expect(countRenderedLines([], false)).toBe(0);
});
it('should return 1 for empty options with cancel', () => {
expect(countRenderedLines([], true)).toBe(1);
});
});
describe('handleKeyInput', () => {
// 3 options + cancel = 4 total items
const totalItems = 4;
const optionCount = 3;
const hasCancelOption = true;
describe('move up (arrow up / k)', () => {
it('should move up with arrow key', () => {
const result = handleKeyInput('\x1B[A', 1, totalItems, hasCancelOption, optionCount);
expect(result).toEqual({ action: 'move', newIndex: 0 });
});
it('should move up with vim k key', () => {
const result = handleKeyInput('k', 2, totalItems, hasCancelOption, optionCount);
expect(result).toEqual({ action: 'move', newIndex: 1 });
});
it('should wrap around from first item to last', () => {
const result = handleKeyInput('\x1B[A', 0, totalItems, hasCancelOption, optionCount);
expect(result).toEqual({ action: 'move', newIndex: 3 });
});
});
describe('move down (arrow down / j)', () => {
it('should move down with arrow key', () => {
const result = handleKeyInput('\x1B[B', 0, totalItems, hasCancelOption, optionCount);
expect(result).toEqual({ action: 'move', newIndex: 1 });
});
it('should move down with vim j key', () => {
const result = handleKeyInput('j', 1, totalItems, hasCancelOption, optionCount);
expect(result).toEqual({ action: 'move', newIndex: 2 });
});
it('should wrap around from last item to first', () => {
const result = handleKeyInput('\x1B[B', 3, totalItems, hasCancelOption, optionCount);
expect(result).toEqual({ action: 'move', newIndex: 0 });
});
});
describe('confirm (Enter)', () => {
it('should confirm with carriage return', () => {
const result = handleKeyInput('\r', 2, totalItems, hasCancelOption, optionCount);
expect(result).toEqual({ action: 'confirm', selectedIndex: 2 });
});
it('should confirm with newline', () => {
const result = handleKeyInput('\n', 0, totalItems, hasCancelOption, optionCount);
expect(result).toEqual({ action: 'confirm', selectedIndex: 0 });
});
it('should confirm cancel position when Enter on cancel item', () => {
const result = handleKeyInput('\r', 3, totalItems, hasCancelOption, optionCount);
expect(result).toEqual({ action: 'confirm', selectedIndex: 3 });
});
});
describe('cancel (Escape)', () => {
it('should return optionCount as cancelIndex when hasCancelOption', () => {
const result = handleKeyInput('\x1B', 1, totalItems, true, optionCount);
expect(result).toEqual({ action: 'cancel', cancelIndex: 3 });
});
it('should return -1 as cancelIndex when no cancel option', () => {
const result = handleKeyInput('\x1B', 1, 3, false, optionCount);
expect(result).toEqual({ action: 'cancel', cancelIndex: -1 });
});
});
describe('exit (Ctrl+C)', () => {
it('should return exit action', () => {
const result = handleKeyInput('\x03', 0, totalItems, hasCancelOption, optionCount);
expect(result).toEqual({ action: 'exit' });
});
});
describe('unrecognized keys', () => {
it('should return none for regular characters', () => {
const result = handleKeyInput('a', 0, totalItems, hasCancelOption, optionCount);
expect(result).toEqual({ action: 'none' });
});
it('should return none for space', () => {
const result = handleKeyInput(' ', 0, totalItems, hasCancelOption, optionCount);
expect(result).toEqual({ action: 'none' });
});
it('should return none for numbers', () => {
const result = handleKeyInput('1', 0, totalItems, hasCancelOption, optionCount);
expect(result).toEqual({ action: 'none' });
});
});
describe('without cancel option', () => {
const noCancelTotal = 3;
it('should wrap up correctly without cancel', () => {
const result = handleKeyInput('\x1B[A', 0, noCancelTotal, false, optionCount);
expect(result).toEqual({ action: 'move', newIndex: 2 });
});
it('should wrap down correctly without cancel', () => {
const result = handleKeyInput('\x1B[B', 2, noCancelTotal, false, optionCount);
expect(result).toEqual({ action: 'move', newIndex: 0 });
});
});
describe('single option', () => {
it('should wrap around with 1 item + cancel (totalItems=2)', () => {
const result = handleKeyInput('\x1B[B', 1, 2, true, 1);
expect(result).toEqual({ action: 'move', newIndex: 0 });
});
it('should confirm single option', () => {
const result = handleKeyInput('\r', 0, 1, false, 1);
expect(result).toEqual({ action: 'confirm', selectedIndex: 0 });
});
});
});
describe('selectOption', () => {
it('should return null for empty options', async () => {
const { selectOption } = await import('../prompt/index.js');
const result = await selectOption('Test:', []);
expect(result).toBeNull();
});
});
describe('selectOptionWithDefault', () => {
it('should return default for empty options', async () => {
const { selectOptionWithDefault } = await import('../prompt/index.js');
const result = await selectOptionWithDefault('Test:', [], 'fallback');
expect(result).toBe('fallback');
});
it('should have return type that allows null (cancel)', async () => {
const { selectOptionWithDefault } = await import('../prompt/index.js');
// When options are empty, default is returned (not null)
const result: string | null = await selectOptionWithDefault('Test:', [], 'fallback');
expect(result).toBe('fallback');
});
});
describe('selectOptionWithDefault cancel behavior', () => {
it('handleKeyInput should return cancel with optionCount when hasCancelOption is true', () => {
// Simulates ESC key press with cancel option enabled (as selectOptionWithDefault now does)
const result = handleKeyInput('\x1B', 0, 4, true, 3);
expect(result).toEqual({ action: 'cancel', cancelIndex: 3 });
});
it('handleKeyInput should support navigating to Cancel item', () => {
// With 3 options + cancel, totalItems = 4, cancel is at index 3
const downResult = handleKeyInput('\x1B[B', 2, 4, true, 3);
expect(downResult).toEqual({ action: 'move', newIndex: 3 });
// Confirming on cancel index (3) should return confirm with selectedIndex 3
const confirmResult = handleKeyInput('\r', 3, 4, true, 3);
expect(confirmResult).toEqual({ action: 'confirm', selectedIndex: 3 });
});
});
});