From c8e114e700f7eee4147952fab43569462ee9a75c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Tue, 4 Aug 2026 17:18:24 +0800 Subject: [PATCH] feat: add AI unit test generation plugin UI --- .../workutils/biz/aitest/AiTestPane.java | 425 ++++++++++++++++++ .../workutils/biz/aitest/AiTestPlugin.java | 74 +++ .../workutils/biz/aitest/ToolchainCheck.java | 4 + .../biz/aitest/ToolchainValidation.java | 9 + .../biz/aitest/ToolchainValidator.java | 77 ++++ .../aitest/application/AiTestWorkflow.java | 40 +- .../config/AiTestModulePreferenceService.java | 61 +++ .../workutils/system/ui/MainWindow.java | 3 +- .../workutils/system/ui/icon/AppIcon.java | 1 + .../aitest/AiTestPaneArchitectureTest.java | 31 ++ .../biz/aitest/AiTestPluginTest.java | 29 ++ .../biz/aitest/ToolchainValidatorTest.java | 38 ++ .../application/AiTestWorkflowTest.java | 8 + .../AiTestModulePreferenceServiceTest.java | 23 + 14 files changed, 816 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/example/workutils/biz/aitest/AiTestPane.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/AiTestPlugin.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/ToolchainCheck.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/ToolchainValidation.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/ToolchainValidator.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/config/AiTestModulePreferenceService.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/AiTestPaneArchitectureTest.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/AiTestPluginTest.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/ToolchainValidatorTest.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/config/AiTestModulePreferenceServiceTest.java diff --git a/src/main/java/com/example/workutils/biz/aitest/AiTestPane.java b/src/main/java/com/example/workutils/biz/aitest/AiTestPane.java new file mode 100644 index 0000000..5e7adcf --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/AiTestPane.java @@ -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 projectCombo = new ComboBox<>(); + private final ComboBox 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 coverageTarget = new Spinner<>(0, 100, 85); + private final Spinner 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 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 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 void runAsync(Callable operation, Consumer 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 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 projects, List history) { } + private record ProjectData(MavenProjectModel model, String specification, String preferredModule) { } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/AiTestPlugin.java b/src/main/java/com/example/workutils/biz/aitest/AiTestPlugin.java new file mode 100644 index 0000000..ef4aa4c --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/AiTestPlugin.java @@ -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); + } + }; + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/ToolchainCheck.java b/src/main/java/com/example/workutils/biz/aitest/ToolchainCheck.java new file mode 100644 index 0000000..7b46751 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/ToolchainCheck.java @@ -0,0 +1,4 @@ +package com.example.workutils.biz.aitest; + +public record ToolchainCheck(String name, boolean successful, String detail) { +} diff --git a/src/main/java/com/example/workutils/biz/aitest/ToolchainValidation.java b/src/main/java/com/example/workutils/biz/aitest/ToolchainValidation.java new file mode 100644 index 0000000..8dd177e --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/ToolchainValidation.java @@ -0,0 +1,9 @@ +package com.example.workutils.biz.aitest; + +import java.util.List; + +public record ToolchainValidation(boolean ready, List checks) { + public ToolchainValidation { + checks = List.copyOf(checks); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/ToolchainValidator.java b/src/main/java/com/example/workutils/biz/aitest/ToolchainValidator.java new file mode 100644 index 0000000..a1394de --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/ToolchainValidator.java @@ -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 output) { + List 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 command, Map environment, + String requiredText, Consumer 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 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 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 shellCommand(String executable, String argument) { + List 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 javaEnvironment(AiTestSettings settings) { + if (settings.jdk8Home().toString().isBlank()) { + return Map.of(); + } + Map 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"); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/application/AiTestWorkflow.java b/src/main/java/com/example/workutils/biz/aitest/application/AiTestWorkflow.java index 2945689..e9ee655 100644 --- a/src/main/java/com/example/workutils/biz/aitest/application/AiTestWorkflow.java +++ b/src/main/java/com/example/workutils/biz/aitest/application/AiTestWorkflow.java @@ -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 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) { diff --git a/src/main/java/com/example/workutils/biz/aitest/config/AiTestModulePreferenceService.java b/src/main/java/com/example/workutils/biz/aitest/config/AiTestModulePreferenceService.java new file mode 100644 index 0000000..b1a1a6b --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/config/AiTestModulePreferenceService.java @@ -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 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()); + } +} diff --git a/src/main/java/com/example/workutils/system/ui/MainWindow.java b/src/main/java/com/example/workutils/system/ui/MainWindow.java index 18da749..6d87fc6 100644 --- a/src/main/java/com/example/workutils/system/ui/MainWindow.java +++ b/src/main/java/com/example/workutils/system/ui/MainWindow.java @@ -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; diff --git a/src/main/java/com/example/workutils/system/ui/icon/AppIcon.java b/src/main/java/com/example/workutils/system/ui/icon/AppIcon.java index f1f467d..dd4c32e 100644 --- a/src/main/java/com/example/workutils/system/ui/icon/AppIcon.java +++ b/src/main/java/com/example/workutils/system/ui/icon/AppIcon.java @@ -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); diff --git a/src/test/java/com/example/workutils/biz/aitest/AiTestPaneArchitectureTest.java b/src/test/java/com/example/workutils/biz/aitest/AiTestPaneArchitectureTest.java new file mode 100644 index 0000000..e80a1ed --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/AiTestPaneArchitectureTest.java @@ -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")); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/AiTestPluginTest.java b/src/test/java/com/example/workutils/biz/aitest/AiTestPluginTest.java new file mode 100644 index 0000000..7130de0 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/AiTestPluginTest.java @@ -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()); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/ToolchainValidatorTest.java b/src/test/java/com/example/workutils/biz/aitest/ToolchainValidatorTest.java new file mode 100644 index 0000000..e3859e5 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/ToolchainValidatorTest.java @@ -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)); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/application/AiTestWorkflowTest.java b/src/test/java/com/example/workutils/biz/aitest/application/AiTestWorkflowTest.java index 565772c..9e1e07a 100644 --- a/src/test/java/com/example/workutils/biz/aitest/application/AiTestWorkflowTest.java +++ b/src/test/java/com/example/workutils/biz/aitest/application/AiTestWorkflowTest.java @@ -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 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) { diff --git a/src/test/java/com/example/workutils/biz/aitest/config/AiTestModulePreferenceServiceTest.java b/src/test/java/com/example/workutils/biz/aitest/config/AiTestModulePreferenceServiceTest.java new file mode 100644 index 0000000..bf3c1f1 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/config/AiTestModulePreferenceServiceTest.java @@ -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("")); + } +}