diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/ElementFormatter.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/ElementFormatter.java index f5cd76ec0..b6a400b07 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/ElementFormatter.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/ElementFormatter.java @@ -426,4 +426,17 @@ public ListBoxModel getBaselineItems() { private void add(final ListBoxModel options, final Baseline baseline) { options.add(getDisplayName(baseline), baseline.name()); } + + /** + * Returns all available aggregation modes as a {@link ListBoxModel}. + * + * @return the aggregation modes in a {@link ListBoxModel} + */ + public ListBoxModel getAggregationItems() { + var options = new ListBoxModel(); + for (MetricAggregation aggregation : MetricAggregation.values()) { + options.add(aggregation.name(), aggregation.name()); + } + return options; + } } diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/MetricAggregation.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/MetricAggregation.java new file mode 100644 index 000000000..ce487d185 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/MetricAggregation.java @@ -0,0 +1,40 @@ +package io.jenkins.plugins.coverage.metrics.model; + +import edu.hm.hafner.coverage.Metric; + +/** + * Defines the aggregation mode for software metrics that can be aggregated in different ways (e.g., cyclomatic + * complexity can be reported as total, maximum, or average). For coverage metrics, this aggregation is not applicable + * and will be ignored. + * + * @author Akash Manna + */ +public enum MetricAggregation { + /** The total value of the metric (sum of all values). */ + TOTAL, + /** The maximum value of the metric. */ + MAXIMUM, + /** The average value of the metric. */ + AVERAGE; + + /** + * Returns whether the specified metric supports aggregation modes. + * + * @param metric + * the metric to check + * + * @return {@code true} if the metric supports aggregation modes, {@code false} otherwise + */ + public static boolean isSupported(final Metric metric) { + return !metric.isCoverage(); + } + + /** + * Returns the default aggregation mode. + * + * @return the default aggregation mode + */ + public static MetricAggregation getDefault() { + return TOTAL; + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java index 56d760481..4b431cafe 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java @@ -14,6 +14,7 @@ import io.jenkins.plugins.coverage.metrics.model.Baseline; import io.jenkins.plugins.coverage.metrics.model.ElementFormatter; +import io.jenkins.plugins.coverage.metrics.model.MetricAggregation; import io.jenkins.plugins.util.JenkinsFacade; import io.jenkins.plugins.util.QualityGate; @@ -24,6 +25,7 @@ * * @author Johannes Walter */ +@SuppressWarnings("PMD.DataClass") public class CoverageQualityGate extends QualityGate { @Serial private static final long serialVersionUID = -397278599489426668L; @@ -32,6 +34,7 @@ public class CoverageQualityGate extends QualityGate { private final Metric metric; private Baseline baseline = Baseline.PROJECT; + private MetricAggregation aggregation = MetricAggregation.TOTAL; /** * Creates a new instance of {@link CoverageQualityGate}. @@ -62,6 +65,16 @@ public CoverageQualityGate(final Metric metric) { setCriticality(criticality); } + CoverageQualityGate(final double threshold, final Metric metric, + final Baseline baseline, final QualityGateCriticality criticality, + final MetricAggregation aggregation) { + this(metric, threshold); + + setBaseline(baseline); + setCriticality(criticality); + setAggregation(aggregation); + } + /** * Sets the baseline that will be used for the quality gate evaluation. * @@ -73,6 +86,18 @@ public final void setBaseline(final Baseline baseline) { this.baseline = baseline; } + /** + * Sets the aggregation mode for software metrics (total, maximum, or average). This is only applicable for + * software metrics like cyclomatic complexity. For coverage metrics, this setting is ignored. + * + * @param aggregation + * the aggregation mode to use + */ + @DataBoundSetter + public final void setAggregation(final MetricAggregation aggregation) { + this.aggregation = aggregation; + } + /** * Returns a human-readable name of the quality gate. * @@ -80,6 +105,10 @@ public final void setBaseline(final Baseline baseline) { */ @Override public String getName() { + if (MetricAggregation.isSupported(metric) && aggregation != MetricAggregation.TOTAL) { + return "%s - %s (%s)".formatted(FORMATTER.getDisplayName(getBaseline()), + FORMATTER.getDisplayName(getMetric()), aggregation); + } return "%s - %s".formatted(FORMATTER.getDisplayName(getBaseline()), FORMATTER.getDisplayName(getMetric())); } @@ -92,6 +121,10 @@ public Baseline getBaseline() { return baseline; } + public MetricAggregation getAggregation() { + return aggregation; + } + /** * Descriptor of the {@link CoverageQualityGate}. */ @@ -141,5 +174,19 @@ public ListBoxModel doFillBaselineItems() { } return new ListBoxModel(); } + + /** + * Returns a model with all {@link MetricAggregation aggregation modes}. + * + * @return a model with all {@link MetricAggregation aggregation modes}. + */ + @POST + @SuppressWarnings("unused") // used by Stapler view data binding + public ListBoxModel doFillAggregationItems() { + if (jenkins.hasPermission(Jenkins.READ)) { + return FORMATTER.getAggregationItems(); + } + return new ListBoxModel(); + } } } diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java index aabf52dcc..365d7e983 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java @@ -1,10 +1,20 @@ package io.jenkins.plugins.coverage.metrics.steps; import java.util.Collection; +import java.util.List; import java.util.Locale; +import java.util.Optional; +import java.util.stream.Stream; +import edu.hm.hafner.coverage.Metric; +import edu.hm.hafner.coverage.Node; +import edu.hm.hafner.coverage.Value; +import edu.umd.cs.findbugs.annotations.CheckForNull; + +import io.jenkins.plugins.coverage.metrics.model.Baseline; import io.jenkins.plugins.coverage.metrics.model.CoverageStatistics; import io.jenkins.plugins.coverage.metrics.model.ElementFormatter; +import io.jenkins.plugins.coverage.metrics.model.MetricAggregation; import io.jenkins.plugins.util.QualityGateEvaluator; import io.jenkins.plugins.util.QualityGateResult; import io.jenkins.plugins.util.QualityGateStatus; @@ -17,18 +27,36 @@ class CoverageQualityGateEvaluator extends QualityGateEvaluator { private static final ElementFormatter FORMATTER = new ElementFormatter(); private final CoverageStatistics statistics; + @CheckForNull + private final Node rootNode; CoverageQualityGateEvaluator(final Collection qualityGates, final CoverageStatistics statistics) { + this(qualityGates, statistics, null); + } + + CoverageQualityGateEvaluator(final Collection qualityGates, + final CoverageStatistics statistics, @CheckForNull final Node rootNode) { super(qualityGates); this.statistics = statistics; + this.rootNode = rootNode; } @Override protected void evaluate(final CoverageQualityGate qualityGate, final QualityGateResult result) { var baseline = qualityGate.getBaseline(); - var possibleValue = statistics.getValue(baseline, qualityGate.getMetric()); + var metric = qualityGate.getMetric(); + var aggregation = qualityGate.getAggregation(); + + Optional possibleValue; + if (MetricAggregation.isSupported(metric) && aggregation != MetricAggregation.TOTAL) { + possibleValue = computeAggregatedValue(rootNode, metric, aggregation, baseline); + } + else { + possibleValue = statistics.getValue(baseline, metric); + } + if (possibleValue.isPresent()) { var actualValue = possibleValue.get(); var status = actualValue.isOutOfValidRange( @@ -39,4 +67,95 @@ protected void evaluate(final CoverageQualityGate qualityGate, final QualityGate result.add(qualityGate, QualityGateStatus.INACTIVE, "n/a"); } } + + /** + * Computes an aggregated value (maximum or average) for a metric from the node tree. + * + * @param node + * the root node to compute from + * @param metric + * the metric to compute + * @param aggregation + * the aggregation mode (MAXIMUM or AVERAGE) + * @param baseline + * the baseline (currently only PROJECT is supported for custom aggregation) + * + * @return the computed value, or empty if not computable + */ + private Optional computeAggregatedValue(@CheckForNull final Node node, final Metric metric, + final MetricAggregation aggregation, final Baseline baseline) { + if (baseline != Baseline.PROJECT) { + return statistics.getValue(baseline, metric); + } + + if (node == null) { + return statistics.getValue(baseline, metric); + } + + var allValues = collectLeafValues(node, metric).toList(); + + if (allValues.isEmpty()) { + return Optional.empty(); + } + + if (aggregation == MetricAggregation.MAXIMUM) { + return allValues.stream().reduce(Value::max); + } + else if (aggregation == MetricAggregation.AVERAGE) { + return computeAverage(allValues); + } + + return Optional.empty(); + } + + /** + * Collects all leaf values for a metric from a node tree. For metrics computed at the method level (like + * complexity), this collects values from all methods. For class-level metrics, it collects from all classes. + * + * @param node + * the node to start from + * @param metric + * the metric to collect + * + * @return a stream of all leaf values + */ + private Stream collectLeafValues(final Node node, final Metric metric) { + Stream nodeValue = node.getValue(metric).stream(); + + Stream childValues = node.getChildren().stream() + .flatMap(child -> collectLeafValues(child, metric)); + + if (node.getMetric() == Metric.METHOD + || node.getMetric() == Metric.CLASS) { + return Stream.concat(nodeValue, childValues); + } + + var childValuesList = childValues.toList(); + return childValuesList.isEmpty() ? nodeValue : childValuesList.stream(); + } + + /** + * Computes the average of a list of values. For integer metrics like complexity, this computes the arithmetic + * mean. For coverage metrics, this computes the average percentage. + * + * @param values + * the values to average + * + * @return the average value, or empty if no values + */ + private Optional computeAverage(final List values) { + if (values.isEmpty()) { + return Optional.empty(); + } + + var sum = values.stream().reduce(Value::add); + if (sum.isEmpty()) { + return Optional.empty(); + } + + var metric = values.get(0).getMetric(); + var totalValue = sum.get(); + + return Optional.of(new Value(metric, totalValue.asDouble() / values.size())); + } } diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageReporter.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageReporter.java index 0776c2b37..1f612f139 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageReporter.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageReporter.java @@ -68,7 +68,7 @@ private CoverageBuildAction computeActionWithoutHistory( final FilteredLog log) throws InterruptedException { var statistics = new CoverageStatistics(rootNode.aggregateValues(), List.of(), List.of(), List.of(), EMPTY_VALUES, List.of()); - var evaluator = new CoverageQualityGateEvaluator(qualityGates, statistics); + var evaluator = new CoverageQualityGateEvaluator(qualityGates, statistics, rootNode); var qualityGateStatus = evaluator.evaluate(notifier, log); paintSourceFiles(build, workspace, sourceCodeEncoding, sourceCodeRetention, id, rootNode, @@ -122,7 +122,7 @@ private CoverageBuildAction computeCoverageBasedOnReferenceBuild( var statistics = new CoverageStatistics(overallValues, overallDelta, modifiedLinesValues, modifiedLinesDelta, modifiedFilesValues, modifiedFilesDelta); - var evaluator = new CoverageQualityGateEvaluator(qualityGates, statistics); + var evaluator = new CoverageQualityGateEvaluator(qualityGates, statistics, rootNode); var qualityGateResult = evaluator.evaluate(notifier, log); var filesToStore = computePaintedFiles(rootNode, sourceCodeRetention, log, modifiedLinesCoverageRoot); diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java index 2b85ca7ea..1cc8dc1d0 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java @@ -48,6 +48,7 @@ */ @SuppressWarnings("PMD.CouplingBetweenObjects") class CoverageXmlStream extends AbstractXmlStream { + private static final String LEGACY_COMPLEXITY_MAXIMUM = "COMPLEXITY_MAXIMUM"; private static final Collector ARRAY_JOINER = Collectors.joining(", ", "[", "]"); private static String[] toArray(final String value) { @@ -109,7 +110,7 @@ static void registerConverters(final XStream2 xStream) { xStream.registerConverter(new FractionConverter()); xStream.registerConverter(new SimpleConverter<>(Value.class, Value::serialize, Value::valueOf)); - xStream.registerConverter(new SimpleConverter<>(Metric.class, Metric::name, Metric::valueOf)); + xStream.registerConverter(new SimpleConverter<>(Metric.class, Metric::name, CoverageXmlStream::metricValueOf)); } @Override @@ -117,6 +118,22 @@ protected Node createDefaultValue() { return new ModuleNode("Empty"); } + /** + * Converts a string to a {@link Metric} value. Handles legacy metric names like COMPLEXITY_MAXIMUM by mapping + * them to the new metric names. + * + * @param metricName + * the name of the metric + * + * @return the metric value + */ + private static Metric metricValueOf(final String metricName) { + if (LEGACY_COMPLEXITY_MAXIMUM.equals(metricName)) { + return Metric.CYCLOMATIC_COMPLEXITY; + } + return Metric.valueOf(metricName); + } + /** * {@link Converter} for {@link Fraction} instances so that only the values will be serialized. After reading the * values back from the stream, the string representation will be converted to an actual instance again. diff --git a/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.jelly b/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.jelly index 1de4e6a86..9701f063a 100644 --- a/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.jelly +++ b/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.jelly @@ -13,6 +13,10 @@ + + + + diff --git a/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.properties b/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.properties index 7344b478e..4b76ed960 100644 --- a/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.properties +++ b/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.properties @@ -1,4 +1,5 @@ title.threshold=Threshold title.baseline=Baseline title.metric=Metric +title.aggregation=Aggregation title.warning=Step or Build Result diff --git a/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/help-aggregation.html b/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/help-aggregation.html new file mode 100644 index 000000000..0520fd95e --- /dev/null +++ b/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/help-aggregation.html @@ -0,0 +1,11 @@ +
+ Defines how to aggregate the metric values for software metrics (e.g., cyclomatic complexity). +
    +
  • TOTAL: Sum of all values (default)
  • +
  • MAXIMUM: Maximum value found in any method or class
  • +
  • AVERAGE: Average value across all methods or classes
  • +
+ This setting is only applicable for software metrics like cyclomatic complexity, cognitive complexity, and NPath + complexity. For coverage metrics (e.g., line coverage), this setting is ignored and the aggregated coverage + percentage is always used. +
diff --git a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java index f33fd6b6d..69b25ccb5 100644 --- a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java +++ b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java @@ -4,7 +4,13 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import edu.hm.hafner.coverage.ClassNode; +import edu.hm.hafner.coverage.Coverage.CoverageBuilder; +import edu.hm.hafner.coverage.MethodNode; import edu.hm.hafner.coverage.Metric; +import edu.hm.hafner.coverage.ModuleNode; +import edu.hm.hafner.coverage.Node; +import edu.hm.hafner.coverage.Value; import edu.hm.hafner.util.FilteredLog; import java.util.ArrayList; @@ -311,6 +317,271 @@ void shouldAddAllQualityGates() { assertThatStatusWillBeOverwritten(evaluator); } + @Test + void shouldSupportMaximumAggregation() { + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(10.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics()); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful(); + } + + @Test + void shouldSupportMaximumAggregationWithRootNode() { + var rootNode = createNodeTreeWithComplexity(); + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(15.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // The maximum complexity in the tree is 20, which is > 15.0 + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful(); + } + + @Test + void shouldSupportAverageAggregation() { + var rootNode = createNodeTreeWithComplexity(); + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(10.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.AVERAGE); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // The average complexity is (5 + 10 + 15 + 20) / 4 = 12.5, which is > 10.0 + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful(); + } + + @Test + void shouldHandleTotalAggregationAsDefault() { + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(149.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + // Do not set aggregation, should default to TOTAL + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics()); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // Should use total value from statistics (150) which is > 149.0 + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful().hasMessages( + "[Overall project - Cyclomatic Complexity]: ≪Unstable≫ - (Actual value: 150, Quality gate: 149.00)"); + } + + @Test + void shouldNotApplyAggregationForCoverageMetrics() { + var rootNode = createNodeTreeWithCoverage(); + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(60.0, Metric.LINE, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // For coverage metrics, aggregation should be ignored and use statistics value (50%) + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful().hasMessages( + "[Overall project - Line Coverage]: ≪Unstable≫ - (Actual value: 50.00%, Quality gate: 60.00)"); + } + + @Test + void shouldSupportMultipleAggregationsInSameEvaluation() { + var rootNode = createNodeTreeWithComplexity(); + Collection qualityGates = new ArrayList<>(); + + var maxGate = new CoverageQualityGate(15.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + maxGate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(maxGate); + + var avgGate = new CoverageQualityGate(10.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.FAILURE); + avgGate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.AVERAGE); + qualityGates.add(avgGate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // Both should fail: maximum is 20 > 15.0 and average is 12.5 > 10.0 + assertThat(result).hasOverallStatus(QualityGateStatus.FAILED).isNotSuccessful(); + } + + @Test + void shouldHandleEmptyNodeTree() { + var rootNode = new ModuleNode("empty"); + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(10.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // Empty node tree has no values to aggregate, should mark as inactive + assertThat(result).hasOverallStatus(QualityGateStatus.INACTIVE).isInactive(); + } + + @Test + void shouldHandleNullRootNodeForAggregation() { + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(10.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + // No root node provided + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics()); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // Should fall back to statistics value when root node is null + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful(); + } + + @Test + void shouldFallBackToStatisticsForNonProjectBaseline() { + var rootNode = createNodeTreeWithComplexity(); + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(10.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.MODIFIED_LINES, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // For non-PROJECT baselines, should use statistics even with custom aggregation + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful(); + } + + @Test + void shouldSupportMaximumAggregationForLinesOfCode() { + var rootNode = createNodeTreeWithLOC(); + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(55.0, Metric.LOC, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // The maximum LOC is 60, which is > 55.0 + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful(); + } + + /** + * Creates a node tree with cyclomatic complexity values for testing. + * Structure: Module -> Class -> Methods with complexity values: 5, 10, 15, 20 + * + * @return a node tree with complexity values + */ + private static Node createNodeTreeWithComplexity() { + var root = new ModuleNode("TestProject"); + var classNode = new ClassNode("TestClass"); + root.addChild(classNode); + + var method1 = new MethodNode("method1", "()V"); + method1.addValue(new Value(Metric.CYCLOMATIC_COMPLEXITY, 5)); + classNode.addChild(method1); + + var method2 = new MethodNode("method2", "()V"); + method2.addValue(new Value(Metric.CYCLOMATIC_COMPLEXITY, 10)); + classNode.addChild(method2); + + var method3 = new MethodNode("method3", "()V"); + method3.addValue(new Value(Metric.CYCLOMATIC_COMPLEXITY, 15)); + classNode.addChild(method3); + + var method4 = new MethodNode("method4", "()V"); + method4.addValue(new Value(Metric.CYCLOMATIC_COMPLEXITY, 20)); + classNode.addChild(method4); + + return root; + } + + /** + * Creates a node tree with LOC values for testing. + * Structure: Module -> Class -> Methods with LOC values: 30, 40, 50, 60 + * + * @return a node tree with LOC values + */ + private static Node createNodeTreeWithLOC() { + var root = new ModuleNode("TestProject"); + var classNode = new ClassNode("TestClass"); + root.addChild(classNode); + + var method1 = new MethodNode("method1", "()V"); + method1.addValue(new Value(Metric.LOC, 30)); + classNode.addChild(method1); + + var method2 = new MethodNode("method2", "()V"); + method2.addValue(new Value(Metric.LOC, 40)); + classNode.addChild(method2); + + var method3 = new MethodNode("method3", "()V"); + method3.addValue(new Value(Metric.LOC, 50)); + classNode.addChild(method3); + + var method4 = new MethodNode("method4", "()V"); + method4.addValue(new Value(Metric.LOC, 60)); + classNode.addChild(method4); + + return root; + } + + /** + * Creates a node tree with coverage values for testing. + * Structure: Module -> Class -> Methods with line coverage + * + * @return a node tree with coverage values + */ + private static Node createNodeTreeWithCoverage() { + var root = new ModuleNode("TestProject"); + var classNode = new ClassNode("TestClass"); + root.addChild(classNode); + + var method1 = new MethodNode("method1", "()V"); + method1.addValue(new CoverageBuilder().withMetric(Metric.LINE).withCovered(5).withMissed(5).build()); + classNode.addChild(method1); + + var method2 = new MethodNode("method2", "()V"); + method2.addValue(new CoverageBuilder().withMetric(Metric.LINE).withCovered(8).withMissed(2).build()); + classNode.addChild(method2); + + return root; + } + private static void assertThatStatusWillBeOverwritten(final CoverageQualityGateEvaluator evaluator) { var log = new FilteredLog("Errors"); var result = evaluator.evaluate(new NullResultHandler(), log);