Repository: jenkinsci/code-coverage-api-plugin
Issue: Computing Negative Deltas Doesn't Seem to Work

### Jenkins and plugins versions report

 Code Coverage API Version 4.4.0 

### What Operating System are you using (both controller, and any agents involved in the problem)?

Linux Ubuntu Agent and Controller

### Reproduction steps
1. Have a project setup where code coverage is dropping when a pull request is opened.
2. Run `recordCoverage` with the following configuration
```
recordCoverage(
        checksAnnotationScope: 'MODIFIED_LINES', checksName: 'Code Coverage',
        failOnError: true, name: 'Code Coverage',
        qualityGates: [
            [baseline: 'PROJECT_DELTA', metric: 'LINE', threshold: -1.0, criticality: 'UNSTABLE'],
            [baseline: 'PROJECT_DELTA', metric: 'BRANCH', threshold: -1.0, criticality: 'UNSTABLE']
        ],
        sourceDirectories: [[path: 'src']],
        scm: 'code-repo', sourceCodeRetention: 'LAST_BUILD',
        tools: [[parser: 'JACOCO', pattern: '**/reports/jacoco/**/jacocoTestReport.xml']]
)
```

### Expected Results

```
13:21:49  [Coverage] Evaluating quality gates
13:21:49  [Coverage] -> Some quality gates have been missed: overall result is UNSTABLE
13:21:49  [Coverage] -> Details for each quality gate:
13:21:49  [Coverage] -> [Overall project (difference to reference job) - Line Coverage]: ≪Success≫ - (Actual value: +0.33%, Quality gate: -1.00)
13:21:49  [Coverage] -> [Overall project (difference to reference job) - Branch Coverage]: ≪Unstable≫ - (Actual value: -1.37%, Quality gate: -1.00)
```

### Actual Results

```
13:21:49  [Coverage] Evaluating quality gates
13:21:49  [Coverage] -> All quality gates have been passed
13:21:49  [Coverage] -> Details for each quality gate:
13:21:49  [Coverage] -> [Overall project (difference to reference job) - Line Coverage]: ≪Success≫ - (Actual value: +0.33%, Quality gate: -1.00)
13:21:49  [Coverage] -> [Overall project (difference to reference job) - Branch Coverage]: ≪Success≫ - (Actual value: -1.37%, Quality gate: -1.00)
```

### Anything else?

To add a bit more information, as I'm not sure if this is a bug or expected behaviour that negative threshold delta values aren't checked properly. I'm wondering if there's something I'm missing, as it seems like the plugin should be able to accommodate something like this.

My use case is to prevent a Delta drop in the quality gate by more than 1%. It seems like providing a `-` threshold value doesn't do what I'd expect it to. I expect that any threshold value that's < -1 would fail; in my case the value is `-1.37`.

The inverse case of using positive threshold values works as expected, but doesn't fit my use case. If I were to use `1` instead of `-1`, both my quality gates would fail as the `Line Coverage` < 1.

Changed files:
plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java
plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java

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 146213f9..082ce456 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
@@ -3,6 +3,13 @@ package io.jenkins.plugins.coverage.metrics.steps;
 import java.util.Collection;
 import java.util.Locale;
 
+import org.apache.commons.lang3.math.Fraction;
+
+import edu.hm.hafner.coverage.FractionValue;
+import edu.hm.hafner.coverage.Metric;
+import edu.hm.hafner.coverage.SafeFraction;
+import edu.hm.hafner.coverage.Value;
+
 import io.jenkins.plugins.coverage.metrics.model.CoverageStatistics;
 import io.jenkins.plugins.coverage.metrics.model.ElementFormatter;
 import io.jenkins.plugins.util.QualityGateEvaluator;
@@ -15,10 +22,13 @@ import io.jenkins.plugins.util.QualityGateStatus;
  * @author Johannes Walter
  */
 class CoverageQualityGateEvaluator extends QualityGateEvaluator<CoverageQualityGate> {
+    private static final Fraction HUNDRED = Fraction.getFraction("100.0");
+
     private static final ElementFormatter FORMATTER = new ElementFormatter();
     private final CoverageStatistics statistics;
 
-    CoverageQualityGateEvaluator(final Collection<? extends CoverageQualityGate> qualityGates, final CoverageStatistics statistics) {
+    CoverageQualityGateEvaluator(final Collection<? extends CoverageQualityGate> qualityGates,
+            final CoverageStatistics statistics) {
         super(qualityGates);
 
         this.statistics = statistics;
@@ -29,14 +39,41 @@ class CoverageQualityGateEvaluator extends QualityGateEvaluator<CoverageQualityG
         var baseline = qualityGate.getBaseline();
         var possibleValue = statistics.getValue(baseline, qualityGate.getMetric());
         if (possibleValue.isPresent()) {
-            var actualValue = possibleValue.get();
+            var actualValue = convertActualValue(possibleValue.get());
 
             var status = actualValue.isOutOfValidRange(
                     qualityGate.getThreshold()) ? qualityGate.getStatus() : QualityGateStatus.PASSED;
-            result.add(qualityGate, status, FORMATTER.format(actualValue, Locale.ENGLISH));
+            result.add(qualityGate, status, FORMATTER.format(possibleValue.get(), Locale.ENGLISH));
         }
         else {
             result.add(qualityGate, QualityGateStatus.INACTIVE, "n/a");
         }
     }
+
+    /**
+     * Converts the actual value to a percentage if necessary. Delta values are internally stored as fractions, but
+     * users expect percentages when they are displayed or used in thresholds.
+     *
+     * @param value
+     *         the actual stored value
+     *
+     * @return the converted value
+     */
+    private Value convertActualValue(final Value value) {
+        var metric = value.getMetric();
+        if (metric.equals(Metric.COMPLEXITY)
+                || metric.equals(Metric.COMPLEXITY_MAXIMUM)
+                || metric.equals(Metric.LOC)) {
+            return value; // ignore integer based metrics
+        }
+        if (value instanceof FractionValue) { // delta percentage
+            return new FractionValue(metric, covertToPercentage((FractionValue) value));
+        }
+
+        return value;
+    }
+
+    private Fraction covertToPercentage(final FractionValue value) {
+        return new SafeFraction(value.getFraction()).multiplyBy(HUNDRED);
+    }
 }
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 53561661..9d28b1fd 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
@@ -5,6 +5,8 @@ import java.util.Collection;
 import java.util.List;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import edu.hm.hafner.coverage.Metric;
 
@@ -137,6 +139,51 @@ class CoverageQualityGateEvaluatorTest extends AbstractCoverageTest {
                 "-> [Modified files (difference to overall project) - Line Coverage]: ≪Success≫ - (Actual value: +5.00%, Quality gate: 0.00)");
     }
 
+    @ParameterizedTest(name = "A quality gate of {0} should not be passed if the coverage drops by 10%")
+    @ValueSource(ints = {8, 1, 0, -1, -8})
+    void shouldHandleNegativeValues(final double minimum) {
+        Collection<CoverageQualityGate> qualityGates = new ArrayList<>();
+
+        qualityGates.add(new CoverageQualityGate(minimum, Metric.FILE, Baseline.PROJECT_DELTA, QualityGateCriticality.UNSTABLE));
+
+        CoverageQualityGateEvaluator evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics());
+        QualityGateResult result = evaluator.evaluate();
+
+        assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful().isNotInactive().hasMessages(
+                String.format(
+                "-> [Overall project (difference to reference job) - File Coverage]: ≪Unstable≫ - (Actual value: -10.00%%, Quality gate: %.2f)", minimum));
+    }
+
+    @ParameterizedTest(name = "A quality gate of {0} should be passed if the coverage is at 50%")
+    @ValueSource(ints = {-10, 0, 10, 50})
+    void shouldPassAllThresholds(final double minimum) {
+        Collection<CoverageQualityGate> qualityGates = new ArrayList<>();
+
+        qualityGates.add(new CoverageQualityGate(minimum, Metric.LINE, Baseline.PROJECT, QualityGateCriticality.UNSTABLE));
+
+        CoverageQualityGateEvaluator evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics());
+        QualityGateResult result = evaluator.evaluate();
+
+        assertThat(result).hasOverallStatus(QualityGateStatus.PASSED).isSuccessful().isNotInactive().hasMessages(
+                String.format(
+                "-> [Overall project - Line Coverage]: ≪Success≫ - (Actual value: 50.00%%, Quality gate: %.2f)", minimum));
+    }
+
+    @ParameterizedTest(name = "A quality gate of {0} should not be passed if the coverage is at 50%")
+    @ValueSource(ints = {51, 60, 70, 200})
+    void shouldFailAllThresholds(final double minimum) {
+        Collection<CoverageQualityGate> qualityGates = new ArrayList<>();
+
+        qualityGates.add(new CoverageQualityGate(minimum, Metric.LINE, Baseline.PROJECT, QualityGateCriticality.UNSTABLE));
+
+        CoverageQualityGateEvaluator evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics());
+        QualityGateResult result = evaluator.evaluate();
+
+        assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful().isNotInactive().hasMessages(
+                String.format(
+                "-> [Overall project - Line Coverage]: ≪Unstable≫ - (Actual value: 50.00%%, Quality gate: %.2f)", minimum));
+    }
+
     @Test
     void shouldReportUnstableIfLargerThanThreshold() {
         Collection<CoverageQualityGate> qualityGates = new ArrayList<>();
@@ -155,7 +202,7 @@ class CoverageQualityGateEvaluatorTest extends AbstractCoverageTest {
     }
 
     @Test
-    void shouldReportUnstableIfWorseAndSuccessIfBetter2() {
+    void shouldReportUnstableIfWorseAndSuccessIfLargerThanThreshold() {
         Collection<CoverageQualityGate> qualityGates = new ArrayList<>();
 
         var minimum = 0;