diff --git a/src/main/java/com/example/workutils/biz/aitest/AiTestPane.java b/src/main/java/com/example/workutils/biz/aitest/AiTestPane.java index cd82944..be8559c 100644 --- a/src/main/java/com/example/workutils/biz/aitest/AiTestPane.java +++ b/src/main/java/com/example/workutils/biz/aitest/AiTestPane.java @@ -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 coverageTarget = new Spinner<>(0, 100, 85); private final Spinner 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 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 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 void runSilent(Callable operation, Consumer success, + Consumer 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) { diff --git a/src/test/java/com/example/workutils/biz/aitest/AiTestPaneArchitectureTest.java b/src/test/java/com/example/workutils/biz/aitest/AiTestPaneArchitectureTest.java index 7ecbbc1..3e1eecd 100644 --- a/src/test/java/com/example/workutils/biz/aitest/AiTestPaneArchitectureTest.java +++ b/src/test/java/com/example/workutils/biz/aitest/AiTestPaneArchitectureTest.java @@ -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 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) baseline::getText)); + assertEquals("HEAD", FxTestSupport.fx((Callable) 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 List tabDescendants(TabPane tabs, Class type) { + List result = new ArrayList<>(); + tabs.getTabs().forEach(tab -> collect(tab.getContent(), type, result)); + return result; + } + + private static List descendants(Parent root, Class type) { + List result = new ArrayList<>(); + collect(root, type, result); + return result; + } + + private static void collect(Node node, Class type, List result) { + if (type.isInstance(node)) { + result.add(type.cast(node)); + } + if (node instanceof Parent parent) { + parent.getChildrenUnmodifiable().forEach(child -> collect(child, type, result)); + } } }