feat: add AI unit test generation plugin UI
This commit is contained in:
@@ -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) {
|
||||
|
||||
+61
@@ -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) {
|
||||
|
||||
+23
@@ -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(""));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user