mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
2253d05618
Remove the nested v3/ subdirectory by moving all active source code, tests, configs, and assets directly to the project root. Archive v2/ (dead code, 847MB) to v2-archive.tar.gz. Key changes: - Move v3/src/ → src/, v3/tests/ → tests/, v3/packages/ → packages/ - Move v3/tsconfig.json, v3/vitest.config.ts to root - Merge v3/package.json into root package.json (ESM, scripts, deps, exports) - Merge v3/scripts/ into scripts/, v3/implementation/ into docs/implementation/ - Update all CI workflows to remove "cd v3 &&" and v3/ path prefixes - Fix all init installer path resolution (3-level-up → 2-level-up) - Fix hooks to use "node ./dist/cli/bundle.js" instead of bare "aqe" - Update settings.json ADR/DDD directories to docs/implementation/ - Fix helper scripts (statusline, adr-compliance, ddd-tracker, guidance-hooks) - Update 58 agent definitions: aqe/v3/ → aqe/ memory namespaces - Update 15 guidance shards: v3/src/ → src/ path references - Fix sync interfaces: remove v3/ and v2/ path references - Fix test path calculations (process.cwd() + '..' no longer needed) - Fix flaky perf test threshold (500ms → 1000ms for CI environments) - Fix complexity-analyzer test to match ADR-051 keyword-based eligibility Build passes, 17,901 tests pass, CLI and MCP bundles verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
509 lines
17 KiB
TypeScript
509 lines
17 KiB
TypeScript
/**
|
|
* Agentic QE v3 - Gap Detector Service Tests
|
|
*
|
|
* Tests for GapDetectorService: gap detection, prioritization strategies,
|
|
* test suggestion generation, and risk scoring.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import {
|
|
GapDetectorService,
|
|
createGapDetectorService,
|
|
createGapDetectorServiceWithDependencies,
|
|
} from '../../../../src/domains/coverage-analysis/services/gap-detector';
|
|
import type {
|
|
CoverageData,
|
|
FileCoverage,
|
|
CoverageGap,
|
|
GapDetectionRequest,
|
|
} from '../../../../src/domains/coverage-analysis/interfaces';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createMockMemory() {
|
|
return {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn().mockResolvedValue(undefined),
|
|
delete: vi.fn().mockResolvedValue(undefined),
|
|
storeVector: vi.fn().mockResolvedValue(undefined),
|
|
vectorSearch: vi.fn().mockResolvedValue([]),
|
|
list: vi.fn().mockResolvedValue([]),
|
|
};
|
|
}
|
|
|
|
function createFileCoverage(overrides: Partial<FileCoverage> = {}): FileCoverage {
|
|
return {
|
|
path: overrides.path ?? 'src/service.ts',
|
|
lines: overrides.lines ?? { covered: 50, total: 100 },
|
|
branches: overrides.branches ?? { covered: 5, total: 20 },
|
|
functions: overrides.functions ?? { covered: 3, total: 10 },
|
|
statements: overrides.statements ?? { covered: 55, total: 100 },
|
|
uncoveredLines: overrides.uncoveredLines ?? [10, 11, 12, 30, 31, 50],
|
|
uncoveredBranches: overrides.uncoveredBranches ?? [5, 15],
|
|
};
|
|
}
|
|
|
|
function createCoverageData(files: FileCoverage[] = []): CoverageData {
|
|
const fileList = files.length > 0 ? files : [createFileCoverage()];
|
|
return {
|
|
files: fileList,
|
|
summary: { line: 50, branch: 25, function: 30, statement: 55, files: fileList.length },
|
|
};
|
|
}
|
|
|
|
function makeGap(overrides: Partial<CoverageGap> = {}): CoverageGap {
|
|
return {
|
|
id: overrides.id ?? 'gap-abc',
|
|
file: overrides.file ?? 'src/test.ts',
|
|
lines: overrides.lines ?? [10, 11, 12],
|
|
branches: overrides.branches ?? [5],
|
|
riskScore: overrides.riskScore ?? 0.5,
|
|
severity: overrides.severity ?? 'medium',
|
|
recommendation: overrides.recommendation ?? 'Add tests',
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GapDetectorService', () => {
|
|
let memory: ReturnType<typeof createMockMemory>;
|
|
let service: GapDetectorService;
|
|
|
|
beforeEach(() => {
|
|
memory = createMockMemory();
|
|
service = createGapDetectorService(memory as any);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// =========================================================================
|
|
// Constructor
|
|
// =========================================================================
|
|
|
|
describe('construction', () => {
|
|
it('should create instance via legacy memory backend', () => {
|
|
const svc = new GapDetectorService(memory as any);
|
|
expect(svc).toBeInstanceOf(GapDetectorService);
|
|
});
|
|
|
|
it('should create instance via dependencies object', () => {
|
|
const svc = createGapDetectorServiceWithDependencies({ memory: memory as any });
|
|
expect(svc).toBeInstanceOf(GapDetectorService);
|
|
});
|
|
|
|
it('should accept optional LLM router in dependencies', () => {
|
|
const mockRouter = { chat: vi.fn() };
|
|
const svc = createGapDetectorServiceWithDependencies({
|
|
memory: memory as any,
|
|
llmRouter: mockRouter as any,
|
|
});
|
|
expect(svc).toBeInstanceOf(GapDetectorService);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// detectGaps
|
|
// =========================================================================
|
|
|
|
describe('detectGaps', () => {
|
|
it('should detect gaps for files below minCoverage', async () => {
|
|
// Arrange
|
|
const request: GapDetectionRequest = {
|
|
coverageData: createCoverageData([
|
|
createFileCoverage({
|
|
lines: { covered: 20, total: 100 },
|
|
branches: { covered: 2, total: 20 },
|
|
functions: { covered: 1, total: 10 },
|
|
statements: { covered: 20, total: 100 },
|
|
uncoveredLines: [5, 6, 7, 8, 9, 10, 20, 21],
|
|
uncoveredBranches: [3, 7],
|
|
}),
|
|
]),
|
|
minCoverage: 80,
|
|
};
|
|
|
|
// Act
|
|
const result = await service.detectGaps(request);
|
|
|
|
// Assert
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.gaps.length).toBeGreaterThan(0);
|
|
expect(result.value.totalUncoveredLines).toBe(8);
|
|
}
|
|
});
|
|
|
|
it('should return empty gaps when all files meet threshold', async () => {
|
|
// Arrange
|
|
const request: GapDetectionRequest = {
|
|
coverageData: createCoverageData([
|
|
createFileCoverage({
|
|
lines: { covered: 95, total: 100 },
|
|
branches: { covered: 19, total: 20 },
|
|
functions: { covered: 10, total: 10 },
|
|
statements: { covered: 97, total: 100 },
|
|
uncoveredLines: [],
|
|
uncoveredBranches: [],
|
|
}),
|
|
]),
|
|
minCoverage: 80,
|
|
};
|
|
|
|
// Act
|
|
const result = await service.detectGaps(request);
|
|
|
|
// Assert
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.gaps).toHaveLength(0);
|
|
}
|
|
});
|
|
|
|
it('should group contiguous uncovered lines into regions', async () => {
|
|
// Arrange
|
|
const request: GapDetectionRequest = {
|
|
coverageData: createCoverageData([
|
|
createFileCoverage({
|
|
lines: { covered: 40, total: 100 },
|
|
branches: { covered: 5, total: 20 },
|
|
functions: { covered: 3, total: 10 },
|
|
statements: { covered: 40, total: 100 },
|
|
uncoveredLines: [10, 11, 12, 50, 51, 52],
|
|
uncoveredBranches: [],
|
|
}),
|
|
]),
|
|
minCoverage: 80,
|
|
};
|
|
|
|
// Act
|
|
const result = await service.detectGaps(request);
|
|
|
|
// Assert
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
// Two regions: [10,11,12] and [50,51,52]
|
|
expect(result.value.gaps.length).toBe(2);
|
|
}
|
|
});
|
|
|
|
it('should use default minCoverage of 80 when not specified', async () => {
|
|
// Arrange
|
|
const request: GapDetectionRequest = {
|
|
coverageData: createCoverageData([
|
|
createFileCoverage({
|
|
lines: { covered: 75, total: 100 },
|
|
branches: { covered: 14, total: 20 },
|
|
functions: { covered: 7, total: 10 },
|
|
statements: { covered: 75, total: 100 },
|
|
uncoveredLines: [1, 2, 3],
|
|
uncoveredBranches: [],
|
|
}),
|
|
]),
|
|
};
|
|
|
|
// Act
|
|
const result = await service.detectGaps(request);
|
|
|
|
// Assert - file coverage < 80, so gaps should be found
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.gaps.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('should store gap patterns as vectors', async () => {
|
|
// Arrange
|
|
const request: GapDetectionRequest = {
|
|
coverageData: createCoverageData([
|
|
createFileCoverage({
|
|
uncoveredLines: [1, 2, 3],
|
|
lines: { covered: 20, total: 100 },
|
|
branches: { covered: 2, total: 20 },
|
|
functions: { covered: 1, total: 10 },
|
|
statements: { covered: 20, total: 100 },
|
|
}),
|
|
]),
|
|
};
|
|
|
|
// Act
|
|
await service.detectGaps(request);
|
|
|
|
// Assert
|
|
expect(memory.storeVector).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should calculate estimated effort based on gap sizes', async () => {
|
|
// Arrange
|
|
const request: GapDetectionRequest = {
|
|
coverageData: createCoverageData([
|
|
createFileCoverage({
|
|
uncoveredLines: Array.from({ length: 40 }, (_, i) => i + 1),
|
|
lines: { covered: 10, total: 100 },
|
|
branches: { covered: 1, total: 20 },
|
|
functions: { covered: 1, total: 10 },
|
|
statements: { covered: 10, total: 100 },
|
|
}),
|
|
]),
|
|
};
|
|
|
|
// Act
|
|
const result = await service.detectGaps(request);
|
|
|
|
// Assert
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.estimatedEffort).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// prioritizeGaps
|
|
// =========================================================================
|
|
|
|
describe('prioritizeGaps', () => {
|
|
const gaps: CoverageGap[] = [
|
|
makeGap({ id: 'low', riskScore: 0.2, severity: 'low', lines: [1, 2] }),
|
|
makeGap({ id: 'high', riskScore: 0.8, severity: 'high', lines: [10, 11, 12, 13, 14] }),
|
|
makeGap({ id: 'med', riskScore: 0.5, severity: 'medium', lines: [20, 21, 22] }),
|
|
];
|
|
|
|
it('should sort by risk score descending with risk strategy', () => {
|
|
// Act
|
|
const sorted = service.prioritizeGaps(gaps, 'risk');
|
|
|
|
// Assert
|
|
expect(sorted[0].id).toBe('high');
|
|
expect(sorted[1].id).toBe('med');
|
|
expect(sorted[2].id).toBe('low');
|
|
});
|
|
|
|
it('should sort by lines count descending with size strategy', () => {
|
|
// Act
|
|
const sorted = service.prioritizeGaps(gaps, 'size');
|
|
|
|
// Assert
|
|
expect(sorted[0].lines.length).toBeGreaterThanOrEqual(sorted[1].lines.length);
|
|
expect(sorted[1].lines.length).toBeGreaterThanOrEqual(sorted[2].lines.length);
|
|
});
|
|
|
|
it('should prioritize domain files with recent-changes strategy', () => {
|
|
// Arrange
|
|
const domainGaps: CoverageGap[] = [
|
|
makeGap({ id: 'lib', file: 'lib/util.ts', riskScore: 0.9 }),
|
|
makeGap({ id: 'domain', file: 'src/domains/auth/service.ts', riskScore: 0.3 }),
|
|
makeGap({ id: 'kernel', file: 'src/kernel/core.ts', riskScore: 0.5 }),
|
|
];
|
|
|
|
// Act
|
|
const sorted = service.prioritizeGaps(domainGaps, 'recent-changes');
|
|
|
|
// Assert
|
|
expect(sorted[0].file).toContain('src/domains/');
|
|
expect(sorted[sorted.length - 1].file).toContain('lib/');
|
|
});
|
|
|
|
it('should not mutate the original array', () => {
|
|
// Arrange
|
|
const original = [...gaps];
|
|
|
|
// Act
|
|
service.prioritizeGaps(gaps, 'risk');
|
|
|
|
// Assert
|
|
expect(gaps.map((g) => g.id)).toEqual(original.map((g) => g.id));
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// suggestTests
|
|
// =========================================================================
|
|
|
|
describe('suggestTests', () => {
|
|
it('should suggest unit tests for any gap with uncovered lines', async () => {
|
|
// Arrange
|
|
const gap = makeGap({ lines: [10, 11, 12], branches: [], severity: 'medium' });
|
|
|
|
// Act
|
|
const result = await service.suggestTests(gap);
|
|
|
|
// Assert
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
const unitSuggestions = result.value.filter((s) => s.type === 'unit');
|
|
expect(unitSuggestions.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('should suggest integration tests for large gaps with branches', async () => {
|
|
// Arrange
|
|
const gap = makeGap({
|
|
lines: Array.from({ length: 25 }, (_, i) => i + 1),
|
|
branches: [5, 10, 15],
|
|
severity: 'high',
|
|
});
|
|
|
|
// Act
|
|
const result = await service.suggestTests(gap);
|
|
|
|
// Assert
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
const integrationSuggestions = result.value.filter((s) => s.type === 'integration');
|
|
expect(integrationSuggestions.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('should suggest e2e tests for critical large gaps', async () => {
|
|
// Arrange
|
|
const gap = makeGap({
|
|
lines: Array.from({ length: 30 }, (_, i) => i + 1),
|
|
branches: [5, 10],
|
|
severity: 'critical',
|
|
});
|
|
|
|
// Act
|
|
const result = await service.suggestTests(gap);
|
|
|
|
// Assert
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
const e2eSuggestions = result.value.filter((s) => s.type === 'e2e');
|
|
expect(e2eSuggestions.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('should include test templates in suggestions', async () => {
|
|
// Arrange
|
|
const gap = makeGap({
|
|
file: 'src/domains/auth/service.ts',
|
|
lines: Array.from({ length: 25 }, (_, i) => i + 1),
|
|
branches: [5],
|
|
severity: 'high',
|
|
});
|
|
|
|
// Act
|
|
const result = await service.suggestTests(gap);
|
|
|
|
// Assert
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
const withTemplate = result.value.filter((s) => s.testTemplate);
|
|
expect(withTemplate.length).toBeGreaterThan(0);
|
|
expect(withTemplate[0].testTemplate).toContain('describe');
|
|
}
|
|
});
|
|
|
|
it('should estimate effort proportional to gap size', async () => {
|
|
// Arrange
|
|
const smallGap = makeGap({ lines: [1, 2], severity: 'low' });
|
|
const largeGap = makeGap({
|
|
lines: Array.from({ length: 50 }, (_, i) => i + 1),
|
|
branches: Array.from({ length: 10 }, (_, i) => i + 1),
|
|
severity: 'critical',
|
|
});
|
|
|
|
// Act
|
|
const smallResult = await service.suggestTests(smallGap);
|
|
const largeResult = await service.suggestTests(largeGap);
|
|
|
|
// Assert
|
|
expect(smallResult.success).toBe(true);
|
|
expect(largeResult.success).toBe(true);
|
|
if (smallResult.success && largeResult.success) {
|
|
const smallEffort = smallResult.value.reduce((s, v) => s + v.estimatedEffort, 0);
|
|
const largeEffort = largeResult.value.reduce((s, v) => s + v.estimatedEffort, 0);
|
|
expect(largeEffort).toBeGreaterThan(smallEffort);
|
|
}
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// analyzeGapsWithLLM
|
|
// =========================================================================
|
|
|
|
describe('analyzeGapsWithLLM', () => {
|
|
it('should return error when LLM router is not available', async () => {
|
|
// Act
|
|
const result = await service.analyzeGapsWithLLM([makeGap()]);
|
|
|
|
// Assert
|
|
expect(result.success).toBe(false);
|
|
if (!result.success) {
|
|
expect(result.error.message).toContain('not available');
|
|
}
|
|
});
|
|
|
|
it('should return empty analysis for zero gaps', async () => {
|
|
// Arrange
|
|
const mockRouter = { chat: vi.fn() };
|
|
const svc = createGapDetectorServiceWithDependencies({
|
|
memory: memory as any,
|
|
llmRouter: mockRouter as any,
|
|
});
|
|
|
|
// Act
|
|
const result = await svc.analyzeGapsWithLLM([]);
|
|
|
|
// Assert
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.prioritizedGaps).toHaveLength(0);
|
|
expect(result.value.summary).toContain('No coverage gaps');
|
|
}
|
|
});
|
|
|
|
it('should call LLM router and parse valid JSON response', async () => {
|
|
// Arrange
|
|
const mockResponse = {
|
|
content: JSON.stringify({
|
|
prioritizedGaps: [{ gapId: 'gap-1', riskWeight: 0.9, explanation: 'Critical path', businessImpact: 'high' }],
|
|
suggestedTests: [{ gapId: 'gap-1', testDescription: 'Test login', testType: 'unit', estimatedEffort: 2 }],
|
|
effortEstimate: { totalHours: 5, confidence: 0.8, breakdown: { 'auth.ts': 5 } },
|
|
summary: 'One critical gap found.',
|
|
}),
|
|
};
|
|
const mockRouter = { chat: vi.fn().mockResolvedValue(mockResponse) };
|
|
const svc = createGapDetectorServiceWithDependencies({
|
|
memory: memory as any,
|
|
llmRouter: mockRouter as any,
|
|
});
|
|
|
|
// Act
|
|
const result = await svc.analyzeGapsWithLLM([makeGap({ id: 'gap-1' })]);
|
|
|
|
// Assert
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.prioritizedGaps).toHaveLength(1);
|
|
expect(result.value.summary).toContain('critical');
|
|
}
|
|
});
|
|
|
|
it('should fall back to heuristic analysis when LLM response is not valid JSON', async () => {
|
|
// Arrange
|
|
const mockRouter = { chat: vi.fn().mockResolvedValue({ content: 'Not valid JSON at all' }) };
|
|
const svc = createGapDetectorServiceWithDependencies({
|
|
memory: memory as any,
|
|
llmRouter: mockRouter as any,
|
|
});
|
|
const gaps = [makeGap({ id: 'gap-fb' })];
|
|
|
|
// Act
|
|
const result = await svc.analyzeGapsWithLLM(gaps);
|
|
|
|
// Assert
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.value.summary).toContain('heuristic');
|
|
}
|
|
});
|
|
});
|
|
});
|