takt/src/__tests__/engine-report.test.ts

179 lines
6.1 KiB
TypeScript

/**
* Tests for engine report event emission (movement:report)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { EventEmitter } from 'node:events';
import { existsSync } from 'node:fs';
import { isReportObjectConfig } from '../core/workflow/index.js';
import type { WorkflowMovement, ReportObjectConfig, ReportConfig } from '../core/models/index.js';
/**
* Extracted emitMovementReports logic for unit testing.
* Mirrors engine.ts emitMovementReports + emitIfReportExists.
*
* reportDir already includes the `.takt/reports/` prefix (set by engine constructor).
*/
function emitMovementReports(
emitter: EventEmitter,
movement: WorkflowMovement,
reportDir: string,
projectCwd: string,
): void {
if (!movement.report || !reportDir) return;
const baseDir = join(projectCwd, reportDir);
if (typeof movement.report === 'string') {
emitIfReportExists(emitter, movement, baseDir, movement.report);
} else if (isReportObjectConfig(movement.report)) {
emitIfReportExists(emitter, movement, baseDir, movement.report.name);
} else {
for (const rc of movement.report) {
emitIfReportExists(emitter, movement, baseDir, rc.path);
}
}
}
function emitIfReportExists(
emitter: EventEmitter,
movement: WorkflowMovement,
baseDir: string,
fileName: string,
): void {
const filePath = join(baseDir, fileName);
if (existsSync(filePath)) {
emitter.emit('movement:report', movement, filePath, fileName);
}
}
/** Create a minimal WorkflowMovement for testing */
function createMovement(overrides: Partial<WorkflowMovement> = {}): WorkflowMovement {
return {
name: 'test-movement',
agent: 'coder',
agentDisplayName: 'Coder',
instructionTemplate: '',
passPreviousResponse: false,
...overrides,
};
}
describe('emitMovementReports', () => {
let tmpDir: string;
let reportBaseDir: string;
// reportDir now includes .takt/reports/ prefix (matches engine constructor behavior)
const reportDirName = '.takt/reports/test-report-dir';
beforeEach(() => {
tmpDir = join(tmpdir(), `takt-report-test-${Date.now()}`);
reportBaseDir = join(tmpDir, reportDirName);
mkdirSync(reportBaseDir, { recursive: true });
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('should emit movement:report when string report file exists', () => {
// Given: a movement with string report and the file exists
const movement = createMovement({ report: 'plan.md' });
writeFileSync(join(reportBaseDir, 'plan.md'), '# Plan', 'utf-8');
const emitter = new EventEmitter();
const handler = vi.fn();
emitter.on('movement:report', handler);
// When
emitMovementReports(emitter, movement, reportDirName, tmpDir);
// Then
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(movement, join(reportBaseDir, 'plan.md'), 'plan.md');
});
it('should not emit when string report file does not exist', () => {
// Given: a movement with string report but file doesn't exist
const movement = createMovement({ report: 'missing.md' });
const emitter = new EventEmitter();
const handler = vi.fn();
emitter.on('movement:report', handler);
// When
emitMovementReports(emitter, movement, reportDirName, tmpDir);
// Then
expect(handler).not.toHaveBeenCalled();
});
it('should emit movement:report when ReportObjectConfig report file exists', () => {
// Given: a movement with ReportObjectConfig and the file exists
const report: ReportObjectConfig = { name: '03-review.md', format: '# Review' };
const movement = createMovement({ report });
writeFileSync(join(reportBaseDir, '03-review.md'), '# Review\nOK', 'utf-8');
const emitter = new EventEmitter();
const handler = vi.fn();
emitter.on('movement:report', handler);
// When
emitMovementReports(emitter, movement, reportDirName, tmpDir);
// Then
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(movement, join(reportBaseDir, '03-review.md'), '03-review.md');
});
it('should emit for each existing file in ReportConfig[] array', () => {
// Given: a movement with array report, two files exist, one missing
const report: ReportConfig[] = [
{ label: 'Scope', path: '01-scope.md' },
{ label: 'Decisions', path: '02-decisions.md' },
{ label: 'Missing', path: '03-missing.md' },
];
const movement = createMovement({ report });
writeFileSync(join(reportBaseDir, '01-scope.md'), '# Scope', 'utf-8');
writeFileSync(join(reportBaseDir, '02-decisions.md'), '# Decisions', 'utf-8');
const emitter = new EventEmitter();
const handler = vi.fn();
emitter.on('movement:report', handler);
// When
emitMovementReports(emitter, movement, reportDirName, tmpDir);
// Then: emitted for scope and decisions, not for missing
expect(handler).toHaveBeenCalledTimes(2);
expect(handler).toHaveBeenCalledWith(movement, join(reportBaseDir, '01-scope.md'), '01-scope.md');
expect(handler).toHaveBeenCalledWith(movement, join(reportBaseDir, '02-decisions.md'), '02-decisions.md');
});
it('should not emit when movement has no report', () => {
// Given: a movement without report
const movement = createMovement({ report: undefined });
const emitter = new EventEmitter();
const handler = vi.fn();
emitter.on('movement:report', handler);
// When
emitMovementReports(emitter, movement, reportDirName, tmpDir);
// Then
expect(handler).not.toHaveBeenCalled();
});
it('should not emit when reportDir is empty', () => {
// Given: a movement with report but empty reportDir
const movement = createMovement({ report: 'plan.md' });
writeFileSync(join(reportBaseDir, 'plan.md'), '# Plan', 'utf-8');
const emitter = new EventEmitter();
const handler = vi.fn();
emitter.on('movement:report', handler);
// When: empty reportDir
emitMovementReports(emitter, movement, '', tmpDir);
// Then
expect(handler).not.toHaveBeenCalled();
});
});