Skip to content

Commit 24d2b03

Browse files
committed
Add tests for tournaments to ensure correct contestant calculation
1 parent 7280463 commit 24d2b03

8 files changed

Lines changed: 279 additions & 9 deletions

File tree

build.gradle.kts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,32 @@ allprojects {
1212
version = "4.0.3-SNAPSHOT"
1313

1414
repositories {
15+
mavenCentral()
16+
17+
// Spigot
18+
maven("https://hub.spigotmc.org/nexus/content/groups/public/")
19+
20+
// Paper, Velocity
1521
maven("https://repo.papermc.io/repository/maven-public")
22+
23+
// WorldEdit
24+
maven("https://maven.enginehub.org/repo/")
1625
}
1726

1827
java {
1928
toolchain {
2029
languageVersion.set(JavaLanguageVersion.of(17))
2130
}
2231
}
32+
33+
dependencies {
34+
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
35+
testImplementation(platform("org.junit:junit-bom:5.10.2"))
36+
testImplementation("org.junit.jupiter:junit-jupiter")
37+
testImplementation("org.mockito:mockito-core:5.+")
38+
}
39+
40+
tasks.test {
41+
useJUnitPlatform()
42+
}
2343
}

module/build.gradle.kts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
subprojects {
22
dependencies {
33
compileOnlyApi(project(":plugin"))
4+
5+
testRuntimeOnly(rootProject.libs.paper.api)
6+
testRuntimeOnly(project(":plugin"))
47
}
58

69
tasks.jar {
@@ -11,4 +14,4 @@ subprojects {
1114
archiveFileName.set("${project.name}.jar")
1215
archiveClassifier.set("")
1316
}
14-
}
17+
}

module/tournaments/src/main/java/org/battleplugins/arena/module/tournaments/Tournament.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import org.bukkit.Bukkit;
1818
import org.bukkit.entity.Player;
1919
import org.jetbrains.annotations.Nullable;
20+
import org.jetbrains.annotations.VisibleForTesting;
2021

2122
import java.time.Duration;
2223
import java.util.ArrayList;
@@ -455,7 +456,8 @@ public static Tournament createTournament(Tournaments tournaments, Arena arena)
455456
return new Tournament(tournaments, arena, teamSize.getMin() == Integer.MAX_VALUE ? teamSize.getMin() : teamSize.getMax(), requiredPlayers, requiredContestants);
456457
}
457458

458-
private static List<Contestant> calculateContestants(Set<Player> queuedPlayers, int maxContestantSize, int requiredContestantsPerRound) {
459+
@VisibleForTesting
460+
public static List<Contestant> calculateContestants(Set<Player> queuedPlayers, int maxContestantSize, int requiredContestantsPerRound) {
459461
List<Player> playersList = new ArrayList<>(queuedPlayers);
460462

461463
int safeRequiredContestants = Math.max(1, requiredContestantsPerRound);
@@ -495,15 +497,16 @@ private static List<Contestant> calculateContestants(Set<Player> queuedPlayers,
495497
// we don't have a situation where one contestant has 1 player and
496498
// the rest have many more, only if the number of contestants is not
497499
// multiple of 2^k
498-
if (contestants.size() == 1 || Integer.bitCount(contestants.size()) == 1) {
500+
if (contestants.isEmpty() || contestants.size() == 1 || Integer.bitCount(contestants.size()) == 1) {
499501
return contestants;
500502
}
501503

502504
int minPlayersPerContestant = totalPlayers / contestants.size();
503505
int remainingPlayers = totalPlayers % contestants.size();
506+
int cursor = 0;
504507
for (int i = 0; i < contestants.size(); i++) {
505508
Contestant contestant = contestants.get(i);
506-
int start = i * minPlayersPerContestant;
509+
int start = cursor;
507510
int end = start + minPlayersPerContestant;
508511
if (i < remainingPlayers) {
509512
end++;
@@ -512,6 +515,7 @@ private static List<Contestant> calculateContestants(Set<Player> queuedPlayers,
512515
contestant.clearPlayers();
513516
List<Player> players = playersList.subList(start, end);
514517
players.forEach(contestant::addPlayer);
518+
cursor = end;
515519
}
516520

517521
return contestants;

module/tournaments/src/main/resources/tournament-config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@ advance-time: 10s
1919
# The commands to run when a player wins a tournament. The argument
2020
# %player_name% will be replaced with the player's name. The commands are run
2121
# in the console. These are executed individually for each player, so be careful
22-
# with commands that affect multiple players (like message broadcasts.
22+
# with commands that affect multiple players (like message broadcasts).
2323
commands-on-win:
2424
- "give %player_name% diamond 16"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package org.battleplugins.arena.module.tournaments.algorithm;
2+
3+
import org.battleplugins.arena.module.tournaments.Contestant;
4+
import org.battleplugins.arena.module.tournaments.ContestantPair;
5+
import org.bukkit.entity.Player;
6+
import org.junit.jupiter.api.Test;
7+
8+
import java.util.ArrayList;
9+
import java.util.HashSet;
10+
import java.util.List;
11+
import java.util.Set;
12+
13+
import static org.junit.jupiter.api.Assertions.assertEquals;
14+
import static org.junit.jupiter.api.Assertions.assertFalse;
15+
import static org.junit.jupiter.api.Assertions.assertTrue;
16+
import static org.mockito.Mockito.mock;
17+
18+
class SingleEliminationTournamentCalculatorTest {
19+
20+
private final SingleEliminationTournamentCalculator calculator = new SingleEliminationTournamentCalculator();
21+
22+
@Test
23+
void advanceRoundReturnsCompleteWhenContestantsAreZeroOrOne() {
24+
TournamentCalculator.MatchResult noContestants = this.calculator.advanceRound(List.of());
25+
assertTrue(noContestants.complete());
26+
assertTrue(noContestants.contestantPairs().isEmpty());
27+
28+
TournamentCalculator.MatchResult oneContestant = this.calculator.advanceRound(List.of(createContestant(1, 0)));
29+
assertTrue(oneContestant.complete());
30+
assertTrue(oneContestant.contestantPairs().isEmpty());
31+
}
32+
33+
@Test
34+
void advanceRoundPairsByByesThenTeamSizeAndAssignsByeToLast() {
35+
Contestant c1 = createContestant(1, 2);
36+
Contestant c2 = createContestant(3, 1);
37+
Contestant c3 = createContestant(1, 1);
38+
Contestant c4 = createContestant(4, 0);
39+
Contestant c5 = createContestant(2, 0);
40+
41+
TournamentCalculator.MatchResult result = this.calculator.advanceRound(List.of(c1, c2, c3, c4, c5));
42+
43+
assertFalse(result.complete());
44+
assertEquals(3, result.contestantPairs().size());
45+
46+
ContestantPair pair1 = result.contestantPairs().get(0);
47+
ContestantPair pair2 = result.contestantPairs().get(1);
48+
ContestantPair pair3 = result.contestantPairs().get(2);
49+
50+
assertEquals(c1, pair1.contestant1());
51+
assertEquals(c2, pair1.contestant2());
52+
53+
assertEquals(c3, pair2.contestant1());
54+
assertEquals(c4, pair2.contestant2());
55+
56+
assertEquals(c5, pair3.contestant1());
57+
assertTrue(pair3.autoAdvance());
58+
}
59+
60+
@Test
61+
void advanceRoundDoesNotMutateInputOrder() {
62+
Contestant c1 = createContestant(1, 0);
63+
Contestant c2 = createContestant(2, 3);
64+
Contestant c3 = createContestant(3, 1);
65+
66+
List<Contestant> input = new ArrayList<>(List.of(c1, c2, c3));
67+
List<Contestant> expectedOrder = List.copyOf(input);
68+
69+
this.calculator.advanceRound(input);
70+
71+
assertEquals(expectedOrder, input);
72+
}
73+
74+
@Test
75+
void advanceRoundGivesByeToContestantWithFewestExistingByes() {
76+
Contestant highByes = createContestant(2, 4);
77+
Contestant mediumByes = createContestant(2, 2);
78+
Contestant lowByes = createContestant(2, 0);
79+
80+
TournamentCalculator.MatchResult result = this.calculator.advanceRound(List.of(highByes, mediumByes, lowByes));
81+
82+
assertEquals(2, result.contestantPairs().size());
83+
ContestantPair autoAdvancePair = result.contestantPairs().stream().filter(ContestantPair::autoAdvance).findFirst().orElseThrow();
84+
assertEquals(lowByes, autoAdvancePair.contestant1());
85+
}
86+
87+
private static Contestant createContestant(int players, int byes) {
88+
Set<Player> members = createPlaceholderPlayers(players);
89+
90+
Contestant contestant = new Contestant(members);
91+
for (int i = 0; i < byes; i++) {
92+
contestant.addBye();
93+
}
94+
95+
return contestant;
96+
}
97+
98+
private static Set<Player> createPlaceholderPlayers(int count) {
99+
Set<Player> rawPlayers = new HashSet<>();
100+
for (int i = 0; i < count; i++) {
101+
rawPlayers.add(mock(Player.class));
102+
}
103+
return rawPlayers;
104+
}
105+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package org.battleplugins.arena.module.tournaments.algorithm;
2+
3+
import org.battleplugins.arena.module.tournaments.Contestant;
4+
import org.battleplugins.arena.module.tournaments.Tournament;
5+
import org.bukkit.entity.Player;
6+
import org.junit.jupiter.api.Test;
7+
8+
import java.util.Collections;
9+
import java.util.HashSet;
10+
import java.util.IdentityHashMap;
11+
import java.util.List;
12+
import java.util.Set;
13+
14+
import static org.junit.jupiter.api.Assertions.assertEquals;
15+
import static org.junit.jupiter.api.Assertions.assertTrue;
16+
import static org.mockito.Mockito.mock;
17+
18+
class TournamentContestantCalculationTest {
19+
20+
@Test
21+
void calculateContestantsBalancesPlayerCountsForNonPowerOfTwoContestants() throws Exception {
22+
Set<Player> players = createPlaceholderPlayers(10);
23+
24+
List<Contestant> contestants = Tournament.calculateContestants(players, 3, 2);
25+
26+
assertEquals(3, contestants.size());
27+
assertContestantSizes(contestants, List.of(4, 3, 3));
28+
assertAllPlayersAreAssignedExactlyOnce(players, contestants);
29+
}
30+
31+
@Test
32+
void calculateContestantsKeepsNaturalDistributionForPowerOfTwoContestants() throws Exception {
33+
Set<Player> players = createPlaceholderPlayers(5);
34+
35+
List<Contestant> contestants = Tournament.calculateContestants(players, 2, 2);
36+
37+
assertEquals(2, contestants.size());
38+
assertContestantSizes(contestants, List.of(3, 2));
39+
assertAllPlayersAreAssignedExactlyOnce(players, contestants);
40+
}
41+
42+
@Test
43+
void calculateContestantsHandlesVeryHighRequiredContestantsSafely() throws Exception {
44+
Set<Player> players = createPlaceholderPlayers(3);
45+
46+
List<Contestant> contestants = Tournament.calculateContestants(players, 5, 8);
47+
48+
assertEquals(3, contestants.size());
49+
assertContestantSizes(contestants, List.of(1, 1, 1));
50+
assertAllPlayersAreAssignedExactlyOnce(players, contestants);
51+
}
52+
53+
@Test
54+
void calculateContestantsCreatesAdditionalContestantWhenRemainderMeetsRoundRequirement() throws Exception {
55+
Set<Player> players = createPlaceholderPlayers(5);
56+
57+
List<Contestant> contestants = Tournament.calculateContestants(players, 2, 1);
58+
59+
assertEquals(3, contestants.size());
60+
assertContestantSizes(contestants, List.of(2, 2, 1));
61+
assertAllPlayersAreAssignedExactlyOnce(players, contestants);
62+
}
63+
64+
@Test
65+
void calculateContestantsTreatsNonPositiveRequiredContestantsAsOne() {
66+
Set<Player> players = createPlaceholderPlayers(7);
67+
68+
List<Contestant> contestants = Tournament.calculateContestants(players, 3, 0);
69+
70+
assertEquals(3, contestants.size());
71+
assertContestantSizes(contestants, List.of(3, 2, 2));
72+
assertAllPlayersAreAssignedExactlyOnce(players, contestants);
73+
}
74+
75+
@Test
76+
void calculateContestantsWithMaxContestantSizeOneCreatesOneContestantPerPlayer() {
77+
Set<Player> players = createPlaceholderPlayers(6);
78+
79+
List<Contestant> contestants = Tournament.calculateContestants(players, 1, 2);
80+
81+
assertEquals(6, contestants.size());
82+
assertContestantSizes(contestants, List.of(1, 1, 1, 1, 1, 1));
83+
assertAllPlayersAreAssignedExactlyOnce(players, contestants);
84+
}
85+
86+
@Test
87+
void calculateContestantsWithLargeMaxContestantSizeStillTargetsRequiredContestants() {
88+
Set<Player> players = createPlaceholderPlayers(6);
89+
90+
List<Contestant> contestants = Tournament.calculateContestants(players, 100, 2);
91+
92+
assertEquals(2, contestants.size());
93+
assertContestantSizes(contestants, List.of(3, 3));
94+
assertAllPlayersAreAssignedExactlyOnce(players, contestants);
95+
}
96+
97+
@Test
98+
void calculateContestantsReturnsEmptyListForEmptyInput() {
99+
List<Contestant> contestants = Tournament.calculateContestants(Set.of(), 4, 2);
100+
101+
assertTrue(contestants.isEmpty());
102+
}
103+
104+
private static void assertAllPlayersAreAssignedExactlyOnce(Set<Player> expectedPlayers, List<Contestant> contestants) {
105+
Set<Player> expectedByIdentity = Collections.newSetFromMap(new IdentityHashMap<>());
106+
expectedByIdentity.addAll(expectedPlayers);
107+
108+
Set<Player> assignedByIdentity = Collections.newSetFromMap(new IdentityHashMap<>());
109+
int totalAssigned = 0;
110+
for (Contestant contestant : contestants) {
111+
Set<Player> members = contestant.getPlayers();
112+
totalAssigned += members.size();
113+
assignedByIdentity.addAll(members);
114+
}
115+
116+
assertEquals(expectedByIdentity.size(), totalAssigned);
117+
assertEquals(expectedByIdentity.size(), assignedByIdentity.size());
118+
assertTrue(assignedByIdentity.containsAll(expectedByIdentity));
119+
}
120+
121+
private static Set<Player> createPlaceholderPlayers(int count) {
122+
Set<Player> rawPlayers = new HashSet<>();
123+
for (int i = 0; i < count; i++) {
124+
rawPlayers.add(mock(Player.class));
125+
}
126+
return rawPlayers;
127+
}
128+
129+
private static void assertContestantSizes(List<Contestant> contestants, List<Integer> expectedSizes) {
130+
List<Integer> actualSizes = contestants.stream().map(c -> c.getPlayers().size()).sorted((a, b) -> b - a).toList();
131+
List<Integer> sortedExpected = expectedSizes.stream().sorted((a, b) -> b - a).toList();
132+
133+
assertEquals(sortedExpected, actualSizes);
134+
135+
int minSize = actualSizes.get(actualSizes.size() - 1);
136+
int maxSize = actualSizes.get(0);
137+
assertTrue(maxSize - minSize <= 1 || actualSizes.size() == 2);
138+
}
139+
}

plugin/build.gradle.kts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@ val supportedVersions = listOf(
1212
"1.21.7", "1.21.8", "1.21.9", "1.21.10", "26.1.1", "26.1.2"
1313
)
1414

15-
repositories {
16-
maven("https://maven.enginehub.org/repo/")
17-
}
18-
1915
dependencies {
2016
implementation(libs.bstats.bukkit)
2117
compileOnlyApi(libs.paper.api)

settings.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ dependencyResolutionManagement {
1111

1212
// Paper, Velocity
1313
maven("https://repo.papermc.io/repository/maven-public")
14+
15+
// WorldEdit
16+
maven("https://maven.enginehub.org/repo/")
1417
}
1518
}
1619

0 commit comments

Comments
 (0)