diff --git a/src/main/java/edu/hm/hafner/coverage/Coverage.java b/src/main/java/edu/hm/hafner/coverage/Coverage.java index 461b9943..0a64fb59 100644 --- a/src/main/java/edu/hm/hafner/coverage/Coverage.java +++ b/src/main/java/edu/hm/hafner/coverage/Coverage.java @@ -24,6 +24,7 @@ public final class Coverage extends Value { @Serial private static final long serialVersionUID = -3802318446471137305L; private static final String FRACTION_SEPARATOR = "/"; + private static final String N_A = "n/a"; /** * Creates a new {@link Coverage} instance from the provided string representation. The string representation is @@ -129,7 +130,7 @@ public int getMissed() { @Override public Coverage add(final Value other) { - var otherCoverage = ensureSameMetricAndType(other); + var otherCoverage = castValue(other); return new CoverageBuilder().withMetric(getMetric()) .withCovered(getCovered() + otherCoverage.getCovered()) @@ -138,15 +139,15 @@ public Coverage add(final Value other) { } @Override - public Fraction delta(final Value other) { - var otherCoverage = ensureSameMetricAndType(other); + public Difference subtract(final Value other) { + ensureSameMetricAndType(other); - return getCoveredPercentage().subtract(otherCoverage.getCoveredPercentage()); + return new Difference(getMetric(), asDouble() - other.asDouble()); } @Override public Coverage max(final Value other) { - var otherCoverage = ensureSameMetricAndType(other); + var otherCoverage = castValue(other); Ensure.that(getTotal() == otherCoverage.getTotal()) .isTrue("Cannot compute maximum of coverages %s and %s since total differs", this, other); @@ -157,8 +158,9 @@ public Coverage max(final Value other) { return otherCoverage; } - private Coverage ensureSameMetricAndType(final Value other) { - ensureSameMetric(other); + private Coverage castValue(final Value other) { + ensureSameMetricAndType(other); + return (Coverage) other; // the type is checked in ensureSameMetric } @@ -184,6 +186,37 @@ public boolean isSet() { return getTotal() > 0; } + @Override + public String asText(final Locale locale) { + if (isSet()) { + return String.format(locale, "%.2f%%", asRounded()); + } + return N_A; + } + + @Override + public String asInformativeText(final Locale locale) { + if (isSet()) { + return String.format(locale, "%.2f%% (%d/%d)", asRounded(), getCovered(), getTotal()); + } + return N_A; + } + + @Override + public int asInteger() { + return getCoveredPercentage().toInt(); + } + + @Override + public double asDouble() { + return getCoveredPercentage().toDouble(); + } + + @Override + protected String serializeValue() { + return String.format(Locale.ENGLISH, "%d/%d", getCovered(), getTotal()); + } + @Override @Generated public boolean equals(final Object o) { @@ -216,8 +249,8 @@ public String toString() { } @Override - public String asText() { - return String.format(Locale.ENGLISH, "%d/%d", getCovered(), getTotal()); + public double asRounded() { + return getCoveredPercentage().toRounded(); } /** diff --git a/src/main/java/edu/hm/hafner/coverage/Difference.java b/src/main/java/edu/hm/hafner/coverage/Difference.java new file mode 100644 index 00000000..1d754699 --- /dev/null +++ b/src/main/java/edu/hm/hafner/coverage/Difference.java @@ -0,0 +1,97 @@ +package edu.hm.hafner.coverage; + +import java.io.Serial; +import java.util.Locale; + +import org.apache.commons.lang3.math.Fraction; + +/** + * A leaf in the tree that represents a delta of two {@link Value} instances. Such values are used to show the + * delta (i.e., the difference) of two other values. The delta uses a slightly different textual representation than the + * plain value: positive values are prefixed with a plus sign, zero is also handled differently. + * + * @author Ullrich Hafner + */ +public class Difference extends Value { + @Serial + private static final long serialVersionUID = -1115727256219835389L; + /** Serialization prefix for delta values. */ + public static final String DELTA = "Δ"; + + /** + * Returns a {@code null} object that indicates that no value has been recorded. + * + * @param metric + * the coverage metric + * + * @return the {@code null} object + */ + public static Difference nullObject(final Metric metric) { + return new Difference(metric, 0); + } + + /** + * Creates a new leaf with the given value for the specified metric. + * + * @param metric + * the coverage metric + * @param value + * the value to store + */ + public Difference(final Metric metric, final Fraction value) { + super(metric, value); + } + + /** + * Creates a new leaf with the given value for the specified metric. + * + * @param metric + * the coverage metric + * @param value + * the value to store + */ + public Difference(final Metric metric, final double value) { + super(metric, value); + } + + /** + * Creates a new leaf with the given value (a fraction) for the specified metric. + * + * @param metric + * the coverage metric + * @param numerator + * the numerator, i.e., the three in 'three sevenths' + * @param denominator + * the denominator, i.ee, the seven in 'three sevenths' + */ + public Difference(final Metric metric, final int numerator, final int denominator) { + super(metric, numerator, denominator); + } + + /** + * Creates a new leaf with the given value for the specified metric. + * + * @param metric + * the coverage metric + * @param value + * the value + */ + public Difference(final Metric metric, final int value) { + super(metric, value); + } + + @Override + public String asText(final Locale locale) { + return getMetric().formatDelta(locale, asDouble()); + } + + @Override + public String asInformativeText(final Locale locale) { + return getMetric().formatDelta(locale, asDouble()); + } + + @Override + protected String serializeValue() { + return DELTA + super.serializeValue(); + } +} diff --git a/src/main/java/edu/hm/hafner/coverage/FileNode.java b/src/main/java/edu/hm/hafner/coverage/FileNode.java index 5438babf..a2adc8b4 100644 --- a/src/main/java/edu/hm/hafner/coverage/FileNode.java +++ b/src/main/java/edu/hm/hafner/coverage/FileNode.java @@ -21,7 +21,6 @@ import org.apache.commons.io.FilenameUtils; import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.math.Fraction; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -58,7 +57,7 @@ public final class FileNode extends Node { private final SortedSet modifiedLines = new TreeSet<>(); private final NavigableMap indirectCoverageChanges = new TreeMap<>(); - private final NavigableMap coverageDelta = new TreeMap<>(); + private final NavigableMap coverageDelta = new TreeMap<>(); private TreeString relativePath; // @since 0.22.0 @@ -536,7 +535,7 @@ public void computeDelta(final FileNode referenceFile) { NavigableMap referenceCoverage = referenceFile.getMetricsDistribution(); getMetricsDistribution().forEach((metric, value) -> { if (referenceCoverage.containsKey(metric)) { - coverageDelta.put(metric, value.delta(referenceCoverage.get(metric))); + coverageDelta.put(metric, value.subtract(referenceCoverage.get(metric))); } }); } @@ -550,8 +549,8 @@ public void computeDelta(final FileNode referenceFile) { * * @return the delta for the specified metric */ - public Fraction getDelta(final Metric metric) { - return coverageDelta.getOrDefault(metric, Fraction.ZERO); + public Value getDelta(final Metric metric) { + return coverageDelta.getOrDefault(metric, Value.nullObject(metric)); } /** diff --git a/src/main/java/edu/hm/hafner/coverage/Metric.java b/src/main/java/edu/hm/hafner/coverage/Metric.java index 0442567e..3d4f91bb 100644 --- a/src/main/java/edu/hm/hafner/coverage/Metric.java +++ b/src/main/java/edu/hm/hafner/coverage/Metric.java @@ -1,5 +1,9 @@ package edu.hm.hafner.coverage; +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.Arrays; import java.util.List; import java.util.Locale; @@ -27,38 +31,48 @@ public enum Metric { * Nodes that can have children. These notes compute their coverage values on the fly based on their children's * coverage. */ - CONTAINER("Container Coverage", new CoverageOfChildrenEvaluator()), - MODULE("Module Coverage", new CoverageOfChildrenEvaluator()), - PACKAGE("Package Coverage", new CoverageOfChildrenEvaluator()), - FILE("File Coverage", new CoverageOfChildrenEvaluator()), - CLASS("Class Coverage", new CoverageOfChildrenEvaluator()), - METHOD("Method Coverage", new CoverageOfChildrenEvaluator()), + CONTAINER("Container Coverage", "Container", new CoverageOfChildrenEvaluator()), + MODULE("Module Coverage", "Module", new CoverageOfChildrenEvaluator()), + PACKAGE("Package Coverage", "Package", new CoverageOfChildrenEvaluator()), + FILE("File Coverage", "File", new CoverageOfChildrenEvaluator()), + CLASS("Class Coverage", "Class", new CoverageOfChildrenEvaluator()), + METHOD("Method Coverage", "Method", new CoverageOfChildrenEvaluator()), /** Coverage values that are leaves in the tree. */ - LINE("Line Coverage", new ValuesAggregator()), - BRANCH("Branch Coverage", new ValuesAggregator()), - INSTRUCTION("Instruction Coverage", new ValuesAggregator()), - MCDC_PAIR("Modified Condition and Decision Coverage", new ValuesAggregator()), - FUNCTION_CALL("Function Call Coverage", new ValuesAggregator()), + LINE("Line Coverage", "Line", new ValuesAggregator()), + BRANCH("Branch Coverage", "Branch", new ValuesAggregator()), + INSTRUCTION("Instruction Coverage", "Instruction", new ValuesAggregator()), + MCDC_PAIR("Modified Condition and Decision Coverage", "MC/DC Pair", new ValuesAggregator()), + FUNCTION_CALL("Function Call Coverage", "Function Call", new ValuesAggregator()), /** Additional coverage values obtained from mutation testing. */ - MUTATION("Mutation Coverage", new ValuesAggregator()), - TEST_STRENGTH("Test Strength", new ValuesAggregator()), - - CYCLOMATIC_COMPLEXITY("Cyclomatic Complexity", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, MetricValueType.METHOD_METRIC), - LOC("Lines of Code", new LocEvaluator(), MetricTendency.SMALLER_IS_BETTER, MetricValueType.METRIC), - TESTS("Number of Tests", new ValuesAggregator(), MetricTendency.LARGER_IS_BETTER, MetricValueType.CLASS_METRIC), - NCSS("Non Commenting Source Statements", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, MetricValueType.METRIC), - COGNITIVE_COMPLEXITY("Cognitive Complexity", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, MetricValueType.METHOD_METRIC), - NPATH_COMPLEXITY("N-Path Complexity", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, MetricValueType.METHOD_METRIC), - ACCESS_TO_FOREIGN_DATA("Access to Foreign Data", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, MetricValueType.METRIC), - COHESION("Class Cohesion", new ValuesAggregator(Value::max, "maximum"), + MUTATION("Mutation Coverage", "Mutation", new ValuesAggregator()), + TEST_STRENGTH("Test Strength", "Test Strength", new ValuesAggregator()), + + CYCLOMATIC_COMPLEXITY("Cyclomatic Complexity", "Complexity", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, + MetricValueType.METHOD_METRIC, new IntegerFormatter()), + LOC("Lines of Code", "LOC", new LocEvaluator(), MetricTendency.SMALLER_IS_BETTER, + MetricValueType.METRIC, new IntegerFormatter()), + TESTS("Number of Tests", "Tests", new ValuesAggregator(), MetricTendency.LARGER_IS_BETTER, + MetricValueType.CLASS_METRIC, new IntegerFormatter()), + NCSS("Non Commenting Source Statements", "NCSS", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, + MetricValueType.METRIC, new IntegerFormatter()), + COGNITIVE_COMPLEXITY("Cognitive Complexity", "Cognitive Complexity", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, + MetricValueType.METHOD_METRIC, new IntegerFormatter()), + NPATH_COMPLEXITY("N-Path Complexity", "N-Path", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, + MetricValueType.METHOD_METRIC, new IntegerFormatter()), + ACCESS_TO_FOREIGN_DATA("Access to Foreign Data", "Foreign Data", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, + MetricValueType.METRIC, new IntegerFormatter()), + COHESION("Class Cohesion", "Cohesion", new ValuesAggregator(Value::max, "maximum"), MetricTendency.LARGER_IS_BETTER, MetricValueType.CLASS_METRIC, new PercentageFormatter()), - FAN_OUT("Fan Out", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, MetricValueType.METRIC), - NUMBER_OF_ACCESSORS("Number of Accessors", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, MetricValueType.CLASS_METRIC), - WEIGHT_OF_CLASS("Weight of Class", new ValuesAggregator(Value::max, "maximum"), + FAN_OUT("Fan Out", "Fan Out", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, + MetricValueType.METRIC, new IntegerFormatter()), + NUMBER_OF_ACCESSORS("Number of Accessors", "Accessors", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, + MetricValueType.CLASS_METRIC, new IntegerFormatter()), + WEIGHT_OF_CLASS("Weight of Class", "Weigth", new ValuesAggregator(Value::max, "maximum"), MetricTendency.LARGER_IS_BETTER, MetricValueType.CLASS_METRIC, new PercentageFormatter()), - WEIGHED_METHOD_COUNT("Weighted Method Count", new ValuesAggregator(), MetricTendency.SMALLER_IS_BETTER, MetricValueType.CLASS_METRIC); + WEIGHED_METHOD_COUNT("Weighted Method Count", "Methods", new ValuesAggregator(), + MetricTendency.SMALLER_IS_BETTER, MetricValueType.CLASS_METRIC, new IntegerFormatter()); /** * Returns the metric that belongs to the specified tag. @@ -102,27 +116,28 @@ private static String normalize(final String name) { } private final String displayName; - @SuppressFBWarnings("SE_BAD_FIELD") + private final String label; private final MetricEvaluator evaluator; private final MetricTendency tendency; private final MetricValueType type; private final MetricFormatter formatter; - Metric(final String displayName, final MetricEvaluator evaluator) { - this(displayName, evaluator, MetricTendency.LARGER_IS_BETTER); + Metric(final String displayName, final String label, final MetricEvaluator evaluator) { + this(displayName, label, evaluator, MetricTendency.LARGER_IS_BETTER); } - Metric(final String displayName, final MetricEvaluator evaluator, final MetricTendency tendency) { - this(displayName, evaluator, tendency, MetricValueType.COVERAGE); + Metric(final String displayName, final String label, final MetricEvaluator evaluator, final MetricTendency tendency) { + this(displayName, label, evaluator, tendency, MetricValueType.COVERAGE); } - Metric(final String displayName, final MetricEvaluator evaluator, final MetricTendency tendency, final MetricValueType type) { - this(displayName, evaluator, tendency, type, new IntegerFormatter()); + Metric(final String displayName, final String label, final MetricEvaluator evaluator, final MetricTendency tendency, final MetricValueType type) { + this(displayName, label, evaluator, tendency, type, new CoverageFormatter()); } - Metric(final String displayName, final MetricEvaluator evaluator, final MetricTendency tendency, final MetricValueType type, + Metric(final String displayName, final String label, final MetricEvaluator evaluator, final MetricTendency tendency, final MetricValueType type, final MetricFormatter formatter) { this.displayName = displayName; + this.label = label; this.evaluator = evaluator; this.tendency = tendency; this.type = type; @@ -133,6 +148,10 @@ public String getDisplayName() { return displayName; } + public String getLabel() { + return label; + } + public MetricTendency getTendency() { return tendency; } @@ -198,25 +217,43 @@ public List getTargetNodes(final Node node) { /** * Formats the specified value according to the metrics formatter. * + * @param locale + * the locale to use + * @param value + * the value to format + * + * @return the formatted value + */ + public String format(final Locale locale, final double value) { + return formatter.format(locale, value); + } + + /** + * Formats the specified value according to the metrics formatter. + * + * @param locale + * the locale to use * @param value * the value to format * * @return the formatted value */ - public String format(final double value) { - return formatter.format(value); + public String formatDelta(final Locale locale, final double value) { + return formatter.formatDelta(locale, value); } /** * Formats the specified mean value according to the metrics formatter. * + * @param locale + * the locale to use * @param value * the mean value to format * * @return the formatted mean value */ - public String formatMean(final double value) { - return formatter.formatMean(value); + public String formatMean(final Locale locale, final double value) { + return formatter.formatMean(locale, value); } public String getAggregationType() { @@ -266,7 +303,10 @@ public enum MetricValueType { CLASS_METRIC } - private abstract static class MetricEvaluator { + private abstract static class MetricEvaluator implements Serializable { + @Serial + private static final long serialVersionUID = -537814226149186300L; + final Optional compute(final Node node, final Metric searchMetric) { return getValue(node, searchMetric).or(() -> computeDerivedValue(node, searchMetric)); } @@ -288,6 +328,9 @@ Optional getValue(final Node node, final Metric searchMetric) { } private static class CoverageOfChildrenEvaluator extends MetricEvaluator { + @Serial + private static final long serialVersionUID = 8788686429559762490L; + @Override public boolean isAggregatingChildren() { return true; @@ -351,6 +394,10 @@ private boolean hasCoverage(final Node node, final Metric metric) { } private static class ValuesAggregator extends MetricEvaluator { + @Serial + private static final long serialVersionUID = 7908490688181149667L; + + @SuppressFBWarnings("SE_BAD_FIELD") private final BinaryOperator accumulator; private final String name; @@ -391,6 +438,9 @@ protected Optional getDefaultValue(final Node node) { } private static class LocEvaluator extends ValuesAggregator { + @Serial + private static final long serialVersionUID = 8819577749737375989L; + @Override protected Optional getDefaultValue(final Node node) { return LINE.getValueFor(node).map(this::getTotal); @@ -403,33 +453,95 @@ private Value getTotal(final Value leaf) { } } - private interface MetricFormatter { - String format(double value); + private static class MetricFormatter implements Serializable { + @Serial + private static final long serialVersionUID = 7402798036375016965L; + + String format(final Locale locale, final double value) { + return formatDouble(locale, value); + } + + String formatMean(final Locale locale, final double value) { + return formatDouble(locale, value); + } + + String formatDelta(final Locale locale, final double value) { + var rounded = toRounded(value, 2); + if (rounded == 0) { + return "±0"; + } + return String.format(locale, "%+.2f", rounded); + } - String formatMean(double value); + final String formatDouble(final Locale locale, final double value) { + return String.format(locale, "%.2f", value); + } + + final double toRounded(final double value, final int scale) { + return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).doubleValue(); + } + + String percentage(final String value) { + return value + "%"; + } + } + + private static class CoverageFormatter extends MetricFormatter { + @Serial + private static final long serialVersionUID = 4337117939462815181L; + + @Override + String formatMean(final Locale locale, final double value) { + return percentage(formatDouble(locale, value)); + } + + @Override + String format(final Locale locale, final double value) { + return percentage(formatDouble(locale, value)); + } + + @Override + String formatDelta(final Locale locale, final double value) { + return percentage(super.formatDelta(locale, value)); + } } - private static class IntegerFormatter implements MetricFormatter { + private static class PercentageFormatter extends MetricFormatter { + @Serial + private static final long serialVersionUID = -4995914265987128828L; + @Override - public String format(final double value) { - return String.valueOf(Math.round(value)); + String format(final Locale locale, final double value) { + return percentage(formatDouble(locale, value * 100)); } @Override - public String formatMean(final double value) { - return String.format(Locale.ENGLISH, "%.2f", value); + String formatMean(final Locale locale, final double value) { + return percentage(formatDouble(locale, value * 100)); + } + + @Override + String formatDelta(final Locale locale, final double value) { + return percentage(super.formatDelta(locale, value * 100)); } } - private static class PercentageFormatter implements MetricFormatter { + private static class IntegerFormatter extends MetricFormatter { + @Serial + private static final long serialVersionUID = 8053070560640902081L; + @Override - public String format(final double value) { - return String.format(Locale.ENGLISH, "%.2f%%", value * 100); + String format(final Locale locale, final double value) { + return String.format(locale, "%d", Math.round(toRounded(value, 0))); } @Override - public String formatMean(final double value) { - return format(value); + String formatDelta(final Locale locale, final double value) { + var rounded = toRounded(value, 0); + if (rounded == 0) { + return "±0"; + } + return String.format(locale, "%+d", Math.round(toRounded(value, 0))); } } } diff --git a/src/main/java/edu/hm/hafner/coverage/Node.java b/src/main/java/edu/hm/hafner/coverage/Node.java index 729dbb2d..e01f24a4 100644 --- a/src/main/java/edu/hm/hafner/coverage/Node.java +++ b/src/main/java/edu/hm/hafner/coverage/Node.java @@ -21,7 +21,6 @@ import java.util.stream.Stream; import org.apache.commons.lang3.RegExUtils; -import org.apache.commons.lang3.math.Fraction; import org.apache.commons.lang3.tuple.ImmutablePair; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -367,23 +366,23 @@ public List aggregateValues() { /** * Computes the delta of all metrics between this node and the specified reference node as fractions. Each delta - * value is computed by the value specific {@link Value#delta(Value)} method. If the reference node does not contain + * value is computed by the value specific {@link Value#subtract(Value)} method. If the reference node does not contain * a specific metric, then no delta is computed and the metric is omitted in the result map. * * @param reference * the reference node * - * @return the delta coverage for each available metric as fraction + * @return the delta coverage for each available metric */ - public NavigableMap computeDelta(final Node reference) { - NavigableMap deltaPercentages = new TreeMap<>(); + public List computeDelta(final Node reference) { + List deltaPercentages = new ArrayList<>(); NavigableMap metricPercentages = getMetricsDistribution(); NavigableMap referencePercentages = reference.getMetricsDistribution(); for (Entry entry : metricPercentages.entrySet()) { var key = entry.getKey(); if (referencePercentages.containsKey(key)) { - deltaPercentages.put(key, entry.getValue().delta(referencePercentages.get(key))); + deltaPercentages.add(entry.getValue().subtract(referencePercentages.get(key))); } } return deltaPercentages; diff --git a/src/main/java/edu/hm/hafner/coverage/Percentage.java b/src/main/java/edu/hm/hafner/coverage/Percentage.java index ad235cea..3cfe489c 100644 --- a/src/main/java/edu/hm/hafner/coverage/Percentage.java +++ b/src/main/java/edu/hm/hafner/coverage/Percentage.java @@ -2,6 +2,8 @@ import java.io.Serial; import java.io.Serializable; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.Locale; import java.util.Objects; @@ -21,8 +23,11 @@ public final class Percentage implements Serializable { /** null value. */ public static final Percentage ZERO = new Percentage(0, 1); + private static final Percentage ALMOST_HUNDRED = new Percentage(9_999, 10_000); static final String TOTALS_ZERO_MESSAGE = "Totals must not greater than zero."; + private static final int ALMOST_PERFECT_INTEGER = 99; + private static final double ALMOST_PERFECT_DOUBLE = 99.99; /** * Creates an instance of {@link Percentage} in the range [0,100] from a {@link Fraction fraction} within the range @@ -119,13 +124,40 @@ public double toDouble() { return items * 100.0 / total; } + /** + * Returns this percentage as a double value in the interval [0, 100]. The returned value is rounded to 2 digits aft + * + * @return the coverage percentage + */ + public double toRounded() { + var value = BigDecimal.valueOf(toDouble()); + var rounded = value.setScale(2, RoundingMode.HALF_UP).doubleValue(); + if (rounded == 100.0 && isNotPerfect()) { + return ALMOST_PERFECT_DOUBLE; + } + return rounded; + } + /** * Returns this percentage as an int value in the interval [0, 100]. * * @return the coverage percentage */ public int toInt() { - return Math.round(items * 100.0f / total); + var value = Math.round(items * 100.0f / total); + if (value == 100 && isNotPerfect()) { + return ALMOST_PERFECT_INTEGER; + } + return value; + } + + /** + * Formats a percentage to plain text and rounds the value to two decimals. By default, the English locale is used. + * + * @return the formatted percentage as plain text + */ + public String formatPercentage() { + return formatPercentage(Locale.ENGLISH); } /** @@ -137,7 +169,15 @@ public int toInt() { * @return the formatted percentage as plain text */ public String formatPercentage(final Locale locale) { - return String.format(locale, "%.2f%%", toDouble()); + var formatted = String.format(locale, "%.2f%%", toDouble()); + if (formatted.startsWith("100") && isNotPerfect()) { + return ALMOST_HUNDRED.formatPercentage(locale); + } + return formatted; + } + + private boolean isNotPerfect() { + return items != total; } /** diff --git a/src/main/java/edu/hm/hafner/coverage/Value.java b/src/main/java/edu/hm/hafner/coverage/Value.java index 160566dc..bb917bb2 100644 --- a/src/main/java/edu/hm/hafner/coverage/Value.java +++ b/src/main/java/edu/hm/hafner/coverage/Value.java @@ -2,6 +2,8 @@ import java.io.Serial; import java.io.Serializable; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.Collection; import java.util.Locale; import java.util.NoSuchElementException; @@ -16,8 +18,8 @@ import edu.umd.cs.findbugs.annotations.CheckReturnValue; /** - * A leaf in the tree that contains a value. The value is for a coverage metric like line, instruction, branch, or - * mutation coverage or a software-metric like loc or complexity. + * A leaf in the tree that contains a numeric value. Such values are used for arbitrary software-metric like + * loc or complexity. The value is stored as a fraction to allow exact calculations. * * @author Ullrich Hafner */ @@ -41,7 +43,7 @@ public class Value implements Serializable { * if the value is not found * @see #findValue(Metric, Collection) */ - public static Value getValue(final Metric metric, final Collection values) { + public static Value getValue(final Metric metric, final Collection values) { return findValue(metric, values) .orElseThrow(() -> new NoSuchElementException("No value for metric " + metric + " in " + values)); } @@ -57,10 +59,11 @@ public static Value getValue(final Metric metric, final Collection values * @return the value with the specified metric, or an empty optional if the value is not found * @see #getValue(Metric, Collection) */ - public static Optional findValue(final Metric metric, final Collection values) { + public static Optional findValue(final Metric metric, final Collection values) { return values.stream() .filter(v -> metric.equals(v.getMetric())) - .findAny(); + .findAny() + .map(Value.class::cast); } /** @@ -85,10 +88,13 @@ public static Value valueOf(final String stringRepresentation) { if (StringUtils.contains(cleanedFormat, METRIC_SEPARATOR)) { var metric = Metric.fromName(StringUtils.substringBefore(cleanedFormat, METRIC_SEPARATOR)); var value = StringUtils.substringAfter(cleanedFormat, METRIC_SEPARATOR); - if (metric.isCoverage()) { + if (value.contains("/")) { return Coverage.valueOf(metric, value); } - return new Value(metric, Fraction.getFraction(value)); + if (value.startsWith(Difference.DELTA)) { + return new Difference(metric, readFraction(value, 1)); + } + return new Value(metric, readFraction(value, 0)); } } catch (NumberFormatException exception) { @@ -97,6 +103,10 @@ public static Value valueOf(final String stringRepresentation) { throw new IllegalArgumentException(errorMessage); } + private static Fraction readFraction(final String value, final int beginIndex) { + return Fraction.getFraction(value.substring(beginIndex).replace(':', '/')); + } + /** * Returns a {@code null} object that indicates that no value has been recorded. * @@ -125,6 +135,18 @@ public Value(final Metric metric, final Fraction value) { this.fraction = value; } + /** + * Creates a new leaf with the given value for the specified metric. + * + * @param metric + * the coverage metric + * @param value + * the value to store + */ + public Value(final Metric metric, final double value) { + this(metric, Fraction.getFraction(value)); + } + /** * Creates a new leaf with the given value (a fraction) for the specified metric. * @@ -159,7 +181,7 @@ public Fraction getFraction() { return fraction; } - protected void ensureSameMetric(final Value other) { + protected void ensureSameMetricAndType(final Value other) { if (!hasSameMetric(other)) { throw new IllegalArgumentException( "Cannot calculate with different metrics: %s and %s".formatted(this, other)); @@ -182,7 +204,7 @@ protected void ensureSameMetric(final Value other) { */ @CheckReturnValue public Value add(final Value other) { - ensureSameMetric(other); + ensureSameMetricAndType(other); return new Value(getMetric(), asSafeFraction().add(other.fraction)); } @@ -198,10 +220,10 @@ public Value add(final Value other) { * if the metrics of the two instances are different */ @CheckReturnValue - public Fraction delta(final Value other) { - ensureSameMetric(other); + public Difference subtract(final Value other) { + ensureSameMetricAndType(other); - return asSafeFraction().subtract(other.fraction); + return new Difference(getMetric(), asSafeFraction().subtract(other.fraction)); } /** @@ -212,8 +234,10 @@ public Fraction delta(final Value other) { * * @return the maximum value */ + @CheckReturnValue public Value max(final Value other) { - ensureSameMetric(other); + ensureSameMetricAndType(other); + if (fraction.doubleValue() < other.fraction.doubleValue()) { return other; } @@ -248,16 +272,63 @@ private SafeFraction asSafeFraction() { * @return serialization of this value as a String */ public final String serialize() { - return String.format(Locale.ENGLISH, "%s: %s", getMetric(), asText()); + return String.format(Locale.ENGLISH, "%s: %s", getMetric(), serializeValue()); + } + + /** + * Serializes the value of this instance into a String (without the metric). + * + * @return the value of this instance as a String + */ + protected String serializeValue() { + if (fraction.getDenominator() == 1) { + return String.valueOf(fraction.getNumerator()); + } + return String.format(Locale.ENGLISH, "%d:%d", fraction.getNumerator(), fraction.getDenominator()); } /** * Returns this value as a text. * + * @param locale + * the locale to use + * * @return this value formatted as a String */ - public String asText() { - return getMetric().format(asDouble()); + public String asText(final Locale locale) { + return getMetric().format(locale, asDouble()); + } + + /** + * Returns this value as an informative text. + * + * @param locale + * the locale to use + * + * @return this value formatted as a String + */ + public String asInformativeText(final Locale locale) { + return getMetric().format(locale, asDouble()); + } + + /** + * Returns a short summary of this value as a human-readable text. + * + * @param locale the locale to use + * @return the summary of this value as a human-readable text + */ + public String getSummary(final Locale locale) { + return String.format(locale, "%s: %s", getMetric().getDisplayName(), asText(locale)); + } + + /** + * Returns the details of this value as a human-readable text. + * + * @param locale the locale to use + * @return the details of this value as a human-readable text + */ + public String getDetails(final Locale locale) { + return String.format(locale, "%s: %s", getMetric().getDisplayName(), asInformativeText(locale)); } /** @@ -266,7 +337,7 @@ public String asText() { * @return this value as an integer */ public int asInteger() { - return fraction.getProperWhole(); + return (int) round(fraction.doubleValue(), 0); } /** @@ -278,6 +349,21 @@ public double asDouble() { return fraction.doubleValue(); } + /** + * Returns this value as rounded double. + * + * @return this value as a double + */ + public double asRounded() { + return round(fraction.doubleValue(), 2); + } + + private double round(final double value, final int scale) { + return BigDecimal.valueOf(value) + .setScale(scale, RoundingMode.HALF_UP) + .doubleValue(); + } + /** * Returns whether this value has the same metric as the specified value. * diff --git a/src/test/java/edu/hm/hafner/coverage/CoverageTest.java b/src/test/java/edu/hm/hafner/coverage/CoverageTest.java index c2faecc3..645c82d9 100644 --- a/src/test/java/edu/hm/hafner/coverage/CoverageTest.java +++ b/src/test/java/edu/hm/hafner/coverage/CoverageTest.java @@ -1,6 +1,7 @@ package edu.hm.hafner.coverage; -import org.apache.commons.lang3.math.Fraction; +import java.util.Locale; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -24,24 +25,41 @@ @DefaultLocale("en") @SuppressFBWarnings(value = "RV_RETURN_VALUE_IGNORED", justification = "Exception is thrown anyway") class CoverageTest { - private static final Coverage NO_COVERAGE = new CoverageBuilder() - .withMetric(Metric.LINE) - .withCovered(0) - .withMissed(0) - .build(); + private static final Coverage NO_COVERAGE = Coverage.nullObject(Metric.LINE); + + @Test + void shouldHandlePercentageRounding() { + var builder = new CoverageBuilder().withMetric(Metric.LINE); + + var oneThird = builder.withCovered(1).withMissed(2).build(); + + assertThat(oneThird.asInteger()).isEqualTo(33); + assertThat(oneThird.asDouble()).isEqualTo(100.0 / 3); + assertThat(oneThird.asRounded()).isEqualTo(33.33); + + var twoThirds = builder.withCovered(2).withMissed(1).build(); + + assertThat(twoThirds.asInteger()).isEqualTo(67); + assertThat(twoThirds.asDouble()).isEqualTo(200.0 / 3); + assertThat(twoThirds.asRounded()).isEqualTo(66.67); + + assertThat(twoThirds.asText(Locale.ENGLISH)).isEqualTo("66.67%"); + assertThat(twoThirds.asInformativeText(Locale.ENGLISH)).isEqualTo("66.67% (2/3)"); + assertThat(twoThirds.serialize()).isEqualTo("LINE: 2/3"); + } @Test void shouldComputeDelta() { var builder = new CoverageBuilder().withMetric(Metric.LINE); - var worse = builder.withCovered(0).withMissed(2).build(); - var ok = builder.withCovered(1).withMissed(1).build(); - var better = builder.withCovered(2).withMissed(0).build(); + var worse = builder.withCovered(0).withMissed(2).build(); // 0% + var ok = builder.withCovered(1).withMissed(1).build(); // 50% + var better = builder.withCovered(2).withMissed(0).build(); // 100% - assertThat(worse.delta(better).doubleValue()).isEqualTo(getDelta("-1/1")); - assertThat(better.delta(worse).doubleValue()).isEqualTo(getDelta("1/1")); - assertThat(worse.delta(ok).doubleValue()).isEqualTo(getDelta("-1/2")); - assertThat(ok.delta(worse).doubleValue()).isEqualTo(getDelta("1/2")); + assertThat(worse.subtract(better).asDouble()).isEqualTo(-100); + assertThat(better.subtract(worse).asDouble()).isEqualTo(100); + assertThat(worse.subtract(ok).asDouble()).isEqualTo(-50); + assertThat(ok.subtract(worse).asDouble()).isEqualTo(50); } @Test @@ -60,10 +78,6 @@ void shouldCompareWithThreshold() { assertThat(hundred.isOutOfValidRange(100.1)).isTrue(); } - private double getDelta(final String value) { - return Fraction.getFraction(value).doubleValue(); - } - @Test void shouldComputeMaximum() { var builder = new CoverageBuilder().withMetric(Metric.LINE); @@ -112,13 +126,13 @@ void shouldThrowExceptionWithIncompatibleValue() { assertThatIllegalArgumentException().isThrownBy(() -> coverage.add(loc)); assertThatIllegalArgumentException().isThrownBy(() -> coverage.max(loc)); - assertThatIllegalArgumentException().isThrownBy(() -> coverage.delta(loc)); + assertThatIllegalArgumentException().isThrownBy(() -> coverage.subtract(loc)); assertThatIllegalArgumentException().isThrownBy(() -> coverage.add(wrongMetric)); assertThatIllegalArgumentException().isThrownBy(() -> coverage.max(wrongMetric)); - assertThatIllegalArgumentException().isThrownBy(() -> coverage.delta(wrongMetric)); + assertThatIllegalArgumentException().isThrownBy(() -> coverage.subtract(wrongMetric)); assertThatIllegalArgumentException().isThrownBy(() -> wrongMetric.add(loc)); assertThatIllegalArgumentException().isThrownBy(() -> wrongMetric.max(loc)); - assertThatIllegalArgumentException().isThrownBy(() -> wrongMetric.delta(loc)); + assertThatIllegalArgumentException().isThrownBy(() -> wrongMetric.subtract(loc)); } @Test diff --git a/src/test/java/edu/hm/hafner/coverage/DifferenceTest.java b/src/test/java/edu/hm/hafner/coverage/DifferenceTest.java new file mode 100644 index 00000000..cebb2b2e --- /dev/null +++ b/src/test/java/edu/hm/hafner/coverage/DifferenceTest.java @@ -0,0 +1,117 @@ +package edu.hm.hafner.coverage; + +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +import static edu.hm.hafner.coverage.assertions.Assertions.*; + +class DifferenceTest { + @Test + void shouldFormatPercentageWithSign() { + var positive = new Difference(Metric.COHESION, 2, 3); + + assertThat(positive.asInteger()).isEqualTo(1); + assertThat(positive.asDouble()).isEqualTo(2.0 / 3); + assertThat(positive.asRounded()).isEqualTo(0.67); + + assertThat(positive.asText(Locale.ENGLISH)).isEqualTo("+66.67%"); + assertThat(positive.asInformativeText(Locale.ENGLISH)).isEqualTo("+66.67%"); + assertThat(positive.serialize()).isEqualTo("COHESION: Δ2:3"); + assertThat(positive).isEqualTo(Value.valueOf("COHESION: Δ2:3")); + + var negative = new Difference(Metric.COHESION, -2, 3); + + assertThat(negative.asInteger()).isEqualTo(-1); + assertThat(negative.asDouble()).isEqualTo(-2.0 / 3); + assertThat(negative.asRounded()).isEqualTo(-0.67); + + assertThat(negative.asText(Locale.ENGLISH)).isEqualTo("-66.67%"); + assertThat(negative.asInformativeText(Locale.ENGLISH)).isEqualTo("-66.67%"); + assertThat(negative.serialize()).isEqualTo("COHESION: Δ-2:3"); + assertThat(negative).isEqualTo(Value.valueOf("COHESION: Δ-2:3")); + + var zero = new Difference(Metric.COHESION, 0); + + assertThat(zero.asInteger()).isEqualTo(0); + assertThat(zero.asDouble()).isEqualTo(0); + assertThat(zero.asRounded()).isEqualTo(0); + + assertThat(zero.asText(Locale.ENGLISH)).isEqualTo("±0%"); + assertThat(zero.asInformativeText(Locale.ENGLISH)).isEqualTo("±0%"); + assertThat(zero.serialize()).isEqualTo("COHESION: Δ0"); + assertThat(zero).isEqualTo(Value.valueOf("COHESION: Δ0")); + } + + @Test + void shouldFormatCoverageWithSign() { + var positive = new Difference(Metric.LINE, 200, 3); + + assertThat(positive.asInteger()).isEqualTo(67); + assertThat(positive.asDouble()).isEqualTo(200.0 / 3); + assertThat(positive.asRounded()).isEqualTo(66.67); + + assertThat(positive.asText(Locale.ENGLISH)).isEqualTo("+66.67%"); + assertThat(positive.asInformativeText(Locale.ENGLISH)).isEqualTo("+66.67%"); + assertThat(positive.serialize()).isEqualTo("LINE: Δ200:3"); + assertThat(positive).isEqualTo(Value.valueOf("LINE: Δ200:3")); + + var negative = new Difference(Metric.LINE, -200, 3); + + assertThat(negative.asInteger()).isEqualTo(-67); + assertThat(negative.asDouble()).isEqualTo(-200.0 / 3); + assertThat(negative.asRounded()).isEqualTo(-66.67); + + assertThat(negative.asText(Locale.ENGLISH)).isEqualTo("-66.67%"); + assertThat(negative.asInformativeText(Locale.ENGLISH)).isEqualTo("-66.67%"); + assertThat(negative.serialize()).isEqualTo("LINE: Δ-200:3"); + assertThat(negative).isEqualTo(Value.valueOf("LINE: Δ-200:3")); + + var zero = new Difference(Metric.LINE, 0); + + assertThat(zero.asInteger()).isEqualTo(0); + assertThat(zero.asDouble()).isEqualTo(0); + assertThat(zero.asRounded()).isEqualTo(0); + + assertThat(zero.asText(Locale.ENGLISH)).isEqualTo("±0%"); + assertThat(zero.asInformativeText(Locale.ENGLISH)).isEqualTo("±0%"); + assertThat(zero.serialize()).isEqualTo("LINE: Δ0"); + assertThat(zero).isEqualTo(Value.valueOf("LINE: Δ0")); + } + + @Test + void shouldFormatIntegerWithSign() { + var positive = new Difference(Metric.LOC, 2); + + assertThat(positive.asInteger()).isEqualTo(2); + assertThat(positive.asDouble()).isEqualTo(2.0); + assertThat(positive.asRounded()).isEqualTo(2); + + assertThat(positive.asText(Locale.ENGLISH)).isEqualTo("+2"); + assertThat(positive.asInformativeText(Locale.ENGLISH)).isEqualTo("+2"); + assertThat(positive.serialize()).isEqualTo("LOC: Δ2"); + assertThat(positive).isEqualTo(Value.valueOf("LOC: Δ2")); + + var negative = new Difference(Metric.LOC, -2); + + assertThat(negative.asInteger()).isEqualTo(-2); + assertThat(negative.asDouble()).isEqualTo(-2.0); + assertThat(negative.asRounded()).isEqualTo(-2); + + assertThat(negative.asText(Locale.ENGLISH)).isEqualTo("-2"); + assertThat(negative.asInformativeText(Locale.ENGLISH)).isEqualTo("-2"); + assertThat(negative.serialize()).isEqualTo("LOC: Δ-2"); + assertThat(negative).isEqualTo(Value.valueOf("LOC: Δ-2")); + + var zero = new Difference(Metric.LOC, 0); + + assertThat(zero.asInteger()).isEqualTo(0); + assertThat(zero.asDouble()).isEqualTo(0); + assertThat(zero.asRounded()).isEqualTo(0); + + assertThat(zero.asText(Locale.ENGLISH)).isEqualTo("±0"); + assertThat(zero.asInformativeText(Locale.ENGLISH)).isEqualTo("±0"); + assertThat(zero.serialize()).isEqualTo("LOC: Δ0"); + assertThat(zero).isEqualTo(Value.valueOf("LOC: Δ0")); + } +} diff --git a/src/test/java/edu/hm/hafner/coverage/FileNodeTest.java b/src/test/java/edu/hm/hafner/coverage/FileNodeTest.java index 467884af..2779b515 100644 --- a/src/test/java/edu/hm/hafner/coverage/FileNodeTest.java +++ b/src/test/java/edu/hm/hafner/coverage/FileNodeTest.java @@ -3,7 +3,6 @@ import java.io.IOException; import java.util.NavigableMap; -import org.apache.commons.lang3.math.Fraction; import org.junit.jupiter.api.Test; import edu.hm.hafner.coverage.Mutation.MutationBuilder; @@ -128,8 +127,7 @@ void shouldComputeDelta() { assertThat(fileB.hasDelta(Metric.LINE)).isTrue(); assertThat(fileB.hasDelta(Metric.BRANCH)).isFalse(); - assertThat(fileB.getDelta(Metric.LINE)) - .isEqualTo(Fraction.getFraction(20 - 10, 20).reduce()); + assertThat(fileB.getDelta(Metric.LINE).asDouble()).isEqualTo(50); } @Test diff --git a/src/test/java/edu/hm/hafner/coverage/FractionValueTest.java b/src/test/java/edu/hm/hafner/coverage/FractionValueTest.java index 39590bd7..00ad78aa 100644 --- a/src/test/java/edu/hm/hafner/coverage/FractionValueTest.java +++ b/src/test/java/edu/hm/hafner/coverage/FractionValueTest.java @@ -42,8 +42,8 @@ void shouldVerifyContract() { assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> fifty.add(hundred)); assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> fifty.add(loc)); - assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> hundred.delta(fifty)); - assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> hundred.delta(loc)); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> hundred.subtract(fifty)); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> hundred.subtract(loc)); assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> loc.max(hundred)); assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> loc.max(fifty)); @@ -57,6 +57,6 @@ void shouldReturnDelta() { assertThat(fifty.isOutOfValidRange(50.1)).isTrue(); assertThat(fifty.isOutOfValidRange(50)).isFalse(); - assertThat(hundred.delta(fifty)).isEqualTo(Fraction.getFraction(50, 1)); + assertThat(hundred.subtract(fifty).asDouble()).isEqualTo(50); } } diff --git a/src/test/java/edu/hm/hafner/coverage/MetricTest.java b/src/test/java/edu/hm/hafner/coverage/MetricTest.java index 0caa1da9..e9c38cd8 100644 --- a/src/test/java/edu/hm/hafner/coverage/MetricTest.java +++ b/src/test/java/edu/hm/hafner/coverage/MetricTest.java @@ -1,5 +1,6 @@ package edu.hm.hafner.coverage; +import java.util.Locale; import java.util.NavigableSet; import org.apache.commons.lang3.math.Fraction; @@ -74,9 +75,26 @@ void shouldGetCoverageMetrics() { */ @Test void shouldCorrectlyImplementIsContainer() { - assertThat(Metric.MODULE).isContainer().isCoverage().hasDisplayName("Module Coverage"); - assertThat(Metric.FILE).isContainer().isCoverage().hasDisplayName("File Coverage"); - assertThat(Metric.LINE).isNotContainer().isCoverage().hasDisplayName("Line Coverage"); + assertThat(Metric.MODULE) + .isContainer() + .isCoverage() + .hasDisplayName("Module Coverage") + .hasLabel("Module"); + assertThat(Metric.FILE) + .isContainer() + .isCoverage() + .hasDisplayName("File Coverage") + .hasLabel("File"); + assertThat(Metric.LINE) + .isNotContainer() + .isCoverage() + .hasDisplayName("Line Coverage") + .hasLabel("Line"); + assertThat(Metric.LOC) + .isNotContainer() + .isNotCoverage() + .hasDisplayName("Lines of Code") + .hasLabel("LOC"); } @Test @@ -90,7 +108,7 @@ void shouldCorrectlyComputeLoc() { assertThat(Metric.LOC.getValueFor(node)).hasValueSatisfying(loc -> { assertThat(loc.asInteger()).isEqualTo(10); - assertThat(loc.asText()).isEqualTo("10"); + assertThat(loc.asText(Locale.ENGLISH)).isEqualTo("10"); assertThat(loc) .hasMetric(Metric.LOC) .hasFraction(Fraction.getFraction(10, 1)); @@ -105,12 +123,12 @@ void shouldFormatMetricValues() { var complexity = Metric.CYCLOMATIC_COMPLEXITY; assertThat(complexity).hasTendency(MetricTendency.SMALLER_IS_BETTER) .isNotContainer().isNotCoverage().hasDisplayName("Cyclomatic Complexity"); - assertThat(complexity.format(355)).isEqualTo("355"); - assertThat(complexity.formatMean(355)).isEqualTo("355.00"); + assertThat(complexity.format(Locale.ENGLISH, 355)).isEqualTo("355"); + assertThat(complexity.formatMean(Locale.ENGLISH, 355)).isEqualTo("355.00"); assertThat(complexity.getAggregationType()).isEqualTo("total"); assertThat(complexity.getType()).isEqualTo(MetricValueType.METHOD_METRIC); assertThat(complexity.parseValue("355.7")).satisfies(value -> - assertThat(value.asText()).isEqualTo("356")); + assertThat(value.asText(Locale.ENGLISH)).isEqualTo("356")); assertThat(complexity.getTargetNodes(root)).hasSize(1) .first().extracting(Node::getName).isEqualTo("method()"); @@ -118,12 +136,12 @@ void shouldFormatMetricValues() { var cohesion = Metric.COHESION; assertThat(cohesion).hasTendency(MetricTendency.LARGER_IS_BETTER) .isNotContainer().isNotCoverage().hasDisplayName("Class Cohesion"); - assertThat(cohesion.format(0.355)).isEqualTo("35.50%"); - assertThat(cohesion.formatMean(0.355)).isEqualTo("35.50%"); + assertThat(cohesion.format(Locale.ENGLISH, 0.355)).isEqualTo("35.50%"); + assertThat(cohesion.formatMean(Locale.ENGLISH, 0.355)).isEqualTo("35.50%"); assertThat(cohesion.getAggregationType()).isEqualTo("maximum"); assertThat(cohesion.getType()).isEqualTo(MetricValueType.CLASS_METRIC); assertThat(cohesion.parseValue("0.355")).satisfies(value -> - assertThat(value.asText()).isEqualTo("35.50%")); + assertThat(value.asText(Locale.ENGLISH)).isEqualTo("35.50%")); assertThat(cohesion.getTargetNodes(root)).hasSize(1) .first().extracting(Node::getName).isEqualTo("class"); diff --git a/src/test/java/edu/hm/hafner/coverage/NodeTest.java b/src/test/java/edu/hm/hafner/coverage/NodeTest.java index 7b3b6a13..6bb45aa1 100644 --- a/src/test/java/edu/hm/hafner/coverage/NodeTest.java +++ b/src/test/java/edu/hm/hafner/coverage/NodeTest.java @@ -3,10 +3,8 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.NavigableMap; import java.util.NoSuchElementException; -import org.apache.commons.lang3.math.Fraction; import org.assertj.core.api.ThrowingConsumer; import org.junit.jupiter.api.Test; import org.junitpioneer.jupiter.DefaultLocale; @@ -754,13 +752,12 @@ void shouldComputeDelta() { coverageBuilder.withMetric(BRANCH).withCovered(1).withMissed(1).build() )); - NavigableMap delta = fileA.computeDelta(fileB); + List delta = fileA.computeDelta(fileB); - assertThat(delta) - .containsKeys(FILE, LINE, BRANCH) - .doesNotContainKey(MUTATION); - assertThat(delta.getOrDefault(LINE, Fraction.ZERO)).isEqualTo(Fraction.getFraction(10, 10)); - assertThat(delta.getOrDefault(BRANCH, Fraction.ZERO)).isEqualTo(Fraction.getFraction(1, 2)); + assertThat(delta).map(Difference::getMetric) + .containsExactly(FILE, LINE, BRANCH, LOC).doesNotContain(MUTATION); + assertThat(delta.get(1).asDouble()).isEqualTo(100); + assertThat(delta.get(2).asDouble()).isEqualTo(50); } @Test diff --git a/src/test/java/edu/hm/hafner/coverage/PercentageTest.java b/src/test/java/edu/hm/hafner/coverage/PercentageTest.java index 0db00469..c3871c9f 100644 --- a/src/test/java/edu/hm/hafner/coverage/PercentageTest.java +++ b/src/test/java/edu/hm/hafner/coverage/PercentageTest.java @@ -69,4 +69,34 @@ void shouldSerializeInstance() { assertThatIllegalArgumentException().isThrownBy(() -> Percentage.valueOf("1/0")); assertThatIllegalArgumentException().isThrownBy(() -> Percentage.valueOf("2/1")); } + + @Test + void shouldRoundCorrectly() { + var oneThird = Percentage.valueOf(1, 3); + + assertThat(oneThird.serializeToString()).isEqualTo("1/3"); + assertThat(oneThird.formatPercentage(Locale.GERMAN)).isEqualTo("33,33%"); + assertThat(oneThird.formatPercentage()).isEqualTo("33.33%"); + assertThat(oneThird.toInt()).isEqualTo(33); + assertThat(oneThird.toRounded()).isEqualTo(33.33); + + var twoThirds = Percentage.valueOf(2, 3); + + assertThat(twoThirds.serializeToString()).isEqualTo("2/3"); + assertThat(twoThirds.formatPercentage(Locale.GERMAN)).isEqualTo("66,67%"); + assertThat(twoThirds.formatPercentage()).isEqualTo("66.67%"); + assertThat(twoThirds.toInt()).isEqualTo(67); + assertThat(twoThirds.toRounded()).isEqualTo(66.67); + } + + @Test + void shouldHandle100PercentCorrectly() { + var oneMissing = Percentage.valueOf(1_000_000 - 1, 1_000_000); + + assertThat(oneMissing.formatPercentage(Locale.GERMAN)).isEqualTo("99,99%"); + assertThat(oneMissing.formatPercentage()).isEqualTo("99.99%"); + assertThat(oneMissing.toInt()).isEqualTo(99); + assertThat(oneMissing.toDouble()).isEqualTo(99.9999); + assertThat(oneMissing.toRounded()).isEqualTo(99.99); + } } diff --git a/src/test/java/edu/hm/hafner/coverage/ValueTest.java b/src/test/java/edu/hm/hafner/coverage/ValueTest.java index 0c1fc8a5..4a8c66a7 100644 --- a/src/test/java/edu/hm/hafner/coverage/ValueTest.java +++ b/src/test/java/edu/hm/hafner/coverage/ValueTest.java @@ -1,6 +1,7 @@ package edu.hm.hafner.coverage; import java.util.List; +import java.util.Locale; import java.util.NoSuchElementException; import org.apache.commons.lang3.math.Fraction; @@ -11,12 +12,84 @@ import static edu.hm.hafner.coverage.assertions.Assertions.*; class ValueTest { + @Test + void shouldProvideNullObject() { + var zero = Value.nullObject(Metric.LOC); + + assertThat(zero) + .hasMetric(Metric.LOC) + .hasFraction(Fraction.ZERO); + + assertThat(zero.add(zero)).isEqualTo(zero); + assertThat(zero.subtract(zero)).isEqualTo(Difference.nullObject(Metric.LOC)); + assertThat(zero.max(zero)).isEqualTo(zero); + + assertThat(zero.asInteger()).isZero(); + assertThat(zero.asDouble()).isEqualTo(0); + assertThat(zero.asRounded()).isEqualTo(0); + + assertThat(zero.asText(Locale.ENGLISH)).isEqualTo("0"); + assertThat(zero.getSummary(Locale.ENGLISH)).isEqualTo("Lines of Code: 0"); + assertThat(zero.asInformativeText(Locale.ENGLISH)).isEqualTo("0"); + assertThat(zero.getDetails(Locale.ENGLISH)).isEqualTo("Lines of Code: 0"); + assertThat(zero.serialize()).isEqualTo("LOC: 0"); + + assertThatInstanceIsCorrectlySerializedAndDeserialized(zero); + } + + private void assertThatInstanceIsCorrectlySerializedAndDeserialized(final Value value) { + assertThat(Value.valueOf(value.serialize())).isEqualTo(value); + } + + @Test + void shouldHandlePercentageRounding() { + var oneThird = new Value(Metric.COHESION, 1, 3); + + assertThat(oneThird.asInteger()).isZero(); + assertThat(oneThird.asDouble()).isEqualTo(1.0 / 3); + assertThat(oneThird.asRounded()).isEqualTo(0.33); + + var twoThirds = new Value(Metric.COHESION, 2, 3); + + assertThat(twoThirds.asInteger()).isEqualTo(1); + assertThat(twoThirds.asDouble()).isEqualTo(2.0 / 3); + assertThat(twoThirds.asRounded()).isEqualTo(0.67); + + assertThat(twoThirds.asText(Locale.ENGLISH)).isEqualTo("66.67%"); + assertThat(twoThirds.asInformativeText(Locale.ENGLISH)).isEqualTo("66.67%"); + assertThat(twoThirds.serialize()).isEqualTo("COHESION: 2:3"); + + assertThat(oneThird.max(twoThirds)).isEqualTo(twoThirds); + assertThat(twoThirds.max(oneThird)).isEqualTo(twoThirds); + + assertThatInstanceIsCorrectlySerializedAndDeserialized(oneThird); + assertThatInstanceIsCorrectlySerializedAndDeserialized(twoThirds); + } + + @Test + void shouldHandleDoubleValues() { + var fraction = new Value(Metric.COHESION, 1, 3); + var value = new Value(Metric.COHESION, 1.0 / 3.0); + + assertThat(value.asInteger()).isZero(); + assertThat(value.asDouble()).isEqualTo(1.0 / 3); + assertThat(value.asRounded()).isEqualTo(0.33); + assertThat(value.asDouble()).isEqualTo(fraction.asDouble()); + + assertThatInstanceIsCorrectlySerializedAndDeserialized(value); + } + @Test void shouldReturnCorrectValueOfCoverage() { var container = Value.valueOf("CONTAINER: 1/1"); assertThat(container) - .isInstanceOf(Coverage.class); + .isInstanceOfSatisfying(Coverage.class, coverage -> { + assertThat(coverage.getMetric()).isEqualTo(Metric.CONTAINER); + assertThat(coverage.getCovered()).isOne(); + assertThat(coverage.getMissed()).isZero(); + }); + assertThat(Value.valueOf("MODULE: 1/1")) .isInstanceOf(Coverage.class); assertThat(Value.valueOf("PACKAGE: 1/1")) @@ -35,20 +108,24 @@ void shouldReturnCorrectValueOfCoverage() { .isInstanceOf(Coverage.class); assertThat(Value.valueOf("MUTATION: 1/1")) .isInstanceOf(Coverage.class); - - assertThat((Coverage) container) - .hasCovered(1) - .hasMissed(0); } @Test void shouldReturnCorrectValueOfFractionValue() { - var fractionValue = Value.valueOf("COMPLEXITY: 1/1"); + var complexity = Value.valueOf("COMPLEXITY: 1"); - assertThat(fractionValue) + assertThat(complexity) .isInstanceOf(Value.class) .hasMetric(Metric.CYCLOMATIC_COMPLEXITY) .hasFraction(Fraction.getFraction(1, 1)); + + assertThat(complexity.asText(Locale.ENGLISH)).isEqualTo("1"); + assertThat(complexity.getSummary(Locale.ENGLISH)).isEqualTo("Cyclomatic Complexity: 1"); + assertThat(complexity.asInformativeText(Locale.ENGLISH)).isEqualTo("1"); + assertThat(complexity.getDetails(Locale.ENGLISH)).isEqualTo("Cyclomatic Complexity: 1"); + assertThat(complexity.serialize()).isEqualTo("CYCLOMATIC_COMPLEXITY: 1"); + + assertThatInstanceIsCorrectlySerializedAndDeserialized(complexity); } @Test @@ -57,43 +134,79 @@ void shouldReturnCorrectValueOfLinesOfCode() { assertThat(linesOfCode).isInstanceOf(Value.class).hasMetric(Metric.LOC); assertThat(linesOfCode.asInteger()).isEqualTo(1); - assertThat(linesOfCode.asText()).isEqualTo("1"); + assertThat(linesOfCode.asText(Locale.ENGLISH)).isEqualTo("1"); + + assertThat(linesOfCode.asText(Locale.ENGLISH)).isEqualTo("1"); + assertThat(linesOfCode.getSummary(Locale.ENGLISH)).isEqualTo("Lines of Code: 1"); + assertThat(linesOfCode.asInformativeText(Locale.ENGLISH)).isEqualTo("1"); + assertThat(linesOfCode.getDetails(Locale.ENGLISH)).isEqualTo("Lines of Code: 1"); + assertThat(linesOfCode.serialize()).isEqualTo("LOC: 1"); + + assertThatInstanceIsCorrectlySerializedAndDeserialized(linesOfCode); } @Test void shouldThrowExceptionOnInvalidStringRepresentation() { var badRepresentation = "Bad representation"; - var badNumber = "COMPLEXITY: BadNumber"; - assertThatIllegalArgumentException() .isThrownBy(() -> Value.valueOf(badRepresentation)) .withMessageContaining("Cannot convert '%s' to a valid Value instance.".formatted(badRepresentation)); + + var badNumber = "COMPLEXITY: BadNumber"; assertThatIllegalArgumentException() .isThrownBy(() -> Value.valueOf(badNumber)) .withMessageContaining("Cannot convert '%s' to a valid Value instance.".formatted(badNumber)); } @Test - @SuppressWarnings("Varifier") - void shouldGetValue() { + @SuppressWarnings("ResultOfMethodCallIgnored") + void shouldThrowExceptionWhenUsingDifferentType() { var linesOfCode = new Value(Metric.LOC, 10); + var complexity = new Value(Metric.CYCLOMATIC_COMPLEXITY, 10); + var coverage = Coverage.nullObject(Metric.LOC); + + assertThatIllegalArgumentException() + .isThrownBy(() -> linesOfCode.add(complexity)) + .withMessageContaining("Cannot calculate with different metrics"); + + assertThatIllegalArgumentException() + .isThrownBy(() -> linesOfCode.add(coverage)) + .withMessageContaining("Cannot calculate with different types"); + } + + @Test + void shouldFindValueInCollection() { + Value linesOfCode = new Value(Metric.LOC, 10); var cyclomaticComplexity = new Value(Metric.CYCLOMATIC_COMPLEXITY, 20); var ncss = new Value(Metric.NCSS, 30); var npathComplexity = new Value(Metric.NPATH_COMPLEXITY, 40); - var coginitiveComplexity = new Value(Metric.COGNITIVE_COMPLEXITY, 50); + var cognitiveComplexity = new Value(Metric.COGNITIVE_COMPLEXITY, 50); - List values = List.of(linesOfCode, cyclomaticComplexity, ncss, npathComplexity, coginitiveComplexity); + List values = List.of(linesOfCode, cyclomaticComplexity, ncss, npathComplexity, cognitiveComplexity); assertThat(Value.getValue(Metric.LOC, values)) .isEqualTo(linesOfCode); + assertThat(Value.findValue(Metric.LOC, values)) + .contains(linesOfCode); assertThat(Value.getValue(Metric.CYCLOMATIC_COMPLEXITY, values)) .isEqualTo(cyclomaticComplexity); + assertThat(Value.findValue(Metric.CYCLOMATIC_COMPLEXITY, values)) + .contains(cyclomaticComplexity); assertThat(Value.getValue(Metric.NCSS, values)) .isEqualTo(ncss); + assertThat(Value.findValue(Metric.NCSS, values)) + .contains(ncss); assertThat(Value.getValue(Metric.NPATH_COMPLEXITY, values)) .isEqualTo(npathComplexity); + assertThat(Value.findValue(Metric.NPATH_COMPLEXITY, values)) + .contains(npathComplexity); assertThat(Value.getValue(Metric.COGNITIVE_COMPLEXITY, values)) - .isEqualTo(coginitiveComplexity); + .isEqualTo(cognitiveComplexity); + assertThat(Value.findValue(Metric.COGNITIVE_COMPLEXITY, values)) + .contains(cognitiveComplexity); + + assertThat(Value.findValue(Metric.LINE, values)) + .isEmpty(); assertThatExceptionOfType(NoSuchElementException.class) .isThrownBy(() -> Value.getValue(Metric.LINE, values)) .withMessageContaining("No value for metric"); diff --git a/src/test/java/edu/hm/hafner/coverage/parser/MetricsParserTest.java b/src/test/java/edu/hm/hafner/coverage/parser/MetricsParserTest.java index 55dac148..147cd99b 100644 --- a/src/test/java/edu/hm/hafner/coverage/parser/MetricsParserTest.java +++ b/src/test/java/edu/hm/hafner/coverage/parser/MetricsParserTest.java @@ -1,5 +1,7 @@ package edu.hm.hafner.coverage.parser; +import java.util.Locale; + import org.junit.jupiter.api.Test; import org.junitpioneer.jupiter.DefaultLocale; @@ -58,7 +60,7 @@ void shouldParseAllMetrics() { new Value(WEIGHED_METHOD_COUNT, 354)); assertThat(tree.getValue(CYCLOMATIC_COMPLEXITY)).hasValueSatisfying(value -> { - assertThat(value.asText()).isEqualTo("355"); + assertThat(value.asText(Locale.ENGLISH)).isEqualTo("355"); assertThat(value.asInteger()).isEqualTo(355); assertThat(value.asDouble()).isEqualTo(355.0); });