feat: reorganize AI test plugin into four tabs

This commit is contained in:
张家训
2026-08-05 11:34:40 +08:00
parent 45fcdebd66
commit 2f3ddd221a
2 changed files with 175 additions and 58 deletions
@@ -16,6 +16,9 @@ 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.biz.aitest.project.ModuleLoadPresentation;
import com.example.workutils.biz.aitest.ui.DirectoryPathSelector;
import com.example.workutils.biz.aitest.ui.TaskProgressState;
import com.example.workutils.system.support.AppExecutors;
import javafx.application.Platform;
import javafx.collections.FXCollections;
@@ -24,8 +27,8 @@ import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.Window;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Callable;
@@ -46,8 +49,10 @@ public class AiTestPane extends BorderPane {
private final TextField baselineField = new TextField("master");
private final TextField targetRefField = new TextField("HEAD");
private final CheckBox includeWorkingTree = new CheckBox("包含未提交改动");
private final TextField jdk8Field = new TextField();
private final TextField mavenField = new TextField();
private final DirectoryPathSelector jdkSelector =
new DirectoryPathSelector("JDK 8 Home", this::ownerWindow);
private final DirectoryPathSelector mavenSelector =
new DirectoryPathSelector("Maven Home", this::ownerWindow);
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);
@@ -59,6 +64,7 @@ public class AiTestPane extends BorderPane {
private final Button integrateButton = new Button("合并到本地分支");
private final ProgressBar progressBar = new ProgressBar(0);
private final Label statusLabel = new Label("请选择项目并检查工具链");
private final Label moduleStatusLabel = new Label("请选择项目");
private final Label toolchainStatus = new Label("工具链未检查");
private final TabPane resultTabs = new TabPane();
private final ListView<String> generatedTests = new ListView<>();
@@ -69,6 +75,7 @@ public class AiTestPane extends BorderPane {
private final TextField targetBranch = new TextField();
private final TextField commitMessage = new TextField("test: add AI generated incremental tests");
private final LatestRequestGate projectLoadGate = new LatestRequestGate();
private final TaskProgressState taskProgressState = new TaskProgressState();
private volatile AiTestRun currentRun;
private volatile boolean busy;
@@ -102,6 +109,8 @@ public class AiTestPane extends BorderPane {
cancelButton.setDisable(true);
projectCombo.setMaxWidth(Double.MAX_VALUE);
moduleCombo.setMaxWidth(Double.MAX_VALUE);
baselineField.setId("baseline-ref");
targetRefField.setId("target-ref");
projectCombo.setCellFactory(ignored -> projectCell());
projectCombo.setButtonCell(projectCell());
}
@@ -120,14 +129,17 @@ public class AiTestPane extends BorderPane {
addRow(selection, 0, "项目", projectCombo, "生成模块", moduleCombo);
addRow(selection, 1, "基线分支/Commit", baselineField, "目标分支/Commit", targetRefField);
selection.add(includeWorkingTree, 1, 2);
selection.add(moduleStatusLabel, 3, 2, 2, 1);
VBox projectPage = page(selection);
GridPane toolchain = grid();
Button validateToolchain = new Button("检查并保存工具链");
addRow(toolchain, 0, "JDK 8 Home", jdk8Field, "Maven Home", mavenField);
addRow(toolchain, 0, "JDK 8 Home", jdkSelector, "Maven Home", mavenSelector);
addRow(toolchain, 1, "CodeFree 命令", codeFreeField, "状态", toolchainStatus);
addRow(toolchain, 2, "覆盖率目标", coverageTarget, "最大修复轮次", repairRounds);
toolchain.add(validateToolchain, 4, 2);
validateToolchain.setOnAction(event -> validateAndSaveToolchain());
VBox toolchainPage = page(toolchain);
defaultSpecification.setPrefRowCount(7);
projectSpecification.setPrefRowCount(7);
@@ -136,6 +148,8 @@ public class AiTestPane extends BorderPane {
specifications.setDividerPositions(0.5);
Button saveSpecifications = new Button("保存质量规范");
saveSpecifications.setOnAction(event -> saveSpecifications());
VBox specificationPage = page(specifications, saveSpecifications);
VBox.setVgrow(specifications, Priority.ALWAYS);
resultTabs.getTabs().addAll(tab("生成文件", generatedTests), tab("覆盖率", coverageArea),
tab("运行日志", logArea), tab("历史摘要", historyArea));
@@ -148,18 +162,19 @@ public class AiTestPane extends BorderPane {
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 resultPage = page(actions, resultTabs, titled("审核并合并", integration));
VBox.setVgrow(resultTabs, Priority.ALWAYS);
return content;
TabPane mainTabs = new TabPane(tab("项目与增量", projectPage), tab("工具链", toolchainPage),
tab("质量规范", specificationPage), tab("结果与合并", resultPage));
mainTabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);
return mainTabs;
}
private Node buildStatus() {
progressBar.setMaxWidth(Double.MAX_VALUE);
taskProgressState.finish();
applyProgressState();
HBox.setHgrow(progressBar, Priority.ALWAYS);
HBox status = new HBox(12, progressBar, statusLabel);
status.setAlignment(Pos.CENTER_LEFT);
@@ -172,8 +187,10 @@ public class AiTestPane extends BorderPane {
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 -> { });
runSilent(() -> {
modulePreferences.save(project.path(), module.artifactId());
return null;
}, ignoredResult -> { }, failure -> appendLog("保存模块偏好失败: " + message(failure)));
}
});
analyzeButton.setOnAction(event -> analyze());
@@ -184,7 +201,7 @@ public class AiTestPane extends BorderPane {
}
private void loadInitialData() {
runAsync(() -> new InitialData(settingsService.load(), specificationService.loadDefault(),
runSilent(() -> new InitialData(settingsService.load(), specificationService.loadDefault(),
projectCatalog.load(), historyService.load()), data -> {
applySettings(data.settings());
defaultSpecification.setText(data.defaultSpec());
@@ -192,6 +209,9 @@ public class AiTestPane extends BorderPane {
historyArea.setText(data.history().stream().map(this::formatHistory)
.reduce("", (left, right) -> left + right + "\n"));
statusLabel.setText(data.projects().isEmpty() ? "project_paths.txt 中没有可用项目" : "请选择项目");
}, failure -> {
statusLabel.setText("初始化失败: " + message(failure));
appendLog(statusLabel.getText());
});
}
@@ -202,7 +222,7 @@ public class AiTestPane extends BorderPane {
if (project == null || !project.available()) {
return;
}
statusLabel.setText("正在加载项目模块...");
moduleStatusLabel.setText("正在加载项目模块...");
AppExecutors.io().submit(() -> {
try {
ProjectData data = new ProjectData(projectAnalyzer.analyze(project.path()),
@@ -219,9 +239,8 @@ public class AiTestPane extends BorderPane {
if (!projectLoadGate.isLatest(projectRequest)) {
return;
}
statusLabel.setText(failure.getMessage() == null
? failure.getClass().getSimpleName() : failure.getMessage());
appendLog("项目加载失败: " + statusLabel.getText());
moduleStatusLabel.setText("项目加载失败: " + message(failure));
appendLog(moduleStatusLabel.getText());
});
}
});
@@ -230,14 +249,10 @@ public class AiTestPane extends BorderPane {
private void applyProjectData(ProjectData 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());
ModuleLoadPresentation presentation = ModuleLoadPresentation.from(
data.model(), data.preferredModule());
moduleCombo.setValue(presentation.selectedModule().orElse(null));
moduleStatusLabel.setText(presentation.message());
}
private void validateAndSaveToolchain() {
@@ -255,13 +270,14 @@ public class AiTestPane extends BorderPane {
private void saveSpecifications() {
AiTestProject project = projectCombo.getValue();
runAsync(() -> {
runSilent(() -> {
specificationService.saveDefault(defaultSpecification.getText());
if (project != null) {
specificationService.saveProject(project.path(), projectSpecification.getText());
}
return null;
}, ignored -> statusLabel.setText("质量规范已保存"));
}, ignored -> statusLabel.setText("质量规范已保存"),
failure -> statusLabel.setText("保存质量规范失败: " + message(failure)));
}
private void analyze() {
@@ -323,8 +339,11 @@ public class AiTestPane extends BorderPane {
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())));
if (progress.round() > 0) {
taskProgressState.update(Math.min(0.95,
progress.round() / (double) Math.max(1, repairRounds.getValue())));
applyProgressState();
}
appendLog(progress.state() + " [轮次 " + progress.round() + "] " + progress.message());
});
}
@@ -349,13 +368,34 @@ public class AiTestPane extends BorderPane {
});
}
private <T> void runSilent(Callable<T> operation, Consumer<T> success,
Consumer<Exception> failureHandler) {
AppExecutors.io().submit(() -> {
try {
T result = operation.call();
Platform.runLater(() -> success.accept(result));
} catch (Exception failure) {
Platform.runLater(() -> failureHandler.accept(failure));
}
});
}
private void setBusy(boolean value) {
busy = value;
cancelButton.setDisable(!value);
updateButtons();
if (value) {
progressBar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
taskProgressState.start();
} else {
taskProgressState.finish();
}
applyProgressState();
}
private void applyProgressState() {
progressBar.setVisible(taskProgressState.visible());
progressBar.setManaged(taskProgressState.visible());
progressBar.setProgress(taskProgressState.progress());
}
private void updateButtons() {
@@ -367,15 +407,15 @@ public class AiTestPane extends BorderPane {
}
private AiTestSettings settingsFromForm() {
return new AiTestSettings(path(jdk8Field.getText()), path(mavenField.getText()),
return new AiTestSettings(jdkSelector.getPath(), mavenSelector.getPath(),
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());
jdkSelector.setPath(settings.jdk8Home());
mavenSelector.setPath(settings.mavenHome());
codeFreeField.setText(settings.codeFreeCommand());
coverageTarget.getValueFactory().setValue(settings.coverageTarget());
repairRounds.getValueFactory().setValue(settings.maxRepairRounds());
@@ -402,6 +442,12 @@ public class AiTestPane extends BorderPane {
return grid;
}
private static VBox page(Node... nodes) {
VBox page = new VBox(16, nodes);
page.setPadding(new Insets(20));
return page;
}
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);
@@ -437,8 +483,12 @@ public class AiTestPane extends BorderPane {
};
}
private static Path path(String value) {
return value == null || value.isBlank() ? Path.of("") : Path.of(value.trim());
private Window ownerWindow() {
return getScene() == null ? null : getScene().getWindow();
}
private static String message(Exception failure) {
return failure.getMessage() == null ? failure.getClass().getSimpleName() : failure.getMessage();
}
private String formatHistory(AiTestRunSummary summary) {
@@ -1,35 +1,102 @@
package com.example.workutils.biz.aitest;
import com.example.workutils.biz.aitest.application.AiTestHistoryService;
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.AiTestProjectCatalog;
import com.example.workutils.biz.aitest.project.MavenProjectAnalyzer;
import com.example.workutils.biz.aitest.ui.DirectoryPathSelector;
import com.example.workutils.system.ui.FxTestSupport;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.Accordion;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.TabPane;
import javafx.scene.control.TextField;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class AiTestPaneArchitectureTest {
@Test
void exposesConfigurationWorkflowReviewAndIntegrationControls() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/com/example/workutils/biz/aitest/AiTestPane.java"));
void presentsFourMainTabsWithoutAccordionAndStartsIdle() throws Exception {
AiTestPane pane = createPane();
assertTrue(source.contains("projectCombo"));
assertTrue(source.contains("moduleCombo"));
assertTrue(source.contains("baselineField"));
assertTrue(source.contains("targetRefField"));
assertTrue(source.contains("new TextField(\"master\")"));
assertTrue(source.contains("LatestRequestGate"));
assertTrue(source.contains("isLatest(projectRequest)"));
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"));
TabPane mainTabs = assertInstanceOf(TabPane.class, FxTestSupport.fx(pane::getCenter));
assertEquals(List.of("项目与增量", "工具链", "质量规范", "结果与合并"),
FxTestSupport.fx(() -> mainTabs.getTabs().stream().map(tab -> tab.getText()).toList()));
assertEquals(0, FxTestSupport.fx(() -> tabDescendants(mainTabs, Accordion.class).size()));
assertEquals(2, FxTestSupport.fx(
() -> tabDescendants(mainTabs, DirectoryPathSelector.class).size()));
List<DirectoryPathSelector> selectors = FxTestSupport.fx(
() -> tabDescendants(mainTabs, DirectoryPathSelector.class));
assertFalse(FxTestSupport.fx(() -> selectors.get(0).pathField().isEditable()));
assertFalse(FxTestSupport.fx(() -> selectors.get(1).pathField().isEditable()));
ProgressBar progress = FxTestSupport.fx(() -> descendants(pane, ProgressBar.class).get(0));
assertFalse(FxTestSupport.fx(progress::isVisible));
assertFalse(FxTestSupport.fx(progress::isManaged));
TextField baseline = FxTestSupport.fx(() -> textField(mainTabs, "baseline-ref"));
TextField target = FxTestSupport.fx(() -> textField(mainTabs, "target-ref"));
assertEquals("master", FxTestSupport.fx((Callable<String>) baseline::getText));
assertEquals("HEAD", FxTestSupport.fx((Callable<String>) target::getText));
}
private static AiTestPane createPane() throws Exception {
AiTestSettingsService settingsService = mock(AiTestSettingsService.class);
when(settingsService.load()).thenReturn(new AiTestSettings(Path.of("jdk8"), Path.of("maven"),
"codefree", 85, 3, Duration.ofMinutes(5), Duration.ofMinutes(15)));
TestSpecificationService specifications = mock(TestSpecificationService.class);
when(specifications.loadDefault()).thenReturn("");
AiTestProjectCatalog catalog = mock(AiTestProjectCatalog.class);
when(catalog.load()).thenReturn(List.of());
AiTestHistoryService history = mock(AiTestHistoryService.class);
when(history.load()).thenReturn(List.of());
return FxTestSupport.fx(() -> new AiTestPane(mock(AiTestWorkflow.class), catalog,
mock(MavenProjectAnalyzer.class), settingsService, specifications,
mock(AiTestModulePreferenceService.class), history, mock(ToolchainValidator.class)));
}
private static TextField textField(TabPane tabs, String id) {
return tabDescendants(tabs, TextField.class).stream()
.filter(field -> id.equals(field.getId()))
.findFirst().orElseThrow();
}
private static <T extends Node> List<T> tabDescendants(TabPane tabs, Class<T> type) {
List<T> result = new ArrayList<>();
tabs.getTabs().forEach(tab -> collect(tab.getContent(), type, result));
return result;
}
private static <T extends Node> List<T> descendants(Parent root, Class<T> type) {
List<T> result = new ArrayList<>();
collect(root, type, result);
return result;
}
private static <T extends Node> void collect(Node node, Class<T> type, List<T> result) {
if (type.isInstance(node)) {
result.add(type.cast(node));
}
if (node instanceof Parent parent) {
parent.getChildrenUnmodifiable().forEach(child -> collect(child, type, result));
}
}
}