feat: orchestrate AI unit test generation workflow
This commit is contained in:
@@ -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;
|
||||
|
||||
+34
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user