takt: takt (#248)
This commit is contained in:
parent
e1bfbbada1
commit
5478d766cd
72
src/__tests__/LogManager.test.ts
Normal file
72
src/__tests__/LogManager.test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('chalk', () => {
|
||||
const passthrough = (value: string) => value;
|
||||
const bold = Object.assign((value: string) => value, {
|
||||
cyan: (value: string) => value,
|
||||
});
|
||||
|
||||
return {
|
||||
default: {
|
||||
gray: passthrough,
|
||||
blue: passthrough,
|
||||
yellow: passthrough,
|
||||
red: passthrough,
|
||||
green: passthrough,
|
||||
white: passthrough,
|
||||
bold,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { LogManager } from '../shared/ui/LogManager.js';
|
||||
|
||||
describe('LogManager', () => {
|
||||
beforeEach(() => {
|
||||
// Given: テスト間でシングルトン状態が共有されないようにする
|
||||
LogManager.resetInstance();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should filter by info level as debug=false, info=true, error=true', () => {
|
||||
// Given: ログレベルが info
|
||||
const manager = LogManager.getInstance();
|
||||
manager.setLogLevel('info');
|
||||
|
||||
// When: 各レベルの出力可否を判定する
|
||||
const debugResult = manager.shouldLog('debug');
|
||||
const infoResult = manager.shouldLog('info');
|
||||
const errorResult = manager.shouldLog('error');
|
||||
|
||||
// Then: info基準のフィルタリングが適用される
|
||||
expect(debugResult).toBe(false);
|
||||
expect(infoResult).toBe(true);
|
||||
expect(errorResult).toBe(true);
|
||||
});
|
||||
|
||||
it('should reflect level change after setLogLevel', () => {
|
||||
// Given: 初期レベル(info)
|
||||
const manager = LogManager.getInstance();
|
||||
|
||||
// When: warn レベルに変更する
|
||||
manager.setLogLevel('warn');
|
||||
|
||||
// Then: info は抑制され warn は出力対象になる
|
||||
expect(manager.shouldLog('info')).toBe(false);
|
||||
expect(manager.shouldLog('warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('should clear singleton state when resetInstance is called', () => {
|
||||
// Given: エラーレベルに変更済みのインスタンス
|
||||
const first = LogManager.getInstance();
|
||||
first.setLogLevel('error');
|
||||
expect(first.shouldLog('info')).toBe(false);
|
||||
|
||||
// When: シングルトンをリセットして再取得する
|
||||
LogManager.resetInstance();
|
||||
const second = LogManager.getInstance();
|
||||
|
||||
// Then: 新しいインスタンスは初期レベルに戻る
|
||||
expect(second.shouldLog('info')).toBe(true);
|
||||
});
|
||||
});
|
||||
78
src/__tests__/branchGitCommands.test.ts
Normal file
78
src/__tests__/branchGitCommands.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { parseDistinctHashes, runGit } from '../infra/task/branchGitCommands.js';
|
||||
|
||||
const mockExecFileSync = vi.mocked(execFileSync);
|
||||
|
||||
describe('parseDistinctHashes', () => {
|
||||
it('should remove only consecutive duplicates', () => {
|
||||
// Given: 連続重複と非連続重複を含む出力
|
||||
const output = 'a\na\nb\nb\na\n';
|
||||
|
||||
// When: ハッシュを解析する
|
||||
const result = parseDistinctHashes(output);
|
||||
|
||||
// Then: 連続重複のみ除去される
|
||||
expect(result).toEqual(['a', 'b', 'a']);
|
||||
});
|
||||
|
||||
it('should return empty array when output is empty', () => {
|
||||
// Given: 空文字列
|
||||
const output = '';
|
||||
|
||||
// When: ハッシュを解析する
|
||||
const result = parseDistinctHashes(output);
|
||||
|
||||
// Then: 空配列を返す
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should trim each line and drop blank lines', () => {
|
||||
// Given: 前後空白と空行を含む出力
|
||||
const output = ' hash1 \n\n hash2\n \n';
|
||||
|
||||
// When: ハッシュを解析する
|
||||
const result = parseDistinctHashes(output);
|
||||
|
||||
// Then: トリム済みの値のみ残る
|
||||
expect(result).toEqual(['hash1', 'hash2']);
|
||||
});
|
||||
|
||||
it('should return single hash as one-element array', () => {
|
||||
// Given: 単一ハッシュ
|
||||
const output = 'single-hash';
|
||||
|
||||
// When: ハッシュを解析する
|
||||
const result = parseDistinctHashes(output);
|
||||
|
||||
// Then: 1件配列として返る
|
||||
expect(result).toEqual(['single-hash']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runGit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should execute git command with expected options and trim output', () => {
|
||||
// Given: gitコマンドのモック応答
|
||||
mockExecFileSync.mockReturnValue(' abc123 \n' as never);
|
||||
|
||||
// When: runGit を実行する
|
||||
const result = runGit('/repo', ['rev-parse', 'HEAD']);
|
||||
|
||||
// Then: execFileSync が正しい引数で呼ばれ、trimされた値を返す
|
||||
expect(mockExecFileSync).toHaveBeenCalledWith('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: '/repo',
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
expect(result).toBe('abc123');
|
||||
});
|
||||
});
|
||||
80
src/__tests__/provider-resolution.test.ts
Normal file
80
src/__tests__/provider-resolution.test.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveMovementProviderModel } from '../core/piece/provider-resolution.js';
|
||||
|
||||
describe('resolveMovementProviderModel', () => {
|
||||
it('should prefer step.provider when step provider is defined', () => {
|
||||
// Given: step.provider が指定されている
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: 'codex', model: undefined, personaDisplayName: 'coder' },
|
||||
provider: 'claude',
|
||||
personaProviders: { coder: 'opencode' },
|
||||
});
|
||||
|
||||
// When: provider/model を解決する
|
||||
// Then: step.provider が最優先になる
|
||||
expect(result.provider).toBe('codex');
|
||||
});
|
||||
|
||||
it('should use personaProviders when step.provider is undefined', () => {
|
||||
// Given: step.provider が未定義で personaProviders に対応がある
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: undefined, model: undefined, personaDisplayName: 'reviewer' },
|
||||
provider: 'claude',
|
||||
personaProviders: { reviewer: 'opencode' },
|
||||
});
|
||||
|
||||
// When: provider/model を解決する
|
||||
// Then: personaProviders の値が使われる
|
||||
expect(result.provider).toBe('opencode');
|
||||
});
|
||||
|
||||
it('should fallback to input.provider when persona mapping is missing', () => {
|
||||
// Given: step.provider 未定義かつ persona マッピングが存在しない
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: undefined, model: undefined, personaDisplayName: 'unknown' },
|
||||
provider: 'mock',
|
||||
personaProviders: { reviewer: 'codex' },
|
||||
});
|
||||
|
||||
// When: provider/model を解決する
|
||||
// Then: input.provider が使われる
|
||||
expect(result.provider).toBe('mock');
|
||||
});
|
||||
|
||||
it('should return undefined provider when all provider candidates are missing', () => {
|
||||
// Given: provider の候補がすべて未定義
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: undefined, model: undefined, personaDisplayName: 'none' },
|
||||
provider: undefined,
|
||||
personaProviders: undefined,
|
||||
});
|
||||
|
||||
// When: provider/model を解決する
|
||||
// Then: provider は undefined になる
|
||||
expect(result.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prefer step.model over input.model', () => {
|
||||
// Given: step.model と input.model が両方指定されている
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: undefined, model: 'step-model', personaDisplayName: 'coder' },
|
||||
model: 'input-model',
|
||||
});
|
||||
|
||||
// When: provider/model を解決する
|
||||
// Then: step.model が最優先になる
|
||||
expect(result.model).toBe('step-model');
|
||||
});
|
||||
|
||||
it('should fallback to input.model when step.model is undefined', () => {
|
||||
// Given: step.model が未定義で input.model が指定されている
|
||||
const result = resolveMovementProviderModel({
|
||||
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
|
||||
model: 'input-model',
|
||||
});
|
||||
|
||||
// When: provider/model を解決する
|
||||
// Then: input.model が使われる
|
||||
expect(result.model).toBe('input-model');
|
||||
});
|
||||
});
|
||||
103
src/__tests__/streamDiagnostics.test.ts
Normal file
103
src/__tests__/streamDiagnostics.test.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { debugMock, createLoggerMock } = vi.hoisted(() => ({
|
||||
debugMock: vi.fn(),
|
||||
createLoggerMock: vi.fn(),
|
||||
}));
|
||||
|
||||
createLoggerMock.mockImplementation(() => ({
|
||||
debug: debugMock,
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/debug.js', () => ({
|
||||
createLogger: createLoggerMock,
|
||||
}));
|
||||
|
||||
import { createStreamDiagnostics } from '../shared/utils/streamDiagnostics.js';
|
||||
|
||||
describe('createStreamDiagnostics', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-02-11T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should log connected event with elapsedMs', () => {
|
||||
// Given: 診断オブジェクト
|
||||
const diagnostics = createStreamDiagnostics('component', { runId: 'r1' });
|
||||
|
||||
// When: 接続完了を通知する
|
||||
diagnostics.onConnected();
|
||||
|
||||
// Then: elapsedMs を含むデバッグログが出力される
|
||||
expect(debugMock).toHaveBeenCalledWith('Stream connected', {
|
||||
runId: 'r1',
|
||||
elapsedMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should log first event only once even when called twice', () => {
|
||||
// Given: 診断オブジェクト
|
||||
const diagnostics = createStreamDiagnostics('component', { runId: 'r2' });
|
||||
|
||||
// When: first event を2回通知する
|
||||
diagnostics.onFirstEvent('event-a');
|
||||
diagnostics.onFirstEvent('event-b');
|
||||
|
||||
// Then: first event ログは1回だけ出る
|
||||
expect(debugMock).toHaveBeenCalledTimes(1);
|
||||
expect(debugMock).toHaveBeenCalledWith('Stream first event', {
|
||||
runId: 'r2',
|
||||
firstEventType: 'event-a',
|
||||
elapsedMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include eventCount and durationMs on completion', () => {
|
||||
// Given: 複数イベントを処理した診断オブジェクト
|
||||
const diagnostics = createStreamDiagnostics('component', { runId: 'r3' });
|
||||
diagnostics.onConnected();
|
||||
diagnostics.onEvent('turn.started');
|
||||
vi.advanceTimersByTime(120);
|
||||
diagnostics.onEvent('turn.completed');
|
||||
vi.advanceTimersByTime(80);
|
||||
|
||||
// When: 完了通知を行う
|
||||
diagnostics.onCompleted('normal', 'done');
|
||||
|
||||
// Then: 集計情報を含む完了ログが出力される
|
||||
expect(debugMock).toHaveBeenLastCalledWith('Stream completed', {
|
||||
runId: 'r3',
|
||||
reason: 'normal',
|
||||
detail: 'done',
|
||||
eventCount: 2,
|
||||
lastEventType: 'turn.completed',
|
||||
durationMs: 200,
|
||||
connected: true,
|
||||
iterationStarted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should increment eventCount and use it in stream error log', () => {
|
||||
// Given: 1イベント処理済みの診断オブジェクト
|
||||
const diagnostics = createStreamDiagnostics('component', { runId: 'r4' });
|
||||
diagnostics.onEvent('turn.started');
|
||||
|
||||
// When: ストリームエラーを通知する
|
||||
diagnostics.onStreamError('turn.failed', 'failed');
|
||||
|
||||
// Then: eventCount がエラーログに反映される
|
||||
expect(debugMock).toHaveBeenLastCalledWith('Stream error event', {
|
||||
runId: 'r4',
|
||||
eventType: 'turn.failed',
|
||||
message: 'failed',
|
||||
eventCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
39
src/__tests__/taskStatusLabel.test.ts
Normal file
39
src/__tests__/taskStatusLabel.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatTaskStatusLabel } from '../features/tasks/list/taskStatusLabel.js';
|
||||
import type { TaskListItem } from '../infra/task/types.js';
|
||||
|
||||
describe('formatTaskStatusLabel', () => {
|
||||
it("should format pending task as '[running] name'", () => {
|
||||
// Given: pending タスク
|
||||
const task: TaskListItem = {
|
||||
kind: 'pending',
|
||||
name: 'implement test',
|
||||
createdAt: '2026-02-11T00:00:00.000Z',
|
||||
filePath: '/tmp/task.md',
|
||||
content: 'content',
|
||||
};
|
||||
|
||||
// When: ステータスラベルを生成する
|
||||
const result = formatTaskStatusLabel(task);
|
||||
|
||||
// Then: pending は running 表示になる
|
||||
expect(result).toBe('[running] implement test');
|
||||
});
|
||||
|
||||
it("should format failed task as '[failed] name'", () => {
|
||||
// Given: failed タスク
|
||||
const task: TaskListItem = {
|
||||
kind: 'failed',
|
||||
name: 'retry payment',
|
||||
createdAt: '2026-02-11T00:00:00.000Z',
|
||||
filePath: '/tmp/task.md',
|
||||
content: 'content',
|
||||
};
|
||||
|
||||
// When: ステータスラベルを生成する
|
||||
const result = formatTaskStatusLabel(task);
|
||||
|
||||
// Then: failed は failed 表示になる
|
||||
expect(result).toBe('[failed] retry payment');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user