From 5478d766cd6b2c7de56fdbbb033e5503ec40932b Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:50:17 +0900 Subject: [PATCH] takt: takt (#248) --- src/__tests__/LogManager.test.ts | 72 +++++++++++++++ src/__tests__/branchGitCommands.test.ts | 78 ++++++++++++++++ src/__tests__/provider-resolution.test.ts | 80 +++++++++++++++++ src/__tests__/streamDiagnostics.test.ts | 103 ++++++++++++++++++++++ src/__tests__/taskStatusLabel.test.ts | 39 ++++++++ 5 files changed, 372 insertions(+) create mode 100644 src/__tests__/LogManager.test.ts create mode 100644 src/__tests__/branchGitCommands.test.ts create mode 100644 src/__tests__/provider-resolution.test.ts create mode 100644 src/__tests__/streamDiagnostics.test.ts create mode 100644 src/__tests__/taskStatusLabel.test.ts diff --git a/src/__tests__/LogManager.test.ts b/src/__tests__/LogManager.test.ts new file mode 100644 index 0000000..b3b59cc --- /dev/null +++ b/src/__tests__/LogManager.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/branchGitCommands.test.ts b/src/__tests__/branchGitCommands.test.ts new file mode 100644 index 0000000..6ddb2bf --- /dev/null +++ b/src/__tests__/branchGitCommands.test.ts @@ -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'); + }); +}); diff --git a/src/__tests__/provider-resolution.test.ts b/src/__tests__/provider-resolution.test.ts new file mode 100644 index 0000000..fa60189 --- /dev/null +++ b/src/__tests__/provider-resolution.test.ts @@ -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'); + }); +}); diff --git a/src/__tests__/streamDiagnostics.test.ts b/src/__tests__/streamDiagnostics.test.ts new file mode 100644 index 0000000..ba4fc92 --- /dev/null +++ b/src/__tests__/streamDiagnostics.test.ts @@ -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, + }); + }); +}); diff --git a/src/__tests__/taskStatusLabel.test.ts b/src/__tests__/taskStatusLabel.test.ts new file mode 100644 index 0000000..54ba349 --- /dev/null +++ b/src/__tests__/taskStatusLabel.test.ts @@ -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'); + }); +});