リトライ時のムーブメント選択で失敗箇所にカーソルを初期配置する

selectOption → selectOptionWithDefault に変更し、前回失敗したムーブメントが
デフォルト選択されるようにした。Enter一発で失敗箇所から再開できる。
This commit is contained in:
nrslib 2026-02-19 20:08:14 +09:00
parent 391e56b51a
commit a8adfdd02a
3 changed files with 94 additions and 9 deletions

View File

@ -2,9 +2,10 @@
* Tests for prompt module (cursor-based interactive menu)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Readable } from 'node:stream';
import chalk from 'chalk';
import { setupRawStdin, restoreStdin } from './helpers/stdinSimulator.js';
import type { SelectOptionItem, KeyInputResult } from '../shared/prompt/index.js';
import {
renderMenu,
@ -331,6 +332,74 @@ describe('prompt', () => {
});
});
describe('selectOptionWithDefault (stdin E2E)', () => {
afterEach(() => {
restoreStdin();
});
it('should place cursor on default value and confirm it with Enter', async () => {
// Enter key only — confirms whatever the cursor is on
setupRawStdin(['\r']);
const { selectOptionWithDefault } = await import('../shared/prompt/index.js');
const options = [
{ label: 'plan', value: 'plan' },
{ label: 'implement', value: 'implement' },
{ label: 'review', value: 'review' },
];
const result = await selectOptionWithDefault('Start from:', options, 'review');
// If cursor starts at 'review' (index 2), Enter should select it
expect(result).toBe('review');
});
it('should place cursor on first item when default is the first option', async () => {
setupRawStdin(['\r']);
const { selectOptionWithDefault } = await import('../shared/prompt/index.js');
const options = [
{ label: 'plan', value: 'plan' },
{ label: 'implement', value: 'implement' },
{ label: 'review', value: 'review' },
];
const result = await selectOptionWithDefault('Start from:', options, 'plan');
expect(result).toBe('plan');
});
it('should place cursor on middle item when default is the middle option', async () => {
setupRawStdin(['\r']);
const { selectOptionWithDefault } = await import('../shared/prompt/index.js');
const options = [
{ label: 'plan', value: 'plan' },
{ label: 'implement', value: 'implement' },
{ label: 'review', value: 'review' },
];
const result = await selectOptionWithDefault('Start from:', options, 'implement');
expect(result).toBe('implement');
});
it('should fall back to first item when default value does not exist', async () => {
setupRawStdin(['\r']);
const { selectOptionWithDefault } = await import('../shared/prompt/index.js');
const options = [
{ label: 'plan', value: 'plan' },
{ label: 'implement', value: 'implement' },
];
const result = await selectOptionWithDefault('Start from:', options, 'nonexistent');
// defaultValue not found → falls back to index 0
expect(result).toBe('plan');
});
});
describe('isFullWidth', () => {
it('should return true for CJK ideographs', () => {
expect(isFullWidth('漢'.codePointAt(0)!)).toBe(true);

View File

@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
mockExistsSync,
mockSelectPiece,
mockSelectOption,
mockSelectOptionWithDefault,
mockResolvePieceConfigValue,
mockLoadPieceByIdentifier,
mockGetPieceDescription,
@ -15,7 +15,7 @@ const {
} = vi.hoisted(() => ({
mockExistsSync: vi.fn(() => true),
mockSelectPiece: vi.fn(),
mockSelectOption: vi.fn(),
mockSelectOptionWithDefault: vi.fn(),
mockResolvePieceConfigValue: vi.fn(),
mockLoadPieceByIdentifier: vi.fn(),
mockGetPieceDescription: vi.fn(() => ({
@ -41,7 +41,7 @@ vi.mock('../features/pieceSelection/index.js', () => ({
}));
vi.mock('../shared/prompt/index.js', () => ({
selectOption: (...args: unknown[]) => mockSelectOption(...args),
selectOptionWithDefault: (...args: unknown[]) => mockSelectOptionWithDefault(...args),
}));
vi.mock('../shared/ui/index.js', () => ({
@ -129,7 +129,7 @@ beforeEach(() => {
mockSelectPiece.mockResolvedValue('default');
mockResolvePieceConfigValue.mockReturnValue(3);
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue('plan');
mockSelectOptionWithDefault.mockResolvedValue('plan');
mockRunRetryMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
mockStartReExecution.mockReturnValue({
name: 'my-task',
@ -158,9 +158,25 @@ describe('retryFailedTask', () => {
expect(mockExecuteAndCompleteTask).toHaveBeenCalled();
});
it('should pass failed movement as default to selectOptionWithDefault', async () => {
const task = makeFailedTask(); // failure.movement = 'review'
await retryFailedTask(task, '/project');
expect(mockSelectOptionWithDefault).toHaveBeenCalledWith(
'Start from movement:',
expect.arrayContaining([
expect.objectContaining({ value: 'plan' }),
expect.objectContaining({ value: 'implement' }),
expect.objectContaining({ value: 'review' }),
]),
'review',
);
});
it('should pass non-initial movement as startMovement', async () => {
const task = makeFailedTask();
mockSelectOption.mockResolvedValue('implement');
mockSelectOptionWithDefault.mockResolvedValue('implement');
await retryFailedTask(task, '/project');

View File

@ -10,7 +10,7 @@ import type { TaskListItem } from '../../../infra/task/index.js';
import { TaskRunner } from '../../../infra/task/index.js';
import { loadPieceByIdentifier, resolvePieceConfigValue, getPieceDescription } from '../../../infra/config/index.js';
import { selectPiece } from '../../pieceSelection/index.js';
import { selectOption } from '../../../shared/prompt/index.js';
import { selectOptionWithDefault } from '../../../shared/prompt/index.js';
import { info, header, blankLine, status } from '../../../shared/ui/index.js';
import { createLogger } from '../../../shared/utils/index.js';
import type { PieceConfig } from '../../../core/models/index.js';
@ -60,12 +60,12 @@ async function selectStartMovement(
const effectiveDefault = defaultIdx >= 0 ? movements[defaultIdx] : movements[0];
const options = movements.map((name) => ({
label: name === effectiveDefault ? `${name} (default)` : name,
label: name,
value: name,
description: name === pieceConfig.initialMovement ? 'Initial movement' : undefined,
}));
return await selectOption<string>('Start from movement:', options);
return await selectOptionWithDefault<string>('Start from movement:', options, effectiveDefault ?? movements[0]!);
}
function buildRetryFailureInfo(task: TaskListItem): RetryFailureInfo {