From 7d1d6cd23825208ff42c01138350b7bec0f7396d Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Sun, 9 Aug 2026 19:29:02 +0530 Subject: [PATCH] Temporal coupling metric --- .../forensics/miner/ForensicsTableModel.java | 37 ++++- .../forensics/miner/ForensicsViewModel.java | 27 ++++ .../forensics/miner/RepositoryStatistics.java | 28 +++- .../miner/RepositoryStatisticsXmlStream.java | 1 + .../forensics/miner/TemporalCoupling.java | 145 ++++++++++++++++++ .../miner/TemporalCouplingTableModel.java | 130 ++++++++++++++++ .../miner/TemporalCouplingViewModel.java | 55 +++++++ .../miner/ForensicsViewModel/index.jelly | 12 ++ .../forensics/miner/Messages.properties | 5 + .../TemporalCouplingViewModel/index.jelly | 27 ++++ .../miner/ForensicsTableModelTest.java | 56 +++++-- .../miner/RepositoryStatisticsTest.java | 61 ++++++++ .../miner/TemporalCouplingTableModelTest.java | 106 +++++++++++++ .../forensics/miner/TemporalCouplingTest.java | 69 +++++++++ 14 files changed, 747 insertions(+), 12 deletions(-) create mode 100644 src/main/java/io/jenkins/plugins/forensics/miner/TemporalCoupling.java create mode 100644 src/main/java/io/jenkins/plugins/forensics/miner/TemporalCouplingTableModel.java create mode 100644 src/main/java/io/jenkins/plugins/forensics/miner/TemporalCouplingViewModel.java create mode 100644 src/main/resources/io/jenkins/plugins/forensics/miner/TemporalCouplingViewModel/index.jelly create mode 100644 src/test/java/io/jenkins/plugins/forensics/miner/TemporalCouplingTableModelTest.java create mode 100644 src/test/java/io/jenkins/plugins/forensics/miner/TemporalCouplingTest.java diff --git a/src/main/java/io/jenkins/plugins/forensics/miner/ForensicsTableModel.java b/src/main/java/io/jenkins/plugins/forensics/miner/ForensicsTableModel.java index dd95ee31..346d7b25 100644 --- a/src/main/java/io/jenkins/plugins/forensics/miner/ForensicsTableModel.java +++ b/src/main/java/io/jenkins/plugins/forensics/miner/ForensicsTableModel.java @@ -27,11 +27,15 @@ *
  • total number of commits
  • *
  • time of last commit
  • *
  • time of first commit
  • + *
  • maximum temporal coupling
  • * * * @author Ullrich Hafner */ public class ForensicsTableModel extends TableModel { + private static final int COUPLING_RESPONSIVE_PRIORITY = 20_000; + private static final double NO_COUPLING = 0.0; + private final RepositoryStatistics statistics; ForensicsTableModel(final RepositoryStatistics statistics) { @@ -81,13 +85,30 @@ public List getColumns() { .withDataPropertyKey("churn") .withType(ColumnType.NUMBER) .build()); + columns.add(builder.withHeaderLabel(Messages.Table_Column_MaxCoupling()) + .withDataPropertyKey("maxCoupling") + .withType(ColumnType.NUMBER) + .withResponsivePriority(COUPLING_RESPONSIVE_PRIORITY) + .build()); return columns; } @Override public List getRows() { - return statistics.getFileStatistics().stream().map(ForensicsRow::new).collect(Collectors.toList()); + var couplings = statistics.getTemporalCouplings(); + + return statistics.getFileStatistics().stream() + .map(file -> new ForensicsRow(file, findMaxCoupling(couplings, file.getFileName()))) + .collect(Collectors.toList()); + } + + private double findMaxCoupling(final List couplings, final String fileName) { + return couplings.stream() + .filter(coupling -> coupling.contains(fileName)) + .mapToDouble(TemporalCoupling::getCouplingPercentage) + .max() + .orElse(NO_COUPLING); } /** @@ -95,9 +116,11 @@ public List getRows() { */ public static class ForensicsRow { private final FileStatistics fileStatistics; + private final double maxCoupling; - ForensicsRow(final FileStatistics fileStatistics) { + ForensicsRow(final FileStatistics fileStatistics, final double maxCoupling) { this.fileStatistics = fileStatistics; + this.maxCoupling = maxCoupling; } /** @@ -140,5 +163,15 @@ public int getLinesOfCode() { public int getChurn() { return fileStatistics.getAbsoluteChurn(); } + + /** + * Returns the strongest temporal coupling of this file with any other repository file, given as percentage in + * the interval {@code [0.0, 100.0]}. + * + * @return the maximum coupling in percent, or {@code 0} if this file is not coupled with another file + */ + public double getMaxCoupling() { + return maxCoupling; + } } } diff --git a/src/main/java/io/jenkins/plugins/forensics/miner/ForensicsViewModel.java b/src/main/java/io/jenkins/plugins/forensics/miner/ForensicsViewModel.java index 2367dc37..58713818 100644 --- a/src/main/java/io/jenkins/plugins/forensics/miner/ForensicsViewModel.java +++ b/src/main/java/io/jenkins/plugins/forensics/miner/ForensicsViewModel.java @@ -21,6 +21,8 @@ * @author Ullrich Hafner */ public class ForensicsViewModel extends DefaultAsyncTableContentProvider implements ModelObject { + private static final String TEMPORAL_COUPLING_URL = "temporalCoupling"; + private final Run owner; private final RepositoryStatistics repositoryStatistics; private final String scmKey; @@ -85,6 +87,27 @@ public String getCommitsModel() { FileStatistics::getNumberOfCommits, 5, 10, 25, 50, 100, 250)); } + /** + * Returns whether the mined statistics contain temporal couplings, i.e. whether the temporal coupling view should + * be shown. + * + * @return {@code true} if there are temporal couplings, {@code false} otherwise + */ + @SuppressWarnings("unused") // Called by jelly view + public boolean hasTemporalCouplings() { + return !repositoryStatistics.getTemporalCouplings().isEmpty(); + } + + /** + * Returns the relative URL of the temporal coupling view. + * + * @return the URL of the temporal coupling view + */ + @SuppressWarnings("unused") // Called by jelly view + public String getTemporalCouplingUrl() { + return TEMPORAL_COUPLING_URL; + } + /** * Returns a new subpage for the selected link. * @@ -99,6 +122,10 @@ public String getCommitsModel() { */ @SuppressWarnings("unused") //called by jelly view public Object getDynamic(final String link, final StaplerRequest2 request, final StaplerResponse2 response) { + if (TEMPORAL_COUPLING_URL.equals(link)) { + return new TemporalCouplingViewModel(owner, repositoryStatistics); + } + try { CommitDecorator decorator = CommitDecoratorFactory.findCommitDecorator(owner, scmKey); diff --git a/src/main/java/io/jenkins/plugins/forensics/miner/RepositoryStatistics.java b/src/main/java/io/jenkins/plugins/forensics/miner/RepositoryStatistics.java index b5e6a8f0..abfacace 100644 --- a/src/main/java/io/jenkins/plugins/forensics/miner/RepositoryStatistics.java +++ b/src/main/java/io/jenkins/plugins/forensics/miner/RepositoryStatistics.java @@ -27,7 +27,7 @@ */ public class RepositoryStatistics implements Serializable { @Serial - private static final long serialVersionUID = 8L; // release 0.8.0 + private static final long serialVersionUID = 9L; // release 1.9.0 @CheckForNull @SuppressWarnings("PMD.LooseCoupling") @@ -43,6 +43,9 @@ public class RepositoryStatistics implements Serializable { private int totalLinesOfCode; private int totalChurn; + @SuppressWarnings("PMD.LooseCoupling") + private ArrayList temporalCouplings = new ArrayList<>(); // since 1.9.0 + /** * Creates an empty instance of {@link RepositoryStatistics} with no latest commit ID set. */ @@ -79,6 +82,9 @@ protected Object readResolve() { statisticsMapping = statisticsPerFile; statisticsPerFile = null; // set to null to remove the field from serialization } + if (temporalCouplings == null) { // before 1.9.0: no couplings have been mined + temporalCouplings = new ArrayList<>(); + } return this; } @@ -274,6 +280,26 @@ public CommitStatistics getLatestStatistics() { return statistics; } + /** + * Returns the temporal couplings of all repository files, i.e. the pairs of files that have been changed together + * in the same commit. + * + * @return the temporal couplings, or an empty list if the SCM does not provide this information + */ + public List getTemporalCouplings() { + return Collections.unmodifiableList(temporalCouplings); + } + + /** + * Sets the temporal couplings of all repository files. + * + * @param couplings + * the temporal couplings to store + */ + public void setTemporalCouplings(final List couplings) { + temporalCouplings = new ArrayList<>(couplings); + } + @Override public boolean equals(final Object o) { if (this == o) { diff --git a/src/main/java/io/jenkins/plugins/forensics/miner/RepositoryStatisticsXmlStream.java b/src/main/java/io/jenkins/plugins/forensics/miner/RepositoryStatisticsXmlStream.java index f5f662e5..ff7e739e 100644 --- a/src/main/java/io/jenkins/plugins/forensics/miner/RepositoryStatisticsXmlStream.java +++ b/src/main/java/io/jenkins/plugins/forensics/miner/RepositoryStatisticsXmlStream.java @@ -27,5 +27,6 @@ protected void configureXStream(final XStream2 xStream) { xStream.alias("diff", CommitDiffItem.class); xStream.alias("repo", RepositoryStatistics.class); xStream.alias("file", FileStatistics.class); + xStream.alias("coupling", TemporalCoupling.class); } } diff --git a/src/main/java/io/jenkins/plugins/forensics/miner/TemporalCoupling.java b/src/main/java/io/jenkins/plugins/forensics/miner/TemporalCoupling.java new file mode 100644 index 00000000..cab58401 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/forensics/miner/TemporalCoupling.java @@ -0,0 +1,145 @@ +package io.jenkins.plugins.forensics.miner; + +import edu.hm.hafner.util.Generated; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; +import java.util.StringJoiner; + +/** + * Stores the temporal coupling of a pair of repository files. Two files are temporally coupled if they are frequently + * modified together within the same commit: such a coupling reveals a hidden dependency between these files that is + * not visible in the source code itself. See "Your Code as a Crime Scene" by Adam Tornhill, page 72, for details. + * + *

    + * This model is independent of the actual SCM implementation, so every SCM plugin can compute and store these + * couplings in the {@link RepositoryStatistics} of a build. + *

    + * + * @author Akash Manna + */ +public final class TemporalCoupling implements Serializable { + @Serial + private static final long serialVersionUID = 1L; // since 1.9.0 + + private static final double PERCENTAGE_FACTOR = 100.0; + private static final double ROUNDING_FACTOR = 10.0; + + private final String leftFile; + private final String rightFile; + private final int coChanges; + private final double couplingRatio; + + /** + * Creates a new instance of {@link TemporalCoupling}. + * + * @param leftFile + * the absolute path of the first file of this coupling + * @param rightFile + * the absolute path of the second file of this coupling + * @param coChanges + * the number of commits that changed both files + * @param couplingRatio + * the strength of the coupling in the interval {@code [0.0, 1.0]}: it is defined as the number of shared + * commits divided by the smaller number of total commits of both files + */ + public TemporalCoupling(final String leftFile, final String rightFile, final int coChanges, + final double couplingRatio) { + this.leftFile = leftFile; + this.rightFile = rightFile; + this.coChanges = coChanges; + this.couplingRatio = couplingRatio; + } + + /** + * Returns the absolute path of the first file of this coupling. + * + * @return the path of the first file + */ + public String getLeftFile() { + return leftFile; + } + + /** + * Returns the absolute path of the second file of this coupling. + * + * @return the path of the second file + */ + public String getRightFile() { + return rightFile; + } + + /** + * Returns the number of commits that changed both files of this coupling. + * + * @return the number of shared commits + */ + public int getCoChanges() { + return coChanges; + } + + /** + * Returns the strength of this coupling in the interval {@code [0.0, 1.0]}. A value of {@code 1.0} means that both + * files have always been changed together. + * + * @return the coupling ratio + */ + public double getCouplingRatio() { + return couplingRatio; + } + + /** + * Returns the strength of this coupling in the interval {@code [0.0, 100.0]}, rounded to one decimal place. + * + * @return the coupling ratio as percentage + */ + public double getCouplingPercentage() { + return Math.round(couplingRatio * PERCENTAGE_FACTOR * ROUNDING_FACTOR) / ROUNDING_FACTOR; + } + + /** + * Returns whether the specified file is part of this coupling. + * + * @param fileName + * the absolute path of the file to check + * + * @return {@code true} if the file is the left or right file of this coupling, {@code false} otherwise + */ + public boolean contains(final String fileName) { + return Objects.equals(leftFile, fileName) || Objects.equals(rightFile, fileName); + } + + @Override + @Generated + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + var that = (TemporalCoupling) o; + return coChanges == that.coChanges + && Double.compare(couplingRatio, that.couplingRatio) == 0 + && Objects.equals(leftFile, that.leftFile) + && Objects.equals(rightFile, that.rightFile); + } + + @Override + @Generated + public int hashCode() { + return Objects.hash(leftFile, rightFile, coChanges, couplingRatio); + } + + @Override + @Generated + public String toString() { + return new StringJoiner(", ", TemporalCoupling.class.getSimpleName() + "[", "]") + .add("leftFile=" + leftFile) + .add("rightFile=" + rightFile) + .add("coChanges=" + coChanges) + .add("couplingRatio=" + couplingRatio) + .toString(); + } +} diff --git a/src/main/java/io/jenkins/plugins/forensics/miner/TemporalCouplingTableModel.java b/src/main/java/io/jenkins/plugins/forensics/miner/TemporalCouplingTableModel.java new file mode 100644 index 00000000..7156365c --- /dev/null +++ b/src/main/java/io/jenkins/plugins/forensics/miner/TemporalCouplingTableModel.java @@ -0,0 +1,130 @@ +package io.jenkins.plugins.forensics.miner; + +import org.apache.commons.io.FilenameUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import io.jenkins.plugins.datatables.DetailedCell; +import io.jenkins.plugins.datatables.TableColumn; +import io.jenkins.plugins.datatables.TableColumn.ColumnBuilder; +import io.jenkins.plugins.datatables.TableColumn.ColumnCss; +import io.jenkins.plugins.datatables.TableColumn.ColumnType; +import io.jenkins.plugins.datatables.TableModel; + +import static j2html.TagCreator.*; + +/** + * Provides the dynamic model for the details table that shows the temporal couplings of all repository files. + * + *

    + * This temporal coupling model consists of the following columns: + *

    + *
      + *
    • name of the first file of the coupling
    • + *
    • name of the second file of the coupling
    • + *
    • number of commits that changed both files
    • + *
    • strength of the coupling in percent
    • + *
    + * + * @author Akash Manna + */ +public class TemporalCouplingTableModel extends TableModel { + static final String TEMPORAL_COUPLING_ID = "temporal-coupling"; + + private final List temporalCouplings; + + TemporalCouplingTableModel(final List temporalCouplings) { + super(); + + this.temporalCouplings = List.copyOf(temporalCouplings); + } + + @Override + public String getId() { + return TEMPORAL_COUPLING_ID; + } + + @Override + public List getColumns() { + List columns = new ArrayList<>(); + + var builder = new ColumnBuilder(); + + columns.add(builder.withHeaderLabel(Messages.Table_Column_File()) + .withDetailedCell() + .withDataPropertyKey("leftFile") + .withHeaderClass(ColumnCss.NONE) + .build()); + columns.add(builder.withHeaderLabel(Messages.Table_Column_CoupledFile()) + .withDetailedCell() + .withDataPropertyKey("rightFile") + .withHeaderClass(ColumnCss.NONE) + .build()); + columns.add(builder.withHeaderLabel(Messages.Table_Column_CoChanges()) + .withPlainValueCell() + .withDataPropertyKey("coChanges") + .withType(ColumnType.NUMBER) + .build()); + columns.add(builder.withHeaderLabel(Messages.Table_Column_CouplingPercentage()) + .withDataPropertyKey("couplingPercentage") + .withType(ColumnType.NUMBER) + .build()); + + return columns; + } + + @Override + public List getRows() { + return temporalCouplings.stream().map(TemporalCouplingRow::new).collect(Collectors.toList()); + } + + /** + * A table row that shows the temporal coupling of a pair of files. + */ + public static class TemporalCouplingRow { + private final TemporalCoupling temporalCoupling; + + TemporalCouplingRow(final TemporalCoupling temporalCoupling) { + this.temporalCoupling = temporalCoupling; + } + + /** + * Shows the first file of this coupling: the column shows the name without the path. The full path is shown as + * an additional tooltip. + * + * @return the file name column (as HTML span tag) + */ + public DetailedCell getLeftFile() { + return createFileCell(temporalCoupling.getLeftFile()); + } + + /** + * Shows the second file of this coupling: the column shows the name without the path. The full path is shown + * as an additional tooltip. + * + * @return the file name column (as HTML span tag) + */ + public DetailedCell getRightFile() { + return createFileCell(temporalCoupling.getRightFile()); + } + + private DetailedCell createFileCell(final String fullPath) { + var fileName = FilenameUtils.getName(fullPath); + var cell = span().withText(fileName) + .attr("data-bs-toggle", "tooltip") + .attr("data-bs-placement", "left") + .attr("title", fullPath).render(); + return new DetailedCell<>(cell, fileName); + } + + public int getCoChanges() { + return temporalCoupling.getCoChanges(); + } + + public double getCouplingPercentage() { + return temporalCoupling.getCouplingPercentage(); + } + } +} diff --git a/src/main/java/io/jenkins/plugins/forensics/miner/TemporalCouplingViewModel.java b/src/main/java/io/jenkins/plugins/forensics/miner/TemporalCouplingViewModel.java new file mode 100644 index 00000000..fd0e9bc8 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/forensics/miner/TemporalCouplingViewModel.java @@ -0,0 +1,55 @@ +package io.jenkins.plugins.forensics.miner; + +import hudson.model.ModelObject; +import hudson.model.Run; + +import io.jenkins.plugins.datatables.DefaultAsyncTableContentProvider; + +/** + * Server side model that provides the data for the details view of the temporal couplings. The layout of the associated + * view is defined in the corresponding jelly view 'index.jelly' in the {@link TemporalCouplingViewModel} package. + * + * @author Akash Manna + */ +public class TemporalCouplingViewModel extends DefaultAsyncTableContentProvider implements ModelObject { + private final Run owner; + private final RepositoryStatistics repositoryStatistics; + + /** + * Creates a new {@link TemporalCouplingViewModel} instance. + * + * @param owner + * the build as owner of this view + * @param repositoryStatistics + * the statistics that contain the temporal couplings to show in the view + */ + TemporalCouplingViewModel(final Run owner, final RepositoryStatistics repositoryStatistics) { + super(); + + this.owner = owner; + this.repositoryStatistics = repositoryStatistics; + } + + public Run getOwner() { + return owner; + } + + @Override + public String getDisplayName() { + return Messages.TemporalCoupling_Action(); + } + + /** + * Returns the number of temporal couplings that are shown in this view. + * + * @return the number of couplings + */ + public int getNumberOfCouplings() { + return repositoryStatistics.getTemporalCouplings().size(); + } + + @Override + public TemporalCouplingTableModel getTableModel(final String id) { + return new TemporalCouplingTableModel(repositoryStatistics.getTemporalCouplings()); + } +} diff --git a/src/main/resources/io/jenkins/plugins/forensics/miner/ForensicsViewModel/index.jelly b/src/main/resources/io/jenkins/plugins/forensics/miner/ForensicsViewModel/index.jelly index 61c38c40..dc273afc 100644 --- a/src/main/resources/io/jenkins/plugins/forensics/miner/ForensicsViewModel/index.jelly +++ b/src/main/resources/io/jenkins/plugins/forensics/miner/ForensicsViewModel/index.jelly @@ -29,6 +29,18 @@ + + + +
    diff --git a/src/main/resources/io/jenkins/plugins/forensics/miner/Messages.properties b/src/main/resources/io/jenkins/plugins/forensics/miner/Messages.properties index f75b46a7..db35d496 100644 --- a/src/main/resources/io/jenkins/plugins/forensics/miner/Messages.properties +++ b/src/main/resources/io/jenkins/plugins/forensics/miner/Messages.properties @@ -9,6 +9,10 @@ Table.Column.CommitId=Commit Table.Column.AddedLines=Added Lines Table.Column.DeletedLines=Deleted Lines Table.Column.Author=Author +Table.Column.MaxCoupling=Max. Coupling +Table.Column.CoupledFile=Coupled File +Table.Column.CoChanges=#Shared Commits +Table.Column.CouplingPercentage=Coupling TrendChart.Files.Legend.Label=#Files TrendChart.Loc.Legend.Label=#Lines Of Code @@ -20,6 +24,7 @@ TrendChart.Churn.Legend.Deleted=Deleted Forensics.Action=SCM Forensics ForensicsView.Title=SCM Forensics of ''{0}'' FileView.Title=Details of {0} +TemporalCoupling.Action=Temporal Coupling Step.Name=Mine SCM repository TrendChart.Added.Legend.Label=Added lines TrendChart.Deleted.Legend.Label=Deleted lines diff --git a/src/main/resources/io/jenkins/plugins/forensics/miner/TemporalCouplingViewModel/index.jelly b/src/main/resources/io/jenkins/plugins/forensics/miner/TemporalCouplingViewModel/index.jelly new file mode 100644 index 00000000..ec93b489 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/forensics/miner/TemporalCouplingViewModel/index.jelly @@ -0,0 +1,27 @@ + + + + + + + + + + +

    ${%Number of coupled files}: ${it.numberOfCouplings}

    + +
    + +
    +
    + + + +
    +
    + +
    + +
    + +
    diff --git a/src/test/java/io/jenkins/plugins/forensics/miner/ForensicsTableModelTest.java b/src/test/java/io/jenkins/plugins/forensics/miner/ForensicsTableModelTest.java index 5171e487..698cebef 100644 --- a/src/test/java/io/jenkins/plugins/forensics/miner/ForensicsTableModelTest.java +++ b/src/test/java/io/jenkins/plugins/forensics/miner/ForensicsTableModelTest.java @@ -2,8 +2,14 @@ import org.junit.jupiter.api.Test; +import edu.hm.hafner.util.TreeString; +import edu.hm.hafner.util.TreeStringBuilder; + +import java.util.List; + import io.jenkins.plugins.datatables.DetailedCell; import io.jenkins.plugins.datatables.TableColumn; +import io.jenkins.plugins.forensics.miner.FileStatistics.FileStatisticsBuilder; import io.jenkins.plugins.forensics.miner.ForensicsTableModel.ForensicsRow; import static io.jenkins.plugins.forensics.assertions.Assertions.*; @@ -11,6 +17,12 @@ import static org.mockito.Mockito.*; class ForensicsTableModelTest { + private static final String FILE = "file"; + private static final String OTHER_FILE = "other-file"; + private static final String UNCOUPLED_FILE = "uncoupled-file"; + private static final TreeString FILE_TREE_STRING = new TreeStringBuilder().intern(FILE); + private static final int ONE_DAY = 60 * 60 * 24; + @Test void shouldCreateForensicsTableModel() { var statistics = new RepositoryStatistics(); @@ -19,7 +31,7 @@ void shouldCreateForensicsTableModel() { assertThat(tableModel).isNotNull(); assertThat(tableModel).hasId(ForensicsJobAction.FORENSICS_ID); assertThat(tableModel.getColumns()) - .hasSize(7) + .hasSize(8) .extracting(TableColumn::getHeaderLabel) .containsExactly( Messages.Table_Column_File(), @@ -28,7 +40,8 @@ void shouldCreateForensicsTableModel() { Messages.Table_Column_LastCommit(), Messages.Table_Column_AddedAt(), Messages.Table_Column_LOC(), - Messages.Table_Column_Churn() + Messages.Table_Column_Churn(), + Messages.Table_Column_MaxCoupling() ); assertThatJson(tableModel.getColumns().get(0).getDefinition()).node("render") .isEqualTo(""" @@ -50,21 +63,45 @@ void shouldReturnRows() { var actual = tableModel.getRows().get(0); assertThat(actual).isInstanceOf(ForensicsRow.class); - assertThat((ForensicsRow) actual).hasAuthorsSize(0); + assertThat((ForensicsRow) actual).hasAuthorsSize(1); + } + + @Test + void shouldShowNoCouplingIfNoCouplingsHaveBeenMined() { + var statistics = new RepositoryStatistics(); + statistics.add(createFileStatistics()); + + var tableModel = new ForensicsTableModel(statistics); + + assertThat((ForensicsRow) tableModel.getRows().get(0)).hasMaxCoupling(0); + } + + @Test + void shouldShowTheStrongestCouplingOfAFile() { + var statistics = new RepositoryStatistics(); + statistics.add(createFileStatistics()); + statistics.setTemporalCouplings(List.of( + new TemporalCoupling(FILE, OTHER_FILE, 3, 0.25), + new TemporalCoupling(UNCOUPLED_FILE, FILE, 9, 0.8), + new TemporalCoupling(OTHER_FILE, UNCOUPLED_FILE, 20, 1.0))); + + var tableModel = new ForensicsTableModel(statistics); + + assertThat((ForensicsRow) tableModel.getRows().get(0)).hasMaxCoupling(80.0); } private FileStatistics createFileStatistics() { - FileStatistics fileStatistics = mock(FileStatistics.class); - CommitDiffItem commitDiffItem = mock(CommitDiffItem.class); - when(commitDiffItem.getTotalAddedLines()).thenReturn(1); - fileStatistics.inspectCommit(commitDiffItem); + var fileStatistics = new FileStatisticsBuilder().build(FILE); + fileStatistics.inspectCommit(new CommitDiffItem("1", "one", ONE_DAY) + .addLines(1) + .setNewPath(FILE_TREE_STRING)); return fileStatistics; } @Test void checkForensicsRowGetters() { FileStatistics fileStatisticsStub = mock(FileStatistics.class); - var forensicsRow = new ForensicsRow(fileStatisticsStub); + var forensicsRow = new ForensicsRow(fileStatisticsStub, 7.5); when(fileStatisticsStub.getFileName()).thenReturn("filename"); when(fileStatisticsStub.getNumberOfAuthors()).thenReturn(1); @@ -81,7 +118,8 @@ void checkForensicsRowGetters() { .hasModifiedAt(3) .hasAddedAt(4) .hasLinesOfCode(5) - .hasChurn(6); + .hasChurn(6) + .hasMaxCoupling(7.5); assertThat(forensicsRow.getFileName()).isInstanceOfSatisfying(DetailedCell.class, cell -> { assertThat(cell.getDisplay()).isEqualTo(fileName); diff --git a/src/test/java/io/jenkins/plugins/forensics/miner/RepositoryStatisticsTest.java b/src/test/java/io/jenkins/plugins/forensics/miner/RepositoryStatisticsTest.java index 61d72ffb..5e121d0c 100644 --- a/src/test/java/io/jenkins/plugins/forensics/miner/RepositoryStatisticsTest.java +++ b/src/test/java/io/jenkins/plugins/forensics/miner/RepositoryStatisticsTest.java @@ -6,7 +6,9 @@ import edu.hm.hafner.util.TreeString; import edu.hm.hafner.util.TreeStringBuilder; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.NoSuchElementException; import java.util.Set; @@ -22,6 +24,7 @@ class RepositoryStatisticsTest { private static final String NOTHING = "nothing"; private static final String FILE = "file"; + private static final String OTHER_FILE = "other-file"; private static final TreeString FILE_TREE_STRING = new TreeStringBuilder().intern(FILE); private static final int ONE_DAY = 60 * 60 * 24; @@ -33,6 +36,7 @@ void shouldCreateEmptyInstance() { assertThat(empty).isEmpty() .hasNoFiles() .hasNoFileStatistics() + .hasNoTemporalCouplings() .hasLatestCommitId(StringUtils.EMPTY) .hasTotalLinesOfCode(0) .hasTotalChurn(0); @@ -90,4 +94,61 @@ private CommitDiffItem createCommit() { .addLines(3) .setNewPath(FILE_TREE_STRING); } + + @Test + void shouldHaveNoTemporalCouplingsByDefault() { + var statistics = new RepositoryStatistics(); + + assertThat(statistics).hasNoTemporalCouplings(); + assertThat(statistics.getTemporalCouplings()).isEmpty(); + } + + @Test + void shouldStoreTemporalCouplings() { + var statistics = new RepositoryStatistics(); + + var first = new TemporalCoupling(FILE, OTHER_FILE, 5, 0.5); + var second = new TemporalCoupling(OTHER_FILE, NOTHING, 2, 0.25); + statistics.setTemporalCouplings(List.of(first, second)); + + assertThat(statistics).hasTemporalCouplings(first, second); + assertThat(statistics.getTemporalCouplings()).containsExactly(first, second); + } + + @Test + void shouldReplaceExistingTemporalCouplings() { + var statistics = new RepositoryStatistics(); + + var first = new TemporalCoupling(FILE, OTHER_FILE, 5, 0.5); + statistics.setTemporalCouplings(List.of(first)); + + var second = new TemporalCoupling(OTHER_FILE, NOTHING, 2, 0.25); + statistics.setTemporalCouplings(List.of(second)); + + assertThat(statistics).hasTemporalCouplings(second); + } + + @Test + void shouldNotReflectChangesOfTheSourceListInTheStoredTemporalCouplings() { + var statistics = new RepositoryStatistics(); + + var couplings = new ArrayList(); + couplings.add(new TemporalCoupling(FILE, OTHER_FILE, 5, 0.5)); + statistics.setTemporalCouplings(couplings); + + couplings.add(new TemporalCoupling(OTHER_FILE, NOTHING, 2, 0.25)); + + assertThat(statistics.getTemporalCouplings()).hasSize(1); + } + + @Test + void shouldNotAllowModificationsOfTheReturnedTemporalCouplings() { + var statistics = new RepositoryStatistics(); + statistics.setTemporalCouplings(List.of(new TemporalCoupling(FILE, OTHER_FILE, 5, 0.5))); + + var couplings = statistics.getTemporalCouplings(); + + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> couplings.add(new TemporalCoupling(OTHER_FILE, NOTHING, 2, 0.25))); + } } diff --git a/src/test/java/io/jenkins/plugins/forensics/miner/TemporalCouplingTableModelTest.java b/src/test/java/io/jenkins/plugins/forensics/miner/TemporalCouplingTableModelTest.java new file mode 100644 index 00000000..2debd747 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/forensics/miner/TemporalCouplingTableModelTest.java @@ -0,0 +1,106 @@ +package io.jenkins.plugins.forensics.miner; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import io.jenkins.plugins.datatables.DetailedCell; +import io.jenkins.plugins.datatables.TableColumn; +import io.jenkins.plugins.forensics.miner.TemporalCouplingTableModel.TemporalCouplingRow; + +import static io.jenkins.plugins.forensics.assertions.Assertions.*; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.*; + +/** + * Tests the class {@link TemporalCouplingTableModel}. + * + * @author Akash Manna + */ +class TemporalCouplingTableModelTest { + private static final String LEFT_FILE_NAME = "Left.java"; + private static final String RIGHT_FILE_NAME = "Right.java"; + private static final String LEFT_FILE = "src/main/java/" + LEFT_FILE_NAME; + private static final String RIGHT_FILE = "src/main/java/" + RIGHT_FILE_NAME; + private static final int CO_CHANGES = 12; + private static final double COUPLING_RATIO = 0.75; + + @Test + void shouldCreateTemporalCouplingTableModel() { + var tableModel = new TemporalCouplingTableModel(List.of()); + + assertThat(tableModel).isNotNull(); + assertThat(tableModel).hasId(TemporalCouplingTableModel.TEMPORAL_COUPLING_ID); + assertThat(tableModel.getColumns()) + .hasSize(4) + .extracting(TableColumn::getHeaderLabel) + .containsExactly( + Messages.Table_Column_File(), + Messages.Table_Column_CoupledFile(), + Messages.Table_Column_CoChanges(), + Messages.Table_Column_CouplingPercentage() + ); + assertThatJson(tableModel.getColumns().get(0).getDefinition()).node("render") + .isEqualTo(""" + { + "_" : "display", + "sort": "sort" + } + """); + assertThatJson(tableModel.getColumns().get(1).getDefinition()).node("render") + .isEqualTo(""" + { + "_" : "display", + "sort": "sort" + } + """); + assertThatJson(tableModel.getColumns().get(2).getDefinition()).node("render").isAbsent(); + assertThatJson(tableModel.getColumns().get(3).getDefinition()).node("render").isAbsent(); + } + + @Test + void shouldHaveNoRowsForEmptyCouplings() { + var tableModel = new TemporalCouplingTableModel(List.of()); + + assertThat(tableModel).hasNoRows(); + } + + @Test + void shouldReturnRows() { + var tableModel = new TemporalCouplingTableModel(List.of(createCoupling())); + + assertThat(tableModel.getRows()).hasSize(1); + + var actual = tableModel.getRows().get(0); + assertThat(actual).isInstanceOf(TemporalCouplingRow.class); + assertThat((TemporalCouplingRow) actual) + .hasCoChanges(CO_CHANGES) + .hasCouplingPercentage(75.0); + } + + @Test + void shouldShowFileNamesWithFullPathAsTooltip() { + var row = new TemporalCouplingRow(createCoupling()); + var expectedLeftCell = createCellHtml(LEFT_FILE_NAME, LEFT_FILE); + var expectedRightCell = createCellHtml(RIGHT_FILE_NAME, RIGHT_FILE); + + assertThat(row.getLeftFile()).isInstanceOfSatisfying(DetailedCell.class, + cell -> { + assertThat(cell.getDisplay()).isEqualTo(expectedLeftCell); + assertThat(cell.getSort()).isEqualTo(LEFT_FILE_NAME); + }); + assertThat(row.getRightFile()).isInstanceOfSatisfying(DetailedCell.class, + cell -> { + assertThat(cell.getDisplay()).isEqualTo(expectedRightCell); + assertThat(cell.getSort()).isEqualTo(RIGHT_FILE_NAME); + }); + } + + private String createCellHtml(final String fileName, final String fullPath) { + return "%s" + .formatted(fullPath, fileName); + } + + private TemporalCoupling createCoupling() { + return new TemporalCoupling(LEFT_FILE, RIGHT_FILE, CO_CHANGES, COUPLING_RATIO); + } +} diff --git a/src/test/java/io/jenkins/plugins/forensics/miner/TemporalCouplingTest.java b/src/test/java/io/jenkins/plugins/forensics/miner/TemporalCouplingTest.java new file mode 100644 index 00000000..34b729a5 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/forensics/miner/TemporalCouplingTest.java @@ -0,0 +1,69 @@ +package io.jenkins.plugins.forensics.miner; + +import org.junit.jupiter.api.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +import static io.jenkins.plugins.forensics.assertions.Assertions.*; + +/** + * Tests the class {@link TemporalCoupling}. + * + * @author Akash Manna + */ +class TemporalCouplingTest { + private static final String LEFT_FILE = "src/main/java/Left.java"; + private static final String RIGHT_FILE = "src/main/java/Right.java"; + private static final int CO_CHANGES = 12; + private static final double COUPLING_RATIO = 0.75; + + @Test + void shouldCreateTemporalCoupling() { + var coupling = createCoupling(); + + assertThat(coupling) + .hasLeftFile(LEFT_FILE) + .hasRightFile(RIGHT_FILE) + .hasCoChanges(CO_CHANGES) + .hasCouplingRatio(COUPLING_RATIO) + .hasCouplingPercentage(75.0); + } + + @Test + void shouldRoundCouplingPercentageToOneDecimalPlace() { + var coupling = new TemporalCoupling(LEFT_FILE, RIGHT_FILE, 2, 2.0 / 3.0); + + assertThat(coupling).hasCouplingPercentage(66.7); + } + + @Test + void shouldHandleTheBoundsOfTheCouplingRatio() { + assertThat(new TemporalCoupling(LEFT_FILE, RIGHT_FILE, 0, 0)).hasCouplingPercentage(0); + assertThat(new TemporalCoupling(LEFT_FILE, RIGHT_FILE, CO_CHANGES, 1)).hasCouplingPercentage(100.0); + } + + @Test + void shouldFindParticipatingFiles() { + var coupling = createCoupling(); + + assertThat(coupling.contains(LEFT_FILE)).isTrue(); + assertThat(coupling.contains(RIGHT_FILE)).isTrue(); + assertThat(coupling.contains("src/main/java/Other.java")).isFalse(); + } + + @Test + void shouldObeyEqualsContract() { + EqualsVerifier.simple().forClass(TemporalCoupling.class).verify(); + } + + @Test + void shouldProvideToString() { + assertThat(createCoupling()).hasToString( + "TemporalCoupling[leftFile=%s, rightFile=%s, coChanges=%d, couplingRatio=%s]".formatted( + LEFT_FILE, RIGHT_FILE, CO_CHANGES, COUPLING_RATIO)); + } + + private TemporalCoupling createCoupling() { + return new TemporalCoupling(LEFT_FILE, RIGHT_FILE, CO_CHANGES, COUPLING_RATIO); + } +}