リトライ時のムーブメント選択で失敗箇所にカーソルを初期配置する
selectOption → selectOptionWithDefault に変更し、前回失敗したムーブメントが デフォルト選択されるようにした。Enter一発で失敗箇所から再開できる。
This commit is contained in:
parent
391e56b51a
commit
a8adfdd02a
@ -2,9 +2,10 @@
|
|||||||
* Tests for prompt module (cursor-based interactive menu)
|
* 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 { Readable } from 'node:stream';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
|
import { setupRawStdin, restoreStdin } from './helpers/stdinSimulator.js';
|
||||||
import type { SelectOptionItem, KeyInputResult } from '../shared/prompt/index.js';
|
import type { SelectOptionItem, KeyInputResult } from '../shared/prompt/index.js';
|
||||||
import {
|
import {
|
||||||
renderMenu,
|
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', () => {
|
describe('isFullWidth', () => {
|
||||||
it('should return true for CJK ideographs', () => {
|
it('should return true for CJK ideographs', () => {
|
||||||
expect(isFullWidth('漢'.codePointAt(0)!)).toBe(true);
|
expect(isFullWidth('漢'.codePointAt(0)!)).toBe(true);
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
const {
|
const {
|
||||||
mockExistsSync,
|
mockExistsSync,
|
||||||
mockSelectPiece,
|
mockSelectPiece,
|
||||||
mockSelectOption,
|
mockSelectOptionWithDefault,
|
||||||
mockResolvePieceConfigValue,
|
mockResolvePieceConfigValue,
|
||||||
mockLoadPieceByIdentifier,
|
mockLoadPieceByIdentifier,
|
||||||
mockGetPieceDescription,
|
mockGetPieceDescription,
|
||||||
@ -15,7 +15,7 @@ const {
|
|||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockExistsSync: vi.fn(() => true),
|
mockExistsSync: vi.fn(() => true),
|
||||||
mockSelectPiece: vi.fn(),
|
mockSelectPiece: vi.fn(),
|
||||||
mockSelectOption: vi.fn(),
|
mockSelectOptionWithDefault: vi.fn(),
|
||||||
mockResolvePieceConfigValue: vi.fn(),
|
mockResolvePieceConfigValue: vi.fn(),
|
||||||
mockLoadPieceByIdentifier: vi.fn(),
|
mockLoadPieceByIdentifier: vi.fn(),
|
||||||
mockGetPieceDescription: vi.fn(() => ({
|
mockGetPieceDescription: vi.fn(() => ({
|
||||||
@ -41,7 +41,7 @@ vi.mock('../features/pieceSelection/index.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../shared/prompt/index.js', () => ({
|
vi.mock('../shared/prompt/index.js', () => ({
|
||||||
selectOption: (...args: unknown[]) => mockSelectOption(...args),
|
selectOptionWithDefault: (...args: unknown[]) => mockSelectOptionWithDefault(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../shared/ui/index.js', () => ({
|
vi.mock('../shared/ui/index.js', () => ({
|
||||||
@ -129,7 +129,7 @@ beforeEach(() => {
|
|||||||
mockSelectPiece.mockResolvedValue('default');
|
mockSelectPiece.mockResolvedValue('default');
|
||||||
mockResolvePieceConfigValue.mockReturnValue(3);
|
mockResolvePieceConfigValue.mockReturnValue(3);
|
||||||
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
|
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
|
||||||
mockSelectOption.mockResolvedValue('plan');
|
mockSelectOptionWithDefault.mockResolvedValue('plan');
|
||||||
mockRunRetryMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
|
mockRunRetryMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
|
||||||
mockStartReExecution.mockReturnValue({
|
mockStartReExecution.mockReturnValue({
|
||||||
name: 'my-task',
|
name: 'my-task',
|
||||||
@ -158,9 +158,25 @@ describe('retryFailedTask', () => {
|
|||||||
expect(mockExecuteAndCompleteTask).toHaveBeenCalled();
|
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 () => {
|
it('should pass non-initial movement as startMovement', async () => {
|
||||||
const task = makeFailedTask();
|
const task = makeFailedTask();
|
||||||
mockSelectOption.mockResolvedValue('implement');
|
mockSelectOptionWithDefault.mockResolvedValue('implement');
|
||||||
|
|
||||||
await retryFailedTask(task, '/project');
|
await retryFailedTask(task, '/project');
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import type { TaskListItem } from '../../../infra/task/index.js';
|
|||||||
import { TaskRunner } from '../../../infra/task/index.js';
|
import { TaskRunner } from '../../../infra/task/index.js';
|
||||||
import { loadPieceByIdentifier, resolvePieceConfigValue, getPieceDescription } from '../../../infra/config/index.js';
|
import { loadPieceByIdentifier, resolvePieceConfigValue, getPieceDescription } from '../../../infra/config/index.js';
|
||||||
import { selectPiece } from '../../pieceSelection/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 { info, header, blankLine, status } from '../../../shared/ui/index.js';
|
||||||
import { createLogger } from '../../../shared/utils/index.js';
|
import { createLogger } from '../../../shared/utils/index.js';
|
||||||
import type { PieceConfig } from '../../../core/models/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 effectiveDefault = defaultIdx >= 0 ? movements[defaultIdx] : movements[0];
|
||||||
|
|
||||||
const options = movements.map((name) => ({
|
const options = movements.map((name) => ({
|
||||||
label: name === effectiveDefault ? `${name} (default)` : name,
|
label: name,
|
||||||
value: name,
|
value: name,
|
||||||
description: name === pieceConfig.initialMovement ? 'Initial movement' : undefined,
|
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 {
|
function buildRetryFailureInfo(task: TaskListItem): RetryFailureInfo {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user