diff --git a/src/main/java/com/example/workutils/biz/aitest/application/AiTestHistoryService.java b/src/main/java/com/example/workutils/biz/aitest/application/AiTestHistoryService.java new file mode 100644 index 0000000..4ccdcc7 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/application/AiTestHistoryService.java @@ -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 load() throws IOException { + if (Files.notExists(runsDirectory)) { + return List.of(); + } + List result = new ArrayList<>(); + try (Stream 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; + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/application/AiTestProgress.java b/src/main/java/com/example/workutils/biz/aitest/application/AiTestProgress.java new file mode 100644 index 0000000..1b8397b --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/application/AiTestProgress.java @@ -0,0 +1,4 @@ +package com.example.workutils.biz.aitest.application; + +public record AiTestProgress(AiTestRunState state, int round, String message) { +} diff --git a/src/main/java/com/example/workutils/biz/aitest/application/AiTestRequest.java b/src/main/java/com/example/workutils/biz/aitest/application/AiTestRequest.java new file mode 100644 index 0000000..f7648f7 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/application/AiTestRequest.java @@ -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(); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/application/AiTestRun.java b/src/main/java/com/example/workutils/biz/aitest/application/AiTestRun.java new file mode 100644 index 0000000..eab36b7 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/application/AiTestRun.java @@ -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 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 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 files, int rounds, String detail, + TemporaryWorktree temporary, BranchIntegrationResult result) { + return new AiTestRun(id, request, newState, model, increment, newCoverage, files, + rounds, detail, temporary, result); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/application/AiTestRunState.java b/src/main/java/com/example/workutils/biz/aitest/application/AiTestRunState.java new file mode 100644 index 0000000..5e16f26 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/application/AiTestRunState.java @@ -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 +} diff --git a/src/main/java/com/example/workutils/biz/aitest/application/AiTestRunSummary.java b/src/main/java/com/example/workutils/biz/aitest/application/AiTestRunSummary.java new file mode 100644 index 0000000..be81d9f --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/application/AiTestRunSummary.java @@ -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) { +} diff --git a/src/main/java/com/example/workutils/biz/aitest/application/AiTestWorkflow.java b/src/main/java/com/example/workutils/biz/aitest/application/AiTestWorkflow.java new file mode 100644 index 0000000..2945689 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/application/AiTestWorkflow.java @@ -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 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 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 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 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 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 progress) { + emit(progress, run.state(), run.generationRounds(), run.message()); + saveQuietly(run); + return run; + } + + private AiTestRun failAndClose(AiTestRun run, String detail, Consumer 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 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 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; + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/git/TemporaryWorktree.java b/src/main/java/com/example/workutils/biz/aitest/git/TemporaryWorktree.java index 1e567da..98ea6ee 100644 --- a/src/main/java/com/example/workutils/biz/aitest/git/TemporaryWorktree.java +++ b/src/main/java/com/example/workutils/biz/aitest/git/TemporaryWorktree.java @@ -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; diff --git a/src/test/java/com/example/workutils/biz/aitest/application/AiTestHistoryServiceTest.java b/src/test/java/com/example/workutils/biz/aitest/application/AiTestHistoryServiceTest.java new file mode 100644 index 0000000..c056fca --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/application/AiTestHistoryServiceTest.java @@ -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()); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/application/AiTestWorkflowTest.java b/src/test/java/com/example/workutils/biz/aitest/application/AiTestWorkflowTest.java new file mode 100644 index 0000000..565772c --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/application/AiTestWorkflowTest.java @@ -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)); + } +}