diff --git a/src/__tests__/prompt.test.ts b/src/__tests__/prompt.test.ts index e6abb59..6f240fa 100644 --- a/src/__tests__/prompt.test.ts +++ b/src/__tests__/prompt.test.ts @@ -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); diff --git a/src/__tests__/taskRetryActions.test.ts b/src/__tests__/taskRetryActions.test.ts index 344dd84..ea172fb 100644 --- a/src/__tests__/taskRetryActions.test.ts +++ b/src/__tests__/taskRetryActions.test.ts @@ -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'); diff --git a/src/features/tasks/list/taskRetryActions.ts b/src/features/tasks/list/taskRetryActions.ts index 57759d0..88aa354 100644 --- a/src/features/tasks/list/taskRetryActions.ts +++ b/src/features/tasks/list/taskRetryActions.ts @@ -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('Start from movement:', options); + return await selectOptionWithDefault('Start from movement:', options, effectiveDefault ?? movements[0]!); } function buildRetryFailureInfo(task: TaskListItem): RetryFailureInfo {