feat: add AI unit test generation plugin UI

This commit is contained in:
张家训
2026-08-04 17:18:24 +08:00
parent e774605217
commit c8e114e700
14 changed files with 816 additions and 7 deletions
@@ -0,0 +1,425 @@
package com.example.workutils.biz.aitest;
import com.example.workutils.biz.aitest.application.AiTestProgress;
import com.example.workutils.biz.aitest.application.AiTestHistoryService;
import com.example.workutils.biz.aitest.application.AiTestRunSummary;
import com.example.workutils.biz.aitest.application.AiTestRequest;
import com.example.workutils.biz.aitest.application.AiTestRun;
import com.example.workutils.biz.aitest.application.AiTestRunState;
import com.example.workutils.biz.aitest.application.AiTestWorkflow;
import com.example.workutils.biz.aitest.config.AiTestModulePreferenceService;
import com.example.workutils.biz.aitest.config.AiTestSettings;
import com.example.workutils.biz.aitest.config.AiTestSettingsService;
import com.example.workutils.biz.aitest.config.TestSpecificationService;
import com.example.workutils.biz.aitest.project.AiTestProject;
import com.example.workutils.biz.aitest.project.AiTestProjectCatalog;
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 com.example.workutils.system.support.AppExecutors;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.function.Consumer;
public class AiTestPane extends BorderPane {
private final AiTestWorkflow workflow;
private final AiTestProjectCatalog projectCatalog;
private final MavenProjectAnalyzer projectAnalyzer;
private final AiTestSettingsService settingsService;
private final TestSpecificationService specificationService;
private final AiTestModulePreferenceService modulePreferences;
private final AiTestHistoryService historyService;
private final ToolchainValidator toolchainValidator;
private final ComboBox<AiTestProject> projectCombo = new ComboBox<>();
private final ComboBox<MavenModule> moduleCombo = new ComboBox<>();
private final TextField baselineField = new TextField("HEAD~1");
private final CheckBox includeWorkingTree = new CheckBox("包含未提交改动");
private final TextField jdk8Field = new TextField();
private final TextField mavenField = new TextField();
private final TextField codeFreeField = new TextField("codefree");
private final Spinner<Integer> coverageTarget = new Spinner<>(0, 100, 85);
private final Spinner<Integer> repairRounds = new Spinner<>(1, 10, 3);
private final TextArea defaultSpecification = new TextArea();
private final TextArea projectSpecification = new TextArea();
private final Button analyzeButton = new Button("分析增量");
private final Button generateButton = new Button("生成并验证");
private final Button cancelButton = new Button("取消");
private final Button integrateButton = new Button("合并到本地分支");
private final ProgressBar progressBar = new ProgressBar(0);
private final Label statusLabel = new Label("请选择项目并检查工具链");
private final Label toolchainStatus = new Label("工具链未检查");
private final TabPane resultTabs = new TabPane();
private final ListView<String> generatedTests = new ListView<>();
private final TextArea coverageArea = readOnlyArea();
private final TextArea logArea = readOnlyArea();
private final TextArea historyArea = readOnlyArea();
private final CheckBox reviewApproved = new CheckBox("我已审核生成的测试代码");
private final TextField targetBranch = new TextField();
private final TextField commitMessage = new TextField("test: add AI generated incremental tests");
private volatile AiTestRun currentRun;
private volatile boolean busy;
public AiTestPane(AiTestWorkflow workflow, AiTestProjectCatalog projectCatalog,
MavenProjectAnalyzer projectAnalyzer, AiTestSettingsService settingsService,
TestSpecificationService specificationService,
AiTestModulePreferenceService modulePreferences,
AiTestHistoryService historyService,
ToolchainValidator toolchainValidator) {
this.workflow = workflow;
this.projectCatalog = projectCatalog;
this.projectAnalyzer = projectAnalyzer;
this.settingsService = settingsService;
this.specificationService = specificationService;
this.modulePreferences = modulePreferences;
this.historyService = historyService;
this.toolchainValidator = toolchainValidator;
buildUi();
bindActions();
loadInitialData();
}
private void buildUi() {
setPadding(new Insets(20));
setTop(buildHeader());
setCenter(buildContent());
setBottom(buildStatus());
generateButton.setDisable(true);
integrateButton.setDisable(true);
cancelButton.setDisable(true);
projectCombo.setMaxWidth(Double.MAX_VALUE);
moduleCombo.setMaxWidth(Double.MAX_VALUE);
projectCombo.setCellFactory(ignored -> projectCell());
projectCombo.setButtonCell(projectCell());
}
private Node buildHeader() {
Label title = new Label("AI 单测生成");
title.setStyle("-fx-font-size: 22px; -fx-font-weight: bold;");
Label subtitle = new Label("CodeFree CLI · JDK 8 · Maven · JUnit 5 · 85% 增量行覆盖率");
VBox header = new VBox(5, title, subtitle);
header.setPadding(new Insets(0, 0, 16, 0));
return header;
}
private Node buildContent() {
GridPane selection = grid();
addRow(selection, 0, "项目", projectCombo, "生成模块", moduleCombo);
addRow(selection, 1, "基线分支/Commit", baselineField, "工作区", includeWorkingTree);
GridPane toolchain = grid();
Button validateToolchain = new Button("检查并保存工具链");
addRow(toolchain, 0, "JDK 8 Home", jdk8Field, "Maven Home", mavenField);
addRow(toolchain, 1, "CodeFree 命令", codeFreeField, "状态", toolchainStatus);
addRow(toolchain, 2, "覆盖率目标", coverageTarget, "最大修复轮次", repairRounds);
toolchain.add(validateToolchain, 4, 2);
validateToolchain.setOnAction(event -> validateAndSaveToolchain());
defaultSpecification.setPrefRowCount(7);
projectSpecification.setPrefRowCount(7);
SplitPane specifications = new SplitPane(titled("默认质量规范(可编辑)", defaultSpecification),
titled("当前项目规范(可编辑)", projectSpecification));
specifications.setDividerPositions(0.5);
Button saveSpecifications = new Button("保存质量规范");
saveSpecifications.setOnAction(event -> saveSpecifications());
resultTabs.getTabs().addAll(tab("生成文件", generatedTests), tab("覆盖率", coverageArea),
tab("运行日志", logArea), tab("历史摘要", historyArea));
resultTabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);
VBox.setVgrow(resultTabs, Priority.ALWAYS);
HBox actions = new HBox(10, analyzeButton, generateButton, cancelButton);
actions.setAlignment(Pos.CENTER_LEFT);
GridPane integration = grid();
addRow(integration, 0, "目标本地分支", targetBranch, "提交信息", commitMessage);
integration.add(reviewApproved, 0, 1, 2, 1);
integration.add(integrateButton, 4, 1);
Accordion setup = new Accordion(new TitledPane("1. 项目与增量", selection),
new TitledPane("2. JDK 8 / Maven / CodeFree", toolchain),
new TitledPane("3. 单测质量规范", new VBox(8, specifications, saveSpecifications)));
setup.setExpandedPane(setup.getPanes().get(0));
VBox content = new VBox(12, setup, actions, resultTabs, titled("审核并合并", integration));
VBox.setVgrow(resultTabs, Priority.ALWAYS);
return content;
}
private Node buildStatus() {
progressBar.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(progressBar, Priority.ALWAYS);
HBox status = new HBox(12, progressBar, statusLabel);
status.setAlignment(Pos.CENTER_LEFT);
status.setPadding(new Insets(12, 0, 0, 0));
return status;
}
private void bindActions() {
projectCombo.valueProperty().addListener((ignored, oldValue, project) -> loadProject(project));
moduleCombo.valueProperty().addListener((ignored, oldValue, module) -> {
AiTestProject project = projectCombo.getValue();
if (project != null && module != null) {
runAsync(() -> { modulePreferences.save(project.path(), module.artifactId()); return null; },
ignoredResult -> { });
}
});
analyzeButton.setOnAction(event -> analyze());
generateButton.setOnAction(event -> generate());
cancelButton.setOnAction(event -> workflow.cancel());
integrateButton.setOnAction(event -> integrate());
reviewApproved.selectedProperty().addListener((ignored, oldValue, approved) -> updateButtons());
}
private void loadInitialData() {
runAsync(() -> new InitialData(settingsService.load(), specificationService.loadDefault(),
projectCatalog.load(), historyService.load()), data -> {
applySettings(data.settings());
defaultSpecification.setText(data.defaultSpec());
projectCombo.setItems(FXCollections.observableArrayList(data.projects()));
historyArea.setText(data.history().stream().map(this::formatHistory)
.reduce("", (left, right) -> left + right + "\n"));
statusLabel.setText(data.projects().isEmpty() ? "project_paths.txt 中没有可用项目" : "请选择项目");
});
}
private void loadProject(AiTestProject project) {
if (project == null || !project.available()) {
moduleCombo.getItems().clear();
return;
}
runAsync(() -> new ProjectData(projectAnalyzer.analyze(project.path()),
specificationService.loadProject(project.path()),
modulePreferences.load(project.path()).orElse("")), data -> {
moduleCombo.setItems(FXCollections.observableArrayList(data.model().modules()));
projectSpecification.setText(data.specification());
MavenModule selected = data.model().moduleByArtifactId(data.preferredModule()).orElse(null);
if (selected == null) {
List<MavenModule> starts = data.model().modules().stream()
.filter(module -> module.artifactId().endsWith("-start")).toList();
selected = starts.size() == 1 ? starts.get(0) : null;
}
moduleCombo.setValue(selected);
statusLabel.setText(selected == null ? "请选择生成模块" : "已选择模块 " + selected.artifactId());
});
}
private void validateAndSaveToolchain() {
AiTestSettings settings = settingsFromForm();
runAsync(() -> {
settingsService.save(settings);
return toolchainValidator.validate(settings, this::appendLog);
}, validation -> {
toolchainStatus.setText(validation.ready() ? "可用" : "检查失败");
toolchainStatus.setStyle(validation.ready() ? "-fx-text-fill: #16803c" : "-fx-text-fill: #c53030");
validation.checks().forEach(check -> appendLog(check.name() + ": "
+ (check.successful() ? "通过" : "失败") + "\n" + check.detail()));
});
}
private void saveSpecifications() {
AiTestProject project = projectCombo.getValue();
runAsync(() -> {
specificationService.saveDefault(defaultSpecification.getText());
if (project != null) {
specificationService.saveProject(project.path(), projectSpecification.getText());
}
return null;
}, ignored -> statusLabel.setText("质量规范已保存"));
}
private void analyze() {
AiTestProject project = projectCombo.getValue();
MavenModule module = moduleCombo.getValue();
if (project == null || module == null || baselineField.getText().isBlank()) {
statusLabel.setText("请选择项目、模块并填写基线");
return;
}
AiTestRequest request = new AiTestRequest(project.path(), module.artifactId(),
baselineField.getText().trim(), includeWorkingTree.isSelected(), settingsFromForm());
runAsync(() -> workflow.analyze(request, this::onProgress), run -> {
currentRun = run;
renderRun(run);
});
}
private void generate() {
AiTestRun analyzed = currentRun;
if (analyzed == null || analyzed.state() != AiTestRunState.ANALYZED) {
return;
}
runAsync(() -> workflow.generateAndVerify(analyzed, this::onProgress), run -> {
currentRun = run;
renderRun(run);
});
}
private void integrate() {
AiTestRun reviewed = currentRun;
if (!reviewApproved.isSelected() || reviewed == null || targetBranch.getText().isBlank()) {
statusLabel.setText("请审核测试并填写目标本地分支");
return;
}
runAsync(() -> workflow.integrate(reviewed, targetBranch.getText().trim(),
commitMessage.getText().trim(), this::onProgress), run -> {
currentRun = run;
renderRun(run);
});
}
private void renderRun(AiTestRun run) {
statusLabel.setText(run.message());
generatedTests.setItems(FXCollections.observableArrayList(run.generatedFiles().stream()
.map(file -> file.path() + "\n\n" + file.content()).toList()));
coverageArea.setText(run.coverage() == null ? "尚无覆盖率结果" :
"增量行覆盖率: " + run.coverage().linePercent() + "%\n"
+ "增量分支覆盖率: " + run.coverage().branchPercent() + "%\n"
+ "目标: " + run.coverage().targetPercent() + "%\n"
+ "结果: " + (run.coverage().passed() ? "通过" : "未通过"));
historyArea.appendText(run.id() + " | " + run.state() + " | " + run.message() + "\n");
progressBar.setProgress(run.state() == AiTestRunState.READY_FOR_REVIEW
|| run.state() == AiTestRunState.SUCCEEDED ? 1 : 0);
updateButtons();
}
private void onProgress(AiTestProgress progress) {
Platform.runLater(() -> {
statusLabel.setText(progress.message());
progressBar.setProgress(progress.round() <= 0 ? ProgressIndicator.INDETERMINATE_PROGRESS
: Math.min(0.95, progress.round() / (double) Math.max(1, repairRounds.getValue())));
appendLog(progress.state() + " [轮次 " + progress.round() + "] " + progress.message());
});
}
private <T> void runAsync(Callable<T> operation, Consumer<T> success) {
setBusy(true);
AppExecutors.io().submit(() -> {
try {
T result = operation.call();
Platform.runLater(() -> {
setBusy(false);
success.accept(result);
});
} catch (Exception failure) {
Platform.runLater(() -> {
setBusy(false);
statusLabel.setText(failure.getMessage() == null
? failure.getClass().getSimpleName() : failure.getMessage());
appendLog("失败: " + statusLabel.getText());
});
}
});
}
private void setBusy(boolean value) {
busy = value;
cancelButton.setDisable(!value);
updateButtons();
if (value) {
progressBar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
}
}
private void updateButtons() {
analyzeButton.setDisable(busy);
generateButton.setDisable(busy || currentRun == null
|| currentRun.state() != AiTestRunState.ANALYZED);
integrateButton.setDisable(busy || !reviewApproved.isSelected() || currentRun == null
|| currentRun.state() != AiTestRunState.READY_FOR_REVIEW);
}
private AiTestSettings settingsFromForm() {
return new AiTestSettings(path(jdk8Field.getText()), path(mavenField.getText()),
codeFreeField.getText().isBlank() ? "codefree" : codeFreeField.getText().trim(),
coverageTarget.getValue(), repairRounds.getValue(), Duration.ofMinutes(5),
Duration.ofMinutes(15));
}
private void applySettings(AiTestSettings settings) {
jdk8Field.setText(settings.jdk8Home().toString());
mavenField.setText(settings.mavenHome().toString());
codeFreeField.setText(settings.codeFreeCommand());
coverageTarget.getValueFactory().setValue(settings.coverageTarget());
repairRounds.getValueFactory().setValue(settings.maxRepairRounds());
}
private void appendLog(String text) {
if (Platform.isFxApplicationThread()) {
logArea.appendText(text + "\n");
} else {
Platform.runLater(() -> logArea.appendText(text + "\n"));
}
}
private static GridPane grid() {
GridPane grid = new GridPane();
grid.setHgap(10);
grid.setVgap(10);
grid.setPadding(new Insets(10));
ColumnConstraints label = new ColumnConstraints();
ColumnConstraints input = new ColumnConstraints();
input.setHgrow(Priority.ALWAYS);
input.setPercentWidth(35);
grid.getColumnConstraints().addAll(label, input, label, input, new ColumnConstraints());
return grid;
}
private static void addRow(GridPane grid, int row, String leftLabel, Node left,
String rightLabel, Node right) {
grid.addRow(row, new Label(leftLabel), left, new Label(rightLabel), right);
GridPane.setHgrow(left, Priority.ALWAYS);
GridPane.setHgrow(right, Priority.ALWAYS);
}
private static TitledPane titled(String title, Node content) {
TitledPane pane = new TitledPane(title, content);
pane.setCollapsible(false);
return pane;
}
private static Tab tab(String title, Node content) {
return new Tab(title, content);
}
private static TextArea readOnlyArea() {
TextArea area = new TextArea();
area.setEditable(false);
area.setWrapText(false);
return area;
}
private static ListCell<AiTestProject> projectCell() {
return new ListCell<>() {
@Override
protected void updateItem(AiTestProject item, boolean empty) {
super.updateItem(item, empty);
setText(empty || item == null ? null : item.name() + " — " + item.path()
+ (item.available() ? "" : "(不可用)"));
}
};
}
private static Path path(String value) {
return value == null || value.isBlank() ? Path.of("") : Path.of(value.trim());
}
private String formatHistory(AiTestRunSummary summary) {
return summary.updatedAt() + " | " + summary.project() + " | " + summary.module()
+ " | " + summary.state() + " | "
+ (summary.lineCoverage() == null ? "-" : summary.lineCoverage() + "%")
+ " | " + summary.message();
}
private record InitialData(AiTestSettings settings, String defaultSpec,
List<AiTestProject> projects, List<AiTestRunSummary> history) { }
private record ProjectData(MavenProjectModel model, String specification, String preferredModule) { }
}
@@ -0,0 +1,74 @@
package com.example.workutils.biz.aitest;
import com.example.workutils.biz.aitest.application.AiTestWorkflow;
import com.example.workutils.biz.aitest.application.AiTestHistoryService;
import com.example.workutils.biz.aitest.config.AiTestModulePreferenceService;
import com.example.workutils.biz.aitest.config.AiTestSettingsService;
import com.example.workutils.biz.aitest.config.TestSpecificationService;
import com.example.workutils.biz.aitest.project.AiTestProjectCatalog;
import com.example.workutils.biz.aitest.project.MavenProjectAnalyzer;
import com.example.workutils.system.plugin.Plugin;
import com.example.workutils.system.plugin.PluginDescriptor;
import com.example.workutils.system.plugin.VisualPluginFactory;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class AiTestPlugin implements Plugin {
private static final PluginDescriptor DESCRIPTOR = PluginDescriptor.builder()
.id("ai-unit-test")
.name("AI 单测生成")
.version("1.0.0")
.description("按 Git 增量生成、验证并合并 JUnit 5 单测")
.order(45)
.icon("AI")
.build();
private final AiTestWorkflow workflow;
private final AiTestProjectCatalog projectCatalog;
private final MavenProjectAnalyzer projectAnalyzer;
private final AiTestSettingsService settingsService;
private final TestSpecificationService specificationService;
private final AiTestModulePreferenceService modulePreferences;
private final AiTestHistoryService historyService;
private final ToolchainValidator toolchainValidator;
@Override
public PluginDescriptor getDescriptor() {
return DESCRIPTOR;
}
@Override
public VisualPluginFactory getVisualFactory() {
return new VisualPluginFactory() {
private AiTestPane pane;
@Override
public Parent getEmbeddedPane() {
if (pane == null) {
pane = createPane();
}
return pane;
}
@Override
public void open(Runnable onClosed) {
AiTestPane content = createPane();
Stage stage = new Stage();
stage.setTitle(DESCRIPTOR.getName());
stage.setScene(new Scene(content, 1180, 820));
stage.setOnHidden(event -> onClosed.run());
stage.show();
}
private AiTestPane createPane() {
return new AiTestPane(workflow, projectCatalog, projectAnalyzer, settingsService,
specificationService, modulePreferences, historyService, toolchainValidator);
}
};
}
}
@@ -0,0 +1,4 @@
package com.example.workutils.biz.aitest;
public record ToolchainCheck(String name, boolean successful, String detail) {
}
@@ -0,0 +1,9 @@
package com.example.workutils.biz.aitest;
import java.util.List;
public record ToolchainValidation(boolean ready, List<ToolchainCheck> checks) {
public ToolchainValidation {
checks = List.copyOf(checks);
}
}
@@ -0,0 +1,77 @@
package com.example.workutils.biz.aitest;
import com.example.workutils.biz.aitest.config.AiTestSettings;
import com.example.workutils.biz.aitest.process.CommandResult;
import com.example.workutils.biz.aitest.process.CommandSpec;
import com.example.workutils.biz.aitest.process.ProcessExecutor;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
@Service
@RequiredArgsConstructor
public class ToolchainValidator {
private final ProcessExecutor executor;
public ToolchainValidation validate(AiTestSettings settings, Consumer<String> output) {
List<ToolchainCheck> checks = new ArrayList<>();
checks.add(check("JDK 8", javaCommand(settings), javaEnvironment(settings), "1.8", output));
checks.add(check("Maven", mavenCommand(settings), javaEnvironment(settings), "Apache Maven", output));
checks.add(check("CodeFree CLI", shellCommand(settings.codeFreeCommand(), "--version"),
Map.of(), "", output));
return new ToolchainValidation(checks.stream().allMatch(ToolchainCheck::successful), checks);
}
private ToolchainCheck check(String name, List<String> command, Map<String, String> environment,
String requiredText, Consumer<String> output) {
CommandResult result = executor.execute(new CommandSpec(Path.of(".").toAbsolutePath().normalize(),
command, environment, Duration.ofSeconds(30)), output);
String detail = (result.stdout() + System.lineSeparator() + result.stderr()).trim();
boolean valid = result.successful() && (requiredText.isBlank() || detail.contains(requiredText));
return new ToolchainCheck(name, valid, detail);
}
private static List<String> javaCommand(AiTestSettings settings) {
String executable = settings.jdk8Home().toString().isBlank() ? "java"
: settings.jdk8Home().resolve("bin").resolve(isWindows() ? "java.exe" : "java").toString();
return shellCommand(executable, "-version");
}
private static List<String> mavenCommand(AiTestSettings settings) {
String executable = settings.mavenHome().toString().isBlank() ? (isWindows() ? "mvn.cmd" : "mvn")
: settings.mavenHome().resolve("bin").resolve(isWindows() ? "mvn.cmd" : "mvn").toString();
return shellCommand(executable, "-version");
}
private static List<String> shellCommand(String executable, String argument) {
List<String> command = new ArrayList<>();
if (isWindows()) {
command.add(System.getenv().getOrDefault("COMSPEC", "cmd.exe"));
command.add("/d");
command.add("/c");
}
command.add(executable);
command.add(argument);
return command;
}
private static Map<String, String> javaEnvironment(AiTestSettings settings) {
if (settings.jdk8Home().toString().isBlank()) {
return Map.of();
}
Map<String, String> environment = new HashMap<>();
environment.put("JAVA_HOME", settings.jdk8Home().toAbsolutePath().normalize().toString());
return environment;
}
private static boolean isWindows() {
return System.getProperty("os.name", "").toLowerCase().contains("win");
}
}
@@ -99,15 +99,18 @@ public class AiTestWorkflow {
String feedback = "首次生成";
for (int round = 1; round <= analyzed.request().settings().maxRepairRounds(); round++) {
int currentRound = 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);
String prompt = buildPrompt(run, module, specifications,
loadExistingTestSamples(module), feedback, round);
TestGenerationResult generated = provider.generate(new TestGenerationRequest(
worktree.path(), analyzed.request().settings().codeFreeCommand(), prompt,
analyzed.request().settings().codeFreeTimeout()), ignored -> { });
analyzed.request().settings().codeFreeTimeout()),
line -> emit(progress, AiTestRunState.GENERATING, currentRound, line));
if (!generated.successful()) {
return failAndClose(run, "CodeFree 生成失败: " + generated.diagnosis(), progress);
}
@@ -117,7 +120,8 @@ public class AiTestWorkflow {
emit(progress, AiTestRunState.VERIFYING, round, "正在编译并运行生成的单测");
MavenExecutionResult compilation = mavenRunner.run(worktree.path(), module,
MavenPhase.TEST_COMPILE, analyzed.request().settings(), ignored -> { });
MavenPhase.TEST_COMPILE, analyzed.request().settings(),
line -> emit(progress, AiTestRunState.VERIFYING, currentRound, line));
if (!compilation.successful()) {
MavenFailureType type = errorClassifier.classify(compilation);
run = run.round(round, null, files, compilation.combinedLog());
@@ -129,7 +133,8 @@ public class AiTestWorkflow {
}
MavenExecutionResult verified = mavenRunner.run(worktree.path(), module,
MavenPhase.JACOCO, analyzed.request().settings(), ignored -> { });
MavenPhase.JACOCO, analyzed.request().settings(),
line -> emit(progress, AiTestRunState.VERIFYING, currentRound, line));
if (!verified.successful()) {
MavenFailureType type = errorClassifier.classify(verified);
run = run.round(round, null, files, verified.combinedLog());
@@ -215,7 +220,7 @@ public class AiTestWorkflow {
}
private static String buildPrompt(AiTestRun run, MavenModule module, String specifications,
String feedback, int round) {
String existingTests, String feedback, int round) {
StringBuilder sources = new StringBuilder();
run.analysis().files().forEach(changed -> {
Path source = run.worktree().path().resolve(changed.sourcePath()).normalize();
@@ -243,10 +248,33 @@ public class AiTestWorkflow {
增量源码:%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);
run.request().settings().coverageTarget(), round, feedback, sources, existingTests);
}
private static String loadExistingTestSamples(MavenModule module) {
Path testRoot = module.directory().resolve("src/test/java");
if (Files.notExists(testRoot)) {
return "<暂无已有单测>";
}
try (Stream<Path> paths = Files.walk(testRoot)) {
StringBuilder samples = new StringBuilder();
for (Path file : paths.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().endsWith("Test.java"))
.limit(5).toList()) {
String content = Files.readString(file, StandardCharsets.UTF_8);
samples.append("\n--- ").append(testRoot.relativize(file)).append(" ---\n")
.append(content, 0, Math.min(content.length(), 12000));
}
return samples.isEmpty() ? "<暂无已有单测>" : samples.toString();
} catch (IOException e) {
return "<已有单测读取失败>";
}
}
private static String repairFeedback(MavenFailureType type, String log, IncrementalCoverage coverage) {
@@ -0,0 +1,61 @@
package com.example.workutils.biz.aitest.config;
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.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.Properties;
@Service
public class AiTestModulePreferenceService {
private final Path file;
@Autowired
public AiTestModulePreferenceService(WorkUtilsProperties properties) {
this(properties.getWorkutilsConfigFilePath().getParent().resolve("ai-test")
.resolve("module-preferences.properties"));
}
public AiTestModulePreferenceService(ApplicationPaths paths) {
this(paths.resolve("data", "ai-test", "module-preferences.properties"));
}
private AiTestModulePreferenceService(Path file) {
this.file = file;
}
public Optional<String> load(Path project) throws IOException {
Properties values = read();
return Optional.ofNullable(values.getProperty(key(project))).filter(value -> !value.isBlank());
}
public void save(Path project, String artifactId) throws IOException {
Properties values = read();
values.setProperty(key(project), artifactId == null ? "" : artifactId);
Files.createDirectories(file.getParent());
try (OutputStream output = Files.newOutputStream(file)) {
values.store(output, "WorkUtils AI test module preferences");
}
}
private Properties read() throws IOException {
Properties values = new Properties();
if (Files.exists(file)) {
try (InputStream input = Files.newInputStream(file)) {
values.load(input);
}
}
return values;
}
private static String key(Path project) {
return Integer.toHexString(project.toAbsolutePath().normalize().toString().hashCode());
}
}
@@ -316,7 +316,8 @@ public class MainWindow {
return switch (pluginId) {
case "scan" -> AppIcon.SCANNER;
case "diff" -> AppIcon.DIFF;
case "api-doc" -> AppIcon.API_DOC;
case "api-doc" -> AppIcon.API_DOC;
case "ai-unit-test" -> AppIcon.AI_TEST;
case "git-sync" -> AppIcon.GIT_SYNC;
case "git-review" -> AppIcon.GIT_REVIEW;
case "mybatisexport" -> AppIcon.SQL_EXPORT;
@@ -15,6 +15,7 @@ public enum AppIcon {
MYBATIS_GENERATOR(Feather.CODE),
SCANNER(Feather.SEARCH),
API_DOC(Feather.FILE_TEXT),
AI_TEST(Feather.CPU),
EXPORT(Feather.DOWNLOAD),
CANCEL(Feather.X),
APP(Feather.TOOL);
@@ -0,0 +1,31 @@
package com.example.workutils.biz.aitest;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AiTestPaneArchitectureTest {
@Test
void exposesConfigurationWorkflowReviewAndIntegrationControls() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/com/example/workutils/biz/aitest/AiTestPane.java"));
assertTrue(source.contains("projectCombo"));
assertTrue(source.contains("moduleCombo"));
assertTrue(source.contains("baselineField"));
assertTrue(source.contains("includeWorkingTree"));
assertTrue(source.contains("defaultSpecification"));
assertTrue(source.contains("projectSpecification"));
assertTrue(source.contains("analyzeButton"));
assertTrue(source.contains("generateButton"));
assertTrue(source.contains("cancelButton"));
assertTrue(source.contains("integrateButton"));
assertTrue(source.contains("progressBar"));
assertTrue(source.contains("resultTabs"));
assertTrue(source.contains("runAsync"));
}
}
@@ -0,0 +1,29 @@
package com.example.workutils.biz.aitest;
import com.example.workutils.biz.aitest.application.AiTestWorkflow;
import com.example.workutils.biz.aitest.application.AiTestHistoryService;
import com.example.workutils.biz.aitest.config.AiTestSettingsService;
import com.example.workutils.biz.aitest.config.AiTestModulePreferenceService;
import com.example.workutils.biz.aitest.config.TestSpecificationService;
import com.example.workutils.biz.aitest.project.AiTestProjectCatalog;
import com.example.workutils.biz.aitest.project.MavenProjectAnalyzer;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
class AiTestPluginTest {
@Test
void exposesEmbeddedAiUnitTestPlugin() {
AiTestPlugin plugin = new AiTestPlugin(mock(AiTestWorkflow.class),
mock(AiTestProjectCatalog.class), mock(MavenProjectAnalyzer.class),
mock(AiTestSettingsService.class), mock(TestSpecificationService.class),
mock(AiTestModulePreferenceService.class), mock(AiTestHistoryService.class),
mock(ToolchainValidator.class));
assertEquals("ai-unit-test", plugin.getDescriptor().getId());
assertEquals("AI 单测生成", plugin.getDescriptor().getName());
assertNotNull(plugin.getVisualFactory());
}
}
@@ -0,0 +1,38 @@
package com.example.workutils.biz.aitest;
import com.example.workutils.biz.aitest.config.AiTestSettings;
import com.example.workutils.biz.aitest.process.CommandResult;
import com.example.workutils.biz.aitest.process.ProcessExecutor;
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
class ToolchainValidatorTest {
@Test
void reportsAllRequiredJdk8MavenAndCodeFreeChecks() {
ProcessExecutor executor = mock(ProcessExecutor.class);
when(executor.execute(any(), any())).thenReturn(
ok("java version \"1.8.0_402\""), ok("Apache Maven 3.9.9"), ok("0.3.6"));
ToolchainValidator validator = new ToolchainValidator(executor);
AiTestSettings settings = new AiTestSettings(Path.of("jdk8"), Path.of("maven"),
"codefree", 85, 3, Duration.ofMinutes(5), Duration.ofMinutes(15));
ToolchainValidation validation = validator.validate(settings, ignored -> { });
assertTrue(validation.ready());
assertEquals(3, validation.checks().size());
assertTrue(validation.checks().stream().anyMatch(check -> check.name().equals("JDK 8")));
verify(executor, times(3)).execute(any(), any());
}
private static CommandResult ok(String output) {
return new CommandResult(0, false, false, output, "", Duration.ofMillis(1));
}
}
@@ -18,8 +18,10 @@ import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import org.mockito.ArgumentCaptor;
class AiTestWorkflowTest {
@@ -29,6 +31,9 @@ class AiTestWorkflowTest {
Path workspace = root.resolve("workspace");
Files.createDirectories(project);
Files.createDirectories(workspace.resolve("order-start"));
Files.createDirectories(workspace.resolve("order-start/src/test/java/a"));
Files.writeString(workspace.resolve("order-start/src/test/java/a/ExistingTest.java"),
"package a; class ExistingTest { void projectStyle() {} }");
MavenModule originalModule = module(project);
MavenModule workspaceModule = module(workspace);
MavenProjectModel originalModel = new MavenProjectModel(project, List.of(originalModule));
@@ -78,6 +83,9 @@ class AiTestWorkflowTest {
assertEquals(AiTestRunState.READY_FOR_REVIEW, result.state());
assertEquals(2, result.generationRounds());
assertEquals(new BigDecimal("90.00"), result.coverage().linePercent());
ArgumentCaptor<TestGenerationRequest> requestCaptor = ArgumentCaptor.forClass(TestGenerationRequest.class);
verify(provider, times(2)).generate(requestCaptor.capture(), any());
assertTrue(requestCaptor.getAllValues().get(0).prompt().contains("projectStyle"));
}
private static MavenModule module(Path root) {
@@ -0,0 +1,23 @@
package com.example.workutils.biz.aitest.config;
import com.example.workutils.system.support.ApplicationPaths;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AiTestModulePreferenceServiceTest {
@Test
void remembersSelectedModulePerProject(@TempDir Path home) throws Exception {
AiTestModulePreferenceService service = new AiTestModulePreferenceService(ApplicationPaths.of(home));
Path project = home.resolve("project");
service.save(project, "order-start");
assertEquals("order-start", service.load(project).orElseThrow());
assertEquals("", service.load(home.resolve("other")).orElse(""));
}
}