feat: orchestrate AI unit test generation workflow

This commit is contained in:
张家训
2026-08-04 17:10:29 +08:00
parent 0c0acac416
commit e774605217
10 changed files with 602 additions and 1 deletions
@@ -0,0 +1,72 @@
package com.example.workutils.biz.aitest.application;
import com.alibaba.fastjson.JSON;
import com.example.workutils.system.config.WorkUtilsProperties;
import com.example.workutils.system.support.ApplicationPaths;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
@Service
public class AiTestHistoryService {
private final Path runsDirectory;
@Autowired
public AiTestHistoryService(WorkUtilsProperties properties) {
this(properties.getWorkutilsConfigFilePath().getParent().resolve("ai-test").resolve("runs"));
}
public AiTestHistoryService(ApplicationPaths paths) {
this(paths.resolve("data", "ai-test", "runs"));
}
private AiTestHistoryService(Path runsDirectory) {
this.runsDirectory = runsDirectory;
}
public void save(AiTestRun run) throws IOException {
AiTestRunSummary summary = summary(run);
Path file = runsDirectory.resolve(safeId(run.id())).resolve("run.json");
Files.createDirectories(file.getParent());
Files.writeString(file, JSON.toJSONString(summary), StandardCharsets.UTF_8);
}
public List<AiTestRunSummary> load() throws IOException {
if (Files.notExists(runsDirectory)) {
return List.of();
}
List<AiTestRunSummary> result = new ArrayList<>();
try (Stream<Path> paths = Files.walk(runsDirectory, 2)) {
for (Path file : paths.filter(path -> path.getFileName().toString().equals("run.json")).toList()) {
result.add(JSON.parseObject(Files.readString(file, StandardCharsets.UTF_8),
AiTestRunSummary.class));
}
}
result.sort(Comparator.comparing(AiTestRunSummary::updatedAt).reversed());
return List.copyOf(result);
}
private static AiTestRunSummary summary(AiTestRun run) {
BigDecimal coverage = run.coverage() == null ? null : run.coverage().linePercent();
return new AiTestRunSummary(run.id(), run.request().project().toString(),
run.request().moduleArtifactId(), run.request().baseline(), run.state().name(),
run.generationRounds(), coverage, run.message(), Instant.now());
}
private static String safeId(String id) {
if (id == null || !id.matches("[A-Za-z0-9._-]+")) {
throw new IllegalArgumentException("Invalid AI test run id");
}
return id;
}
}
@@ -0,0 +1,4 @@
package com.example.workutils.biz.aitest.application;
public record AiTestProgress(AiTestRunState state, int round, String message) {
}
@@ -0,0 +1,12 @@
package com.example.workutils.biz.aitest.application;
import com.example.workutils.biz.aitest.config.AiTestSettings;
import java.nio.file.Path;
public record AiTestRequest(Path project, String moduleArtifactId, String baseline,
boolean includeWorkingTree, AiTestSettings settings) {
public AiTestRequest {
project = project.toAbsolutePath().normalize();
}
}
@@ -0,0 +1,71 @@
package com.example.workutils.biz.aitest.application;
import com.example.workutils.biz.aitest.ai.ResolvedGeneratedFile;
import com.example.workutils.biz.aitest.coverage.IncrementalCoverage;
import com.example.workutils.biz.aitest.git.BranchIntegrationResult;
import com.example.workutils.biz.aitest.git.IncrementAnalysis;
import com.example.workutils.biz.aitest.git.TemporaryWorktree;
import com.example.workutils.biz.aitest.project.MavenProjectModel;
import java.util.List;
public record AiTestRun(String id, AiTestRequest request, AiTestRunState state,
MavenProjectModel projectModel, IncrementAnalysis analysis,
IncrementalCoverage coverage, List<ResolvedGeneratedFile> generatedFiles,
int generationRounds, String message, TemporaryWorktree worktree,
BranchIntegrationResult integration) {
public AiTestRun {
generatedFiles = generatedFiles == null ? List.of() : List.copyOf(generatedFiles);
message = message == null ? "" : message;
}
public static AiTestRun initial(String id, AiTestRequest request) {
return new AiTestRun(id, request, AiTestRunState.CREATED, null, null, null,
List.of(), 0, "", null, null);
}
public AiTestRun analyzed(MavenProjectModel model, IncrementAnalysis increment) {
return copy(AiTestRunState.ANALYZED, model, increment, coverage, generatedFiles,
generationRounds, "增量分析完成", worktree, integration);
}
public AiTestRun workspace(TemporaryWorktree temporary) {
return copy(AiTestRunState.GENERATING, projectModel, analysis, coverage, generatedFiles,
generationRounds, "临时工作区已创建", temporary, integration);
}
public AiTestRun round(int round, IncrementalCoverage newCoverage,
List<ResolvedGeneratedFile> files, String detail) {
return copy(AiTestRunState.VERIFYING, projectModel, analysis, newCoverage, files,
round, detail, worktree, integration);
}
public AiTestRun ready() {
return copy(AiTestRunState.READY_FOR_REVIEW, projectModel, analysis, coverage,
generatedFiles, generationRounds, "验证通过,等待审核", worktree, integration);
}
public AiTestRun fail(String detail) {
return copy(AiTestRunState.FAILED, projectModel, analysis, coverage, generatedFiles,
generationRounds, detail, worktree, integration);
}
public AiTestRun cancelled() {
return copy(AiTestRunState.CANCELLED, projectModel, analysis, coverage, generatedFiles,
generationRounds, "已取消", worktree, integration);
}
public AiTestRun integrated(BranchIntegrationResult result) {
return copy(result.successful() ? AiTestRunState.SUCCEEDED : AiTestRunState.FAILED,
projectModel, analysis, coverage, generatedFiles, generationRounds,
result.message(), worktree, result);
}
private AiTestRun copy(AiTestRunState newState, MavenProjectModel model,
IncrementAnalysis increment, IncrementalCoverage newCoverage,
List<ResolvedGeneratedFile> files, int rounds, String detail,
TemporaryWorktree temporary, BranchIntegrationResult result) {
return new AiTestRun(id, request, newState, model, increment, newCoverage, files,
rounds, detail, temporary, result);
}
}
@@ -0,0 +1,13 @@
package com.example.workutils.biz.aitest.application;
public enum AiTestRunState {
CREATED,
ANALYZED,
GENERATING,
VERIFYING,
READY_FOR_REVIEW,
INTEGRATING,
SUCCEEDED,
FAILED,
CANCELLED
}
@@ -0,0 +1,9 @@
package com.example.workutils.biz.aitest.application;
import java.math.BigDecimal;
import java.time.Instant;
public record AiTestRunSummary(String id, String project, String module, String baseline,
String state, int rounds, BigDecimal lineCoverage,
String message, Instant updatedAt) {
}
@@ -0,0 +1,289 @@
package com.example.workutils.biz.aitest.application;
import com.example.workutils.biz.aitest.ai.GeneratedFileValidator;
import com.example.workutils.biz.aitest.ai.ResolvedGeneratedFile;
import com.example.workutils.biz.aitest.ai.TestGenerationProvider;
import com.example.workutils.biz.aitest.ai.TestGenerationRequest;
import com.example.workutils.biz.aitest.ai.TestGenerationResult;
import com.example.workutils.biz.aitest.config.TestSpecificationService;
import com.example.workutils.biz.aitest.coverage.IncrementalCoverage;
import com.example.workutils.biz.aitest.coverage.IncrementalCoverageCalculator;
import com.example.workutils.biz.aitest.coverage.JacocoLine;
import com.example.workutils.biz.aitest.coverage.JacocoXmlParser;
import com.example.workutils.biz.aitest.git.BranchIntegrationRequest;
import com.example.workutils.biz.aitest.git.BranchIntegrationResult;
import com.example.workutils.biz.aitest.git.GitIncrementAnalyzer;
import com.example.workutils.biz.aitest.git.TargetBranchIntegrator;
import com.example.workutils.biz.aitest.git.TemporaryWorktree;
import com.example.workutils.biz.aitest.git.TemporaryWorktreeManager;
import com.example.workutils.biz.aitest.maven.MavenErrorClassifier;
import com.example.workutils.biz.aitest.maven.MavenExecutionResult;
import com.example.workutils.biz.aitest.maven.MavenFailureType;
import com.example.workutils.biz.aitest.maven.MavenPhase;
import com.example.workutils.biz.aitest.maven.MavenRunner;
import com.example.workutils.biz.aitest.project.MavenModule;
import com.example.workutils.biz.aitest.project.MavenProjectAnalyzer;
import com.example.workutils.biz.aitest.project.MavenProjectModel;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.stream.Stream;
@Service
@RequiredArgsConstructor
public class AiTestWorkflow {
private final MavenProjectAnalyzer projectAnalyzer;
private final GitIncrementAnalyzer incrementAnalyzer;
private final TemporaryWorktreeManager worktreeManager;
private final TestGenerationProvider provider;
private final GeneratedFileValidator generatedFileValidator;
private final MavenRunner mavenRunner;
private final MavenErrorClassifier errorClassifier;
private final JacocoXmlParser jacocoXmlParser;
private final IncrementalCoverageCalculator coverageCalculator;
private final TestSpecificationService specificationService;
private final AiTestHistoryService historyService;
private final TargetBranchIntegrator branchIntegrator;
private final AtomicBoolean cancelled = new AtomicBoolean();
public AiTestRun analyze(AiTestRequest request, Consumer<AiTestProgress> progress) {
cancelled.set(false);
AiTestRun run = AiTestRun.initial(runId(), request);
try {
emit(progress, AiTestRunState.CREATED, 0, "正在分析 Maven 项目和 Git 增量");
MavenProjectModel model = projectAnalyzer.analyze(request.project());
MavenModule selected = requiredModule(model, request.moduleArtifactId());
var increment = incrementAnalyzer.analyze(request.project(), request.baseline(),
request.includeWorkingTree());
for (var file : increment.files()) {
model.modules().stream()
.filter(module -> file.sourcePath().normalize().startsWith(module.relativePath()))
.findFirst()
.filter(changedModule -> model.canReach(selected, changedModule))
.orElseThrow(() -> new IllegalArgumentException(
"所选模块无法覆盖增量文件: " + file.sourcePath()));
}
run = run.analyzed(model, increment);
emit(progress, run.state(), 0, run.message());
saveQuietly(run);
return run;
} catch (RuntimeException failure) {
run = run.fail(message(failure));
saveQuietly(run);
return run;
}
}
public AiTestRun generateAndVerify(AiTestRun analyzed, Consumer<AiTestProgress> progress) {
if (analyzed.state() != AiTestRunState.ANALYZED) {
return analyzed.fail("请先完成增量分析");
}
AiTestRun run = analyzed;
try {
TemporaryWorktree worktree = worktreeManager.create(analyzed.request().project(),
analyzed.request().includeWorkingTree());
run = run.workspace(worktree);
MavenProjectModel workspaceModel = projectAnalyzer.analyze(worktree.path());
MavenModule module = requiredModule(workspaceModel, analyzed.request().moduleArtifactId());
String specifications = loadSpecifications(analyzed.request().project());
String feedback = "首次生成";
for (int round = 1; round <= analyzed.request().settings().maxRepairRounds(); round++) {
if (cancelled.get()) {
worktree.close();
return finish(run.cancelled(), progress);
}
emit(progress, AiTestRunState.GENERATING, round, "CodeFree 正在生成第 " + round + " 轮单测");
String prompt = buildPrompt(run, module, specifications, feedback, round);
TestGenerationResult generated = provider.generate(new TestGenerationRequest(
worktree.path(), analyzed.request().settings().codeFreeCommand(), prompt,
analyzed.request().settings().codeFreeTimeout()), ignored -> { });
if (!generated.successful()) {
return failAndClose(run, "CodeFree 生成失败: " + generated.diagnosis(), progress);
}
List<ResolvedGeneratedFile> files = generatedFileValidator.validateAndResolve(
module.directory(), generated.tests());
writeGenerated(files);
emit(progress, AiTestRunState.VERIFYING, round, "正在编译并运行生成的单测");
MavenExecutionResult compilation = mavenRunner.run(worktree.path(), module,
MavenPhase.TEST_COMPILE, analyzed.request().settings(), ignored -> { });
if (!compilation.successful()) {
MavenFailureType type = errorClassifier.classify(compilation);
run = run.round(round, null, files, compilation.combinedLog());
if (!type.aiRepairable() || round == analyzed.request().settings().maxRepairRounds()) {
return failAndClose(run, "Maven 验证失败(" + type + ")", progress);
}
feedback = repairFeedback(type, compilation.combinedLog(), null);
continue;
}
MavenExecutionResult verified = mavenRunner.run(worktree.path(), module,
MavenPhase.JACOCO, analyzed.request().settings(), ignored -> { });
if (!verified.successful()) {
MavenFailureType type = errorClassifier.classify(verified);
run = run.round(round, null, files, verified.combinedLog());
if (!type.aiRepairable() || round == analyzed.request().settings().maxRepairRounds()) {
return failAndClose(run, "Maven 测试失败(" + type + ")", progress);
}
feedback = repairFeedback(type, verified.combinedLog(), null);
continue;
}
List<JacocoLine> report = new ArrayList<>();
for (Path reportPath : verified.jacocoReports()) {
report.addAll(jacocoXmlParser.parse(module, reportPath));
}
IncrementalCoverage coverage = coverageCalculator.calculate(analyzed.analysis(), report,
analyzed.request().settings().coverageTarget());
run = run.round(round, coverage, files,
"增量行覆盖率 " + coverage.linePercent() + "%");
if (coverage.passed()) {
return finish(run.ready(), progress);
}
feedback = repairFeedback(MavenFailureType.COVERAGE, verified.combinedLog(), coverage);
}
return failAndClose(run, "达到最大修复轮次,增量覆盖率仍未达标", progress);
} catch (Exception failure) {
return failAndClose(run, message(failure), progress);
}
}
public AiTestRun integrate(AiTestRun reviewed, String targetBranch, String commitMessage,
Consumer<AiTestProgress> progress) {
if (reviewed.state() != AiTestRunState.READY_FOR_REVIEW || reviewed.worktree() == null) {
return reviewed.fail("只有验证通过并经用户审核的单测才能合并");
}
emit(progress, AiTestRunState.INTEGRATING, reviewed.generationRounds(), "正在验证目标分支并合并");
MavenModule selected = requiredModule(reviewed.projectModel(), reviewed.request().moduleArtifactId());
BranchIntegrationRequest request = new BranchIntegrationRequest(reviewed.request().project(),
reviewed.worktree().path(), targetBranch, selected.relativePath(), commitMessage);
BranchIntegrationResult result = branchIntegrator.integrate(request, target -> {
MavenProjectModel targetModel = projectAnalyzer.analyze(target);
MavenModule targetModule = requiredModule(targetModel, reviewed.request().moduleArtifactId());
return mavenRunner.run(target, targetModule, MavenPhase.JACOCO,
reviewed.request().settings(), ignored -> { }).successful();
});
AiTestRun integrated = reviewed.integrated(result);
if (result.successful()) {
reviewed.worktree().close();
}
return finish(integrated, progress);
}
public void cancel() {
cancelled.set(true);
provider.cancel();
mavenRunner.cancel();
}
private AiTestRun finish(AiTestRun run, Consumer<AiTestProgress> progress) {
emit(progress, run.state(), run.generationRounds(), run.message());
saveQuietly(run);
return run;
}
private AiTestRun failAndClose(AiTestRun run, String detail, Consumer<AiTestProgress> progress) {
if (run.worktree() != null) {
run.worktree().close();
}
return finish(run.fail(detail), progress);
}
private static MavenModule requiredModule(MavenProjectModel model, String artifactId) {
return model.moduleByArtifactId(artifactId)
.orElseThrow(() -> new IllegalArgumentException("找不到 Maven 模块: " + artifactId));
}
private String loadSpecifications(Path project) {
try {
return nullToEmpty(specificationService.loadDefault()) + "\n\n"
+ nullToEmpty(specificationService.loadProject(project));
} catch (IOException e) {
throw new IllegalStateException("读取单测质量规范失败", e);
}
}
private static String buildPrompt(AiTestRun run, MavenModule module, String specifications,
String feedback, int round) {
StringBuilder sources = new StringBuilder();
run.analysis().files().forEach(changed -> {
Path source = run.worktree().path().resolve(changed.sourcePath()).normalize();
sources.append("\n文件: ").append(changed.sourcePath())
.append("\n变更行: ").append(changed.lines()).append('\n');
try {
sources.append(Files.exists(source) ? Files.readString(source, StandardCharsets.UTF_8)
: "<当前工作区未找到源码,请依据路径和反馈生成>");
} catch (IOException e) {
sources.append("<源码读取失败>");
}
});
return """
你是 JDK8、Maven、JUnit5 项目的单测生成器。只能生成所选模块 src/test/java 下的 Java 测试,
禁止修改生产源码、pom.xml、配置文件或其他模块。遵循现有测试风格和下列质量规范。
质量规范:
%s
所选模块:%s
基线:%s
目标增量行覆盖率:%d%%
当前轮次:%d
上轮反馈:%s
增量源码:%s
只返回纯 JSON,不要 Markdown:
{"action":"WRITE_TESTS","tests":[{"path":"src/test/java/...Test.java","content":"...","targetClass":"...","coveredLines":[1],"scenarios":["..."]}],"assumptions":[],"warnings":[],"diagnosis":""}
""".formatted(specifications, module.artifactId(), run.request().baseline(),
run.request().settings().coverageTarget(), round, feedback, sources);
}
private static String repairFeedback(MavenFailureType type, String log, IncrementalCoverage coverage) {
String tail = log == null ? "" : log.substring(Math.max(0, log.length() - 8000));
return "失败类型=" + type + (coverage == null ? "" : ", 当前增量行覆盖率=" + coverage.linePercent())
+ "\n请仅修复测试代码。Maven 日志末尾:\n" + tail;
}
private static void writeGenerated(List<ResolvedGeneratedFile> files) throws IOException {
for (ResolvedGeneratedFile file : files) {
Files.createDirectories(file.path().getParent());
Files.writeString(file.path(), file.content(), StandardCharsets.UTF_8);
}
}
private void saveQuietly(AiTestRun run) {
try {
historyService.save(run);
} catch (Exception ignored) {
// History must never turn a verified test run into a failed run.
}
}
private static void emit(Consumer<AiTestProgress> progress, AiTestRunState state,
int round, String message) {
progress.accept(new AiTestProgress(state, round, message));
}
private static String runId() {
return Instant.now().toEpochMilli() + "-" + UUID.randomUUID().toString().substring(0, 8);
}
private static String message(Exception failure) {
return failure.getMessage() == null ? failure.getClass().getSimpleName() : failure.getMessage();
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
}
@@ -3,7 +3,7 @@ package com.example.workutils.biz.aitest.git;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicBoolean;
public final class TemporaryWorktree implements AutoCloseable {
public class TemporaryWorktree implements AutoCloseable {
private final Path repository;
private final Path path;
private final Path temporaryParent;
@@ -0,0 +1,34 @@
package com.example.workutils.biz.aitest.application;
import com.example.workutils.biz.aitest.config.AiTestSettings;
import com.example.workutils.system.support.ApplicationPaths;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
class AiTestHistoryServiceTest {
@Test
void persistsRunSummaryWithoutPromptOrProductionSource(@TempDir Path home) throws Exception {
AiTestHistoryService service = new AiTestHistoryService(ApplicationPaths.of(home));
AiTestRequest request = new AiTestRequest(home.resolve("project"), "order-start", "develop",
false, new AiTestSettings(Path.of("jdk8"), Path.of("maven"), "codefree",
85, 3, Duration.ofMinutes(5), Duration.ofMinutes(15)));
AiTestRun run = AiTestRun.initial("run-1", request).fail("Maven仓库认证失败");
service.save(run);
String json = Files.readString(home.resolve("data/ai-test/runs/run-1/run.json"),
StandardCharsets.UTF_8);
assertTrue(json.contains("Maven仓库认证失败"));
assertFalse(json.contains("prompt"));
assertFalse(json.contains("productionSource"));
assertEquals(1, service.load().size());
}
}
@@ -0,0 +1,97 @@
package com.example.workutils.biz.aitest.application;
import com.example.workutils.biz.aitest.ai.*;
import com.example.workutils.biz.aitest.config.*;
import com.example.workutils.biz.aitest.coverage.*;
import com.example.workutils.biz.aitest.git.*;
import com.example.workutils.biz.aitest.maven.*;
import com.example.workutils.biz.aitest.process.CommandResult;
import com.example.workutils.biz.aitest.project.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AiTestWorkflowTest {
@Test
void repairsCoverageUntilReadyWithoutExceedingConfiguredRounds(@TempDir Path root) throws Exception {
Path project = root.resolve("project");
Path workspace = root.resolve("workspace");
Files.createDirectories(project);
Files.createDirectories(workspace.resolve("order-start"));
MavenModule originalModule = module(project);
MavenModule workspaceModule = module(workspace);
MavenProjectModel originalModel = new MavenProjectModel(project, List.of(originalModule));
MavenProjectModel workspaceModel = new MavenProjectModel(workspace, List.of(workspaceModule));
IncrementAnalysis analysis = new IncrementAnalysis("develop", "abc",
List.of(new ChangedLines(Path.of("order-start/src/main/java/a/A.java"), Set.of(2))), false);
MavenProjectAnalyzer projectAnalyzer = mock(MavenProjectAnalyzer.class);
when(projectAnalyzer.analyze(project)).thenReturn(originalModel);
when(projectAnalyzer.analyze(workspace)).thenReturn(workspaceModel);
GitIncrementAnalyzer incrementAnalyzer = mock(GitIncrementAnalyzer.class);
when(incrementAnalyzer.analyze(project, "develop", false)).thenReturn(analysis);
TemporaryWorktreeManager worktrees = mock(TemporaryWorktreeManager.class);
TemporaryWorktree temporary = mock(TemporaryWorktree.class);
when(temporary.path()).thenReturn(workspace);
when(worktrees.create(project, false)).thenReturn(temporary);
TestGenerationProvider provider = mock(TestGenerationProvider.class);
GeneratedTestFile generated = new GeneratedTestFile("src/test/java/a/ATest.java",
"package a; class ATest {}", "a.A", List.of(2), List.of("变更行"));
when(provider.generate(any(), any())).thenReturn(new TestGenerationResult(true,
"WRITE_TESTS", List.of(generated), List.of(), List.of(), ""));
GeneratedFileValidator fileValidator = new GeneratedFileValidator();
MavenRunner maven = mock(MavenRunner.class);
MavenExecutionResult mavenOk = new MavenExecutionResult(MavenPhase.JACOCO,
new CommandResult(0, false, false, "BUILD SUCCESS", "", Duration.ofSeconds(1)),
List.of(), List.of(workspace.resolve("order-start/target/site/jacoco/jacoco.xml")));
when(maven.run(eq(workspace), eq(workspaceModule), any(), any(), any())).thenReturn(mavenOk);
MavenErrorClassifier classifier = new MavenErrorClassifier();
JacocoXmlParser jacoco = mock(JacocoXmlParser.class);
when(jacoco.parse(any(), any())).thenReturn(List.of());
IncrementalCoverageCalculator coverage = mock(IncrementalCoverageCalculator.class);
when(coverage.calculate(eq(analysis), anyList(), eq(85)))
.thenReturn(result(50, false), result(90, true));
AiTestWorkflow workflow = new AiTestWorkflow(projectAnalyzer, incrementAnalyzer, worktrees,
provider, fileValidator, maven, classifier, jacoco, coverage,
mock(TestSpecificationService.class), mock(AiTestHistoryService.class),
mock(TargetBranchIntegrator.class));
AiTestRequest request = new AiTestRequest(project, "order-start", "develop", false,
settings(root));
AiTestRun analyzed = workflow.analyze(request, ignored -> { });
AiTestRun result = workflow.generateAndVerify(analyzed, ignored -> { });
assertEquals(AiTestRunState.READY_FOR_REVIEW, result.state());
assertEquals(2, result.generationRounds());
assertEquals(new BigDecimal("90.00"), result.coverage().linePercent());
}
private static MavenModule module(Path root) {
return new MavenModule(root.resolve("order-start"), Path.of("order-start"),
"com.example", "order-start", "1", Set.of());
}
private static IncrementalCoverage result(int percent, boolean passed) {
return new IncrementalCoverage(10, percent / 10, BigDecimal.valueOf(percent).setScale(2),
BigDecimal.ZERO.setScale(2), 85, true, passed);
}
private static AiTestSettings settings(Path root) {
return new AiTestSettings(root.resolve("jdk8"), root.resolve("maven"), "codefree",
85, 3, Duration.ofMinutes(5), Duration.ofMinutes(15));
}
}