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);
+ }
+}