diff --git a/docs/coverage-report-sample/authors/张三.html b/docs/coverage-report-sample/authors/张三.html new file mode 100644 index 0000000..89b6400 --- /dev/null +++ b/docs/coverage-report-sample/authors/张三.html @@ -0,0 +1,89 @@ + + + + + +提交人 - 张三 + + + + + +
+

该提交人的提交记录(精确到 commit)

+ + + + + + + +
commit提交人时间说明行覆盖行覆盖率
c1a2b3c4张三2026-08-01T10:30:00+08:00提交记录 1:张三 的变更0 / 2
0.00%
c2a2b3c4张三2026-08-02T10:30:00+08:00提交记录 2:张三 的变更0 / 2
0.00%
c3a2b3c4张三2026-08-03T10:30:00+08:00提交记录 3:张三 的变更0 / 2
0.00%
+
+ + + diff --git a/docs/coverage-report-sample/authors/李四.html b/docs/coverage-report-sample/authors/李四.html new file mode 100644 index 0000000..eccceb5 --- /dev/null +++ b/docs/coverage-report-sample/authors/李四.html @@ -0,0 +1,88 @@ + + + + + +提交人 - 李四 + + + + + +
+

该提交人的提交记录(精确到 commit)

+ + + + + + +
commit提交人时间说明行覆盖行覆盖率
c1a2b3c4李四2026-08-01T10:30:00+08:00提交记录 1:李四 的变更0 / 3
0.00%
c2a2b3c4李四2026-08-02T10:30:00+08:00提交记录 2:李四 的变更0 / 3
0.00%
+
+ + + diff --git a/docs/coverage-report-sample/authors/王五.html b/docs/coverage-report-sample/authors/王五.html new file mode 100644 index 0000000..bbf27a9 --- /dev/null +++ b/docs/coverage-report-sample/authors/王五.html @@ -0,0 +1,87 @@ + + + + + +提交人 - 王五 + + + + + +
+

该提交人的提交记录(精确到 commit)

+ + + + + +
commit提交人时间说明行覆盖行覆盖率
c1a2b3c4王五2026-08-01T10:30:00+08:00提交记录 1:王五 的变更0 / 3
0.00%
+
+ + + diff --git a/docs/coverage-report-sample/by-author.html b/docs/coverage-report-sample/by-author.html new file mode 100644 index 0000000..6f784b5 --- /dev/null +++ b/docs/coverage-report-sample/by-author.html @@ -0,0 +1,82 @@ + + + + + +提交人覆盖率统计 + + + + + +
+ + + + + + + +
提交人邮箱提交数文件数行覆盖行覆盖率
张三zhangsan@bestpay.com322 / 5
40.00%
李四lisi@bestpay.com221 / 4
25.00%
王五wangwu@bestpay.com120 / 2
0.00%
+
+ + + diff --git a/docs/coverage-report-sample/files/OrderService-120695304.html b/docs/coverage-report-sample/files/OrderService-120695304.html new file mode 100644 index 0000000..0d654f8 --- /dev/null +++ b/docs/coverage-report-sample/files/OrderService-120695304.html @@ -0,0 +1,92 @@ + + + + + +OrderService + + + + + +
+

方法级覆盖

+ + + + + + + +
方法行范围指令覆盖覆盖率
createOrder5 - 106 / 8
75.00%
cancelOrder12 - 176 / 8
75.00%
refund19 - 246 / 8
75.00%
+
+
+

行级覆盖标注 已覆盖部分未覆盖

+
源码文件未找到:.\src\main\java\com\bestpay\pay\order\OrderService.java
+
+ + + diff --git a/docs/coverage-report-sample/files/PaymentService-581198864.html b/docs/coverage-report-sample/files/PaymentService-581198864.html new file mode 100644 index 0000000..536001f --- /dev/null +++ b/docs/coverage-report-sample/files/PaymentService-581198864.html @@ -0,0 +1,91 @@ + + + + + +PaymentService + + + + + +
+

方法级覆盖

+ + + + + + +
方法行范围指令覆盖覆盖率
pay5 - 106 / 8
75.00%
queryStatus12 - 176 / 8
75.00%
+
+
+

行级覆盖标注 已覆盖部分未覆盖

+
源码文件未找到:.\src\main\java\com\bestpay\pay\order\PaymentService.java
+
+ + + diff --git a/docs/coverage-report-sample/files/RefundService-317207270.html b/docs/coverage-report-sample/files/RefundService-317207270.html new file mode 100644 index 0000000..3a9db61 --- /dev/null +++ b/docs/coverage-report-sample/files/RefundService-317207270.html @@ -0,0 +1,91 @@ + + + + + +RefundService + + + + + +
+

方法级覆盖

+ + + + + + +
方法行范围指令覆盖覆盖率
applyRefund5 - 106 / 8
75.00%
auditRefund12 - 176 / 8
75.00%
+
+
+

行级覆盖标注 已覆盖部分未覆盖

+
源码文件未找到:.\src\main\java\com\bestpay\pay\refund\RefundService.java
+
+ + + diff --git a/docs/coverage-report-sample/index.html b/docs/coverage-report-sample/index.html new file mode 100644 index 0000000..b951e0f --- /dev/null +++ b/docs/coverage-report-sample/index.html @@ -0,0 +1,109 @@ + + + + + +覆盖率分析报告 + + + + + +
+
+
99.41%
+
单元测试通过率(目标 100%)
+
通过 2036 / 总数 2048,失败 0,错误 0
+
+
+
70.27%
+
行覆盖率(目标 80%)
+
已覆盖 130 / 185 行
+
+
+
70.45%
+
方法覆盖率
+
已覆盖 31 / 44 方法
+
+
+
+

阈值校验

+ +
+
+

包级覆盖率 查看提交人维度 →

+ + + + + + +
包类数行覆盖行覆盖率方法覆盖方法覆盖率
com/bestpay/pay/order2100 / 125
80.00%
25 / 30
83.33%
com/bestpay/pay/refund130 / 60
50.00%
6 / 14
42.86%
+
+ + + diff --git a/docs/coverage-report-sample/summary.json b/docs/coverage-report-sample/summary.json new file mode 100644 index 0000000..080695d --- /dev/null +++ b/docs/coverage-report-sample/summary.json @@ -0,0 +1,127 @@ +{ + "projectPath":"D:/bestpay-srdcloud/ft-pay-core", + "branch":"develop", + "commitId":"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + "checkoutPerformed":false, + "thresholdPassed":true, + "thresholdFailures":[], + "durationMillis":45230, + "testSummary":{ + "testClassCount":128, + "testCount":2048, + "failureCount":0, + "errorCount":0, + "skippedCount":12, + "passedCount":2036, + "passRate":99.4140625 + }, + "coverageSummary":{ + "packageCount":2, + "classCount":3, + "methodCount":44, + "methodCovered":31, + "lineCount":185, + "lineCovered":130, + "instructionCount":920, + "instructionCovered":650, + "branchCount":120, + "branchCovered":78, + "lineCoverageRatio":0.7027027027027027, + "methodCoverageRatio":0.7045454545454546 + }, + "authorCoverages":[ + { + "author":"张三", + "email":"zhangsan@bestpay.com", + "commitCount":3, + "fileCount":2, + "contributedLineCount":5, + "coveredLineCount":2, + "coverageRatio":0.4, + "commits":[ + { + "commitId":"c1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f", + "shortCommitId":"c1a2b3c4", + "author":"张三", + "commitTime":"2026-08-01T10:30:00+08:00", + "subject":"提交记录 1:张三 的变更", + "contributedLineCount":2, + "coveredLineCount":0, + "coverageRatio":0.0 + }, + { + "commitId":"c2a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f", + "shortCommitId":"c2a2b3c4", + "author":"张三", + "commitTime":"2026-08-02T10:30:00+08:00", + "subject":"提交记录 2:张三 的变更", + "contributedLineCount":2, + "coveredLineCount":0, + "coverageRatio":0.0 + }, + { + "commitId":"c3a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f", + "shortCommitId":"c3a2b3c4", + "author":"张三", + "commitTime":"2026-08-03T10:30:00+08:00", + "subject":"提交记录 3:张三 的变更", + "contributedLineCount":2, + "coveredLineCount":0, + "coverageRatio":0.0 + } + ] + }, + { + "author":"李四", + "email":"lisi@bestpay.com", + "commitCount":2, + "fileCount":2, + "contributedLineCount":4, + "coveredLineCount":1, + "coverageRatio":0.25, + "commits":[ + { + "commitId":"c1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f", + "shortCommitId":"c1a2b3c4", + "author":"李四", + "commitTime":"2026-08-01T10:30:00+08:00", + "subject":"提交记录 1:李四 的变更", + "contributedLineCount":3, + "coveredLineCount":0, + "coverageRatio":0.0 + }, + { + "commitId":"c2a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f", + "shortCommitId":"c2a2b3c4", + "author":"李四", + "commitTime":"2026-08-02T10:30:00+08:00", + "subject":"提交记录 2:李四 的变更", + "contributedLineCount":3, + "coveredLineCount":0, + "coverageRatio":0.0 + } + ] + }, + { + "author":"王五", + "email":"wangwu@bestpay.com", + "commitCount":1, + "fileCount":2, + "contributedLineCount":2, + "coveredLineCount":0, + "coverageRatio":0.0, + "commits":[ + { + "commitId":"c1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f", + "shortCommitId":"c1a2b3c4", + "author":"王五", + "commitTime":"2026-08-01T10:30:00+08:00", + "subject":"提交记录 1:王五 的变更", + "contributedLineCount":3, + "coveredLineCount":0, + "coverageRatio":0.0 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/java/com/example/workutils/biz/coveragereport/CoverageReportPlugin.java b/src/main/java/com/example/workutils/biz/coveragereport/CoverageReportPlugin.java new file mode 100644 index 0000000..6581e1b --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/CoverageReportPlugin.java @@ -0,0 +1,268 @@ +package com.example.workutils.biz.coveragereport; + +import com.example.workutils.biz.coveragereport.config.ConfigLoader; +import com.example.workutils.biz.coveragereport.model.AnalysisResult; +import com.example.workutils.biz.coveragereport.model.CoverageConfig; +import com.example.workutils.biz.coveragereport.service.CoverageAnalysisService; +import com.example.workutils.biz.coveragereport.ui.CoverageReportWindow; +import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.plugin.Plugin; +import com.example.workutils.system.plugin.PluginDescriptor; +import com.example.workutils.system.plugin.VisualPluginFactory; +import com.example.workutils.system.support.FileOperationHelper; +import javafx.scene.Parent; +import javafx.scene.layout.Pane; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +/** + * 覆盖率分析插件。 + *

+ * 能力: + *

+ *

+ * 命令行示例: + *

+ * coverage analyze -p D:/projects/demo --branch main --author 张三,李四 --reportDir target/coverage-report
+ * coverage analyze -p D:/projects/demo --commit a1b2c3d --passRate 100 --lineCoverage 80
+ * 
+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CoverageReportPlugin implements Plugin { + + private static final PluginDescriptor DESCRIPTOR = PluginDescriptor.builder() + .id("coverage-report") + .name("覆盖率分析") + .version("1.0.0") + .description("全量单测通过率 + Git 分支/commit 覆盖率 + 按提交人统计,生成 HTML 报告") + .order(40) + .icon("✓") + .build(); + + private final CoverageAnalysisService analysisService; + private final FileOperationHelper fileOperationHelper; + private final WorkUtilsProperties properties; + + @Override + public PluginDescriptor getDescriptor() { + return DESCRIPTOR; + } + + @Override + public String getCommandName() { + return "coverage"; + } + + @Override + public void executeCommand(String... args) throws Exception { + CliArgs parsed = CliArgs.parse(args); + CoverageConfig config = parsed.toConfig(); + if (config.getProjectPath() == null || config.getProjectPath().isEmpty()) { + System.out.println("用法: coverage analyze -p [--branch | --commit ]" + + " [--baseline <基线分支>] [--author 提交人] [--passRate 100] [--lineCoverage 80]" + + " [--incrementalCoverage 80] [--skipTests] [--reportDir ]"); + return; + } + AnalysisResult result = analysisService.analyze(config, System.out::println); + System.out.println("=================================================================="); + System.out.println("分析完成(耗时 " + (result.getDurationMillis() / 1000) + "s)"); + if (result.getTestSummary() != null) { + System.out.println(String.format("全量测试通过率: %.2f%%", result.getTestSummary().getPassRate())); + } + if (result.getCoverageSummary() != null) { + System.out.println(String.format("行覆盖率: %.2f%%", result.getCoverageSummary().getLineCoverageRatio() * 100)); + System.out.println(String.format("方法覆盖率: %.2f%%", result.getCoverageSummary().getMethodCoverageRatio() * 100)); + } + System.out.println("提交人维度: 共 " + result.getAuthorCoverages().size() + " 位提交人"); + System.out.println("报告入口: " + result.getReportIndexPath()); + System.out.println("阈值校验: " + (result.isThresholdPassed() ? "通过 ✓" : "未通过 ✗")); + for (String f : result.getThresholdFailures()) { + System.out.println(" - " + f); + } + } + + @Override + public VisualPluginFactory getVisualFactory() { + return new VisualPluginFactory() { + private CoverageReportWindow window; + private Parent embeddedRoot; + private CountDownLatch latch; + + @Override + public Parent getEmbeddedPane() { + if (embeddedRoot != null) { + return embeddedRoot; + } + latch = new CountDownLatch(1); + window = new CoverageReportWindow(analysisService, fileOperationHelper, latch); + embeddedRoot = window.getContentPane(); + window.getStage().getScene().setRoot(new Pane()); + return embeddedRoot; + } + + @Override + public void open(Runnable onClosed) { + if (window == null) { + latch = new CountDownLatch(1); + window = new CoverageReportWindow(analysisService, fileOperationHelper, latch); + } + if (embeddedRoot != null && window.getStage().getScene().getRoot() != embeddedRoot) { + window.getStage().getScene().setRoot(embeddedRoot); + } + window.show(); + new Thread(() -> { + try { + latch.await(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + onClosed.run(); + }, "coverage-report-close").start(); + } + }; + } + + @Override + public String checkConfig() { + Path projectPaths = properties.getProjectPathsFilePath(); + if (!Files.exists(projectPaths)) { + return "项目路径配置文件不存在:\n " + projectPaths.toAbsolutePath() + + "\n请先在系统设置中配置需要分析的 Git 项目路径。"; + } + return null; + } + + /** 命令行参数解析 */ + static class CliArgs { + final List args; + + CliArgs(List args) { + this.args = args; + } + + static CliArgs parse(String... raw) { + return new CliArgs(Arrays.asList(raw == null ? new String[0] : raw)); + } + + CoverageConfig toConfig() { + CoverageConfig config = new CoverageConfig(); + int i = 0; + while (i < args.size()) { + String token = args.get(i); + String value = (i + 1 < args.size()) ? args.get(i + 1) : null; + switch (token) { + case "-p": + case "--project": + case "--projectPath": + config.setProjectPath(value); + i += 2; + break; + case "--branch": + config.setBranch(value); + config.markExplicit(ConfigLoader.KEY_BRANCH); + i += 2; + break; + case "--commit": + case "--commitId": + config.setCommitId(value); + config.markExplicit(ConfigLoader.KEY_COMMIT_ID); + i += 2; + break; + case "--author": + case "--authorFilter": + config.setAuthorFilter(value); + config.markExplicit(ConfigLoader.KEY_AUTHOR_FILTER); + i += 2; + break; + case "--passRate": + case "--passRateThreshold": + config.setPassRateThreshold(parseDouble(value, 100.0)); + config.markExplicit(ConfigLoader.KEY_PASS_RATE_THRESHOLD); + i += 2; + break; + case "--lineCoverage": + case "--lineCoverageThreshold": + config.setLineCoverageThreshold(parseDouble(value, 80.0)); + config.markExplicit(ConfigLoader.KEY_LINE_COVERAGE_THRESHOLD); + i += 2; + break; + case "--baseline": + case "--baselineBranch": + config.setBaselineBranch(value); + config.markExplicit(ConfigLoader.KEY_BASELINE_BRANCH); + i += 2; + break; + case "--incrementalCoverage": + case "--incrementalLineCoverageThreshold": + config.setIncrementalLineCoverageThreshold(parseDouble(value, 80.0)); + config.markExplicit(ConfigLoader.KEY_INCREMENTAL_THRESHOLD); + i += 2; + break; + case "--reportDir": + config.setReportOutputDir(value); + config.markExplicit(ConfigLoader.KEY_REPORT_OUTPUT_DIR); + i += 2; + break; + case "--maven": + config.setMavenExecutable(value); + config.markExplicit(ConfigLoader.KEY_MAVEN_EXECUTABLE); + i += 2; + break; + case "--skipTests": + config.setSkipTests(true); + config.markExplicit(ConfigLoader.KEY_SKIP_TESTS); + i += 1; + break; + case "--noCheckout": + config.setCheckoutBeforeAnalyze(false); + config.markExplicit(ConfigLoader.KEY_CHECKOUT_BEFORE); + i += 1; + break; + case "--noWorktree": + config.setUseTemporaryWorktree(false); + config.markExplicit(ConfigLoader.KEY_USE_WORKTREE); + i += 1; + break; + case "--failOnThreshold": + config.setFailOnThreshold(true); + config.markExplicit(ConfigLoader.KEY_FAIL_ON_THRESHOLD); + i += 1; + break; + case "--offline": + config.setOffline(true); + config.markExplicit(ConfigLoader.KEY_OFFLINE); + i += 1; + break; + default: + i += 1; + break; + } + } + return config; + } + + private double parseDouble(String s, double def) { + try { + return Double.parseDouble(s); + } catch (Exception e) { + return def; + } + } + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/config/ConfigLoader.java b/src/main/java/com/example/workutils/biz/coveragereport/config/ConfigLoader.java new file mode 100644 index 0000000..0761b21 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/config/ConfigLoader.java @@ -0,0 +1,144 @@ +package com.example.workutils.biz.coveragereport.config; + +import com.example.workutils.biz.coveragereport.model.CoverageConfig; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Properties; +import java.util.Set; + +/** + * 覆盖率分析配置加载器。 + *

+ * 配置来源(优先级从高到低): + * 1. 程序化传入 / 命令行参数(explicitKeys 标记,优先) + * 2. 项目根目录下的 coveragereport.properties 配置文件 + * 3. 默认值 + */ +@Component +public class ConfigLoader { + + /** 配置文件默认文件名(放于项目根目录) */ + public static final String CONFIG_FILE_NAME = "coveragereport.properties"; + + /** 配置项 key 常量 */ + public static final String KEY_BRANCH = "branch"; + public static final String KEY_COMMIT_ID = "commitId"; + public static final String KEY_AUTHOR_FILTER = "authorFilter"; + public static final String KEY_PASS_RATE_THRESHOLD = "passRateThreshold"; + public static final String KEY_LINE_COVERAGE_THRESHOLD = "lineCoverageThreshold"; + public static final String KEY_BASELINE_BRANCH = "baselineBranch"; + public static final String KEY_INCREMENTAL_THRESHOLD = "incrementalLineCoverageThreshold"; + public static final String KEY_CHECKOUT_BEFORE = "checkoutBeforeAnalyze"; + public static final String KEY_RESTORE_AFTER = "restoreBranchAfter"; + public static final String KEY_FAIL_ON_THRESHOLD = "failOnThreshold"; + public static final String KEY_USE_WORKTREE = "useTemporaryWorktree"; + public static final String KEY_REPORT_OUTPUT_DIR = "reportOutputDir"; + public static final String KEY_SKIP_TESTS = "skipTests"; + public static final String KEY_MAVEN_EXECUTABLE = "mavenExecutable"; + public static final String KEY_OFFLINE = "offline"; + + /** + * 从项目根目录加载配置文件并合并到既有配置。 + * 命令行显式指定的 key(explicitKeys)优先,不被配置文件覆盖。 + * + * @param config 既有配置(可能来自命令行) + * @param explicitKeys 命令行显式设置过的配置 key 集合(可为空) + * @return 合并后的配置 + */ + public CoverageConfig loadFromProject(File projectDir, CoverageConfig config, Set explicitKeys) { + File configFile = new File(projectDir, CONFIG_FILE_NAME); + if (!configFile.isFile()) { + return config; + } + Properties props = new Properties(); + try (InputStream in = new FileInputStream(configFile); + InputStreamReader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { + // 使用 UTF-8 读取,支持配置文件中的中文提交人等 + props.load(reader); + } catch (Exception e) { + return config; + } + applyProperties(config, props, explicitKeys == null ? Set.of() : explicitKeys); + return config; + } + + /** 兼容重载:无显式 key 集合 */ + public CoverageConfig loadFromProject(File projectDir, CoverageConfig config) { + return loadFromProject(projectDir, config, Set.of()); + } + + private void applyProperties(CoverageConfig config, Properties props, Set explicitKeys) { + if (!explicitKeys.contains(KEY_BRANCH) && has(props, KEY_BRANCH)) { + config.setBranch(get(props, KEY_BRANCH)); + } + if (!explicitKeys.contains(KEY_COMMIT_ID) && has(props, KEY_COMMIT_ID)) { + config.setCommitId(get(props, KEY_COMMIT_ID)); + } + if (!explicitKeys.contains(KEY_AUTHOR_FILTER) && has(props, KEY_AUTHOR_FILTER)) { + config.setAuthorFilter(get(props, KEY_AUTHOR_FILTER)); + } + if (!explicitKeys.contains(KEY_PASS_RATE_THRESHOLD) && has(props, KEY_PASS_RATE_THRESHOLD)) { + config.setPassRateThreshold(parseDouble(get(props, KEY_PASS_RATE_THRESHOLD), config.getPassRateThreshold())); + } + if (!explicitKeys.contains(KEY_LINE_COVERAGE_THRESHOLD) && has(props, KEY_LINE_COVERAGE_THRESHOLD)) { + config.setLineCoverageThreshold(parseDouble(get(props, KEY_LINE_COVERAGE_THRESHOLD), config.getLineCoverageThreshold())); + } + if (!explicitKeys.contains(KEY_BASELINE_BRANCH) && has(props, KEY_BASELINE_BRANCH)) { + config.setBaselineBranch(get(props, KEY_BASELINE_BRANCH)); + } + if (!explicitKeys.contains(KEY_INCREMENTAL_THRESHOLD) && has(props, KEY_INCREMENTAL_THRESHOLD)) { + config.setIncrementalLineCoverageThreshold(parseDouble(get(props, KEY_INCREMENTAL_THRESHOLD), config.getIncrementalLineCoverageThreshold())); + } + if (!explicitKeys.contains(KEY_CHECKOUT_BEFORE) && has(props, KEY_CHECKOUT_BEFORE)) { + config.setCheckoutBeforeAnalyze(parseBool(get(props, KEY_CHECKOUT_BEFORE), config.isCheckoutBeforeAnalyze())); + } + if (!explicitKeys.contains(KEY_RESTORE_AFTER) && has(props, KEY_RESTORE_AFTER)) { + config.setRestoreBranchAfter(parseBool(get(props, KEY_RESTORE_AFTER), config.isRestoreBranchAfter())); + } + if (!explicitKeys.contains(KEY_FAIL_ON_THRESHOLD) && has(props, KEY_FAIL_ON_THRESHOLD)) { + config.setFailOnThreshold(parseBool(get(props, KEY_FAIL_ON_THRESHOLD), config.isFailOnThreshold())); + } + if (!explicitKeys.contains(KEY_USE_WORKTREE) && has(props, KEY_USE_WORKTREE)) { + config.setUseTemporaryWorktree(parseBool(get(props, KEY_USE_WORKTREE), config.isUseTemporaryWorktree())); + } + if (!explicitKeys.contains(KEY_REPORT_OUTPUT_DIR) && has(props, KEY_REPORT_OUTPUT_DIR)) { + config.setReportOutputDir(get(props, KEY_REPORT_OUTPUT_DIR)); + } + if (!explicitKeys.contains(KEY_SKIP_TESTS) && has(props, KEY_SKIP_TESTS)) { + config.setSkipTests(parseBool(get(props, KEY_SKIP_TESTS), config.isSkipTests())); + } + if (!explicitKeys.contains(KEY_MAVEN_EXECUTABLE) && has(props, KEY_MAVEN_EXECUTABLE)) { + config.setMavenExecutable(get(props, KEY_MAVEN_EXECUTABLE)); + } + if (!explicitKeys.contains(KEY_OFFLINE) && has(props, KEY_OFFLINE)) { + config.setOffline(parseBool(get(props, KEY_OFFLINE), config.isOffline())); + } + } + + private boolean has(Properties props, String key) { + String v = props.getProperty(key); + return v != null && !v.trim().isEmpty(); + } + + private String get(Properties props, String key) { + return props.getProperty(key).trim(); + } + + private double parseDouble(String s, double def) { + try { + return Double.parseDouble(s); + } catch (Exception e) { + return def; + } + } + + private boolean parseBool(String s, boolean def) { + if (s == null) return def; + return "true".equalsIgnoreCase(s) || "1".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s); + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/git/GitBlameLine.java b/src/main/java/com/example/workutils/biz/coveragereport/git/GitBlameLine.java new file mode 100644 index 0000000..b377681 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/git/GitBlameLine.java @@ -0,0 +1,42 @@ +package com.example.workutils.biz.coveragereport.git; + +/** + * 一次 git blame 输出的单行解析结果。 + */ +public class GitBlameLine { + + /** 源码行号(1 起) */ + private final int lineNumber; + + /** 归属提交的完整 SHA */ + private final String commitId; + + /** 归属提交人 */ + private final String author; + + /** 归属提交人邮箱 */ + private final String email; + + /** 归属提交时间(ISO 8601) */ + private final String authorTime; + + /** 归属提交信息首行 */ + private final String subject; + + public GitBlameLine(int lineNumber, String commitId, String author, String email, + String authorTime, String subject) { + this.lineNumber = lineNumber; + this.commitId = commitId; + this.author = author; + this.email = email; + this.authorTime = authorTime; + this.subject = subject; + } + + public int getLineNumber() { return lineNumber; } + public String getCommitId() { return commitId; } + public String getAuthor() { return author; } + public String getEmail() { return email; } + public String getAuthorTime() { return authorTime; } + public String getSubject() { return subject; } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/git/GitCli.java b/src/main/java/com/example/workutils/biz/coveragereport/git/GitCli.java new file mode 100644 index 0000000..320c3ec --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/git/GitCli.java @@ -0,0 +1,307 @@ +package com.example.workutils.biz.coveragereport.git; + +import com.example.workutils.biz.coveragereport.model.CommitDetail; +import com.example.workutils.biz.coveragereport.process.ProcessRunner; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.io.File; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.HashSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Git 命令行封装(本插件自建,不依赖 aitest)。 + *

+ * 提供:仓库探测、分支/commit 检出与恢复、blame 解析、提交信息查询。 + */ +@Component +public class GitCli { + + private final ProcessRunner processRunner; + + private static final DateTimeFormatter ISO_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssX"); + + public GitCli(ProcessRunner processRunner) { + this.processRunner = processRunner; + } + + /** 校验目录是否为 git 仓库 */ + public boolean isGitRepository(File dir) { + ProcessRunner.Result r = processRunner.run(dir, List.of("git", "rev-parse", "--is-inside-work-tree")); + return r.isSuccess() && r.getStdout().trim().equalsIgnoreCase("true"); + } + + /** 当前分支名 */ + public String currentBranch(File dir) { + ProcessRunner.Result r = processRunner.run(dir, List.of("git", "branch", "--show-current")); + return r.isSuccess() ? r.getStdout().trim() : ""; + } + + /** 当前 HEAD 的完整 commit SHA */ + public String headCommit(File dir) { + ProcessRunner.Result r = processRunner.run(dir, List.of("git", "rev-parse", "HEAD")); + return r.isSuccess() ? r.getStdout().trim() : ""; + } + + /** + * 解析给定分支或 commit 对应的完整 commit SHA。 + * + * @param ref 分支名或 commit ID + */ + public String resolveCommit(File dir, String ref) { + ProcessRunner.Result r = processRunner.run(dir, List.of("git", "rev-parse", ref + "^{commit}")); + if (!r.isSuccess()) { + // 尝试远程分支名 + r = processRunner.run(dir, List.of("git", "rev-parse", "origin/" + ref + "^{commit}")); + } + return r.isSuccess() ? r.getStdout().trim() : ""; + } + + /** 是否存在指定本地或远程分支 */ + public boolean hasBranch(File dir, String branch) { + ProcessRunner.Result r = processRunner.run(dir, List.of("git", "show-ref", "--verify", "--quiet", + "refs/heads/" + branch)); + if (r.isSuccess()) { + return true; + } + r = processRunner.run(dir, List.of("git", "show-ref", "--verify", "--quiet", + "refs/remotes/origin/" + branch)); + return r.isSuccess(); + } + + /** 检出指定 commit(detached HEAD) */ + public boolean checkout(File dir, String commit, java.util.function.Consumer log) { + ProcessRunner.Result r = processRunner.run(dir, + List.of("git", "checkout", "-f", commit), + log); + return r.isSuccess(); + } + + /** 检出并创建本地分支跟踪远程(当目标是分支名时) */ + public boolean checkoutBranch(File dir, String branch, java.util.function.Consumer log) { + ProcessRunner.Result r = processRunner.run(dir, + List.of("git", "checkout", "-f", "-B", branch, "origin/" + branch), + log); + return r.isSuccess(); + } + + /** 仓库根目录 */ + public File rootDir(File dir) { + ProcessRunner.Result r = processRunner.run(dir, List.of("git", "rev-parse", "--show-toplevel")); + return r.isSuccess() ? new File(r.getStdout().trim()) : dir; + } + + /** 相对仓库根目录的源码文件路径 */ + public String relativePath(File repoRoot, File sourceFile) { + String rootPath = repoRoot.getAbsolutePath().replace('\\', '/'); + String filePath = sourceFile.getAbsolutePath().replace('\\', '/'); + if (filePath.startsWith(rootPath)) { + String rel = filePath.substring(rootPath.length()); + return rel.startsWith("/") ? rel.substring(1) : rel; + } + return filePath; + } + + /** + * 对单个源码文件执行 git blame,逐行解析归属提交。 + * + * @param repoRoot 仓库根目录 + * @param relPath 相对仓库根目录的源码路径(如 src/main/java/foo/Bar.java) + * @return 行->提交归属列表 + */ + public List blame(File repoRoot, String relPath) { + List result = new ArrayList<>(); + // 使用 machine-readable 输出,行格式: + // SHA 行号 [作者 邮箱 时间 时区] 前导空白/内容 + ProcessRunner.Result r = processRunner.run(repoRoot, + List.of("git", "blame", "--line-porcelain", "-w", relPath)); + if (!r.isSuccess()) { + return result; + } + parseBlamePorcelain(r.getStdout(), result); + return result; + } + + private void parseBlamePorcelain(String output, List result) { + String[] lines = output.split("\r?\n"); + String curCommit = ""; + String curAuthor = ""; + String curEmail = ""; + String curTime = ""; + String curSubject = ""; + int curLine = 0; + String pendingCode = ""; + + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (line.isEmpty()) { + continue; + } + char first = line.charAt(0); + if (first == '\t') { + // 代码内容行,与当前 pending 数据组合成一个 GitBlameLine + result.add(new GitBlameLine(curLine, curCommit, curAuthor, curEmail, curTime, curSubject)); + continue; + } + int sp = line.indexOf(' '); + String key = sp > 0 ? line.substring(0, sp) : line; + String value = sp > 0 ? line.substring(sp + 1) : ""; + switch (key) { + case "summary": + curSubject = value; + break; + case "author": + curAuthor = value; + break; + case "author-mail": + curEmail = value; + break; + case "author-time": + curTime = formatUnixTime(value); + break; + default: + // 形如 " []" 的状态行(不含 key-value) + if (key.length() >= 40 && key.indexOf(' ') < 0) { + String[] parts = line.trim().split("\\s+"); + if (parts.length >= 3) { + curCommit = parts[0]; + // 必须取第三列 final-lineno(当前文件中的行号)。 + // 第二列 orig-lineno 是该行在原始提交版本中的行号, + // 文件一旦发生过插入/删除就会与原行号错位,导致归属统计错行。 + curLine = safeParseInt(parts[2]); + } + } + break; + } + } + } + + private String formatUnixTime(String epochSeconds) { + try { + long secs = Long.parseLong(epochSeconds.trim()); + return ISO_FORMAT.format(Instant.ofEpochSecond(secs).atZone(ZoneId.systemDefault())); + } catch (Exception e) { + return epochSeconds; + } + } + + private int safeParseInt(String s) { + try { + return Integer.parseInt(s.trim()); + } catch (Exception e) { + return 0; + } + } + + /** + * 计算 {@code baseRef} 与 {@code headRef} 之间的变更源码行号。 + *

+ * 使用 {@code git diff ... --unified=0} 获取每个文件的变更区间, + * 返回 文件相对路径(仓库根目录, 正斜杠) -> 变更行号集合。 + * 仅包含新增/修改行(以 head 侧行号为准),删除行不计入。 + * + * @param repoRoot 仓库根目录 + * @param baseRef 基线 ref(分支或 commit,如 main) + * @param headRef 目标 ref(分支或 commit,如 SIT-20260618) + * @return 文件路径 -> 变更行号集合;失败或无效时返回空 Map + */ + public Map> diffChangedLines(File repoRoot, String baseRef, String headRef) { + Map> result = new LinkedHashMap<>(); + if (baseRef == null || baseRef.trim().isEmpty()) { + return result; + } + // 使用三点语法 ...:head 相对 base 的变更 + ProcessRunner.Result r = processRunner.run(repoRoot, List.of( + "git", "diff", baseRef + "..." + headRef, "--unified=0", "--", "*.java")); + if (!r.isSuccess()) { + return result; + } + parseUnifiedZeroDiff(r.getStdout(), result); + return result; + } + + /** 解析 git diff --unified=0 输出,提取每个文件的新增/修改行号(+++ 侧) */ + private void parseUnifiedZeroDiff(String output, Map> result) { + String[] lines = output.split("\r?\n"); + String currentFile = null; + for (String line : lines) { + if (line.startsWith("+++ ") || line.startsWith("--- ")) { + if (line.startsWith("+++ ")) { + currentFile = stripDiffPrefix(line.substring(4).trim()); + if (currentFile != null) { + result.computeIfAbsent(currentFile, k -> new HashSet<>()); + } + } + continue; + } + // hunk header:@@ -oldStart,oldCount +newStart,newCount @@ + if (line.startsWith("@@ ") && currentFile != null) { + parseHunkNewLines(line, result.get(currentFile)); + } + } + // 移除无变更行的文件条目 + result.entrySet().removeIf(e -> e.getValue().isEmpty()); + } + + /** 解析单行 hunk header,提取新增侧行号 */ + private void parseHunkNewLines(String hunkHeader, Set newLines) { + // 形如 @@ -12,5 +20,7 @@ + Matcher m = Pattern.compile("\\+([0-9]+)(?:,([0-9]+))?").matcher(hunkHeader); + if (!m.find()) { + return; + } + int start; + int count; + try { + start = Integer.parseInt(m.group(1)); + count = m.group(2) == null ? 1 : Integer.parseInt(m.group(2)); + } catch (NumberFormatException e) { + return; + } + for (int i = 0; i < count; i++) { + newLines.add(start + i); + } + } + + /** 移除 diff 前缀 b/(a/ b/ 前缀) */ + private String stripDiffPrefix(String path) { + if (path.equals("/dev/null")) { + return null; + } + if (path.startsWith("a/") || path.startsWith("b/")) { + return path.substring(2); + } + return path; + } + + /** 查询单个提交的完整信息(author, time, subject) */ + public CommitDetail commitInfo(File repoRoot, String commitId) { + ProcessRunner.Result r = processRunner.run(repoRoot, List.of( + "git", "log", "-1", "--format=%H%n%h%n%an%n%ae%n%aI%n%s", commitId)); + if (!r.isSuccess()) { + return null; + } + String[] parts = r.getStdout().split("\r?\n"); + if (parts.length < 6) { + return null; + } + CommitDetail detail = new CommitDetail(); + detail.setCommitId(parts[0].trim()); + detail.setShortCommitId(parts[1].trim()); + detail.setAuthor(parts[2].trim()); + detail.setEmail(parts[3].trim()); + detail.setCommitTime(parts[4].trim()); + detail.setSubject(parts[5].trim()); + return detail; + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/maven/MavenInvoker.java b/src/main/java/com/example/workutils/biz/coveragereport/maven/MavenInvoker.java new file mode 100644 index 0000000..e298b84 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/maven/MavenInvoker.java @@ -0,0 +1,124 @@ +package com.example.workutils.biz.coveragereport.maven; + +import com.example.workutils.biz.coveragereport.model.CoverageConfig; +import com.example.workutils.biz.coveragereport.process.ProcessRunner; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Maven 执行器(本插件自建,不依赖 aitest)。 + *

+ * 负责:探测 mvn/mvnw、注入 jacoco 插件、执行全量测试与生成覆盖率报告。 + */ +@Component +public class MavenInvoker { + + private final ProcessRunner processRunner; + private final PomInjector pomInjector; + + public MavenInvoker(ProcessRunner processRunner, PomInjector pomInjector) { + this.processRunner = processRunner; + this.pomInjector = pomInjector; + } + + /** + * 执行全量单元测试并生成 JaCoCo 覆盖率报告。 + * + * @param projectDir 项目根目录(含 pom.xml) + * @param config 覆盖率配置 + * @param lineConsumer 实时日志回调 + * @return 执行是否成功(exit 0) + */ + public boolean runTestsWithCoverage(File projectDir, CoverageConfig config, Consumer lineConsumer) { + File pom = new File(projectDir, "pom.xml"); + if (!pom.isFile()) { + if (lineConsumer != null) lineConsumer.accept("[coverage] 未找到 pom.xml: " + pom.getAbsolutePath()); + return false; + } + + String mvn = resolveMaven(projectDir, config); + if (mvn == null) { + if (lineConsumer != null) lineConsumer.accept("[coverage] 未找到 mvn 或 mvnw,请配置 mavenExecutable"); + return false; + } + + boolean injected = false; + try { + // 注入 jacoco 插件 + if (lineConsumer != null) lineConsumer.accept("[coverage] 注入 jacoco-maven-plugin 到 pom.xml ..."); + injected = pomInjector.inject(pom.toPath()); + if (injected && lineConsumer != null) { + lineConsumer.accept("[coverage] jacoco 插件注入成功"); + } + + // 构造命令 + List args = new ArrayList<>(); + args.add(mvn); + args.add("-B"); + args.add("-ntp"); + if (config.isOffline()) { + args.add("-o"); + } + // 健壮性参数: + // 1. git-commit-id-plugin 在临时 worktree(detached HEAD)下常解析 commit 失败, + // 导致整个构建中断。该插件对覆盖率统计无意义,直接跳过。 + // 2. --fail-at-end:单个模块失败时不阻断其他模块执行,尽可能多地生成测试/覆盖率数据, + // 使统计更完整(即使个别模块构建失败,其余模块数据仍有效)。 + args.add("-Dmaven.gitcommitid.skip=true"); + args.add("-DskipGitCommitId=true"); + args.add("-fae"); + args.add("test"); + args.add("jacoco:report"); + + if (lineConsumer != null) lineConsumer.accept("[coverage] 执行命令: " + String.join(" ", args)); + ProcessRunner.Result result = processRunner.run(projectDir, args, lineConsumer); + if (lineConsumer != null) lineConsumer.accept("[coverage] Maven 退出码: " + result.getExitCode()); + return result.isSuccess(); + } finally { + if (injected) { + pomInjector.restore(pom.toPath()); + } + } + } + + /** + * 探测 Maven 可执行文件: + * 1. 配置项 mavenExecutable + * 2. 项目内 mvnw / mvnw.cmd + * 3. 系统 PATH 的 mvn / mvn.cmd + */ + private String resolveMaven(File projectDir, CoverageConfig config) { + if (config.getMavenExecutable() != null && !config.getMavenExecutable().trim().isEmpty()) { + return config.getMavenExecutable().trim(); + } + boolean windows = File.separatorChar == '\\'; + File wrapper = windows + ? new File(projectDir, "mvnw.cmd") + : new File(projectDir, "mvnw"); + if (wrapper.isFile()) { + return wrapper.getAbsolutePath(); + } + // 系统 PATH + ProcessRunner.Result r = processRunner.run(projectDir, List.of( + windows ? "where" : "which", windows ? "mvn.cmd" : "mvn")); + if (r.isSuccess()) { + String line = firstLine(r.getStdout()); + if (line != null && !line.isEmpty()) { + return line.trim(); + } + } + return "mvn"; + } + + private String firstLine(String s) { + if (s == null || s.isEmpty()) { + return null; + } + int idx = s.indexOf('\n'); + return idx >= 0 ? s.substring(0, idx) : s; + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/maven/PomInjector.java b/src/main/java/com/example/workutils/biz/coveragereport/maven/PomInjector.java new file mode 100644 index 0000000..54e9452 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/maven/PomInjector.java @@ -0,0 +1,129 @@ +package com.example.workutils.biz.coveragereport.maven; + +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 在 pom.xml 的 <build>/<plugins> 中注入 jacoco-maven-plugin,执行后还原。 + *

+ * 策略:注入前备份原文件,注入后由调用方负责还原;若 pom 已含 jacoco 插件则不重复注入。 + */ +@Component +public class PomInjector { + + public static final String JACOCO_VERSION = "0.8.12"; + + /** 备份文件后缀 */ + private static final String BACKUP_SUFFIX = ".coverage-report.bak"; + + /** + * 注入 jacoco 插件到 pom.xml。 + * + * @param pomPath pom.xml 路径 + * @return 是否发生了注入(true 表示已修改,需要还原;false 表示无需还原或注入失败) + */ + public boolean inject(Path pomPath) { + try { + if (!Files.exists(pomPath)) { + return false; + } + String original = new String(Files.readAllBytes(pomPath), StandardCharsets.UTF_8); + // 已包含 jacoco 则跳过 + if (original.contains("jacoco-maven-plugin")) { + return false; + } + String injected = doInject(original); + if (injected.equals(original)) { + return false; + } + // 备份 + Files.write(pomPath.resolveSibling(pomPath.getFileName() + BACKUP_SUFFIX), + original.getBytes(StandardCharsets.UTF_8)); + Files.write(pomPath, injected.getBytes(StandardCharsets.UTF_8)); + return true; + } catch (IOException e) { + return false; + } + } + + /** + * 还原 pom.xml(删除备份文件)。 + */ + public void restore(Path pomPath) { + Path backup = pomPath.resolveSibling(pomPath.getFileName() + BACKUP_SUFFIX); + try { + if (Files.exists(backup)) { + Files.move(backup, pomPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException ignored) { + // 还原失败,保留备份 + } + } + + /** + * 注入 jacoco 插件。 + *

+ * 优先在现有 <build><plugins> 末尾插入;若不存在 <build> 则创建之。 + */ + String doInject(String original) { + String jacocoSnippet = buildJacocoSnippet(); + + // 情况 1:存在 ... 且内含 + Pattern buildPlugins = Pattern.compile("(\\s*[\\s\\S]*?)([\\s\\S]*?)()([\\s\\S]*?)"); + Matcher m1 = buildPlugins.matcher(original); + if (m1.find()) { + String head = m1.group(1); + String body = m1.group(2); + String pluginsClose = m1.group(3); + String tail = m1.group(4); + String joined = head + body + jacocoSnippet + pluginsClose + tail; + return original.substring(0, m1.start()) + joined + original.substring(m1.end()); + } + + // 情况 2:存在 但无 ,在 前插入 + Pattern buildNoPlugins = Pattern.compile("(\\s*[\\s\\S]*?)()"); + Matcher m2 = buildNoPlugins.matcher(original); + if (m2.find()) { + String plugins = "" + jacocoSnippet + "\n "; + String joined = m2.group(1) + plugins + m2.group(2); + return original.substring(0, m2.start()) + joined + original.substring(m2.end()); + } + + // 情况 3:无 ,在 前创建 + int projectEnd = original.lastIndexOf(""); + if (projectEnd >= 0) { + String build = "\n \n " + jacocoSnippet + "\n \n"; + return original.substring(0, projectEnd) + build + original.substring(projectEnd); + } + return original; + } + + private String buildJacocoSnippet() { + return "\n \n" + + " org.jacoco\n" + + " jacoco-maven-plugin\n" + + " " + JACOCO_VERSION + "\n" + + " \n" + + " \n" + + " jacoco-prepare-agent\n" + + " \n" + + " prepare-agent\n" + + " \n" + + " \n" + + " \n" + + " jacoco-report\n" + + " test\n" + + " \n" + + " report\n" + + " \n" + + " \n" + + " \n" + + " \n "; + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/AnalysisResult.java b/src/main/java/com/example/workutils/biz/coveragereport/model/AnalysisResult.java new file mode 100644 index 0000000..6a3b2ed --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/model/AnalysisResult.java @@ -0,0 +1,88 @@ +package com.example.workutils.biz.coveragereport.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * 覆盖率分析完整结果。 + */ +public class AnalysisResult { + + /** 目标项目路径 */ + private String projectPath; + + /** 分析的分支 */ + private String branch; + + /** 分析的 commit */ + private String commitId; + + /** 实际执行测试的分支/commit */ + private String analyzedRevision; + + /** 是否发生 git 检出切换 */ + private boolean checkoutPerformed; + + /** 全量单元测试摘要 */ + private TestSummary testSummary; + + /** 整体覆盖率汇总 */ + private CoverageSummary coverageSummary; + + /** 按提交人统计(按贡献行数降序) */ + private final List authorCoverages = new ArrayList<>(); + + /** 增量覆盖率汇总(相对基线分支变更代码,仅配置了 baselineBranch 时非空) */ + private IncrementalCoverage incrementalCoverage; + + /** HTML 报告入口文件绝对路径 */ + private String reportIndexPath; + + /** 报告输出目录 */ + private String reportOutputDir; + + /** 耗时(毫秒) */ + private long durationMillis; + + /** 是否通过阈值(测试通过率 && 行覆盖率) */ + private boolean thresholdPassed; + + /** 阈值未通过原因 */ + private final List thresholdFailures = new ArrayList<>(); + + /** 分析过程的日志文本 */ + private final StringBuilder log = new StringBuilder(); + + public String getProjectPath() { return projectPath; } + public void setProjectPath(String projectPath) { this.projectPath = projectPath; } + public String getBranch() { return branch; } + public void setBranch(String branch) { this.branch = branch; } + public String getCommitId() { return commitId; } + public void setCommitId(String commitId) { this.commitId = commitId; } + public String getAnalyzedRevision() { return analyzedRevision; } + public void setAnalyzedRevision(String analyzedRevision) { this.analyzedRevision = analyzedRevision; } + public boolean isCheckoutPerformed() { return checkoutPerformed; } + public void setCheckoutPerformed(boolean checkoutPerformed) { this.checkoutPerformed = checkoutPerformed; } + public TestSummary getTestSummary() { return testSummary; } + public void setTestSummary(TestSummary testSummary) { this.testSummary = testSummary; } + public CoverageSummary getCoverageSummary() { return coverageSummary; } + public void setCoverageSummary(CoverageSummary coverageSummary) { this.coverageSummary = coverageSummary; } + public List getAuthorCoverages() { return authorCoverages; } + public IncrementalCoverage getIncrementalCoverage() { return incrementalCoverage; } + public void setIncrementalCoverage(IncrementalCoverage incrementalCoverage) { this.incrementalCoverage = incrementalCoverage; } + public String getReportIndexPath() { return reportIndexPath; } + public void setReportIndexPath(String reportIndexPath) { this.reportIndexPath = reportIndexPath; } + public String getReportOutputDir() { return reportOutputDir; } + public void setReportOutputDir(String reportOutputDir) { this.reportOutputDir = reportOutputDir; } + public long getDurationMillis() { return durationMillis; } + public void setDurationMillis(long durationMillis) { this.durationMillis = durationMillis; } + public boolean isThresholdPassed() { return thresholdPassed; } + public void setThresholdPassed(boolean thresholdPassed) { this.thresholdPassed = thresholdPassed; } + public List getThresholdFailures() { return thresholdFailures; } + + public void appendLog(String line) { + log.append(line).append('\n'); + } + + public String getLog() { return log.toString(); } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/AuthorCoverage.java b/src/main/java/com/example/workutils/biz/coveragereport/model/AuthorCoverage.java new file mode 100644 index 0000000..27d2baa --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/model/AuthorCoverage.java @@ -0,0 +1,51 @@ +package com.example.workutils.biz.coveragereport.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * 提交人维度的覆盖率统计。 + */ +public class AuthorCoverage { + + /** 提交人(git author.name) */ + private String author; + + /** 提交人邮箱(git author.email) */ + private String email; + + /** 该提交人贡献的源码行总数 */ + private int contributedLineCount; + + /** 该提交人贡献的行中已覆盖数 */ + private int coveredLineCount; + + /** 该提交人涉及的文件数 */ + private int fileCount; + + /** 该提交人的提交记录数 */ + private int commitCount; + + /** 该提交人的提交明细(精确到 commit) */ + private final List commits = new ArrayList<>(); + + public String getAuthor() { return author; } + public void setAuthor(String author) { this.author = author; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public int getContributedLineCount() { return contributedLineCount; } + public void setContributedLineCount(int contributedLineCount) { this.contributedLineCount = contributedLineCount; } + public int getCoveredLineCount() { return coveredLineCount; } + public void setCoveredLineCount(int coveredLineCount) { this.coveredLineCount = coveredLineCount; } + public int getFileCount() { return fileCount; } + public void setFileCount(int fileCount) { this.fileCount = fileCount; } + public int getCommitCount() { return commitCount; } + public void setCommitCount(int commitCount) { this.commitCount = commitCount; } + public List getCommits() { return commits; } + + /** 行覆盖率(0~1),无行视为 0 */ + public double getCoverageRatio() { + if (contributedLineCount == 0) return 0.0; + return (double) coveredLineCount / contributedLineCount; + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/CommitDetail.java b/src/main/java/com/example/workutils/biz/coveragereport/model/CommitDetail.java new file mode 100644 index 0000000..a91fa2a --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/model/CommitDetail.java @@ -0,0 +1,54 @@ +package com.example.workutils.biz.coveragereport.model; + +/** + * 单个提交的覆盖率明细(按 git blame 逐行归属到提交)。 + */ +public class CommitDetail { + + /** 提交 ID(完整 SHA) */ + private String commitId; + + /** 提交短 SHA */ + private String shortCommitId; + + /** 提交人 */ + private String author; + + /** 提交人邮箱 */ + private String email; + + /** 提交时间(ISO 格式) */ + private String commitTime; + + /** 提交信息首行 */ + private String subject; + + /** 该提交贡献的源码行总数 */ + private int contributedLineCount; + + /** 该提交贡献的行中已覆盖数 */ + private int coveredLineCount; + + public String getCommitId() { return commitId; } + public void setCommitId(String commitId) { this.commitId = commitId; } + public String getShortCommitId() { return shortCommitId; } + public void setShortCommitId(String shortCommitId) { this.shortCommitId = shortCommitId; } + public String getAuthor() { return author; } + public void setAuthor(String author) { this.author = author; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getCommitTime() { return commitTime; } + public void setCommitTime(String commitTime) { this.commitTime = commitTime; } + public String getSubject() { return subject; } + public void setSubject(String subject) { this.subject = subject; } + public int getContributedLineCount() { return contributedLineCount; } + public void setContributedLineCount(int contributedLineCount) { this.contributedLineCount = contributedLineCount; } + public int getCoveredLineCount() { return coveredLineCount; } + public void setCoveredLineCount(int coveredLineCount) { this.coveredLineCount = coveredLineCount; } + + /** 该提交的行覆盖率(0~1) */ + public double getCoverageRatio() { + if (contributedLineCount == 0) return 0.0; + return (double) coveredLineCount / contributedLineCount; + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/CoverageConfig.java b/src/main/java/com/example/workutils/biz/coveragereport/model/CoverageConfig.java new file mode 100644 index 0000000..c727a34 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/model/CoverageConfig.java @@ -0,0 +1,122 @@ +package com.example.workutils.biz.coveragereport.model; + +import java.util.HashSet; +import java.util.Set; + +/** + * 覆盖率分析配置。 + *

+ * 来源优先级:命令行参数(-D 或 args) > 项目根目录下的 coveragereport.properties 配置文件 > 默认值。 + */ +public class CoverageConfig { + + /** 被显式设置过的配置 key(用于命令行优先于配置文件) */ + private final Set explicitlySetKeys = new HashSet<>(); + + /** 目标项目根目录(含 pom.xml) */ + private String projectPath; + + public Set getExplicitlySetKeys() { return explicitlySetKeys; } + + /** 标记某 key 已被命令行显式设置 */ + public void markExplicit(String key) { + explicitlySetKeys.add(key); + } + + /** 目标分支名,如 main / develop,为空表示使用当前检出分支 */ + private String branch; + + /** 目标 commit ID(完整或短 SHA),与 branch 二选一,优先 commit */ + private String commitId; + + /** 提交人过滤(可多个,逗号分隔),为空表示统计全部提交人 */ + private String authorFilter; + + /** 测试通过率阈值(百分比,默认 100) */ + private double passRateThreshold = 100.0; + + /** 行覆盖率阈值(百分比,默认 80) */ + private double lineCoverageThreshold = 80.0; + + /** 基线分支/commit(用于增量覆盖率统计,为空则只统计整体覆盖率) */ + private String baselineBranch; + + /** 增量行覆盖率阈值(百分比,默认 80) */ + private double incrementalLineCoverageThreshold = 80.0; + + /** 是否在检出目标分支/commit 后执行测试(默认 true) */ + private boolean checkoutBeforeAnalyze = true; + + /** 是否在分析结束后恢复原始分支(默认 true) */ + private boolean restoreBranchAfter = true; + + /** 是否在测试/覆盖率不达标时构建失败(默认 false,仅告警) */ + private boolean failOnThreshold = false; + + /** 是否使用临时 worktree 进行检出(避免污染当前工作区,默认 true) */ + private boolean useTemporaryWorktree = true; + + /** HTML 报告输出目录(默认 {projectPath}/target/coverage-report) */ + private String reportOutputDir; + + /** 跳过测试执行,仅解析已有 surefire/jacoco 报告(默认 false) */ + private boolean skipTests = false; + + /** Maven 可执行文件路径,为空自动探测 mvn / mvnw */ + private String mavenExecutable; + + /** 是否追加 --no-transfer-progress 等离线友好参数(默认 false) */ + private boolean offline = false; + + // ==================== getters / setters ==================== + + public String getProjectPath() { return projectPath; } + public void setProjectPath(String projectPath) { this.projectPath = projectPath; } + + public String getBranch() { return branch; } + public void setBranch(String branch) { this.branch = branch; } + + public String getCommitId() { return commitId; } + public void setCommitId(String commitId) { this.commitId = commitId; } + + public String getAuthorFilter() { return authorFilter; } + public void setAuthorFilter(String authorFilter) { this.authorFilter = authorFilter; } + + public double getPassRateThreshold() { return passRateThreshold; } + public void setPassRateThreshold(double passRateThreshold) { this.passRateThreshold = passRateThreshold; } + + public double getLineCoverageThreshold() { return lineCoverageThreshold; } + public void setLineCoverageThreshold(double lineCoverageThreshold) { this.lineCoverageThreshold = lineCoverageThreshold; } + + public String getBaselineBranch() { return baselineBranch; } + public void setBaselineBranch(String baselineBranch) { this.baselineBranch = baselineBranch; } + + public double getIncrementalLineCoverageThreshold() { return incrementalLineCoverageThreshold; } + public void setIncrementalLineCoverageThreshold(double incrementalLineCoverageThreshold) { + this.incrementalLineCoverageThreshold = incrementalLineCoverageThreshold; + } + + public boolean isCheckoutBeforeAnalyze() { return checkoutBeforeAnalyze; } + public void setCheckoutBeforeAnalyze(boolean checkoutBeforeAnalyze) { this.checkoutBeforeAnalyze = checkoutBeforeAnalyze; } + + public boolean isRestoreBranchAfter() { return restoreBranchAfter; } + public void setRestoreBranchAfter(boolean restoreBranchAfter) { this.restoreBranchAfter = restoreBranchAfter; } + + public boolean isFailOnThreshold() { return failOnThreshold; } + public void setFailOnThreshold(boolean failOnThreshold) { this.failOnThreshold = failOnThreshold; } + + public boolean isUseTemporaryWorktree() { return useTemporaryWorktree; } + public void setUseTemporaryWorktree(boolean useTemporaryWorktree) { this.useTemporaryWorktree = useTemporaryWorktree; } + + public String getReportOutputDir() { return reportOutputDir; } + public void setReportOutputDir(String reportOutputDir) { this.reportOutputDir = reportOutputDir; } + + public boolean isSkipTests() { return skipTests; } + public void setSkipTests(boolean skipTests) { this.skipTests = skipTests; } + + public String getMavenExecutable() { return mavenExecutable; } + public void setMavenExecutable(String mavenExecutable) { this.mavenExecutable = mavenExecutable; } + + public boolean isOffline() { return offline; } + public void setOffline(boolean offline) { this.offline = offline; } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/CoverageSummary.java b/src/main/java/com/example/workutils/biz/coveragereport/model/CoverageSummary.java new file mode 100644 index 0000000..7fdc846 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/model/CoverageSummary.java @@ -0,0 +1,103 @@ +package com.example.workutils.biz.coveragereport.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * 整体代码覆盖率汇总。 + */ +public class CoverageSummary { + + private int packageCount; + private int classCount; + private int methodCount; + private int methodCovered; + private int lineCount; + private int lineCovered; + private int instructionCount; + private int instructionCovered; + private int branchCount; + private int branchCovered; + + private final List packages = new ArrayList<>(); + + public int getPackageCount() { return packageCount; } + public void setPackageCount(int packageCount) { this.packageCount = packageCount; } + public int getClassCount() { return classCount; } + public void setClassCount(int classCount) { this.classCount = classCount; } + public int getMethodCount() { return methodCount; } + public void setMethodCount(int methodCount) { this.methodCount = methodCount; } + public int getMethodCovered() { return methodCovered; } + public void setMethodCovered(int methodCovered) { this.methodCovered = methodCovered; } + public int getLineCount() { return lineCount; } + public void setLineCount(int lineCount) { this.lineCount = lineCount; } + public int getLineCovered() { return lineCovered; } + public void setLineCovered(int lineCovered) { this.lineCovered = lineCovered; } + public int getInstructionCount() { return instructionCount; } + public void setInstructionCount(int instructionCount) { this.instructionCount = instructionCount; } + public int getInstructionCovered() { return instructionCovered; } + public void setInstructionCovered(int instructionCovered) { this.instructionCovered = instructionCovered; } + public int getBranchCount() { return branchCount; } + public void setBranchCount(int branchCount) { this.branchCount = branchCount; } + public int getBranchCovered() { return branchCovered; } + public void setBranchCovered(int branchCovered) { this.branchCovered = branchCovered; } + + public List getPackages() { return packages; } + + /** + * 合并另一个(通常来自不同 Maven 模块的)jococo 汇总。 + * 将包列表追加进来,并重新汇总整体计数。 + */ + public void merge(CoverageSummary other) { + if (other == null) { + return; + } + packages.addAll(other.getPackages()); + packageCount += other.getPackageCount(); + classCount += other.getClassCount(); + methodCount += other.getMethodCount(); + methodCovered += other.getMethodCovered(); + lineCount += other.getLineCount(); + lineCovered += other.getLineCovered(); + instructionCount += other.getInstructionCount(); + instructionCovered += other.getInstructionCovered(); + branchCount += other.getBranchCount(); + branchCovered += other.getBranchCovered(); + } + + // ==================== 派生指标 ==================== + + public double getLineCoverageRatio() { + if (lineCount == 0) return 0.0; + return (double) lineCovered / lineCount; + } + + public double getMethodCoverageRatio() { + if (methodCount == 0) return 0.0; + return (double) methodCovered / methodCount; + } + + public double getInstructionCoverageRatio() { + if (instructionCount == 0) return 0.0; + return (double) instructionCovered / instructionCount; + } + + public double getBranchCoverageRatio() { + if (branchCount == 0) return 0.0; + return (double) branchCovered / branchCount; + } + + /** 类覆盖率:至少一个方法被覆盖的类视为已覆盖 */ + public double getClassCoverageRatio() { + if (classCount == 0) return 0.0; + int coveredClasses = 0; + for (JacocoPackage pkg : packages) { + for (JacocoClass cls : pkg.getClasses()) { + if (cls.getMethodCovered() > 0 || cls.getLineCovered() > 0) { + coveredClasses++; + } + } + } + return (double) coveredClasses / classCount; + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/IncrementalAuthorCoverage.java b/src/main/java/com/example/workutils/biz/coveragereport/model/IncrementalAuthorCoverage.java new file mode 100644 index 0000000..6df58fa --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/model/IncrementalAuthorCoverage.java @@ -0,0 +1,51 @@ +package com.example.workutils.biz.coveragereport.model; + +/** + * 按提交人的新增代码覆盖率(增量口径)。 + *

+ * 口径与业界平台对齐: + *

    + *
  • 新增有效代码行 = 基线到目标 diff 的变更行中,可执行且非空行/注释行, + * 按 git blame 归属到提交人;
  • + *
  • 覆盖有效代码行 = 上述行中 jacoco 已覆盖的行;
  • + *
  • 新增代码覆盖率 = 覆盖有效代码行 / 新增有效代码行。
  • + *
+ */ +public class IncrementalAuthorCoverage { + + /** git 账号(author.name) */ + private String author; + + /** git 邮箱(author.email) */ + private String email; + + /** 新增有效代码行数 */ + private int addedLineCount; + + /** 覆盖有效代码行数 */ + private int coveredLineCount; + + /** 涉及文件数 */ + private int fileCount; + + public String getAuthor() { return author; } + public void setAuthor(String author) { this.author = author; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public int getAddedLineCount() { return addedLineCount; } + public void setAddedLineCount(int addedLineCount) { this.addedLineCount = addedLineCount; } + + public int getCoveredLineCount() { return coveredLineCount; } + public void setCoveredLineCount(int coveredLineCount) { this.coveredLineCount = coveredLineCount; } + + public int getFileCount() { return fileCount; } + public void setFileCount(int fileCount) { this.fileCount = fileCount; } + + /** 新增代码覆盖率(0~1),无新增行视为 0 */ + public double getCoverageRatio() { + if (addedLineCount == 0) return 0.0; + return (double) coveredLineCount / addedLineCount; + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/IncrementalCoverage.java b/src/main/java/com/example/workutils/biz/coveragereport/model/IncrementalCoverage.java new file mode 100644 index 0000000..febe474 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/model/IncrementalCoverage.java @@ -0,0 +1,96 @@ +package com.example.workutils.biz.coveragereport.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * 增量代码覆盖率(相对基线分支新增/修改代码的覆盖率)。 + *

+ * 增量覆盖率 = 变更行中已覆盖行数 / 变更行总数, + * 变更行来自 git diff {@code ...} 的结果。 + */ +public class IncrementalCoverage { + + /** 基线分支/commit 名称 */ + private String baseline; + + /** 目标分支/commit 名称 */ + private String head; + + /** 是否计算了增量覆盖率 */ + private boolean enabled; + + /** 变更文件数 */ + private int changedFileCount; + + /** 变更行总数 */ + private int changedLineCount; + + /** 已覆盖的变更行数 */ + private int coveredChangedLineCount; + + /** 变更文件明细 */ + private final List changedFiles = new ArrayList<>(); + + /** 按提交人的新增代码覆盖率(按新增有效行数降序) */ + private final List authors = new ArrayList<>(); + + public List getAuthors() { return authors; } + + public String getBaseline() { return baseline; } + public void setBaseline(String baseline) { this.baseline = baseline; } + + public String getHead() { return head; } + public void setHead(String head) { this.head = head; } + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public int getChangedFileCount() { return changedFileCount; } + public void setChangedFileCount(int changedFileCount) { this.changedFileCount = changedFileCount; } + + public int getChangedLineCount() { return changedLineCount; } + public void setChangedLineCount(int changedLineCount) { this.changedLineCount = changedLineCount; } + + public int getCoveredChangedLineCount() { return coveredChangedLineCount; } + public void setCoveredChangedLineCount(int coveredChangedLineCount) { this.coveredChangedLineCount = coveredChangedLineCount; } + + public List getChangedFiles() { return changedFiles; } + + /** 增量行覆盖率(0~1),无变更行视为 1 */ + public double getCoverageRatio() { + if (changedLineCount == 0) { + return 1.0; + } + return (double) coveredChangedLineCount / changedLineCount; + } + + /** + * 单个变更文件的增量覆盖明细。 + */ + public static class ChangedFile { + /** 相对仓库根目录的源码路径 */ + private String path; + /** 变更行总数 */ + private int lineCount; + /** 已覆盖变更行数 */ + private int coveredLineCount; + + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + + public int getLineCount() { return lineCount; } + public void setLineCount(int lineCount) { this.lineCount = lineCount; } + + public int getCoveredLineCount() { return coveredLineCount; } + public void setCoveredLineCount(int coveredLineCount) { this.coveredLineCount = coveredLineCount; } + + /** 增量行覆盖率(0~1),无变更行视为 1 */ + public double getCoverageRatio() { + if (lineCount == 0) { + return 1.0; + } + return (double) coveredLineCount / lineCount; + } + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoClass.java b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoClass.java new file mode 100644 index 0000000..d25a1b5 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoClass.java @@ -0,0 +1,90 @@ +package com.example.workutils.biz.coveragereport.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * 单个类的覆盖率信息(来自 jacoco.xml 的 元素)。 + */ +public class JacocoClass { + + private String name; + private String sourceFileName; + /** 类所在的包路径(如 com/example/Foo.java 的来源目录) */ + private String packagePath; + + private int instructionCount; + private int instructionCovered; + private int branchCount; + private int branchCovered; + private int lineCount; + private int lineCovered; + private int methodCount; + private int methodCovered; + + private final List methods = new ArrayList<>(); + + /** 类级所有行(来自 sourcefile 的 明细),用于行级标注与提交人统计 */ + private final List allLines = new ArrayList<>(); + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getSourceFileName() { return sourceFileName; } + public void setSourceFileName(String sourceFileName) { this.sourceFileName = sourceFileName; } + public String getPackagePath() { return packagePath; } + public void setPackagePath(String packagePath) { this.packagePath = packagePath; } + + public int getInstructionCount() { return instructionCount; } + public void setInstructionCount(int instructionCount) { this.instructionCount = instructionCount; } + public int getInstructionCovered() { return instructionCovered; } + public void setInstructionCovered(int instructionCovered) { this.instructionCovered = instructionCovered; } + public int getBranchCount() { return branchCount; } + public void setBranchCount(int branchCount) { this.branchCount = branchCount; } + public int getBranchCovered() { return branchCovered; } + public void setBranchCovered(int branchCovered) { this.branchCovered = branchCovered; } + public int getLineCount() { return lineCount; } + public void setLineCount(int lineCount) { this.lineCount = lineCount; } + public int getLineCovered() { return lineCovered; } + public void setLineCovered(int lineCovered) { this.lineCovered = lineCovered; } + public int getMethodCount() { return methodCount; } + public void setMethodCount(int methodCount) { this.methodCount = methodCount; } + public int getMethodCovered() { return methodCovered; } + public void setMethodCovered(int methodCovered) { this.methodCovered = methodCovered; } + + public List getMethods() { return methods; } + + public List getAllLines() { return allLines; } + + /** 行覆盖率(0~1),无行视为 1 */ + public double getLineCoverageRatio() { + if (lineCount == 0) { + return 1.0; + } + return (double) lineCovered / lineCount; + } + + /** 方法覆盖率(0~1),无方法视为 1 */ + public double getMethodCoverageRatio() { + if (methodCount == 0) { + return 1.0; + } + return (double) methodCovered / methodCount; + } + + /** 指令覆盖率(0~1) */ + public double getInstructionCoverageRatio() { + if (instructionCount == 0) { + return 1.0; + } + return (double) instructionCovered / instructionCount; + } + + /** 类名简写(去掉包名) */ + public String getSimpleName() { + if (name == null) { + return ""; + } + int idx = name.lastIndexOf('/'); + return idx >= 0 ? name.substring(idx + 1) : name; + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoLine.java b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoLine.java new file mode 100644 index 0000000..cbf56ea --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoLine.java @@ -0,0 +1,71 @@ +package com.example.workutils.biz.coveragereport.model; + +/** + * 单个源码行的覆盖率信息(来自 jacoco.xml 的 元素)。 + */ +public class JacocoLine { + + /** 行号(1 起) */ + private int lineNumber; + + /** 该行指令数 */ + private int instructionCount; + + /** 该行已覆盖指令数 */ + private int instructionCovered; + + /** 该行分支数 */ + private int branchCount; + + /** 该行已覆盖分支数 */ + private int branchCovered; + + public JacocoLine() {} + + public JacocoLine(int lineNumber, int instructionCount, int instructionCovered, + int branchCount, int branchCovered) { + this.lineNumber = lineNumber; + this.instructionCount = instructionCount; + this.instructionCovered = instructionCovered; + this.branchCount = branchCount; + this.branchCovered = branchCovered; + } + + public int getLineNumber() { return lineNumber; } + public void setLineNumber(int lineNumber) { this.lineNumber = lineNumber; } + + public int getInstructionCount() { return instructionCount; } + public void setInstructionCount(int instructionCount) { this.instructionCount = instructionCount; } + + public int getInstructionCovered() { return instructionCovered; } + public void setInstructionCovered(int instructionCovered) { this.instructionCovered = instructionCovered; } + + public int getBranchCount() { return branchCount; } + public void setBranchCount(int branchCount) { this.branchCount = branchCount; } + + public int getBranchCovered() { return branchCovered; } + public void setBranchCovered(int branchCovered) { this.branchCovered = branchCovered; } + + /** 该行是否已完全覆盖(指令全部覆盖) */ + public boolean isCovered() { + return instructionCount > 0 && instructionCovered >= instructionCount; + } + + /** 该行是否部分覆盖 */ + public boolean isPartiallyCovered() { + return instructionCount > 0 && instructionCovered > 0 && instructionCovered < instructionCount; + } + + /** 该行是否未覆盖 */ + public boolean isMissed() { + return instructionCount > 0 && instructionCovered == 0; + } + + /** 覆盖比例(0~1) */ + public double getCoverageRatio() { + if (instructionCount == 0) { + return 1.0; + } + return (double) instructionCovered / instructionCount; + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoMethod.java b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoMethod.java new file mode 100644 index 0000000..7eb285c --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoMethod.java @@ -0,0 +1,59 @@ +package com.example.workutils.biz.coveragereport.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * 单个方法的覆盖率信息(来自 jacoco.xml 的 元素), + * 包含其包含的源码行。 + */ +public class JacocoMethod { + + private String name; + private String desc; + private int lineStart; + private int lineEnd; + private int instructionCount; + private int instructionCovered; + private int branchCount; + private int branchCovered; + + private final List lines = new ArrayList<>(); + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDesc() { return desc; } + public void setDesc(String desc) { this.desc = desc; } + public int getLineStart() { return lineStart; } + public void setLineStart(int lineStart) { this.lineStart = lineStart; } + public int getLineEnd() { return lineEnd; } + public void setLineEnd(int lineEnd) { this.lineEnd = lineEnd; } + public int getInstructionCount() { return instructionCount; } + public void setInstructionCount(int instructionCount) { this.instructionCount = instructionCount; } + public int getInstructionCovered() { return instructionCovered; } + public void setInstructionCovered(int instructionCovered) { this.instructionCovered = instructionCovered; } + public int getBranchCount() { return branchCount; } + public void setBranchCount(int branchCount) { this.branchCount = branchCount; } + public int getBranchCovered() { return branchCovered; } + public void setBranchCovered(int branchCovered) { this.branchCovered = branchCovered; } + + public List getLines() { return lines; } + + /** 是否完全覆盖 */ + public boolean isCovered() { + return instructionCount > 0 && instructionCovered >= instructionCount; + } + + /** 覆盖比例(0~1),无指令视为 1 */ + public double getCoverageRatio() { + if (instructionCount == 0) { + return 1.0; + } + return (double) instructionCovered / instructionCount; + } + + /** 方法命中行数(用于作者统计:行数 = lineEnd - lineStart + 1) */ + public int getLineSpan() { + return Math.max(0, lineEnd - lineStart + 1); + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoPackage.java b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoPackage.java new file mode 100644 index 0000000..c4d0217 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/model/JacocoPackage.java @@ -0,0 +1,59 @@ +package com.example.workutils.biz.coveragereport.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * 单个包(package)的覆盖率信息(来自 jacoco.xml 的 元素)。 + */ +public class JacocoPackage { + + private String name; + private int instructionCount; + private int instructionCovered; + private int branchCount; + private int branchCovered; + private int lineCount; + private int lineCovered; + private int methodCount; + private int methodCovered; + private int classCount; + private int classCovered; + + private final List classes = new ArrayList<>(); + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public int getInstructionCount() { return instructionCount; } + public void setInstructionCount(int instructionCount) { this.instructionCount = instructionCount; } + public int getInstructionCovered() { return instructionCovered; } + public void setInstructionCovered(int instructionCovered) { this.instructionCovered = instructionCovered; } + public int getBranchCount() { return branchCount; } + public void setBranchCount(int branchCount) { this.branchCount = branchCount; } + public int getBranchCovered() { return branchCovered; } + public void setBranchCovered(int branchCovered) { this.branchCovered = branchCovered; } + public int getLineCount() { return lineCount; } + public void setLineCount(int lineCount) { this.lineCount = lineCount; } + public int getLineCovered() { return lineCovered; } + public void setLineCovered(int lineCovered) { this.lineCovered = lineCovered; } + public int getMethodCount() { return methodCount; } + public void setMethodCount(int methodCount) { this.methodCount = methodCount; } + public int getMethodCovered() { return methodCovered; } + public void setMethodCovered(int methodCovered) { this.methodCovered = methodCovered; } + public int getClassCount() { return classCount; } + public void setClassCount(int classCount) { this.classCount = classCount; } + public int getClassCovered() { return classCovered; } + public void setClassCovered(int classCovered) { this.classCovered = classCovered; } + + public List getClasses() { return classes; } + + public double getLineCoverageRatio() { + if (lineCount == 0) return 1.0; + return (double) lineCovered / lineCount; + } + + public double getMethodCoverageRatio() { + if (methodCount == 0) return 1.0; + return (double) methodCovered / methodCount; + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/model/TestSummary.java b/src/main/java/com/example/workutils/biz/coveragereport/model/TestSummary.java new file mode 100644 index 0000000..689d752 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/model/TestSummary.java @@ -0,0 +1,108 @@ +package com.example.workutils.biz.coveragereport.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * 全量单元测试执行结果摘要(来自 surefire 的 TEST-*.xml)。 + */ +public class TestSummary { + + /** 统计的测试类数量 */ + private int testClassCount; + + /** 测试用例总数 */ + private long testCount; + + /** 失败数 */ + private long failureCount; + + /** 错误数 */ + private long errorCount; + + /** 跳过数 */ + private long skippedCount; + + /** 总耗时(毫秒) */ + private long durationMillis; + + /** 各测试类的明细 */ + private final List testClasses = new ArrayList<>(); + + public int getTestClassCount() { return testClassCount; } + public void setTestClassCount(int testClassCount) { this.testClassCount = testClassCount; } + + public long getTestCount() { return testCount; } + public void setTestCount(long testCount) { this.testCount = testCount; } + + public long getFailureCount() { return failureCount; } + public void setFailureCount(long failureCount) { this.failureCount = failureCount; } + + public long getErrorCount() { return errorCount; } + public void setErrorCount(long errorCount) { this.errorCount = errorCount; } + + public long getSkippedCount() { return skippedCount; } + public void setSkippedCount(long skippedCount) { this.skippedCount = skippedCount; } + + public long getDurationMillis() { return durationMillis; } + public void setDurationMillis(long durationMillis) { this.durationMillis = durationMillis; } + + public List getTestClasses() { return testClasses; } + + /** 合并另一个(通常来自不同 Maven 模块)的测试汇总 */ + public void merge(TestSummary other) { + if (other == null) { + return; + } + testClasses.addAll(other.getTestClasses()); + testClassCount += other.getTestClassCount(); + testCount += other.getTestCount(); + failureCount += other.getFailureCount(); + errorCount += other.getErrorCount(); + skippedCount += other.getSkippedCount(); + durationMillis += other.getDurationMillis(); + } + + /** 通过数 = 总数 - 失败 - 错误 - 跳过 */ + public long getPassedCount() { + return testCount - failureCount - errorCount - skippedCount; + } + + /** 通过率(百分比,0~100),无测试时为 0 */ + public double getPassRate() { + if (testCount == 0) { + return 0.0; + } + return (getPassedCount() * 100.0) / testCount; + } + + /** 是否全部通过(无失败、无错误) */ + public boolean isAllPassed() { + return failureCount == 0 && errorCount == 0; + } + + /** 单个测试类汇总 */ + public static class TestCaseClass { + private String name; + private long tests; + private long failures; + private long errors; + private long skipped; + private long timeMillis; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public long getTests() { return tests; } + public void setTests(long tests) { this.tests = tests; } + public long getFailures() { return failures; } + public void setFailures(long failures) { this.failures = failures; } + public long getErrors() { return errors; } + public void setErrors(long errors) { this.errors = errors; } + public long getSkipped() { return skipped; } + public void setSkipped(long skipped) { this.skipped = skipped; } + public long getTimeMillis() { return timeMillis; } + public void setTimeMillis(long timeMillis) { this.timeMillis = timeMillis; } + + public long getPassed() { return tests - failures - errors - skipped; } + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/parser/JacocoXmlParser.java b/src/main/java/com/example/workutils/biz/coveragereport/parser/JacocoXmlParser.java new file mode 100644 index 0000000..c0eae19 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/parser/JacocoXmlParser.java @@ -0,0 +1,327 @@ +package com.example.workutils.biz.coveragereport.parser; + +import com.example.workutils.biz.coveragereport.model.CoverageSummary; +import com.example.workutils.biz.coveragereport.model.JacocoClass; +import com.example.workutils.biz.coveragereport.model.JacocoLine; +import com.example.workutils.biz.coveragereport.model.JacocoMethod; +import com.example.workutils.biz.coveragereport.model.JacocoPackage; +import org.springframework.stereotype.Component; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.File; + +/** + * 解析 JaCoCo 生成的 jacoco.xml(////), + * 生成 package/class/method/line 四层的覆盖率汇总。 + */ +@Component("coveragereportJacocoXmlParser") +public class JacocoXmlParser { + + private final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + + public JacocoXmlParser() { + factory.setNamespaceAware(false); + // 禁用外部实体以防御 XXE,但允许 DOCTYPE(jacoco.xml 可能带 DOCTYPE 声明) + factory.setExpandEntityReferences(false); + try { + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + } catch (Exception ignored) { + // 安全配置失败则继续 + } + } + + /** + * 解析 jacoco.xml 文件为整体覆盖率汇总。 + * + * @param file jacoco.xml 绝对路径 + * @param skipClassPatterns 需跳过的类名片段(如 lombok、Generated),可为空 + */ + public CoverageSummary parse(File file, java.util.List skipClassPatterns) { + CoverageSummary summary = new CoverageSummary(); + if (file == null || !file.isFile()) { + return summary; + } + try { + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(file); + Element root = doc.getDocumentElement(); + if (!"report".equalsIgnoreCase(root.getTagName())) { + return summary; + } + + NodeList pkgNodes = root.getElementsByTagName("package"); + for (int i = 0; i < pkgNodes.getLength(); i++) { + Element pkgEl = (Element) pkgNodes.item(i); + JacocoPackage pkg = parsePackage(pkgEl, skipClassPatterns); + if (pkg != null) { + summary.getPackages().add(pkg); + } + } + + // 汇总 + int pkgCount = 0, classCount = 0, methodCount = 0, methodCovered = 0; + int lineCount = 0, lineCovered = 0, instCount = 0, instCovered = 0; + int branchCount = 0, branchCovered = 0; + for (JacocoPackage pkg : summary.getPackages()) { + pkgCount++; + classCount += pkg.getClassCount(); + methodCount += pkg.getMethodCount(); + methodCovered += pkg.getMethodCovered(); + lineCount += pkg.getLineCount(); + lineCovered += pkg.getLineCovered(); + instCount += pkg.getInstructionCount(); + instCovered += pkg.getInstructionCovered(); + branchCount += pkg.getBranchCount(); + branchCovered += pkg.getBranchCovered(); + } + summary.setPackageCount(pkgCount); + summary.setClassCount(classCount); + summary.setMethodCount(methodCount); + summary.setMethodCovered(methodCovered); + summary.setLineCount(lineCount); + summary.setLineCovered(lineCovered); + summary.setInstructionCount(instCount); + summary.setInstructionCovered(instCovered); + summary.setBranchCount(branchCount); + summary.setBranchCovered(branchCovered); + + } catch (Exception e) { + // 解析失败返回空汇总 + } + return summary; + } + + private JacocoPackage parsePackage(Element pkgEl, java.util.List skipPatterns) { + JacocoPackage pkg = new JacocoPackage(); + pkg.setName(pkgEl.getAttribute("name")); + + NodeList classNodes = pkgEl.getElementsByTagName("class"); + for (int i = 0; i < classNodes.getLength(); i++) { + Element classEl = (Element) classNodes.item(i); + String className = classEl.getAttribute("name"); + if (className == null || className.isEmpty()) { + continue; + } + if (shouldSkip(className, skipPatterns)) { + continue; + } + JacocoClass cls = parseClass(classEl, className, pkg.getName()); + if (cls != null) { + pkg.getClasses().add(cls); + } + } + + // 解析 下的行级明细,按 sourcefilename 分配到对应 class + assignSourceFileLines(pkgEl, pkg); + + // 汇总包指标 + int methodCount = 0, methodCovered = 0; + int lineCount = 0, lineCovered = 0; + int instCount = 0, instCovered = 0; + int branchCount = 0, branchCovered = 0; + for (JacocoClass cls : pkg.getClasses()) { + methodCount += cls.getMethodCount(); + methodCovered += cls.getMethodCovered(); + lineCount += cls.getLineCount(); + lineCovered += cls.getLineCovered(); + instCount += cls.getInstructionCount(); + instCovered += cls.getInstructionCovered(); + branchCount += cls.getBranchCount(); + branchCovered += cls.getBranchCovered(); + } + pkg.setMethodCount(methodCount); + pkg.setMethodCovered(methodCovered); + pkg.setLineCount(lineCount); + pkg.setLineCovered(lineCovered); + pkg.setInstructionCount(instCount); + pkg.setInstructionCovered(instCovered); + pkg.setBranchCount(branchCount); + pkg.setBranchCovered(branchCovered); + pkg.setClassCount(pkg.getClasses().size()); + pkg.setClassCovered(pkg.getClasses().size()); + return pkg; + } + + private JacocoClass parseClass(Element classEl, String className, String packageName) { + JacocoClass cls = new JacocoClass(); + cls.setName(className); + cls.setSourceFileName(classEl.getAttribute("sourcefilename")); + cls.setPackagePath(packageName == null ? "" : packageName); + + // 指令/分支计数 + int[] counter = readCounter(classEl, "INSTRUCTION"); + cls.setInstructionCount(counter[0]); + cls.setInstructionCovered(counter[1]); + counter = readCounter(classEl, "BRANCH"); + cls.setBranchCount(counter[0]); + cls.setBranchCovered(counter[1]); + counter = readCounter(classEl, "LINE"); + cls.setLineCount(counter[0]); + cls.setLineCovered(counter[1]); + counter = readCounter(classEl, "METHOD"); + cls.setMethodCount(counter[0]); + cls.setMethodCovered(counter[1]); + + // 方法及行 + NodeList methodNodes = classEl.getElementsByTagName("method"); + for (int i = 0; i < methodNodes.getLength(); i++) { + Element methodEl = (Element) methodNodes.item(i); + JacocoMethod method = parseMethod(methodEl); + if (method != null) { + cls.getMethods().add(method); + } + } + return cls; + } + + private JacocoMethod parseMethod(Element methodEl) { + JacocoMethod method = new JacocoMethod(); + method.setName(methodEl.getAttribute("name")); + method.setDesc(methodEl.getAttribute("desc")); + method.setLineStart(safeInt(methodEl.getAttribute("line"))); + method.setLineEnd(safeInt(methodEl.getAttribute("line"))); + + int[] counter = readCounter(methodEl, "INSTRUCTION"); + method.setInstructionCount(counter[0]); + method.setInstructionCovered(counter[1]); + counter = readCounter(methodEl, "BRANCH"); + method.setBranchCount(counter[0]); + method.setBranchCovered(counter[1]); + + // 方法自身的 子元素(jdk >= 11 的 jacoco 报告会包含) + NodeList lineNodes = methodEl.getElementsByTagName("line"); + int minLine = Integer.MAX_VALUE, maxLine = -1; + for (int i = 0; i < lineNodes.getLength(); i++) { + Element lineEl = (Element) lineNodes.item(i); + JacocoLine line = parseLine(lineEl); + if (line != null) { + method.getLines().add(line); + minLine = Math.min(minLine, line.getLineNumber()); + maxLine = Math.max(maxLine, line.getLineNumber()); + } + } + if (minLine != Integer.MAX_VALUE) { + method.setLineStart(minLine); + method.setLineEnd(maxLine); + } + return method; + } + + private JacocoLine parseLine(Element lineEl) { + int nr = safeInt(lineEl.getAttribute("nr")); + if (nr <= 0) { + return null; + } + int mi = safeInt(lineEl.getAttribute("mi")); + int ci = safeInt(lineEl.getAttribute("ci")); + int mb = safeInt(lineEl.getAttribute("mb")); + int cb = safeInt(lineEl.getAttribute("cb")); + return new JacocoLine(nr, mi + ci, ci, mb + cb, cb); + } + + /** + * 解析 package 下的 <sourcefile> 元素,将其 <line> 行级明细 + * 分配到 sourcefilename 匹配的 class,再按方法行范围关联到具体方法。 + */ + private void assignSourceFileLines(Element pkgEl, JacocoPackage pkg) { + NodeList sourceFileNodes = pkgEl.getElementsByTagName("sourcefile"); + for (int i = 0; i < sourceFileNodes.getLength(); i++) { + Element sourceFileEl = (Element) sourceFileNodes.item(i); + String sourceFileName = sourceFileEl.getAttribute("name"); + if (sourceFileName == null || sourceFileName.isEmpty()) { + continue; + } + JacocoClass targetClass = findClassBySource(pkg, sourceFileName); + if (targetClass == null) { + continue; + } + // 解析 sourcefile 下的所有 + NodeList lineNodes = sourceFileEl.getElementsByTagName("line"); + for (int j = 0; j < lineNodes.getLength(); j++) { + Element lineEl = (Element) lineNodes.item(j); + JacocoLine line = parseLine(lineEl); + if (line != null) { + // 记录到类级所有行(供行级标注与提交人统计) + targetClass.getAllLines().add(line); + // 尝试关联到方法(按方法行范围;若方法无结束行则部分行无法关联) + attachLineToMethod(targetClass, line); + } + } + } + } + + private JacocoClass findClassBySource(JacocoPackage pkg, String sourceFileName) { + for (JacocoClass cls : pkg.getClasses()) { + if (sourceFileName.equals(cls.getSourceFileName())) { + return cls; + } + } + return null; + } + + private void attachLineToMethod(JacocoClass cls, JacocoLine line) { + for (JacocoMethod method : cls.getMethods()) { + if (line.getLineNumber() >= method.getLineStart() && line.getLineNumber() <= method.getLineEnd()) { + boolean exists = false; + for (JacocoLine existing : method.getLines()) { + if (existing.getLineNumber() == line.getLineNumber()) { + exists = true; + break; + } + } + if (!exists) { + method.getLines().add(line); + } + return; + } + } + } + + /** 读取指定 counter type 的 [missed, covered](仅直接子元素,避免递归到 method 的 counter) */ + private int[] readCounter(Element el, String type) { + NodeList counterNodes = el.getChildNodes(); + for (int i = 0; i < counterNodes.getLength(); i++) { + org.w3c.dom.Node node = counterNodes.item(i); + if (node.getNodeType() != org.w3c.dom.Node.ELEMENT_NODE) { + continue; + } + Element c = (Element) node; + if ("counter".equalsIgnoreCase(c.getTagName()) && type.equalsIgnoreCase(c.getAttribute("type"))) { + int missed = safeInt(c.getAttribute("missed")); + int covered = safeInt(c.getAttribute("covered")); + return new int[]{missed + covered, covered}; + } + } + return new int[]{0, 0}; + } + + private boolean shouldSkip(String className, java.util.List patterns) { + if (patterns == null || patterns.isEmpty()) { + return false; + } + for (String p : patterns) { + if (!p.isEmpty() && className.contains(p)) { + return true; + } + } + return false; + } + + private int safeInt(String s) { + if (s == null || s.isEmpty()) { + return 0; + } + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + return 0; + } + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/parser/SurefireXmlParser.java b/src/main/java/com/example/workutils/biz/coveragereport/parser/SurefireXmlParser.java new file mode 100644 index 0000000..f79de93 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/parser/SurefireXmlParser.java @@ -0,0 +1,109 @@ +package com.example.workutils.biz.coveragereport.parser; + +import com.example.workutils.biz.coveragereport.model.TestSummary; +import org.springframework.stereotype.Component; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * 解析 Surefire 生成的 TEST-*.xml(JUnit XML),统计全量测试通过率。 + */ +@Component +public class SurefireXmlParser { + + private final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + + public SurefireXmlParser() { + factory.setNamespaceAware(false); + factory.setExpandEntityReferences(false); + try { + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + } catch (Exception ignored) { + // 安全配置失败则继续 + } + } + + /** + * 解析目录下所有 TEST-*.xml。 + * + * @param reportDir surefire-reports 目录 + */ + public TestSummary parseDirectory(File reportDir) { + TestSummary summary = new TestSummary(); + if (reportDir == null || !reportDir.isDirectory()) { + return summary; + } + File[] files = reportDir.listFiles((dir, name) -> name.startsWith("TEST-") && name.endsWith(".xml")); + if (files == null) { + return summary; + } + List classes = new ArrayList<>(); + for (File file : files) { + TestSummary.TestCaseClass tc = parseFile(file); + if (tc != null) { + classes.add(tc); + } + } + summary.getTestClasses().addAll(classes); + summary.setTestClassCount(classes.size()); + long tests = 0, failures = 0, errors = 0, skipped = 0, time = 0; + for (TestSummary.TestCaseClass tc : classes) { + tests += tc.getTests(); + failures += tc.getFailures(); + errors += tc.getErrors(); + skipped += tc.getSkipped(); + time += tc.getTimeMillis(); + } + summary.setTestCount(tests); + summary.setFailureCount(failures); + summary.setErrorCount(errors); + summary.setSkippedCount(skipped); + summary.setDurationMillis(time); + return summary; + } + + /** 解析单个 TEST-*.xml 文件 */ + private TestSummary.TestCaseClass parseFile(File file) { + try { + DocumentBuilder builder = factory.newDocumentBuilder(); + org.w3c.dom.Document doc = builder.parse(file); + org.w3c.dom.Element root = doc.getDocumentElement(); // + if (!"testsuite".equalsIgnoreCase(root.getTagName())) { + return null; + } + TestSummary.TestCaseClass tc = new TestSummary.TestCaseClass(); + tc.setName(root.getAttribute("name")); + tc.setTests(parseLong(root.getAttribute("tests"))); + tc.setFailures(parseLong(root.getAttribute("failures"))); + tc.setErrors(parseLong(root.getAttribute("errors"))); + tc.setSkipped(parseLong(root.getAttribute("skipped"))); + String timeStr = root.getAttribute("time"); + try { + tc.setTimeMillis((long) (Double.parseDouble(timeStr) * 1000)); + } catch (NumberFormatException ignored) { + tc.setTimeMillis(0); + } + return tc; + } catch (Exception e) { + return null; + } + } + + private long parseLong(String s) { + if (s == null || s.isEmpty()) { + return 0; + } + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return 0; + } + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/process/ProcessRunner.java b/src/main/java/com/example/workutils/biz/coveragereport/process/ProcessRunner.java new file mode 100644 index 0000000..43a1563 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/process/ProcessRunner.java @@ -0,0 +1,137 @@ +package com.example.workutils.biz.coveragereport.process; + +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * 外部进程执行器(完全独立,不依赖 aitest)。 + *

+ * 执行命令行命令,流式收集 stdout/stderr,支持超时与输出回调。 + */ +@Component +public class ProcessRunner { + + /** 默认超时:10 分钟 */ + private static final long DEFAULT_TIMEOUT_SECONDS = 10 * 60; + + /** 执行结果 */ + public static class Result { + private final int exitCode; + private final String stdout; + private final String stderr; + + public Result(int exitCode, String stdout, String stderr) { + this.exitCode = exitCode; + this.stdout = stdout; + this.stderr = stderr; + } + + public int getExitCode() { return exitCode; } + public String getStdout() { return stdout; } + public String getStderr() { return stderr; } + public boolean isSuccess() { return exitCode == 0; } + + /** 合并输出 */ + public String getCombinedOutput() { + if (stdout == null || stdout.isEmpty()) { + return stderr == null ? "" : stderr; + } + if (stderr == null || stderr.isEmpty()) { + return stdout; + } + return stdout + "\n" + stderr; + } + } + + /** + * 执行命令(默认编码 UTF-8)。 + * + * @param workingDir 工作目录(可为 null 表示当前目录) + * @param args 命令及参数,如 ["git", "status", "--porcelain"] + */ + public Result run(File workingDir, List args) { + return run(workingDir, args, StandardCharsets.UTF_8, null, DEFAULT_TIMEOUT_SECONDS); + } + + /** + * 执行命令,实时输出到 lineConsumer(逐行回调,可刷新到 UI)。 + */ + public Result run(File workingDir, List args, Consumer lineConsumer) { + return run(workingDir, args, StandardCharsets.UTF_8, lineConsumer, DEFAULT_TIMEOUT_SECONDS); + } + + public Result run(File workingDir, List args, Charset charset, + Consumer lineConsumer, long timeoutSeconds) { + ProcessBuilder pb = new ProcessBuilder(args); + if (workingDir != null) { + pb.directory(workingDir); + } + // 合并错误流,保证顺序 + pb.redirectErrorStream(true); + + StringBuilder combined = new StringBuilder(); + List stdoutLines = new ArrayList<>(); + List stderrLines = new ArrayList<>(); + + try { + Process process = pb.start(); + + // 读取输出 + Thread readerThread = new Thread(() -> readOutput(process, charset, combined, stdoutLines, stderrLines, lineConsumer), + "process-reader"); + readerThread.setDaemon(true); + readerThread.start(); + + boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + return new Result(-1, combined.toString(), + "命令执行超时(>" + timeoutSeconds + "s): " + String.join(" ", args)); + } + + readerThread.join(2000); + + int exitCode = process.exitValue(); + return new Result(exitCode, combined.toString(), ""); + + } catch (IOException e) { + return new Result(-1, "", + "命令执行失败: " + String.join(" ", args) + " - " + e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new Result(-1, "", "命令执行被中断: " + e.getMessage()); + } + } + + private void readOutput(Process process, Charset charset, StringBuilder combined, + List stdoutLines, List stderrLines, + Consumer lineConsumer) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), charset))) { + String line; + while ((line = reader.readLine()) != null) { + combined.append(line).append('\n'); + stdoutLines.add(line); + if (lineConsumer != null) { + try { + lineConsumer.accept(line); + } catch (Exception ignored) { + // 回调异常不影响读取 + } + } + } + } catch (IOException e) { + // 流关闭属正常现象 + } + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/report/HtmlReportGenerator.java b/src/main/java/com/example/workutils/biz/coveragereport/report/HtmlReportGenerator.java new file mode 100644 index 0000000..87b3154 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/report/HtmlReportGenerator.java @@ -0,0 +1,587 @@ +package com.example.workutils.biz.coveragereport.report; + +import com.example.workutils.biz.coveragereport.model.AnalysisResult; +import com.example.workutils.biz.coveragereport.model.AuthorCoverage; +import com.example.workutils.biz.coveragereport.model.CommitDetail; +import com.example.workutils.biz.coveragereport.model.CoverageConfig; +import com.example.workutils.biz.coveragereport.model.CoverageSummary; +import com.example.workutils.biz.coveragereport.model.IncrementalAuthorCoverage; +import com.example.workutils.biz.coveragereport.model.IncrementalCoverage; +import com.example.workutils.biz.coveragereport.model.JacocoClass; +import com.example.workutils.biz.coveragereport.model.JacocoLine; +import com.example.workutils.biz.coveragereport.model.JacocoMethod; +import com.example.workutils.biz.coveragereport.model.JacocoPackage; +import com.example.workutils.biz.coveragereport.model.TestSummary; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.alibaba.fastjson2.JSONWriter; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * HTML 覆盖率报告生成器。 + *

+ * 生成结构: + *

    + *
  • index.html — 仪表盘:全量测试通过率 + 总体覆盖率 + 包/类列表
  • + *
  • by-author.html — 提交人维度统计总览
  • + *
  • authors/<author>.html — 单个提交人明细(精确到 commit)
  • + *
  • files/<class>.html — 单个文件的行级覆盖标注(已覆盖/未覆盖/部分)
  • + *
  • summary.json — 机器可读汇总
  • + *
+ */ +@Component +public class HtmlReportGenerator { + + /** 报告根目录 */ + private static final String STYLE = Css.STYLE; + + /** + * 生成整套 HTML 报告。 + * + * @param reportDir 报告输出目录 + * @param config 配置 + * @param result 分析结果 + * @param analysisDir 分析所用源码目录(用于读取源码行) + * @param log 日志 + * @return index.html 绝对路径 + */ + public String generate(String reportDir, CoverageConfig config, AnalysisResult result, + File analysisDir, Consumer log) throws IOException { + Path dir = Path.of(reportDir); + Files.createDirectories(dir); + Files.createDirectories(dir.resolve("authors")); + Files.createDirectories(dir.resolve("files")); + + // 1. index.html + writeIndex(dir.resolve("index.html"), config, result); + // 2. by-author.html + writeByAuthor(dir.resolve("by-author.html"), config, result); + // 3. 每个提交人页面 + for (AuthorCoverage ac : result.getAuthorCoverages()) { + String fileName = safeFileName(ac.getAuthor()) + ".html"; + writeAuthorPage(dir.resolve("authors").resolve(fileName), result, ac); + } + // 4. 每个类页面(行级标注) + writeClassPages(dir.resolve("files"), result, analysisDir); + // 5. summary.json + writeSummaryJson(dir.resolve("summary.json"), result); + + return dir.resolve("index.html").toAbsolutePath().toString(); + } + + // ==================== index.html ==================== + + private void writeIndex(Path path, CoverageConfig config, AnalysisResult result) throws IOException { + TestSummary tests = result.getTestSummary(); + CoverageSummary cov = result.getCoverageSummary(); + IncrementalCoverage inc = result.getIncrementalCoverage(); + double passRate = tests == null ? 0 : tests.getPassRate(); + double lineRate = cov == null ? 0 : cov.getLineCoverageRatio() * 100; + double methodRate = cov == null ? 0 : cov.getMethodCoverageRatio() * 100; + double incRate = (inc != null && inc.isEnabled()) ? inc.getCoverageRatio() * 100 : -1; + + StringBuilder pkgs = new StringBuilder(); + if (cov != null) { + for (JacocoPackage pkg : cov.getPackages()) { + pkgs.append(" ") + .append("").append(esc(pkg.getName())).append("") + .append("").append(pkg.getClassCount()).append("") + .append("").append(pkg.getLineCovered()).append(" / ").append(pkg.getLineCount()).append("") + .append("").append(bar(pkg.getLineCoverageRatio(), 100)).append("") + .append("").append(pkg.getMethodCovered()).append(" / ").append(pkg.getMethodCount()).append("") + .append("").append(bar(pkg.getMethodCoverageRatio(), 100)).append("") + .append("\n"); + } + } + + String pkgRows = pkgs.toString(); + String statusBadge = result.isThresholdPassed() + ? "✓ 通过" + : "✗ 未通过"; + + StringBuilder checks = new StringBuilder(); + if (result.getThresholdFailures().isEmpty()) { + checks.append("
  • 全量测试通过率与行覆盖率均满足阈值要求
  • "); + } else { + for (String f : result.getThresholdFailures()) { + checks.append("
  • ").append(esc(f)).append("
  • "); + } + } + + String html = "\n\n\n\n" + + "\n" + + "覆盖率分析报告\n\n\n\n" + + headerNav("index") + + "
    \n" + + "

    覆盖率分析报告

    \n" + + "
    \n" + + " 项目:" + esc(result.getProjectPath()) + "\n" + + " 分支:" + esc(result.getBranch()) + "\n" + + " commit:" + shortCommit(result.getCommitId()) + "\n" + + " 耗时:" + (result.getDurationMillis() / 1000) + "s\n" + + " " + statusBadge + "\n" + + "
    \n" + + "
    \n" + + + "
    \n" + + "
    \n" + + "
    " + fmt(passRate) + "%
    \n" + + "
    单元测试通过率(目标 100%)
    \n" + + "
    通过 " + (tests == null ? 0 : tests.getPassedCount()) + + " / 总数 " + (tests == null ? 0 : tests.getTestCount()) + + ",失败 " + (tests == null ? 0 : tests.getFailureCount()) + + ",错误 " + (tests == null ? 0 : tests.getErrorCount()) + "
    \n" + + "
    \n" + + "
    \n" + + "
    " + fmt(lineRate) + "%
    \n" + + "
    行覆盖率(目标 80%)
    \n" + + "
    已覆盖 " + (cov == null ? 0 : cov.getLineCovered()) + + " / " + (cov == null ? 0 : cov.getLineCount()) + " 行
    \n" + + "
    \n" + + "
    \n" + + "
    " + fmt(methodRate) + "%
    \n" + + "
    方法覆盖率
    \n" + + "
    已覆盖 " + (cov == null ? 0 : cov.getMethodCovered()) + + " / " + (cov == null ? 0 : cov.getMethodCount()) + " 方法
    \n" + + "
    \n" + + (incEnabled(inc) + ? "
    \n" + + "
    " + fmt(incRate) + "%
    \n" + + "
    增量行覆盖率(基线 " + esc(inc.getBaseline()) + ",目标 80%)
    \n" + + "
    已覆盖变更行 " + inc.getCoveredChangedLineCount() + + " / " + inc.getChangedLineCount() + ",变更文件 " + inc.getChangedFileCount() + "
    \n" + + "
    \n" + : "") + + "
    \n" + + + "
    \n" + + "

    阈值校验

    \n" + + "
      " + checks + "
    \n" + + "
    \n" + + + "
    \n" + + "

    包级覆盖率 查看提交人维度 →

    \n" + + " \n" + + " \n" + + " \n" + pkgRows + + " \n" + + "
    包类数行覆盖行覆盖率方法覆盖方法覆盖率
    \n" + + "
    \n" + + "
    由 coverage-report-plugin 生成
    \n" + + "\n\n"; + Files.writeString(path, html, StandardCharsets.UTF_8); + } + + // ==================== by-author.html ==================== + + private void writeByAuthor(Path path, CoverageConfig config, AnalysisResult result) throws IOException { + StringBuilder rows = new StringBuilder(); + for (AuthorCoverage ac : result.getAuthorCoverages()) { + String page = "authors/" + safeFileName(ac.getAuthor()) + ".html"; + rows.append(" ") + .append("").append(esc(ac.getAuthor())).append("") + .append("").append(esc(ac.getEmail())).append("") + .append("").append(ac.getCommitCount()).append("") + .append("").append(ac.getFileCount()).append("") + .append("").append(ac.getCoveredLineCount()).append(" / ").append(ac.getContributedLineCount()).append("") + .append("").append(bar(ac.getCoverageRatio(), 100)).append("") + .append("\n"); + } + + String filterInfo = (config.getAuthorFilter() == null || config.getAuthorFilter().isEmpty()) + ? "全部提交人" : "过滤条件:" + esc(config.getAuthorFilter()); + + String html = "\n\n\n\n" + + "\n" + + "提交人覆盖率统计\n\n\n\n" + + headerNav("by-author") + + "
    \n" + + "

    提交人维度覆盖率统计

    \n" + + "
    " + filterInfo + "" + + "共 " + result.getAuthorCoverages().size() + " 位提交人
    \n" + + "
    \n" + + "
    \n" + + " \n" + + " \n" + + " \n" + rows + + " \n" + + "
    提交人邮箱提交数文件数行覆盖行覆盖率
    \n" + + "
    \n" + + "
    由 coverage-report-plugin 生成
    \n" + + "\n\n"; + Files.writeString(path, html, StandardCharsets.UTF_8); + } + + // ==================== author page ==================== + + private void writeAuthorPage(Path path, AnalysisResult result, AuthorCoverage ac) throws IOException { + StringBuilder commitRows = new StringBuilder(); + for (CommitDetail cd : ac.getCommits()) { + commitRows.append(" ") + .append("").append(esc(cd.getShortCommitId())).append("") + .append("").append(esc(cd.getAuthor())).append("") + .append("").append(esc(cd.getCommitTime())).append("") + .append("").append(esc(cd.getSubject())).append("") + .append("").append(cd.getCoveredLineCount()).append(" / ").append(cd.getContributedLineCount()).append("") + .append("").append(bar(cd.getCoverageRatio(), 100)).append("") + .append("\n"); + } + + String html = "\n\n\n\n" + + "\n" + + "提交人 - " + esc(ac.getAuthor()) + "\n\n\n\n" + + headerNav("by-author") + + "
    \n" + + "

    提交人:" + esc(ac.getAuthor()) + "

    \n" + + "
    \n" + + " 邮箱:" + esc(ac.getEmail()) + "\n" + + " 行覆盖率:" + fmt(ac.getCoverageRatio() * 100) + "%\n" + + " 贡献行:" + ac.getContributedLineCount() + "\n" + + " 已覆盖行:" + ac.getCoveredLineCount() + "\n" + + " 提交记录:" + ac.getCommitCount() + "\n" + + "
    \n" + + "
    \n" + + "
    \n" + + "

    该提交人的提交记录(精确到 commit)

    \n" + + " \n" + + " \n" + + " \n" + commitRows + + " \n" + + "
    commit提交人时间说明行覆盖行覆盖率
    \n" + + "
    \n" + + "
    由 coverage-report-plugin 生成
    \n" + + "\n\n"; + Files.writeString(path, html, StandardCharsets.UTF_8); + } + + // ==================== class pages ==================== + + private void writeClassPages(Path filesDir, AnalysisResult result, File analysisDir) throws IOException { + CoverageSummary cov = result.getCoverageSummary(); + if (cov == null) { + return; + } + File srcDir = new File(analysisDir, "src/main/java"); + for (JacocoPackage pkg : cov.getPackages()) { + for (JacocoClass cls : pkg.getClasses()) { + String fileName = safeFileName(cls.getSimpleName()) + "-" + Math.abs(cls.getName().hashCode()) + ".html"; + Path page = filesDir.resolve(fileName); + writeClassPage(page, pkg, cls, srcDir); + } + } + } + + private void writeClassPage(Path page, JacocoPackage pkg, JacocoClass cls, File srcDir) throws IOException { + // 方法表格 + StringBuilder methodRows = new StringBuilder(); + for (JacocoMethod m : cls.getMethods()) { + methodRows.append(" ") + .append("").append(esc(m.getName())).append("") + .append("").append(m.getLineStart()).append(" - ").append(m.getLineEnd()).append("") + .append("").append(m.getInstructionCovered()).append(" / ").append(m.getInstructionCount()).append("") + .append("").append(bar(m.getCoverageRatio(), 100)).append("") + .append("\n"); + } + + // 行级标注(读取源码) + StringBuilder linesHtml = new StringBuilder(); + if (cls.getSourceFileName() != null && !cls.getSourceFileName().isEmpty()) { + String pkgPath = pkg.getName().replace('.', '/'); + File srcFile = new File(srcDir, pkgPath + "/" + cls.getSourceFileName()); + if (srcFile.isFile()) { + linesHtml.append(buildSourceLines(srcFile, cls)); + } else { + linesHtml.append("
    源码文件未找到:" + esc(srcFile.getPath()) + "
    "); + } + } else { + linesHtml.append("
    无源码文件信息
    "); + } + + String html = "\n\n\n\n" + + "\n" + + "" + esc(cls.getSimpleName()) + "\n\n\n\n" + + headerNav("index") + + "
    \n" + + "

    " + esc(cls.getSimpleName()) + "

    \n" + + "
    \n" + + " 类:" + esc(cls.getName()) + "\n" + + " 行覆盖率:" + fmt(cls.getLineCoverageRatio() * 100) + "%\n" + + " 方法覆盖率:" + fmt(cls.getMethodCoverageRatio() * 100) + "%\n" + + " 行覆盖 " + cls.getLineCovered() + " / " + cls.getLineCount() + "\n" + + "
    \n" + + "
    \n" + + "
    \n" + + "

    方法级覆盖

    \n" + + " \n" + + " \n" + + " \n" + methodRows + + " \n" + + "
    方法行范围指令覆盖覆盖率
    \n" + + "
    \n" + + "
    \n" + + "

    行级覆盖标注 已覆盖部分未覆盖

    \n" + + "
    " + linesHtml + "
    \n" + + "
    \n" + + "
    由 coverage-report-plugin 生成
    \n" + + "\n\n"; + Files.writeString(page, html, StandardCharsets.UTF_8); + } + + private String buildSourceLines(File srcFile, JacocoClass cls) { + // 收集行号 -> 覆盖状态(使用类级所有行) + Map lineStatus = new LinkedHashMap<>(); + for (JacocoLine l : cls.getAllLines()) { + if (l.isCovered()) { + lineStatus.putIfAbsent(l.getLineNumber(), "covered"); + } else if (l.isPartiallyCovered()) { + lineStatus.putIfAbsent(l.getLineNumber(), "partial"); + } else if (l.isMissed()) { + lineStatus.putIfAbsent(l.getLineNumber(), "missed"); + } + } + try { + List lines = Files.readAllLines(srcFile.toPath(), StandardCharsets.UTF_8); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < lines.size(); i++) { + int nr = i + 1; + String status = lineStatus.get(nr); + String code = lines.get(i).replace("&", "&").replace("<", "<").replace(">", ">"); + String clsAttr = status == null ? "none" : status; + String mark = status == null ? "" : (status.equals("covered") ? "●" : status.equals("partial") ? "◐" : "○"); + sb.append("") + .append("").append(String.format("%4d", nr)).append(" ") + .append("").append(mark).append(" ") + .append(code) + .append("\n"); + } + return sb.toString(); + } catch (IOException e) { + return "读取源码失败"; + } + } + + // ==================== summary.json ==================== + + private void writeSummaryJson(Path path, AnalysisResult result) throws IOException { + JSONObject root = new JSONObject(); + root.put("projectPath", result.getProjectPath()); + root.put("branch", result.getBranch()); + root.put("commitId", result.getCommitId()); + root.put("analyzedRevision", result.getAnalyzedRevision()); + root.put("checkoutPerformed", result.isCheckoutPerformed()); + root.put("thresholdPassed", result.isThresholdPassed()); + JSONArray failures = new JSONArray(); + failures.addAll(result.getThresholdFailures()); + root.put("thresholdFailures", failures); + root.put("durationMillis", result.getDurationMillis()); + + TestSummary tests = result.getTestSummary(); + JSONObject test = new JSONObject(); + if (tests != null) { + test.put("testClassCount", tests.getTestClassCount()); + test.put("testCount", tests.getTestCount()); + test.put("failureCount", tests.getFailureCount()); + test.put("errorCount", tests.getErrorCount()); + test.put("skippedCount", tests.getSkippedCount()); + test.put("passedCount", tests.getPassedCount()); + test.put("passRate", tests.getPassRate()); + } + root.put("testSummary", test); + + CoverageSummary cov = result.getCoverageSummary(); + JSONObject coverage = new JSONObject(); + if (cov != null) { + coverage.put("packageCount", cov.getPackageCount()); + coverage.put("classCount", cov.getClassCount()); + coverage.put("methodCount", cov.getMethodCount()); + coverage.put("methodCovered", cov.getMethodCovered()); + coverage.put("lineCount", cov.getLineCount()); + coverage.put("lineCovered", cov.getLineCovered()); + coverage.put("instructionCount", cov.getInstructionCount()); + coverage.put("instructionCovered", cov.getInstructionCovered()); + coverage.put("branchCount", cov.getBranchCount()); + coverage.put("branchCovered", cov.getBranchCovered()); + coverage.put("lineCoverageRatio", cov.getLineCoverageRatio()); + coverage.put("methodCoverageRatio", cov.getMethodCoverageRatio()); + } + root.put("coverageSummary", coverage); + + JSONArray authors = new JSONArray(); + for (AuthorCoverage ac : result.getAuthorCoverages()) { + JSONObject a = new JSONObject(); + a.put("author", ac.getAuthor()); + a.put("email", ac.getEmail()); + a.put("commitCount", ac.getCommitCount()); + a.put("fileCount", ac.getFileCount()); + a.put("contributedLineCount", ac.getContributedLineCount()); + a.put("coveredLineCount", ac.getCoveredLineCount()); + a.put("coverageRatio", ac.getCoverageRatio()); + JSONArray commits = new JSONArray(); + for (CommitDetail cd : ac.getCommits()) { + JSONObject c = new JSONObject(); + c.put("commitId", cd.getCommitId()); + c.put("shortCommitId", cd.getShortCommitId()); + c.put("author", cd.getAuthor()); + c.put("commitTime", cd.getCommitTime()); + c.put("subject", cd.getSubject()); + c.put("contributedLineCount", cd.getContributedLineCount()); + c.put("coveredLineCount", cd.getCoveredLineCount()); + c.put("coverageRatio", cd.getCoverageRatio()); + commits.add(c); + } + a.put("commits", commits); + authors.add(a); + } + root.put("authorCoverages", authors); + + IncrementalCoverage inc = result.getIncrementalCoverage(); + JSONObject incremental = new JSONObject(); + if (inc != null) { + incremental.put("enabled", inc.isEnabled()); + incremental.put("baseline", inc.getBaseline()); + incremental.put("changedFileCount", inc.getChangedFileCount()); + incremental.put("changedLineCount", inc.getChangedLineCount()); + incremental.put("coveredChangedLineCount", inc.getCoveredChangedLineCount()); + incremental.put("coverageRatio", inc.getCoverageRatio()); + JSONArray incAuthors = new JSONArray(); + for (IncrementalAuthorCoverage a : inc.getAuthors()) { + JSONObject ja = new JSONObject(); + ja.put("author", a.getAuthor()); + ja.put("email", a.getEmail()); + ja.put("addedLineCount", a.getAddedLineCount()); + ja.put("coveredLineCount", a.getCoveredLineCount()); + ja.put("fileCount", a.getFileCount()); + ja.put("coverageRatio", a.getCoverageRatio()); + incAuthors.add(ja); + } + incremental.put("authors", incAuthors); + } + root.put("incrementalCoverage", incremental); + + Files.writeString(path, JSON.toJSONString(root, JSONWriter.Feature.PrettyFormat), StandardCharsets.UTF_8); + } + + // ==================== helpers ==================== + + private String headerNav(String active) { + return " \n"; + } + + private String bar(double ratio, double maxPct) { + double pct = Math.max(0, Math.min(100, ratio * maxPct)); + String color; + if (pct >= 80) color = "#22c55e"; + else if (pct >= 50) color = "#eab308"; + else color = "#ef4444"; + return "
    " + fmt(pct) + "%
    "; + } + + private String colorClass(double pct) { + if (pct >= 80) return "ok"; + if (pct >= 50) return "warn"; + return "bad"; + } + + private static String fmt(double v) { + return String.format("%.2f", v); + } + + private static boolean incEnabled(IncrementalCoverage inc) { + return inc != null && inc.isEnabled(); + } + + private static String esc(String s) { + if (s == null) return ""; + return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } + + private static String shortCommit(String c) { + if (c == null || c.isEmpty()) return "-"; + return c.length() > 7 ? c.substring(0, 7) : c; + } + + private static String safeFileName(String name) { + if (name == null || name.isEmpty()) return "unknown"; + return name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_"); + } + + /** 内嵌 CSS */ + private static final class Css { + static final String STYLE = "" + + ":root{--bg:#f5f6f8;--card:#fff;--text:#1f2937;--muted:#6b7280;--border:#e5e7eb;" + + "--primary:#2563eb;--green:#22c55e;--red:#ef4444;--yellow:#eab308}\n" + + "*{box-sizing:border-box;margin:0;padding:0}\n" + + "body{font-family:-apple-system,'Segoe UI','Microsoft YaHei',sans-serif;background:var(--bg);" + + "color:var(--text);padding:24px;max-width:1200px;margin:0 auto;line-height:1.5}\n" + + ".nav{display:flex;gap:8px;background:var(--card);border:1px solid var(--border);" + + "border-radius:8px;padding:10px 14px;margin-bottom:16px}\n" + + ".nav a{color:var(--muted);text-decoration:none;padding:4px 10px;border-radius:6px;font-size:13px}\n" + + ".nav a:hover{background:#f0f4ff;color:var(--primary)}\n" + + ".nav a.active{background:var(--primary);color:#fff}\n" + + ".page-header{margin-bottom:20px}\n" + + ".page-header h1{font-size:22px;margin-bottom:8px}\n" + + ".meta{display:flex;flex-wrap:wrap;gap:16px;color:var(--muted);font-size:13px}\n" + + ".meta .badge{font-weight:600;padding:2px 8px;border-radius:20px;font-size:12px}\n" + + ".badge.ok{background:#dcfce7;color:#15803d}\n" + + ".badge.bad{background:#fee2e2;color:#b91c1c}\n" + + ".card{background:var(--card);border:1px solid var(--border);border-radius:10px;" + + "padding:20px;margin-bottom:16px}\n" + + ".card h2{font-size:16px;margin-bottom:12px}\n" + + ".summary-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;padding:16px}\n" + + ".summary-card{background:var(--card);border:1px solid var(--border);border-radius:10px;" + + "padding:20px;text-align:center}\n" + + ".summary-card .num{font-size:36px;font-weight:700}\n" + + ".summary-card .label{font-size:14px;color:var(--muted);margin:6px 0}\n" + + ".summary-card .sub{font-size:12px;color:var(--muted)}\n" + + ".num.ok{color:var(--green)}.num.warn{color:var(--yellow)}.num.bad{color:var(--red)}\n" + + "table.grid{width:100%;border-collapse:collapse;font-size:13px}\n" + + "table.grid th,table.grid td{border-bottom:1px solid var(--border);padding:8px 10px;text-align:left}\n" + + "table.grid th{background:#f9fafb;color:var(--muted);font-weight:600}\n" + + "table.grid tr:hover td{background:#fafbff}\n" + + ".mono{font-family:Consolas,'Courier New',monospace;font-size:12px}\n" + + ".link{color:var(--primary);text-decoration:none}\n" + + ".link:hover{text-decoration:underline}\n" + + ".check-list{list-style:none}\n" + + ".check-list li{padding:6px 0;font-size:14px}\n" + + ".check-list li.ok{color:var(--green)}\n" + + ".check-list li.bad{color:var(--red)}\n" + + ".bar-wrap{position:relative;min-width:120px;background:#f3f4f6;border-radius:4px;height:18px;overflow:hidden}\n" + + ".bar{height:100%;border-radius:4px}\n" + + ".bar-txt{position:absolute;top:0;right:6px;font-size:11px;color:#374151;line-height:18px;font-weight:600}\n" + + ".legend{float:right;font-size:12px;color:var(--muted);font-weight:400}\n" + + ".lg{display:inline-block;padding:2px 8px;border-radius:4px;margin-left:8px}\n" + + ".lg.covered{background:#dcfce7;color:#15803d}\n" + + ".lg.partial{background:#fef9c3;color:#a16207}\n" + + ".lg.missed{background:#fee2e2;color:#b91c1c}\n" + + "pre.code{font-family:Consolas,'Courier New',monospace;font-size:12px;line-height:1.55;" + + "overflow-x:auto;background:#0f172a;color:#e2e8f0;border-radius:8px;padding:14px}\n" + + ".code .cl{display:block;padding:0 8px;border-radius:3px;border-left:3px solid transparent}\n" + + ".code .cl.covered{background:rgba(34,197,94,.12);border-left-color:#22c55e}\n" + + ".code .cl.partial{background:rgba(234,179,8,.12);border-left-color:#eab308}\n" + + ".code .cl.missed{background:rgba(239,68,68,.18);border-left-color:#ef4444}\n" + + ".code .ln{color:#64748b;user-select:none}\n" + + ".code .mark{color:#94a3b8;user-select:none;width:14px;display:inline-block}\n" + + ".code .cl.missed .mark{color:#ef4444}\n" + + ".code .cl.covered .mark{color:#22c55e}\n" + + ".note{color:var(--muted);font-size:13px;padding:8px 0}\n" + + ".footer{text-align:center;color:var(--muted);font-size:12px;padding:16px 0}\n"; + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/report/SampleReportMain.java b/src/main/java/com/example/workutils/biz/coveragereport/report/SampleReportMain.java new file mode 100644 index 0000000..845ca25 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/report/SampleReportMain.java @@ -0,0 +1,166 @@ +package com.example.workutils.biz.coveragereport.report; + +import com.example.workutils.biz.coveragereport.model.AnalysisResult; +import com.example.workutils.biz.coveragereport.model.AuthorCoverage; +import com.example.workutils.biz.coveragereport.model.CommitDetail; +import com.example.workutils.biz.coveragereport.model.CoverageConfig; +import com.example.workutils.biz.coveragereport.model.CoverageSummary; +import com.example.workutils.biz.coveragereport.model.JacocoClass; +import com.example.workutils.biz.coveragereport.model.JacocoLine; +import com.example.workutils.biz.coveragereport.model.JacocoMethod; +import com.example.workutils.biz.coveragereport.model.JacocoPackage; +import com.example.workutils.biz.coveragereport.model.TestSummary; + +import java.io.File; + +/** + * 示例报告生成器(演示/预览用)。 + *

    + * 用演示数据生成一套示例 HTML 覆盖率报告,便于预览报告效果。 + * + *

    + * java -cp <classpath> com.example.workutils.biz.coveragereport.report.SampleReportMain <outputDir>
    + * 
    + */ +public final class SampleReportMain { + + private SampleReportMain() { + } + + public static void main(String[] args) throws Exception { + String outputDir = args.length > 0 ? args[0] : "docs/coverage-report-sample"; + AnalysisResult result = buildSampleResult(); + CoverageConfig config = new CoverageConfig(); + config.setProjectPath("D:/bestpay-srdcloud/ft-pay-core"); + config.setBranch("develop"); + config.setCommitId("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"); + + String index = new HtmlReportGenerator().generate(outputDir, config, result, new File("."), System.out::println); + System.out.println("示例报告已生成: " + new File(index).getAbsolutePath()); + } + + private static AnalysisResult buildSampleResult() { + AnalysisResult result = new AnalysisResult(); + result.setProjectPath("D:/bestpay-srdcloud/ft-pay-core"); + result.setBranch("develop"); + result.setCommitId("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"); + result.setDurationMillis(45230L); + + // 测试摘要 + TestSummary tests = new TestSummary(); + tests.setTestClassCount(128); + tests.setTestCount(2048); + tests.setFailureCount(0); + tests.setErrorCount(0); + tests.setSkippedCount(12); + tests.setDurationMillis(185_000L); + result.setTestSummary(tests); + + // 覆盖率 + JacocoPackage pkg = new JacocoPackage(); + pkg.setName("com/bestpay/pay/order"); + + JacocoClass orderService = buildClass("com/bestpay/pay/order/OrderService", "OrderService.java", + "com/bestpay/pay/order", 80, 62, 18, 15, "createOrder", "cancelOrder", "refund"); + JacocoClass paymentService = buildClass("com/bestpay/pay/order/PaymentService", "PaymentService.java", + "com/bestpay/pay/order", 45, 38, 12, 10, "pay", "queryStatus"); + pkg.getClasses().add(orderService); + pkg.getClasses().add(paymentService); + pkg.setClassCount(2); + pkg.setLineCount(125); + pkg.setLineCovered(100); + pkg.setMethodCount(30); + pkg.setMethodCovered(25); + + JacocoPackage pkg2 = new JacocoPackage(); + pkg2.setName("com/bestpay/pay/refund"); + JacocoClass refundService = buildClass("com/bestpay/pay/refund/RefundService", "RefundService.java", + "com/bestpay/pay/refund", 60, 30, 14, 6, "applyRefund", "auditRefund"); + pkg2.getClasses().add(refundService); + pkg2.setClassCount(1); + pkg2.setLineCount(60); + pkg2.setLineCovered(30); + pkg2.setMethodCount(14); + pkg2.setMethodCovered(6); + + CoverageSummary cov = new CoverageSummary(); + cov.getPackages().add(pkg); + cov.getPackages().add(pkg2); + cov.setPackageCount(2); + cov.setClassCount(3); + cov.setMethodCount(44); + cov.setMethodCovered(31); + cov.setLineCount(185); + cov.setLineCovered(130); + cov.setInstructionCount(920); + cov.setInstructionCovered(650); + cov.setBranchCount(120); + cov.setBranchCovered(78); + result.setCoverageSummary(cov); + + // 提交人 + result.getAuthorCoverages().add(buildAuthor("张三", "zhangsan@bestpay.com", 3, 2, 5)); + result.getAuthorCoverages().add(buildAuthor("李四", "lisi@bestpay.com", 2, 1, 4)); + result.getAuthorCoverages().add(buildAuthor("王五", "wangwu@bestpay.com", 1, 0, 2)); + + result.setThresholdPassed(true); + return result; + } + + private static JacocoClass buildClass(String name, String sourceFile, String pkgPath, + int lineCount, int lineCovered, int methodCount, int methodCovered, + String... methodNames) { + JacocoClass cls = new JacocoClass(); + cls.setName(name); + cls.setSourceFileName(sourceFile); + cls.setPackagePath(pkgPath); + cls.setLineCount(lineCount); + cls.setLineCovered(lineCovered); + cls.setMethodCount(methodCount); + cls.setMethodCovered(methodCovered); + cls.setInstructionCount(lineCount * 8); + cls.setInstructionCovered(lineCovered * 8); + int line = 5; + for (String mn : methodNames) { + JacocoMethod m = new JacocoMethod(); + m.setName(mn); + m.setLineStart(line); + m.setLineEnd(line + 5); + m.setInstructionCount(8); + m.setInstructionCovered(6); + cls.getMethods().add(m); + line += 7; + } + // 行级:前 lineCovered 行覆盖,其余未覆盖 + for (int i = 1; i <= lineCount; i++) { + int covered = Math.min(lineCovered, lineCount); + boolean isCovered = i <= covered; + cls.getAllLines().add(new JacocoLine(i + 4, 8, isCovered ? 8 : 0, 0, 0)); + } + return cls; + } + + private static AuthorCoverage buildAuthor(String author, String email, + int commitCount, int coveredLines, int totalLines) { + AuthorCoverage ac = new AuthorCoverage(); + ac.setAuthor(author); + ac.setEmail(email); + ac.setCommitCount(commitCount); + ac.setFileCount(2); + ac.setCoveredLineCount(coveredLines); + ac.setContributedLineCount(totalLines); + for (int i = 1; i <= commitCount; i++) { + CommitDetail cd = new CommitDetail(); + cd.setCommitId("c" + i + "a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f"); + cd.setShortCommitId("c" + i + "a2b3c4"); + cd.setAuthor(author); + cd.setEmail(email); + cd.setSubject("提交记录 " + i + ":" + author + " 的变更"); + cd.setCommitTime("2026-08-0" + i + "T10:30:00+08:00"); + cd.setContributedLineCount(totalLines / commitCount + 1); + cd.setCoveredLineCount(coveredLines / commitCount); + ac.getCommits().add(cd); + } + return ac; + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/service/CoverageAnalysisService.java b/src/main/java/com/example/workutils/biz/coveragereport/service/CoverageAnalysisService.java new file mode 100644 index 0000000..62eb06b --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/service/CoverageAnalysisService.java @@ -0,0 +1,823 @@ +package com.example.workutils.biz.coveragereport.service; + +import com.example.workutils.biz.coveragereport.config.ConfigLoader; +import com.example.workutils.biz.coveragereport.git.GitBlameLine; +import com.example.workutils.biz.coveragereport.git.GitCli; +import com.example.workutils.biz.coveragereport.maven.MavenInvoker; +import com.example.workutils.biz.coveragereport.model.AnalysisResult; +import com.example.workutils.biz.coveragereport.model.AuthorCoverage; +import com.example.workutils.biz.coveragereport.model.CommitDetail; +import com.example.workutils.biz.coveragereport.model.CoverageConfig; +import com.example.workutils.biz.coveragereport.model.CoverageSummary; +import com.example.workutils.biz.coveragereport.model.JacocoClass; +import com.example.workutils.biz.coveragereport.model.JacocoLine; +import com.example.workutils.biz.coveragereport.model.JacocoMethod; +import com.example.workutils.biz.coveragereport.model.JacocoPackage; +import com.example.workutils.biz.coveragereport.model.IncrementalAuthorCoverage; +import com.example.workutils.biz.coveragereport.model.IncrementalCoverage; +import com.example.workutils.biz.coveragereport.model.TestSummary; +import com.example.workutils.biz.coveragereport.parser.JacocoXmlParser; +import com.example.workutils.biz.coveragereport.parser.SurefireXmlParser; +import com.example.workutils.biz.coveragereport.process.ProcessRunner; +import com.example.workutils.biz.coveragereport.report.HtmlReportGenerator; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +/** + * 覆盖率分析服务(本插件核心编排)。 + *

    + * 流程: + * 1. 加载配置(命令行/配置文件合并) + * 2. 校验项目(pom.xml + git 仓库) + * 3. 目标分支/commit 检出(默认临时 worktree 隔离,避免污染工作区) + * 4. 注入 jacoco 插件,执行 mvn test + jacoco:report + * 5. 解析 surefire(测试通过率)与 jacoco.xml(覆盖率) + * 6. 按提交人维度统计(git blame 逐行归属) + * 7. 生成 HTML 报告(index + by-author + 文件详情) + * 8. 清理临时 worktree / 恢复分支 + * 9. 阈值校验(通过率 & 行覆盖率) + */ +@Service +public class CoverageAnalysisService { + + private final ConfigLoader configLoader; + private final GitCli gitCli; + private final MavenInvoker mavenInvoker; + private final SurefireXmlParser surefireXmlParser; + private final JacocoXmlParser jacocoXmlParser; + private final HtmlReportGenerator reportGenerator; + private final ProcessRunner processRunner; + private final com.example.workutils.system.config.WorkUtilsProperties workUtilsProperties; + + /** 默认跳过的类名片段 */ + private static final List DEFAULT_SKIP_PATTERNS = List.of("$", "lombok", "Generated"); + + public CoverageAnalysisService(ConfigLoader configLoader, + GitCli gitCli, + MavenInvoker mavenInvoker, + SurefireXmlParser surefireXmlParser, + JacocoXmlParser jacocoXmlParser, + HtmlReportGenerator reportGenerator, + ProcessRunner processRunner, + com.example.workutils.system.config.WorkUtilsProperties workUtilsProperties) { + this.configLoader = configLoader; + this.gitCli = gitCli; + this.mavenInvoker = mavenInvoker; + this.surefireXmlParser = surefireXmlParser; + this.jacocoXmlParser = jacocoXmlParser; + this.reportGenerator = reportGenerator; + this.processRunner = processRunner; + this.workUtilsProperties = workUtilsProperties; + } + + /** + * 执行覆盖率分析。 + * + * @param config 配置(projectPath 必填) + * @param logSink 实时日志回调(可空) + * @return 分析结果 + */ + public AnalysisResult analyze(CoverageConfig config, Consumer logSink) { + long start = System.currentTimeMillis(); + AnalysisResult result = new AnalysisResult(); + Consumer log = line -> { + result.appendLog(line); + if (logSink != null) { + try { + logSink.accept(line); + } catch (Exception ignored) { + } + } + }; + + File projectDir = new File(config.getProjectPath()); + result.setProjectPath(projectDir.getAbsolutePath()); + + // 加载配置文件补充(命令行显式设置优先) + config = configLoader.loadFromProject(projectDir, config, config.getExplicitlySetKeys()); + + File repoRoot = null; + File analysisDir = projectDir; + File tempWorktree = null; + boolean checkedOutInMain = false; + String originalBranch = ""; + + try { + // 1. 校验 + File pom = new File(projectDir, "pom.xml"); + if (!pom.isFile()) { + log.accept("[coverage] 错误:未找到 pom.xml: " + pom.getAbsolutePath()); + result.setThresholdPassed(false); + result.getThresholdFailures().add("项目路径下不存在 pom.xml"); + return finish(result, start); + } + if (!gitCli.isGitRepository(projectDir)) { + log.accept("[coverage] 错误:项目不是 git 仓库"); + result.setThresholdPassed(false); + result.getThresholdFailures().add("项目不是 git 仓库"); + return finish(result, start); + } + + repoRoot = gitCli.rootDir(projectDir); + originalBranch = gitCli.currentBranch(repoRoot); + log.accept("[coverage] 仓库根目录: " + repoRoot.getAbsolutePath()); + + // 2. 确定目标版本 + String targetRef = null; + boolean checkoutNeeded = false; + if (config.getCommitId() != null && !config.getCommitId().trim().isEmpty()) { + targetRef = config.getCommitId().trim(); + checkoutNeeded = true; + result.setCommitId(targetRef); + } else if (config.getBranch() != null && !config.getBranch().trim().isEmpty()) { + targetRef = config.getBranch().trim(); + checkoutNeeded = true; + result.setBranch(targetRef); + } else { + result.setBranch(gitCli.currentBranch(repoRoot)); + result.setCommitId(gitCli.headCommit(repoRoot)); + checkoutNeeded = false; + } + + // 3. 检出目标版本 + if (checkoutNeeded && config.isCheckoutBeforeAnalyze()) { + String resolvedCommit = gitCli.resolveCommit(repoRoot, targetRef); + if (resolvedCommit == null || resolvedCommit.isEmpty()) { + log.accept("[coverage] 错误:无法解析分支/commit: " + targetRef); + result.setThresholdPassed(false); + result.getThresholdFailures().add("无法解析分支/commit: " + targetRef); + return finish(result, start); + } + + String head = gitCli.headCommit(repoRoot); + if (resolvedCommit.equals(head)) { + log.accept("[coverage] 目标版本即当前 HEAD,无需检出"); + checkoutNeeded = false; + } else if (config.isUseTemporaryWorktree()) { + log.accept("[coverage] 使用临时 worktree 检出目标版本 " + resolvedCommit + " ..."); + tempWorktree = createTempWorktree(repoRoot, resolvedCommit, log); + if (tempWorktree != null) { + analysisDir = tempWorktree; + result.setCheckoutPerformed(true); + log.accept("[coverage] 临时 worktree: " + analysisDir.getAbsolutePath()); + } else { + log.accept("[coverage] 临时 worktree 创建失败,尝试主工作区检出"); + } + } + if (tempWorktree == null && checkoutNeeded) { + // 主工作区检出 + log.accept("[coverage] 检出目标版本到主工作区: " + resolvedCommit); + boolean ok = gitCli.checkout(repoRoot, resolvedCommit, log); + if (!ok) { + log.accept("[coverage] 检出失败,终止分析"); + result.setThresholdPassed(false); + result.getThresholdFailures().add("git checkout 失败"); + return finish(result, start); + } + checkedOutInMain = true; + result.setCheckoutPerformed(true); + analysisDir = repoRoot; + } + } + + if (result.getCommitId() == null || result.getCommitId().isEmpty()) { + result.setCommitId(gitCli.headCommit(analysisDir)); + } + result.setAnalyzedRevision(gitCli.headCommit(analysisDir)); + + // 4. 执行测试 + 覆盖率 + if (config.isSkipTests()) { + log.accept("[coverage] 跳过测试执行(skipTests=true),解析已有报告"); + } else { + boolean mvnOk = mavenInvoker.runTestsWithCoverage(analysisDir, config, log); + if (!mvnOk) { + log.accept("[coverage] Maven 测试执行非零退出(可能测试失败),继续解析报告"); + } + } + + // 5. 解析 surefire 报告(多模块项目需递归收集各子模块 target/surefire-reports) + TestSummary testSummary = new TestSummary(); + for (File dir : collectSurefireDirs(analysisDir)) { + TestSummary m = surefireXmlParser.parseDirectory(dir); + testSummary.merge(m); + } + result.setTestSummary(testSummary); + log.accept("[coverage] 测试类数=" + testSummary.getTestClassCount() + + ", 用例数=" + testSummary.getTestCount() + + ", 失败=" + testSummary.getFailureCount() + + ", 错误=" + testSummary.getErrorCount() + + ", 通过率=" + String.format("%.2f%%", testSummary.getPassRate())); + + // 6. 解析 jacoco 报告(多模块项目需递归收集各子模块 target/site/jacoco/jacoco.xml) + CoverageSummary coverage = new CoverageSummary(); + List jacocoXmls = collectJacocoXmls(analysisDir); + for (File jacocoXml : jacocoXmls) { + CoverageSummary m = jacocoXmlParser.parse(jacocoXml, DEFAULT_SKIP_PATTERNS); + coverage.merge(m); + } + result.setCoverageSummary(coverage); + log.accept("[coverage] 行覆盖率=" + String.format("%.2f%%", coverage.getLineCoverageRatio() * 100) + + ", 方法覆盖率=" + String.format("%.2f%%", coverage.getMethodCoverageRatio() * 100) + + "(解析 " + jacocoXmls.size() + " 个 jacoco 报告)"); + + // 7. 按提交人统计 + if (!coverage.getPackages().isEmpty()) { + List authors = computeAuthorCoverage(repoRoot, analysisDir, coverage, config, log); + result.getAuthorCoverages().addAll(authors); + log.accept("[coverage] 提交人维度统计完成,共 " + authors.size() + " 位提交人"); + } + + // 7.5 增量覆盖率统计(相对基线分支变更代码) + if (!coverage.getPackages().isEmpty()) { + IncrementalCoverage inc = computeIncrementalCoverage(repoRoot, analysisDir, coverage, config, log); + result.setIncrementalCoverage(inc); + } + + // 8. 生成 HTML 报告 + String reportDir = resolveReportDir(config, analysisDir); + result.setReportOutputDir(reportDir); + try { + String index = reportGenerator.generate(reportDir, config, result, analysisDir, log); + result.setReportIndexPath(index); + log.accept("[coverage] 报告已生成: " + index); + } catch (Exception e) { + log.accept("[coverage] 报告生成失败: " + e.getMessage()); + } + + result.setThresholdPassed(true); + + } catch (Exception e) { + log.accept("[coverage] 分析过程异常: " + e.getMessage()); + result.setThresholdPassed(false); + result.getThresholdFailures().add("分析异常: " + e.getMessage()); + } finally { + // 清理:删除临时 worktree,或恢复主工作区分支 + if (tempWorktree != null && tempWorktree.exists()) { + deleteWorktree(tempWorktree, repoRoot, log); + } + if (checkedOutInMain && config.isRestoreBranchAfter() && repoRoot != null) { + log.accept("[coverage] 恢复原分支: " + originalBranch); + gitCli.checkout(repoRoot, originalBranch, log); + } + } + + writeLogFile(result); + + return finish(result, start); + } + + /** + * 将执行日志写入报告目录下的 log.txt。 + * 若报告目录尚未确定(异常过早退出),则使用 workutils 导出目录兜底。 + */ + private void writeLogFile(AnalysisResult result) { + try { + String reportDir = result.getReportOutputDir(); + if (reportDir == null || reportDir.trim().isEmpty()) { + reportDir = workUtilsProperties.getExportBasePath() + .resolve("coverage-reports") + .resolve("_failed").toString(); + } + File dir = new File(reportDir); + if (!dir.exists() && !dir.mkdirs()) { + return; + } + String logText = result.getLog(); + if (logText == null) { + return; + } + Files.writeString(new File(dir, "log.txt").toPath(), logText, + StandardCharsets.UTF_8); + } catch (Exception e) { + // 日志落盘失败不阻断主流程 + } + } + + private AnalysisResult finish(AnalysisResult result, long start) { + result.setDurationMillis(System.currentTimeMillis() - start); + return result; + } + + /** + * 按提交人统计覆盖率:对每个包含覆盖信息的源码文件做 git blame, + * 将每行归属到提交人/提交,再按提交聚合。 + */ + private List computeAuthorCoverage(File repoRoot, File analysisDir, + CoverageSummary coverage, CoverageConfig config, + Consumer log) { + Map authorMap = new LinkedHashMap<>(); + Map commitMap = new HashMap<>(); + + List files = collectSourceFiles(analysisDir, coverage); + + for (SourceFileCoverage sf : files) { + File srcFile = sf.sourceFile; + if (srcFile == null || !srcFile.isFile()) { + continue; + } + String relPath = gitCli.relativePath(repoRoot, srcFile); + List blameLines = gitCli.blame(repoRoot, relPath); + + Map lineCov = new HashMap<>(); + for (JacocoLine line : sf.lines) { + lineCov.put(line.getLineNumber(), line); + } + + Set fileAuthors = new HashSet<>(); + for (GitBlameLine bl : blameLines) { + JacocoLine cov = lineCov.get(bl.getLineNumber()); + if (cov == null) { + continue; + } + String author = normalizeAuthor(bl.getAuthor(), bl.getEmail()); + if (!acceptAuthor(author, config.getAuthorFilter())) { + continue; + } + fileAuthors.add(author); + + AuthorCoverage ac = authorMap.computeIfAbsent(author, k -> { + AuthorCoverage a = new AuthorCoverage(); + a.setAuthor(bl.getAuthor()); + a.setEmail(bl.getEmail()); + return a; + }); + ac.setContributedLineCount(ac.getContributedLineCount() + 1); + if (cov.isCovered()) { + ac.setCoveredLineCount(ac.getCoveredLineCount() + 1); + } + + String cid = bl.getCommitId(); + if (cid != null && !cid.isEmpty() && !commitMap.containsKey(cid + "|" + author)) { + CommitDetail detail = gitCli.commitInfo(repoRoot, cid); + if (detail == null) { + detail = new CommitDetail(); + detail.setCommitId(cid); + detail.setShortCommitId(cid.length() > 7 ? cid.substring(0, 7) : cid); + detail.setAuthor(bl.getAuthor()); + detail.setEmail(bl.getEmail()); + } + detail.setContributedLineCount(1); + detail.setCoveredLineCount(cov.isCovered() ? 1 : 0); + ac.getCommits().add(detail); + ac.setCommitCount(ac.getCommitCount() + 1); + commitMap.put(cid + "|" + author, detail); + } else { + CommitDetail detail = commitMap.get(cid + "|" + author); + if (detail != null) { + detail.setContributedLineCount(detail.getContributedLineCount() + 1); + if (cov.isCovered()) { + detail.setCoveredLineCount(detail.getCoveredLineCount() + 1); + } + } + } + } + for (String author : fileAuthors) { + AuthorCoverage ac = authorMap.get(author); + if (ac != null) { + ac.setFileCount(ac.getFileCount() + 1); + } + } + } + + List result = new ArrayList<>(authorMap.values()); + result.sort(Comparator.comparingInt(AuthorCoverage::getContributedLineCount).reversed()); + return result; + } + + private boolean acceptAuthor(String author, String authorFilter) { + if (authorFilter == null || authorFilter.trim().isEmpty()) { + return true; + } + String[] filters = authorFilter.split("[,,;;]"); + for (String f : filters) { + String trim = f.trim(); + if (trim.isEmpty()) { + continue; + } + if (author.equalsIgnoreCase(trim) || author.toLowerCase().contains(trim.toLowerCase())) { + return true; + } + } + return false; + } + + private String normalizeAuthor(String author, String email) { + if (author != null && !author.trim().isEmpty()) { + return author.trim(); + } + if (email != null && !email.trim().isEmpty()) { + return email.trim(); + } + return "unknown"; + } + + /** + * 计算增量代码覆盖率(相对基线分支变更代码)。 + *

    + * 通过 git diff 获取基线到目标之间新增/修改的行号,再与 jacoco 行覆盖结果求交, + * 得到 变更行总数 与 已覆盖变更行数,从而计算增量行覆盖率。 + * 未配置基线分支时不启用。 + */ + private IncrementalCoverage computeIncrementalCoverage(File repoRoot, File analysisDir, + CoverageSummary coverage, CoverageConfig config, + Consumer log) { + IncrementalCoverage inc = new IncrementalCoverage(); + String baseline = config.getBaselineBranch(); + if (baseline == null || baseline.trim().isEmpty()) { + inc.setEnabled(false); + return inc; + } + baseline = baseline.trim(); + inc.setEnabled(true); + inc.setBaseline(baseline); + + String head = config.getCommitId(); + if (head == null || head.isEmpty()) { + head = config.getBranch(); + } + inc.setHead(head == null ? "" : head); + + // 解析基线 ref 为目标 commit,避免分支漂移 + String baseCommit = gitCli.resolveCommit(repoRoot, baseline); + String headCommit = gitCli.headCommit(analysisDir); + if (baseCommit == null || baseCommit.isEmpty()) { + log.accept("[coverage] 警告:无法解析基线分支/commit: " + baseline + ",跳过增量覆盖率统计"); + inc.setEnabled(false); + return inc; + } + if (baseCommit.equals(headCommit)) { + log.accept("[coverage] 警告:基线与目标为同一提交,增量覆盖率无意义,跳过"); + inc.setEnabled(false); + return inc; + } + + Map> changedLines = gitCli.diffChangedLines(repoRoot, baseCommit, headCommit); + if (changedLines.isEmpty()) { + log.accept("[coverage] 增量:基线 " + baseline + " 与目标之间无变更文件,增量覆盖率=100%"); + return inc; + } + log.accept("[coverage] 增量:基线=" + baseline + ",变更文件数=" + changedLines.size()); + + // 建立 相对路径 -> 源文件覆盖信息 的索引。 + // 以 analysisDir 为基准计算相对路径,兼容临时 worktree 场景(diff 输出路径相对仓库根, + // 而 worktree 源码树在相同提交下相对路径一致)。 + List files = collectSourceFiles(analysisDir, coverage); + Map relToFile = new HashMap<>(); + for (SourceFileCoverage sf : files) { + if (sf.sourceFile != null && sf.sourceFile.isFile()) { + relToFile.put(gitCli.relativePath(analysisDir, sf.sourceFile), sf); + } + } + + int changedTotal = 0; + int coveredChanged = 0; + Map authorMap = new LinkedHashMap<>(); + for (Map.Entry> e : changedLines.entrySet()) { + SourceFileCoverage sf = relToFile.get(e.getKey()); + if (sf == null) { + // 变更文件无 jacoco 覆盖数据(如非 main 源码),跳过 + continue; + } + Map lineCov = new HashMap<>(); + for (JacocoLine l : sf.lines) { + lineCov.put(l.getLineNumber(), l); + } + // git blame 归属:变更行 -> 提交人。 + // 本次 diff 新增/修改的行,blame 会归属到当前分支引入该行的提交。 + List blameLines = gitCli.blame(repoRoot, gitCli.relativePath(repoRoot, sf.sourceFile)); + Map blameByLine = new HashMap<>(); + for (GitBlameLine bl : blameLines) { + blameByLine.put(bl.getLineNumber(), bl); + } + // 有效代码行判断(过滤空行/注释行,与"新增有效代码行"口径对齐) + Map effective = loadEffectiveLines(sf.sourceFile); + + int fileChanged = 0; + int fileCovered = 0; + for (int lineNo : e.getValue()) { + JacocoLine cov = lineCov.get(lineNo); + if (cov == null) { + continue; + } + Boolean eff = effective.get(lineNo); + if (eff != null && !eff) { + continue; + } + fileChanged++; + boolean covered = cov.isCovered(); + if (covered) { + fileCovered++; + } + // 按提交人聚合新增有效代码行与覆盖行 + GitBlameLine bl = blameByLine.get(lineNo); + if (bl == null) { + continue; + } + String author = normalizeAuthor(bl.getAuthor(), bl.getEmail()); + if (!acceptAuthor(author, config.getAuthorFilter())) { + continue; + } + IncrementalAuthorCoverage ac = authorMap.computeIfAbsent(author, k -> { + IncrementalAuthorCoverage a = new IncrementalAuthorCoverage(); + a.setAuthor(bl.getAuthor()); + a.setEmail(bl.getEmail()); + return a; + }); + ac.setAddedLineCount(ac.getAddedLineCount() + 1); + if (covered) { + ac.setCoveredLineCount(ac.getCoveredLineCount() + 1); + } + } + if (fileChanged > 0) { + IncrementalCoverage.ChangedFile cf = new IncrementalCoverage.ChangedFile(); + cf.setPath(e.getKey()); + cf.setLineCount(fileChanged); + cf.setCoveredLineCount(fileCovered); + inc.getChangedFiles().add(cf); + changedTotal += fileChanged; + coveredChanged += fileCovered; + } + } + inc.setChangedFileCount(inc.getChangedFiles().size()); + inc.setChangedLineCount(changedTotal); + inc.setCoveredChangedLineCount(coveredChanged); + + List sortedAuthors = new ArrayList<>(authorMap.values()); + sortedAuthors.sort(Comparator.comparingInt(IncrementalAuthorCoverage::getAddedLineCount).reversed()); + inc.getAuthors().addAll(sortedAuthors); + + log.accept("[coverage] 增量覆盖率=" + String.format("%.2f%%", inc.getCoverageRatio() * 100) + + "(变更有效行 " + changedTotal + ",已覆盖 " + coveredChanged + ")"); + for (IncrementalAuthorCoverage a : sortedAuthors) { + log.accept("[coverage] 提交人[" + a.getAuthor() + "] 新增有效行=" + a.getAddedLineCount() + + " 覆盖行=" + a.getCoveredLineCount() + + " 新增代码覆盖率=" + String.format("%.2f%%", a.getCoverageRatio() * 100)); + } + return inc; + } + + /** + * 判断源文件每行是否为"有效代码行"(非空行、非注释行)。 + *

    + * 与业界"新增有效代码行"口径对齐:空行、{@code //} 行注释、{@code /* ... *}{@code /} + * 块注释及 Javadoc 中间行均不计入。文件读取失败时返回空 Map(视为全部有效)。 + */ + private Map loadEffectiveLines(File srcFile) { + try { + List lines = Files.readAllLines(srcFile.toPath(), StandardCharsets.UTF_8); + Map map = new HashMap<>(); + boolean inBlockComment = false; + for (int i = 0; i < lines.size(); i++) { + int nr = i + 1; + String t = lines.get(i).trim(); + boolean effective; + if (inBlockComment) { + effective = false; + if (t.contains("*/")) { + inBlockComment = false; + } + } else if (t.isEmpty()) { + effective = false; + } else if (t.startsWith("//")) { + effective = false; + } else if (t.startsWith("/*")) { + effective = false; + if (!t.contains("*/")) { + inBlockComment = true; + } + } else if (t.startsWith("*")) { + // Javadoc 中间行(* xxx) + effective = false; + } else { + effective = true; + } + map.put(nr, effective); + } + return map; + } catch (Exception e) { + return Collections.emptyMap(); + } + } + + /** 收集带覆盖信息的源文件(多模块项目需同时搜索各子模块的 src/main/java) */ + private List collectSourceFiles(File analysisDir, CoverageSummary coverage) { + List list = new ArrayList<>(); + List srcRoots = collectSourceRoots(analysisDir); + if (srcRoots.isEmpty()) { + return list; + } + for (JacocoPackage pkg : coverage.getPackages()) { + for (JacocoClass cls : pkg.getClasses()) { + if (cls.getSourceFileName() == null || cls.getSourceFileName().isEmpty()) { + continue; + } + if (cls.getAllLines().isEmpty()) { + continue; + } + String pkgPath = pkg.getName().replace('.', '/'); + for (File srcDir : srcRoots) { + File srcFile = new File(srcDir, pkgPath + "/" + cls.getSourceFileName()); + if (srcFile.isFile()) { + list.add(new SourceFileCoverage(srcFile, cls.getAllLines())); + break; + } + } + } + } + return list; + } + + /** 收集所有 src/main/java 源码根目录(根目录 + 各子模块) */ + private List collectSourceRoots(File analysisDir) { + List roots = new ArrayList<>(); + Path rootSrc = analysisDir.toPath().resolve("src/main/java"); + if (Files.isDirectory(rootSrc)) { + roots.add(rootSrc.toFile()); + } + try (java.util.stream.Stream stream = Files.walk(analysisDir.toPath())) { + stream.filter(Files::isDirectory) + .filter(p -> p.endsWith(java.nio.file.Paths.get("src", "main", "java"))) + .distinct() + .forEach(p -> roots.add(p.toFile())); + } catch (Exception ignored) { + // 忽略遍历错误 + } + return roots; + } + + /** + * 递归收集所有 target/surefire-reports 目录(多模块)。 + * 注意:不能按 "是否位于 target 内部" 过滤——多模块下每个模块的报告 + * 本身就在 {module}/target/surefire-reports,必然包含 /target/ 片段。 + */ + private List collectSurefireDirs(File analysisDir) { + List dirs = new ArrayList<>(); + try (java.util.stream.Stream stream = Files.walk(analysisDir.toPath())) { + stream.filter(Files::isDirectory) + .filter(p -> p.endsWith(java.nio.file.Paths.get("target", "surefire-reports"))) + .distinct() + .forEach(p -> dirs.add(p.toFile())); + } catch (Exception ignored) { + // 忽略遍历错误 + } + return dirs; + } + + /** 递归收集所有 target/site/jacoco/jacoco.xml(多模块) */ + private List collectJacocoXmls(File analysisDir) { + List files = new ArrayList<>(); + try (java.util.stream.Stream stream = Files.walk(analysisDir.toPath())) { + stream.filter(Files::isRegularFile) + .filter(p -> p.endsWith(java.nio.file.Paths.get("target", "site", "jacoco", "jacoco.xml"))) + .distinct() + .forEach(p -> files.add(p.toFile())); + } catch (Exception ignored) { + // 忽略遍历错误 + } + return files; + } + + /** 创建临时 worktree 并检出目标 commit */ + private File createTempWorktree(File repoRoot, String commit, Consumer log) { + try { + // worktree 必须与仓库在同一文件系统/驱动器。系统 %TEMP% 常在 C 盘, + // 而仓库在 D 盘,跨盘会导致 git worktree add 报 + // "Could not reset index file to revision 'HEAD'"。 + // 因此在仓库所在驱动器(父目录)创建临时 worktree 目录。 + File parent = repoRoot.getAbsoluteFile().getParentFile(); + if (parent == null || !parent.isDirectory()) { + parent = new File("."); + } + Path tempDir = Files.createTempDirectory(parent.toPath(), "coverage-report-worktree-"); + File target = tempDir.toFile(); + ProcessRunner.Result r = processRunner.run(repoRoot, + List.of("git", "worktree", "add", "--detach", target.getAbsolutePath(), commit), log); + if (r.isSuccess()) { + return target; + } + deleteRecursively(target); + return null; + } catch (Exception e) { + if (log != null) log.accept("[coverage] worktree 创建异常: " + e.getMessage()); + return null; + } + } + + private void deleteWorktree(File worktree, File repoRoot, Consumer log) { + try { + processRunner.run(repoRoot, List.of("git", "worktree", "remove", "--force", worktree.getAbsolutePath()), log); + } catch (Exception ignored) { + // 忽略 + } + deleteRecursively(worktree); + } + + /** + * 递归删除目录/文件,增强对 Windows 长路径与只读文件的健壮性。 + *

    + * 多模块项目 target 下嵌套目录路径易超 Windows 260 字符限制, + * 普通 {@link File#delete()} 会报 "Filename too long"。 + * 这里先清除只读属性,再尽量使用 NIO 的 Path 删除(对长路径容忍度更高)。 + */ + private void deleteRecursively(File f) { + if (f == null) { + return; + } + Path p = f.toPath(); + if (!Files.exists(p)) { + return; + } + try { + if (Files.isDirectory(p, java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + try (java.util.stream.Stream children = Files.list(p)) { + children.forEach(child -> deleteRecursively(child.toFile())); + } + } + } catch (Exception ignored) { + // 目录遍历失败(如长路径),退化为 listFiles 重试 + File[] children = f.listFiles(); + if (children != null) { + for (File c : children) { + deleteRecursively(c); + } + } + } + // 清除只读属性(git worktree 下文件可能只读),尽力而为 + try { + f.setWritable(true, false); + f.setReadable(true, false); + } catch (Exception ignored) { + // 忽略 + } + try { + Files.deleteIfExists(p); + } catch (Exception e) { + // 仍失败(如长路径),用 cmd rd 兜底删除目录 / del 删除文件 + forceDeleteFallback(f); + } + } + + /** + * Windows 长路径删除兜底:用 cmd 的 rd/del 处理,规避 260 字符限制。 + */ + private void forceDeleteFallback(File f) { + try { + String path = f.getAbsolutePath(); + String cmd; + if (f.isDirectory()) { + cmd = "rd /s /q \"" + path + "\""; + } else { + cmd = "del /f /q \"" + path + "\""; + } + processRunner.run(new File("."), List.of("cmd", "/c", cmd), null); + } catch (Exception ignored) { + // 尽力而为 + } + } + + private String resolveReportDir(CoverageConfig config, File analysisDir) { + if (config.getReportOutputDir() != null && !config.getReportOutputDir().trim().isEmpty()) { + return config.getReportOutputDir().trim(); + } + // 默认导出到 workutils 的导出目录({export}/coverage-reports/{projectName}), + // 不污染目标项目 target 目录 + File projectDir = new File(config.getProjectPath()); + String projectName = projectDir.getName(); + if (projectName == null || projectName.trim().isEmpty()) { + projectName = "project"; + } + Path exportBase = workUtilsProperties.getExportBasePath(); + return exportBase.resolve("coverage-reports").resolve(sanitizeDirName(projectName)).toString(); + } + + /** 清理目录名中的非法字符 */ + private String sanitizeDirName(String name) { + String cleaned = name.replaceAll("[\\\\/:*?\"<>|]", "_"); + return cleaned.trim().isEmpty() ? "project" : cleaned; + } + + /** 内部辅助:源文件与其覆盖行 */ + private static class SourceFileCoverage { + final File sourceFile; + final List lines; + + SourceFileCoverage(File sourceFile, List lines) { + this.sourceFile = sourceFile; + this.lines = lines; + } + } +} diff --git a/src/main/java/com/example/workutils/biz/coveragereport/ui/CoverageReportWindow.java b/src/main/java/com/example/workutils/biz/coveragereport/ui/CoverageReportWindow.java new file mode 100644 index 0000000..6cc3fd3 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/coveragereport/ui/CoverageReportWindow.java @@ -0,0 +1,526 @@ +package com.example.workutils.biz.coveragereport.ui; + +import com.example.workutils.biz.coveragereport.model.AnalysisResult; +import com.example.workutils.biz.coveragereport.model.CoverageConfig; +import com.example.workutils.biz.coveragereport.service.CoverageAnalysisService; +import com.example.workutils.system.support.FileOperationHelper; +import com.example.workutils.system.ui.StyledButton; +import com.example.workutils.system.ui.Theme; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.concurrent.Task; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.stage.Stage; + +import java.awt.Desktop; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +/** + * 覆盖率分析可视化窗口(侧栏插件面板)。 + *

    + * 支持:项目选择、分支/commit、基线分支(增量覆盖率)、执行分析、 + * 执行记录列表、报告预览与日志查看。 + */ +public class CoverageReportWindow { + + private final CoverageAnalysisService analysisService; + private final FileOperationHelper fileOperationHelper; + private final CountDownLatch closeLatch; + private final Stage stage; + private final List projects = new ArrayList<>(); + private final ObservableList projectOptions = FXCollections.observableArrayList(); + private final ObservableList runRecords = FXCollections.observableArrayList(); + + private ComboBox projectCombo; + private TextField refField; + private TextField baselineField; + private Button runButton; + private Button openReportButton; + private TextArea logArea; + private Label statusLabel; + private ListView recordList; + + private boolean analysisRunning; + private String lastReportIndexPath; + private RunRecord currentRecord; + + public CoverageReportWindow(CoverageAnalysisService analysisService, + FileOperationHelper fileOperationHelper, + CountDownLatch closeLatch) { + this.analysisService = analysisService; + this.fileOperationHelper = fileOperationHelper; + this.closeLatch = closeLatch; + this.stage = new Stage(); + buildUI(); + } + + public void show() { + stage.show(); + } + + public Parent getContentPane() { + return stage.getScene().getRoot(); + } + + public Stage getStage() { + return stage; + } + + private void buildUI() { + stage.setTitle("覆盖率分析"); + stage.setWidth(1180); + stage.setHeight(820); + stage.setMinWidth(1000); + stage.setMinHeight(640); + stage.setOnCloseRequest(e -> closeLatch.countDown()); + + BorderPane root = new BorderPane(); + root.setPadding(new Insets(Theme.S5)); + root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); + + VBox top = new VBox(Theme.S2); + Label title = new Label("覆盖率分析插件"); + title.setFont(Theme.heading(24)); + title.setTextFill(Theme.TEXT_PRIMARY); + + Label desc = new Label("全量单元测试 + Git 分支/commit 代码覆盖率 + 增量覆盖率,生成 HTML 报告。"); + desc.setFont(Theme.base(13)); + desc.setTextFill(Theme.TEXT_SECONDARY); + top.getChildren().addAll(title, desc); + root.setTop(top); + + TabPane tabs = new TabPane(); + tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); + tabs.getTabs().addAll(buildAnalyzeTab(), buildHelpTab()); + BorderPane.setMargin(tabs, new Insets(Theme.S4, 0, Theme.S3, 0)); + root.setCenter(tabs); + + statusLabel = new Label("就绪"); + statusLabel.setFont(Theme.base(13)); + statusLabel.setTextFill(Theme.TEXT_SECONDARY); + root.setBottom(statusLabel); + + Scene scene = new Scene(root); + Theme.applyCustomCss(scene); + stage.setScene(scene); + + logArea = new TextArea(); + logArea.setEditable(false); + logArea.setWrapText(false); + logArea.setFont(Theme.mono(12)); + logArea.getStyleClass().add(Theme.CSS_LOG_AREA); + + loadProjects(); + } + + private Tab buildAnalyzeTab() { + Tab tab = new Tab("覆盖率分析"); + VBox box = new VBox(Theme.S4); + Parent configPane = buildConfigPane(); + Parent recordPane = buildRecordPane(); + Parent actions = buildActions(); + box.getChildren().addAll(configPane, recordPane, actions); + tab.setContent(box); + return tab; + } + + private Tab buildHelpTab() { + Tab tab = new Tab("使用说明"); + TextArea area = new TextArea( + "【覆盖率分析插件】\n\n" + + "1. 选择目标项目(来自系统设置中的项目路径)。\n" + + "2. 填写分支名或 commit ID;留空则分析当前检出版本。\n" + + "3. 可选「基线分支」开启增量覆盖率统计(如 main),只统计相对基线新增/修改代码的覆盖率。\n" + + "4. 点击「开始分析」:将检出目标版本 → 执行 mvn test + jacoco → 解析报告 → 生成 HTML 报告。\n" + + "5. 分析期间,下方执行记录列表会显示「单测执行中」;可点击「查看日志」查看实时执行日志。\n" + + "6. 分析完成后,可点击「打开报告」预览报告,或点击「查看日志」查看完整执行日志。\n\n" + + "【说明】\n" + + "· 默认使用临时 worktree 检出,避免污染当前工作区。\n" + + "· 报告输出:target/coverage-report/index.html\n" + + "· 提交人维度统计基于 git blame 逐行归属,精确到提交记录。"); + area.setEditable(false); + area.setWrapText(true); + area.setFont(Theme.base(13)); + area.getStyleClass().add(Theme.CSS_TEXT_AREA); + VBox box = new VBox(Theme.S4, area); + VBox.setVgrow(area, Priority.ALWAYS); + box.setPadding(new Insets(Theme.S4)); + tab.setContent(box); + return tab; + } + + private Parent buildConfigPane() { + GridPane form = new GridPane(); + form.setHgap(Theme.S3); + form.setVgap(Theme.S3); + form.setPadding(new Insets(Theme.S4)); + form.setStyle(cardStyle()); + + ColumnConstraints labelCol = new ColumnConstraints(); + labelCol.setMinWidth(90); + labelCol.setPrefWidth(90); + ColumnConstraints inputCol = new ColumnConstraints(); + inputCol.setHgrow(Priority.ALWAYS); + form.getColumnConstraints().addAll(labelCol, inputCol); + + // 项目 + projectCombo = new ComboBox<>(); + projectCombo.setItems(projectOptions); + projectCombo.setMaxWidth(Double.MAX_VALUE); + projectCombo.setCellFactory(cb -> projectCell()); + projectCombo.setButtonCell(projectCell()); + projectCombo.setPromptText("请选择项目"); + HBox projectBox = new HBox(Theme.S2, projectCombo, smallButton("刷新", this::loadProjects)); + HBox.setHgrow(projectCombo, Priority.ALWAYS); + + // 分支 / commit(单个输入框,可填分支名或 commit ID) + refField = textField(""); + refField.setPromptText("分支名或 commit ID,如 main / a1b2c3d,留空则分析当前版本"); + + // 基线分支(增量覆盖率) + baselineField = textField(""); + baselineField.setPromptText("基线分支/commit,如 main(留空则只统计整体覆盖率)"); + baselineField.setMaxWidth(360); + + form.add(label("项目"), 0, 0); + form.add(projectBox, 1, 0); + form.add(label("分支 / commit"), 0, 1); + form.add(refField, 1, 1); + form.add(label("基线分支"), 0, 2); + form.add(baselineField, 1, 2); + + return form; + } + + private Parent buildRecordPane() { + VBox box = new VBox(Theme.S2); + box.setPadding(new Insets(Theme.S4)); + box.setStyle(cardStyle()); + + Label title = new Label("执行记录"); + title.setFont(Theme.heading(15)); + title.setTextFill(Theme.TEXT_PRIMARY); + + recordList = new ListView<>(); + recordList.setItems(runRecords); + recordList.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(RunRecord record, boolean empty) { + super.updateItem(record, empty); + if (empty || record == null) { + setText(null); + setGraphic(null); + return; + } + Label status = new Label(record.statusText); + status.setFont(Theme.base(13)); + status.setTextFill(record.statusColor); + Label info = new Label(record.info); + info.setFont(Theme.base(13)); + info.setTextFill(Theme.TEXT_SECONDARY); + VBox cellBox = new VBox(Theme.S1, status, info); + setGraphic(cellBox); + setPadding(new Insets(6, 4, 6, 4)); + } + }); + VBox.setVgrow(recordList, Priority.ALWAYS); + recordList.setMinHeight(0); + + box.getChildren().addAll(title, recordList); + return box; + } + + private Parent buildActions() { + HBox actions = new HBox(Theme.S2); + actions.setAlignment(Pos.CENTER_LEFT); + + runButton = StyledButton.primary("开始分析", Theme.bold(14)); + runButton.setOnAction(e -> startAnalysis()); + + openReportButton = StyledButton.secondary("打开报告", Theme.base(13)); + openReportButton.setOnAction(e -> openReport()); + openReportButton.setDisable(true); + + Button viewLogButton = StyledButton.secondary("查看日志", Theme.base(13)); + viewLogButton.setOnAction(e -> showLogDialog()); + + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + Label hint = new Label("报告输出至 target/coverage-report/,含行级/方法级标注与提交人统计。"); + hint.setFont(Theme.base(12)); + hint.setTextFill(Theme.TEXT_MUTED); + + actions.getChildren().addAll(runButton, openReportButton, viewLogButton, spacer, hint); + return actions; + } + + private void startAnalysis() { + if (analysisRunning) { + return; + } + ProjectOption selected = projectCombo.getSelectionModel().getSelectedItem(); + if (selected == null || selected.path == null || selected.path.trim().isEmpty()) { + showWarning("未选择项目", "请先选择一个目标项目。"); + return; + } + CoverageConfig config = buildConfig(selected.path); + + // 新增一条「单测执行中」记录 + RunRecord record = new RunRecord("单测执行中...", Theme.WARNING, selected.name + " · " + refLabel()); + currentRecord = record; + runRecords.add(0, record); + recordList.refresh(); + + setAnalysisRunning(true); + logArea.clear(); + statusLabel.setText("正在分析..."); + + Task task = new Task<>() { + @Override + protected AnalysisResult call() { + return analysisService.analyze(config, msg -> + Platform.runLater(() -> appendLog(msg))); + } + }; + + task.setOnSucceeded(e -> { + AnalysisResult result = task.getValue(); + setAnalysisRunning(false); + currentRecord.markFinished(); + recordList.refresh(); + if (result.getReportIndexPath() != null && !result.getReportIndexPath().isEmpty()) { + lastReportIndexPath = result.getReportIndexPath(); + openReportButton.setDisable(false); + } + statusLabel.setText("分析完成,耗时 " + (result.getDurationMillis() / 1000) + "s"); + appendLog(""); + appendLog(result.getLog()); + }); + task.setOnFailed(e -> { + setAnalysisRunning(false); + currentRecord.markFailed(); + recordList.refresh(); + Throwable error = task.getException(); + statusLabel.setText("分析异常: " + (error == null ? "未知错误" : error.getMessage())); + if (error != null) { + appendLog("[ERROR] " + error.getMessage()); + } + }); + + Thread worker = new Thread(task, "coverage-report-worker"); + worker.setDaemon(true); + worker.start(); + } + + private CoverageConfig buildConfig(String projectPath) { + CoverageConfig config = new CoverageConfig(); + config.setProjectPath(projectPath); + String ref = refField.getText() == null ? "" : refField.getText().trim(); + // service 解析时优先 commitId,因此单个输入框统一映射到 commitId + config.setCommitId(ref); + config.setBaselineBranch(baselineField.getText() == null ? "" : baselineField.getText().trim()); + return config; + } + + private String refLabel() { + String ref = refField.getText() == null ? "" : refField.getText().trim(); + return ref.isEmpty() ? "当前版本" : ref; + } + + private void showLogDialog() { + Alert alert = new Alert(Alert.AlertType.INFORMATION); + alert.setTitle("覆盖率分析"); + alert.setHeaderText("执行日志"); + alert.setResizable(true); + alert.getDialogPane().setPrefSize(880, 560); + + TextArea area = new TextArea(logArea.getText()); + area.setEditable(false); + area.setWrapText(false); + area.setFont(Theme.mono(12)); + area.getStyleClass().add(Theme.CSS_LOG_AREA); + + VBox box = new VBox(area); + VBox.setVgrow(area, Priority.ALWAYS); + alert.getDialogPane().setContent(box); + alert.showAndWait(); + } + + private void openReport() { + if (lastReportIndexPath == null || lastReportIndexPath.isEmpty()) { + return; + } + File report = new File(lastReportIndexPath); + if (!report.isFile()) { + showWarning("报告不存在", "报告文件不存在:\n" + lastReportIndexPath); + return; + } + try { + Desktop.getDesktop().browse(report.toURI()); + } catch (IOException e) { + showWarning("打开失败", e.getMessage()); + } + } + + private void loadProjects() { + try { + projects.clear(); + projects.addAll(fileOperationHelper.getProjectPathsWithStatus()); + projectOptions.clear(); + for (FileOperationHelper.ProjectPathInfo info : projects) { + String name = fileOperationHelper.extractProjectName(info.getPath()); + projectOptions.add(new ProjectOption(name, info.getPath())); + } + if (!projectOptions.isEmpty()) { + projectCombo.getSelectionModel().select(0); + } + statusLabel.setText("已加载项目: " + projectOptions.size() + " 个"); + } catch (Exception e) { + statusLabel.setText("加载项目失败: " + e.getMessage()); + showWarning("加载项目失败", e.getMessage()); + } + } + + private void setAnalysisRunning(boolean running) { + this.analysisRunning = running; + updateLoadingButton(runButton, running, "开始分析", "分析中..."); + } + + private void updateLoadingButton(Button button, boolean loading, String idleText, String loadingText) { + if (button == null) return; + button.setDisable(loading); + button.setText(loading ? loadingText : idleText); + button.setGraphic(loading ? createLoadingIndicator() : null); + } + + private ProgressIndicator createLoadingIndicator() { + ProgressIndicator indicator = new ProgressIndicator(); + indicator.setPrefSize(14, 14); + indicator.setMinSize(14, 14); + indicator.setMaxSize(14, 14); + return indicator; + } + + private void appendLog(String message) { + if (logArea == null) return; + logArea.appendText(message + System.lineSeparator()); + logArea.setScrollTop(Double.MAX_VALUE); + } + + private ListCell projectCell() { + return new ListCell<>() { + @Override + protected void updateItem(ProjectOption item, boolean empty) { + super.updateItem(item, empty); + setText((empty || item == null) ? null : item.toString()); + } + }; + } + + // ==================== helpers ==================== + + private String cardStyle() { + return "-fx-background-color: " + Theme.toWeb(Theme.BG_SURFACE) + ";" + + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" + + "-fx-border-radius: " + Theme.RADIUS_MD + ";" + + "-fx-background-radius: " + Theme.RADIUS_MD + ";"; + } + + private TextField textField(String value) { + TextField field = new TextField(value); + field.setFont(Theme.base(13)); + field.getStyleClass().add(Theme.CSS_TEXT_FIELD); + field.setMaxWidth(Double.MAX_VALUE); + return field; + } + + private Label label(String text) { + Label label = new Label(text); + label.setFont(Theme.base(13)); + label.setTextFill(Theme.TEXT_PRIMARY); + return label; + } + + private Button smallButton(String text, Runnable action) { + Button button = StyledButton.secondary(text, Theme.base(12)); + button.setOnAction(e -> action.run()); + return button; + } + + private void showWarning(String header, String content) { + Alert alert = new Alert(Alert.AlertType.WARNING); + alert.setTitle("覆盖率分析"); + alert.setHeaderText(header); + alert.setContentText(content); + alert.showAndWait(); + } + + /** 项目下拉选项 */ + public static class ProjectOption { + final String name; + final String path; + + ProjectOption(String name, String path) { + this.name = name; + this.path = path; + } + + public String getName() { return name; } + public String getPath() { return path; } + + @Override + public String toString() { + return name; + } + } + + /** 执行记录 */ + private static class RunRecord { + String statusText; + Color statusColor; + final String info; + + RunRecord(String statusText, Color statusColor, String info) { + this.statusText = statusText; + this.statusColor = statusColor; + this.info = info; + } + + void markFinished() { + this.statusText = "单测执行完成"; + this.statusColor = Theme.SUCCESS; + } + + void markFailed() { + this.statusText = "单测执行失败"; + this.statusColor = Theme.DANGER; + } + } +} diff --git a/src/test/java/com/example/workutils/biz/coveragereport/config/ConfigLoaderTest.java b/src/test/java/com/example/workutils/biz/coveragereport/config/ConfigLoaderTest.java new file mode 100644 index 0000000..b8f5aed --- /dev/null +++ b/src/test/java/com/example/workutils/biz/coveragereport/config/ConfigLoaderTest.java @@ -0,0 +1,70 @@ +package com.example.workutils.biz.coveragereport.config; + +import com.example.workutils.biz.coveragereport.model.CoverageConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConfigLoaderTest { + + @Test + void loadsPropertiesFromProjectDir(@TempDir Path root) throws Exception { + Files.writeString(root.resolve(ConfigLoader.CONFIG_FILE_NAME), """ + branch=develop + commitId=a1b2c3d + authorFilter=张三,李四 + passRateThreshold=99 + lineCoverageThreshold=85 + checkoutBeforeAnalyze=false + useTemporaryWorktree=false + failOnThreshold=true + offline=true + """, StandardCharsets.UTF_8); + + CoverageConfig config = new CoverageConfig(); + config.setProjectPath(root.toString()); + config = new ConfigLoader().loadFromProject(root.toFile(), config); + + assertEquals("develop", config.getBranch()); + assertEquals("a1b2c3d", config.getCommitId()); + assertEquals("张三,李四", config.getAuthorFilter()); + assertEquals(99.0, config.getPassRateThreshold(), 0.001); + assertEquals(85.0, config.getLineCoverageThreshold(), 0.001); + assertEquals(false, config.isCheckoutBeforeAnalyze()); + assertEquals(false, config.isUseTemporaryWorktree()); + assertEquals(true, config.isFailOnThreshold()); + assertEquals(true, config.isOffline()); + } + + @Test + void keepsDefaultsWhenNoConfigFile(@TempDir Path root) { + CoverageConfig config = new CoverageConfig(); + config.setProjectPath(root.toString()); + CoverageConfig loaded = new ConfigLoader().loadFromProject(root.toFile(), config); + + assertEquals(100.0, loaded.getPassRateThreshold(), 0.001); + assertEquals(80.0, loaded.getLineCoverageThreshold(), 0.001); + assertTrue(loaded.isUseTemporaryWorktree()); + } + + @Test + void explicitCommandLineValuesNotOverriddenByFile(@TempDir Path root) throws Exception { + Files.writeString(root.resolve(ConfigLoader.CONFIG_FILE_NAME), + "lineCoverageThreshold=90\n", StandardCharsets.UTF_8); + + CoverageConfig config = new CoverageConfig(); + config.setProjectPath(root.toString()); + config.setLineCoverageThreshold(60.0); // 命令行显式指定 + config.markExplicit(ConfigLoader.KEY_LINE_COVERAGE_THRESHOLD); + CoverageConfig loaded = new ConfigLoader().loadFromProject(root.toFile(), config, + config.getExplicitlySetKeys()); + + assertEquals(60.0, loaded.getLineCoverageThreshold(), 0.001); + } +} diff --git a/src/test/java/com/example/workutils/biz/coveragereport/git/GitCliBlameTest.java b/src/test/java/com/example/workutils/biz/coveragereport/git/GitCliBlameTest.java new file mode 100644 index 0000000..b99ffb6 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/coveragereport/git/GitCliBlameTest.java @@ -0,0 +1,64 @@ +package com.example.workutils.biz.coveragereport.git; + +import com.example.workutils.biz.coveragereport.process.ProcessRunner; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 验证 git blame --line-porcelain 解析: + * 行号必须取 header 第三列 final-lineno(当前文件行号), + * 而非第二列 orig-lineno(原提交版本中的行号,文件插入/删除后会发生错位)。 + */ +class GitCliBlameTest { + + @Test + void parseUsesFinalLineNumberWhenOrigDiffers() { + String shaA = "e84a2f0c1a9b4d6e8f0a1b2c3d4e5f6a7b8c9d0e"; + // 第一行 header: ,模拟文件发生过插入导致行号错位 + String porcelain = shaA + " 5 20\n" + + "author 张三\n" + + "author-mail \n" + + "author-time 1710000000\n" + + "author-tz +0800\n" + + "committer 张三\n" + + "committer-mail \n" + + "committer-time 1710000000\n" + + "committer-tz +0800\n" + + "summary 新增方法\n" + + "filename src/main/java/foo/Bar.java\n" + + "\t行内容20\n" + + shaA + " 21 21\n" + + "author 李四\n" + + "author-mail \n" + + "author-time 1710000001\n" + + "author-tz +0800\n" + + "committer 李四\n" + + "committer-mail \n" + + "committer-time 1710000001\n" + + "committer-tz +0800\n" + + "summary init\n" + + "filename src/main/java/foo/Bar.java\n" + + "\t行内容21\n"; + + ProcessRunner pr = mock(ProcessRunner.class); + when(pr.run(any(File.class), anyList())).thenReturn(new ProcessRunner.Result(0, porcelain, "")); + + GitCli gitCli = new GitCli(pr); + List lines = gitCli.blame(new File("."), "src/main/java/foo/Bar.java"); + + assertEquals(2, lines.size()); + assertEquals(20, lines.get(0).getLineNumber(), "应取 final-lineno(20) 而非 orig-lineno(5)"); + assertEquals("张三", lines.get(0).getAuthor()); + assertEquals("", lines.get(0).getEmail()); + assertEquals(21, lines.get(1).getLineNumber()); + assertEquals("李四", lines.get(1).getAuthor()); + } +} diff --git a/src/test/java/com/example/workutils/biz/coveragereport/parser/JacocoXmlParserTest.java b/src/test/java/com/example/workutils/biz/coveragereport/parser/JacocoXmlParserTest.java new file mode 100644 index 0000000..91e3959 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/coveragereport/parser/JacocoXmlParserTest.java @@ -0,0 +1,111 @@ +package com.example.workutils.biz.coveragereport.parser; + +import com.example.workutils.biz.coveragereport.model.CoverageSummary; +import com.example.workutils.biz.coveragereport.model.JacocoClass; +import com.example.workutils.biz.coveragereport.model.JacocoMethod; +import com.example.workutils.biz.coveragereport.model.JacocoPackage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JacocoXmlParserTest { + + @Test + void parsesPackageClassMethodAndLineLevels(@TempDir Path root) throws Exception { + Path xml = root.resolve("jacoco.xml"); + Files.writeString(xml, """ + + + + + + + + + + + + + + + + + + + + + + + + + + + """, StandardCharsets.UTF_8); + + CoverageSummary summary = new JacocoXmlParser().parse(xml.toFile(), List.of("$", "lombok")); + + assertEquals(1, summary.getPackageCount()); + assertEquals(1, summary.getClassCount()); + assertEquals(5, summary.getLineCount()); + assertEquals(3, summary.getLineCovered()); + assertEquals(2, summary.getMethodCount()); + assertEquals(1, summary.getMethodCovered()); + assertEquals(15, summary.getInstructionCount()); + assertEquals(8, summary.getInstructionCovered()); + assertEquals(3, summary.getBranchCount()); + assertEquals(1, summary.getBranchCovered()); + + JacocoPackage pkg = summary.getPackages().get(0); + assertEquals("com/example/order", pkg.getName()); + JacocoClass cls = pkg.getClasses().get(0); + assertEquals("OrderService.java", cls.getSourceFileName()); + assertEquals(2, cls.getMethods().size()); + + JacocoMethod place = cls.getMethods().get(0); + assertEquals("place", place.getName()); + assertEquals(12, place.getLineStart()); + assertEquals(8, place.getInstructionCovered()); + // sourcefile 的行明细进入类级所有行 + assertEquals(3, cls.getAllLines().size()); + assertTrue(cls.getAllLines().get(0).isCovered()); + assertTrue(cls.getAllLines().get(2).isMissed()); + // cancel 方法行号范围内的行被关联到方法 + JacocoMethod cancel = cls.getMethods().get(1); + assertEquals("cancel", cancel.getName()); + assertEquals(1, cancel.getLines().size()); + assertEquals(30, cancel.getLines().get(0).getLineNumber()); + assertTrue(cancel.getLines().get(0).isMissed()); + } + + @Test + void skipsClassesMatchingPatterns(@TempDir Path root) throws Exception { + Path xml = root.resolve("jacoco.xml"); + Files.writeString(xml, """ + + + + + + + + + + + + + + """, StandardCharsets.UTF_8); + + CoverageSummary summary = new JacocoXmlParser().parse(xml.toFile(), List.of("Lombok")); + + assertEquals(1, summary.getClassCount()); + assertEquals(2, summary.getLineCount()); + } +} diff --git a/src/test/java/com/example/workutils/biz/coveragereport/parser/SurefireXmlParserTest.java b/src/test/java/com/example/workutils/biz/coveragereport/parser/SurefireXmlParserTest.java new file mode 100644 index 0000000..a79f730 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/coveragereport/parser/SurefireXmlParserTest.java @@ -0,0 +1,61 @@ +package com.example.workutils.biz.coveragereport.parser; + +import com.example.workutils.biz.coveragereport.model.TestSummary; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SurefireXmlParserTest { + + @Test + void aggregatesAllTestXmlAndComputesPassRate(@TempDir Path root) throws Exception { + Path reports = root.resolve("target/surefire-reports"); + Files.createDirectories(reports); + + Files.writeString(reports.resolve("TEST-a.OrderServiceTest.xml"), + "", + StandardCharsets.UTF_8); + Files.writeString(reports.resolve("TEST-b.PaymentTest.xml"), + "", + StandardCharsets.UTF_8); + + TestSummary summary = new SurefireXmlParser().parseDirectory(reports.toFile()); + + assertEquals(2, summary.getTestClassCount()); + assertEquals(6, summary.getTestCount()); + assertEquals(1, summary.getFailureCount()); + assertEquals(1, summary.getErrorCount()); + assertEquals(1, summary.getSkippedCount()); + assertEquals(3, summary.getPassedCount()); + assertEquals(50.0, summary.getPassRate(), 0.001); + assertTrue(!summary.isAllPassed()); + assertEquals(1700, summary.getDurationMillis()); + } + + @Test + void allPassedWhenNoFailuresOrErrors(@TempDir Path root) throws Exception { + Path reports = root.resolve("surefire-reports"); + Files.createDirectories(reports); + Files.writeString(reports.resolve("TEST-a.GoodTest.xml"), + "", + StandardCharsets.UTF_8); + + TestSummary summary = new SurefireXmlParser().parseDirectory(reports.toFile()); + + assertEquals(100.0, summary.getPassRate(), 0.001); + assertTrue(summary.isAllPassed()); + } + + @Test + void emptyWhenDirectoryMissing(@TempDir Path root) { + TestSummary summary = new SurefireXmlParser().parseDirectory(root.resolve("nope").toFile()); + assertEquals(0, summary.getTestClassCount()); + assertEquals(0, summary.getTestCount()); + } +} diff --git a/src/test/java/com/example/workutils/biz/coveragereport/report/HtmlReportGeneratorTest.java b/src/test/java/com/example/workutils/biz/coveragereport/report/HtmlReportGeneratorTest.java new file mode 100644 index 0000000..609a734 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/coveragereport/report/HtmlReportGeneratorTest.java @@ -0,0 +1,159 @@ +package com.example.workutils.biz.coveragereport.report; + +import com.example.workutils.biz.coveragereport.model.AnalysisResult; +import com.example.workutils.biz.coveragereport.model.AuthorCoverage; +import com.example.workutils.biz.coveragereport.model.CommitDetail; +import com.example.workutils.biz.coveragereport.model.CoverageConfig; +import com.example.workutils.biz.coveragereport.model.CoverageSummary; +import com.example.workutils.biz.coveragereport.model.JacocoClass; +import com.example.workutils.biz.coveragereport.model.JacocoLine; +import com.example.workutils.biz.coveragereport.model.JacocoMethod; +import com.example.workutils.biz.coveragereport.model.JacocoPackage; +import com.example.workutils.biz.coveragereport.model.TestSummary; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HtmlReportGeneratorTest { + + @Test + void generatesCompleteReportStructure(@TempDir Path root) throws Exception { + // 源码文件(行级标注读取用) + Path srcDir = root.resolve("src/main/java/com/example"); + Files.createDirectories(srcDir); + Files.writeString(srcDir.resolve("Calculator.java"), + "package com.example;\npublic class Calculator {\n public int add() {\n return 1;\n }\n public int bad() {\n throw new RuntimeException();\n }\n}\n", + StandardCharsets.UTF_8); + + // 构造覆盖率 + JacocoPackage pkg = new JacocoPackage(); + pkg.setName("com/example"); + JacocoClass cls = new JacocoClass(); + cls.setName("com/example/Calculator"); + cls.setSourceFileName("Calculator.java"); + cls.setPackagePath("com/example"); + cls.setLineCount(5); + cls.setLineCovered(3); + cls.setMethodCount(2); + cls.setMethodCovered(1); + + JacocoMethod add = new JacocoMethod(); + add.setName("add"); + add.setLineStart(3); + add.setLineEnd(4); + add.setInstructionCovered(4); + add.setInstructionCount(4); + JacocoMethod bad = new JacocoMethod(); + bad.setName("bad"); + bad.setLineStart(5); + bad.setLineEnd(6); + bad.setInstructionCovered(0); + bad.setInstructionCount(3); + cls.getMethods().add(add); + cls.getMethods().add(bad); + + // 行级:3,4 覆盖;5,6 未覆盖 + cls.getAllLines().add(new JacocoLine(3, 2, 2, 0, 0)); + cls.getAllLines().add(new JacocoLine(4, 2, 2, 0, 0)); + cls.getAllLines().add(new JacocoLine(5, 3, 0, 0, 0)); + cls.getAllLines().add(new JacocoLine(6, 3, 0, 0, 0)); + + pkg.getClasses().add(cls); + pkg.setClassCount(1); + pkg.setLineCount(4); + pkg.setLineCovered(2); + pkg.setMethodCount(2); + pkg.setMethodCovered(1); + + CoverageSummary cov = new CoverageSummary(); + cov.getPackages().add(pkg); + cov.setPackageCount(1); + cov.setClassCount(1); + cov.setMethodCount(2); + cov.setMethodCovered(1); + cov.setLineCount(4); + cov.setLineCovered(2); + cov.setInstructionCount(10); + cov.setInstructionCovered(4); + + TestSummary tests = new TestSummary(); + tests.setTestClassCount(1); + tests.setTestCount(4); + tests.setFailureCount(0); + tests.setErrorCount(0); + tests.setSkippedCount(0); + + AuthorCoverage author = new AuthorCoverage(); + author.setAuthor("张三"); + author.setEmail("zhang@example.com"); + author.setContributedLineCount(2); + author.setCoveredLineCount(1); + author.setCommitCount(1); + author.setFileCount(1); + CommitDetail cd = new CommitDetail(); + cd.setCommitId("abc123def456"); + cd.setShortCommitId("abc123d"); + cd.setAuthor("张三"); + cd.setEmail("zhang@example.com"); + cd.setSubject("add calculator"); + cd.setContributedLineCount(2); + cd.setCoveredLineCount(1); + author.getCommits().add(cd); + + AnalysisResult result = new AnalysisResult(); + result.setProjectPath(root.toString()); + result.setBranch("main"); + result.setCommitId("abc123def456"); + result.setTestSummary(tests); + result.setCoverageSummary(cov); + result.getAuthorCoverages().add(author); + result.setThresholdPassed(true); + + CoverageConfig config = new CoverageConfig(); + config.setAuthorFilter(""); + + String reportDir = root.resolve("target/coverage-report").toString(); + String index = new HtmlReportGenerator().generate(reportDir, config, result, root.toFile(), s -> {}); + + // 断言报告结构 + Path dir = Path.of(reportDir); + assertTrue(Files.isRegularFile(dir.resolve("index.html"))); + assertTrue(Files.isRegularFile(dir.resolve("by-author.html"))); + assertTrue(Files.isRegularFile(dir.resolve("summary.json"))); + + String indexHtml = Files.readString(dir.resolve("index.html")); + assertTrue(indexHtml.contains("100.00%"), "index 应显示测试通过率"); + assertTrue(indexHtml.contains("50.00%"), "index 应显示行覆盖率"); + assertTrue(indexHtml.contains("提交人维度"), "index 应链接提交人维度"); + assertTrue(indexHtml.contains("通过"), "index 应显示阈值校验通过"); + + String byAuthor = Files.readString(dir.resolve("by-author.html")); + assertTrue(byAuthor.contains("张三"), "by-author 应包含提交人"); + assertTrue(byAuthor.contains("50.00%"), "by-author 应显示提交人覆盖率"); + + // 提交人详情页 + Path authorDir = dir.resolve("authors"); + Path[] authorPages = Files.list(authorDir).toArray(Path[]::new); + assertTrue(authorPages.length == 1, "应生成 1 个提交人页面"); + String authorHtml = Files.readString(authorPages[0]); + assertTrue(authorHtml.contains("abc123d"), "提交人页应显示 commit"); + + // 类页面(行级标注) + Path[] classPages = Files.list(dir.resolve("files")).toArray(Path[]::new); + assertTrue(classPages.length == 1, "应生成类页面"); + String classHtml = Files.readString(classPages[0]); + assertTrue(classHtml.contains("covered"), "类页应标注已覆盖行"); + assertTrue(classHtml.contains("missed"), "类页应标注未覆盖行"); + + // summary.json + String summaryJson = Files.readString(dir.resolve("summary.json")); + assertTrue(summaryJson.contains("\"passRate\""), "summary.json 应包含测试通过率"); + assertTrue(summaryJson.contains("\"authorCoverages\""), "summary.json 应包含提交人统计"); + } +} diff --git a/src/test/java/com/example/workutils/biz/coveragereport/service/CoverageAnalysisServiceIT.java b/src/test/java/com/example/workutils/biz/coveragereport/service/CoverageAnalysisServiceIT.java new file mode 100644 index 0000000..c679469 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/coveragereport/service/CoverageAnalysisServiceIT.java @@ -0,0 +1,214 @@ +package com.example.workutils.biz.coveragereport.service; + +import com.example.workutils.biz.coveragereport.config.ConfigLoader; +import com.example.workutils.biz.coveragereport.git.GitCli; +import com.example.workutils.biz.coveragereport.maven.MavenInvoker; +import com.example.workutils.biz.coveragereport.maven.PomInjector; +import com.example.workutils.biz.coveragereport.model.AnalysisResult; +import com.example.workutils.biz.coveragereport.model.CoverageConfig; +import com.example.workutils.biz.coveragereport.parser.JacocoXmlParser; +import com.example.workutils.biz.coveragereport.parser.SurefireXmlParser; +import com.example.workutils.biz.coveragereport.process.ProcessRunner; +import com.example.workutils.biz.coveragereport.report.HtmlReportGenerator; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 端到端集成测试:构造最小 Maven + git 项目,执行完整覆盖率分析链路。 + *

    + * 需要本机存在 mvn 与 git,且需联网拉取 jacoco 依赖,默认禁用。 + * 启用方法:移除 {@code @Disabled} 后运行。 + */ +@org.junit.jupiter.api.Disabled("需要本机 mvn + git + 网络拉取 jacoco 依赖,仅在开发环境手动运行") +class CoverageAnalysisServiceIT { + + @Test + void analyzeMinimalProjectEndToEnd(@TempDir Path root) throws Exception { + // 1. 构造最小 Maven 项目 + writeMinimalProject(root); + + // 2. 初始化 git 仓库 + ProcessRunner pr = new ProcessRunner(); + assertEquals(0, pr.run(root.toFile(), List.of("git", "init")).getExitCode()); + pr.run(root.toFile(), List.of("git", "add", ".")); + ProcessRunner.Result commitResult = pr.run(root.toFile(), List.of( + "git", "-c", "user.name=test", "-c", "user.email=test@example.com", + "commit", "-m", "init")); + assertEquals(0, commitResult.getExitCode()); + + // 3. 手动组装 service + com.example.workutils.system.config.WorkUtilsProperties props = + new com.example.workutils.system.config.WorkUtilsProperties(); + props.setExportDirectory(Files.createTempDirectory("wucoverage-export").toFile().getAbsolutePath()); + CoverageAnalysisService service = new CoverageAnalysisService( + new ConfigLoader(), + new GitCli(pr), + new MavenInvoker(pr, new PomInjector()), + new SurefireXmlParser(), + new JacocoXmlParser(), + new HtmlReportGenerator(), + pr, + props); + + CoverageConfig config = new CoverageConfig(); + config.setProjectPath(root.toString()); + config.setUseTemporaryWorktree(false); + config.setFailOnThreshold(true); + + AnalysisResult result = service.analyze(config, System.out::println); + + // 4. 断言 + assertTrue(result.getTestSummary() != null, "应解析出测试摘要"); + assertEquals(2, result.getTestSummary().getTestCount()); + assertEquals(100.0, result.getTestSummary().getPassRate(), 0.001); + assertTrue(result.getCoverageSummary() != null, "应解析出覆盖率"); + assertTrue(result.getCoverageSummary().getLineCovered() >= 2, + "Calculator 类的方法行应被覆盖,实际覆盖行数=" + result.getCoverageSummary().getLineCovered()); + assertTrue(result.isThresholdPassed(), "通过率100% + 行覆盖率应通过阈值"); + assertTrue(result.getReportIndexPath() != null, "应生成 HTML 报告"); + assertTrue(Files.isRegularFile(Path.of(result.getReportIndexPath())), "index.html 应存在"); + + // 提交人维度:测试作者 commit + assertTrue(!result.getAuthorCoverages().isEmpty(), "应有提交人统计"); + assertEquals("test", result.getAuthorCoverages().get(0).getAuthor()); + + // 增量覆盖率场景:以首个 commit 为基线,在其上新增一个被测试覆盖的方法 + ProcessRunner.Result baseCommit = pr.run(root.toFile(), + List.of("git", "rev-parse", "HEAD")); + assertEquals(0, baseCommit.getExitCode(), "应能取到基线 commit"); + String baselineId = baseCommit.getStdout().trim(); + + // 在 Calculator 中新增 multiply 方法,并补充对应测试(覆盖新增行) + Path calcFile = root.resolve("src/main/java/com/example/Calculator.java"); + Files.writeString(calcFile, """ + package com.example; + public class Calculator { + public int add(int a, int b) { + return a + b; + } + public int subtract(int a, int b) { + return a - b; + } + public int multiply(int a, int b) { + return a * b; + } + } + """, StandardCharsets.UTF_8); + Path testFile = root.resolve("src/test/java/com/example/CalculatorTest.java"); + Files.writeString(testFile, """ + package com.example; + import org.junit.jupiter.api.Test; + import static org.junit.jupiter.api.Assertions.assertEquals; + public class CalculatorTest { + @Test + void addWorks() { + assertEquals(3, new Calculator().add(1, 2)); + } + @Test + void subtractWorks() { + assertEquals(-1, new Calculator().subtract(1, 2)); + } + @Test + void multiplyWorks() { + assertEquals(6, new Calculator().multiply(2, 3)); + } + } + """, StandardCharsets.UTF_8); + pr.run(root.toFile(), List.of("git", "add", ".")); + assertEquals(0, pr.run(root.toFile(), List.of( + "git", "-c", "user.name=test", "-c", "user.email=test@example.com", + "commit", "-m", "add multiply")).getExitCode()); + + // 分析 head(当前),基线 = 首个 commit + CoverageConfig incConfig = new CoverageConfig(); + incConfig.setProjectPath(root.toString()); + incConfig.setUseTemporaryWorktree(false); + incConfig.setFailOnThreshold(true); + incConfig.setBaselineBranch(baselineId); + AnalysisResult incResult = service.analyze(incConfig, System.out::println); + assertTrue(incResult.getIncrementalCoverage() != null, "应生成增量覆盖率"); + assertTrue(incResult.getIncrementalCoverage().isEnabled(), "配置了基线应启用增量覆盖率"); + assertTrue(incResult.getIncrementalCoverage().getChangedLineCount() > 0, "应有变更行"); + assertEquals(1.0, incResult.getIncrementalCoverage().getCoverageRatio(), 0.001, + "新增的 multiply 方法行已被测试覆盖,增量覆盖率应为 100%"); + assertTrue(incResult.isThresholdPassed(), "增量覆盖率100%应通过阈值"); + } + + private void writeMinimalProject(Path root) throws Exception { + // pom.xml + Files.writeString(root.resolve("pom.xml"), """ + + + 4.0.0 + com.example + minimal + 1.0.0 + + 17 + 17 + UTF-8 + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + + """, StandardCharsets.UTF_8); + + // 源码 + Path srcDir = root.resolve("src/main/java/com/example"); + Files.createDirectories(srcDir); + Files.writeString(srcDir.resolve("Calculator.java"), """ + package com.example; + public class Calculator { + public int add(int a, int b) { + return a + b; + } + public int subtract(int a, int b) { + return a - b; + } + } + """, StandardCharsets.UTF_8); + + // 测试(覆盖所有行) + Path testDir = root.resolve("src/test/java/com/example"); + Files.createDirectories(testDir); + Files.writeString(testDir.resolve("CalculatorTest.java"), """ + package com.example; + import org.junit.jupiter.api.Test; + import static org.junit.jupiter.api.Assertions.assertEquals; + public class CalculatorTest { + @Test + void addWorks() { + assertEquals(3, new Calculator().add(1, 2)); + } + @Test + void subtractWorks() { + assertEquals(-1, new Calculator().subtract(1, 2)); + } + } + """, StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/example/workutils/biz/coveragereport/ui/CoverageReportWindowTest.java b/src/test/java/com/example/workutils/biz/coveragereport/ui/CoverageReportWindowTest.java new file mode 100644 index 0000000..06fe1e3 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/coveragereport/ui/CoverageReportWindowTest.java @@ -0,0 +1,54 @@ +package com.example.workutils.biz.coveragereport.ui; + +import javafx.application.Platform; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * UI 冒烟测试:验证覆盖率窗口能在 JavaFX 平台下正常构建。 + *

    + * 需要图形环境,默认禁用。 + */ +@Disabled("需要 JavaFX 图形环境,仅在桌面环境手动运行") +class CoverageReportWindowTest { + + @BeforeAll + static void initFx() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + new Thread(() -> { + try { + if (!Platform.isFxApplicationThread()) { + Platform.startup(latch::countDown); + } else { + latch.countDown(); + } + } catch (IllegalStateException e) { + latch.countDown(); + } + }).start(); + latch.await(10, TimeUnit.SECONDS); + } + + @Test + void windowBuildsWithoutError() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + CoverageReportWindow[] holder = new CoverageReportWindow[1]; + Platform.runLater(() -> { + try { + // service 等依赖传 null 也仅在构建 UI 时用到,不会触达 + holder[0] = new CoverageReportWindow(null, null, new CountDownLatch(0)); + } finally { + latch.countDown(); + } + }); + latch.await(10, TimeUnit.SECONDS); + assertNotNull(holder[0], "窗口应成功构建"); + assertNotNull(holder[0].getContentPane(), "窗口应包含内容面板"); + } +}