diff --git a/build.gradle b/build.gradle index 66621d6..4df536f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'org.jetbrains.kotlin.jvm' version '1.8.10' - id 'io.ktor.plugin' version '2.2.3' + id 'io.ktor.plugin' version '3.0.3' id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.10' id("app.cash.sqldelight") version "2.1.0" } @@ -48,7 +48,7 @@ dependencies { implementation("com.google.firebase:firebase-admin:9.4.3") implementation("app.cash.sqldelight:jdbc-driver:2.1.0") implementation("com.zaxxer:HikariCP:4.0.3") - testImplementation("io.ktor:ktor-server-tests-jvm:$ktor_version") + testImplementation("io.ktor:ktor-server-test-host:$ktor_version") testImplementation("org.jetbrains.kotlin:kotlin-test") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index e71ad48..e23df57 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -ktor_version=3.1.0 +ktor_version=3.0.3 kotlin_version=1.8.10 logback_version=1.4.6 kotlin.code.style=official diff --git a/src/main/kotlin/com/frankegan/scottish_stv/domain/CountVotesUseCase.kt b/src/main/kotlin/com/frankegan/scottish_stv/domain/CountVotesUseCase.kt index fd66639..c29d050 100644 --- a/src/main/kotlin/com/frankegan/scottish_stv/domain/CountVotesUseCase.kt +++ b/src/main/kotlin/com/frankegan/scottish_stv/domain/CountVotesUseCase.kt @@ -107,7 +107,10 @@ private fun countVotes( winners += w } - candidatesToVotes = if (winners.size >= numWinners) { + candidatesToVotes = if ( + //Terminate if we've found enough winners or we've run out of candidates + winners.size >= numWinners || candidatesToVotes.isEmpty() + ) { roundData += voteCountingRound( roundNum = roundNum, numWinners = numWinners, @@ -128,7 +131,7 @@ private fun countVotes( exhausted = exhausted.votes, rounds = roundData, ) - } else if (roundWinners.sumOf { it.surplus(quota) } > 0.0) { + } else if (roundWinners.sumOf { it.surplus(quota) } > 0.0) {//A winner's surplus needs to be distributed roundData += voteCountingRound( roundNum = roundNum, numWinners = numWinners, @@ -143,7 +146,7 @@ private fun countVotes( exhausted, quota, ) - } else { + } else {//A loser needs to be eliminated roundData += voteCountingRound( roundNum = roundNum, numWinners = numWinners, diff --git a/src/main/kotlin/com/frankegan/scottish_stv/plugins/Routing.kt b/src/main/kotlin/com/frankegan/scottish_stv/plugins/Routing.kt index 3a45c7f..74006d0 100644 --- a/src/main/kotlin/com/frankegan/scottish_stv/plugins/Routing.kt +++ b/src/main/kotlin/com/frankegan/scottish_stv/plugins/Routing.kt @@ -145,6 +145,7 @@ fun Application.configureRouting( val voteRequests: List = try { call.receive() } catch (e: ContentTransformationException) { + application.log.error(e) return@post call.respond(HttpStatusCode.BadRequest) } val ballot = Ballot( diff --git a/src/test/kotlin/com/frankegan/scottish_stv/ApplicationTest.kt b/src/test/kotlin/com/frankegan/scottish_stv/ApplicationTest.kt index 0c503c1..afd8324 100644 --- a/src/test/kotlin/com/frankegan/scottish_stv/ApplicationTest.kt +++ b/src/test/kotlin/com/frankegan/scottish_stv/ApplicationTest.kt @@ -1,15 +1,29 @@ package com.frankegan.scottish_stv +import com.frankegan.scottish_stv.data.FirebaseAdmin +import com.frankegan.scottish_stv.data.MockAuthenticationDataSource +import com.frankegan.scottish_stv.data.MockElectionsDataSource +import com.frankegan.scottish_stv.plugins.configureFirebaseAuth +import com.frankegan.scottish_stv.plugins.configureRouting import io.ktor.client.request.* import io.ktor.client.statement.* -import io.ktor.server.testing.* -import kotlin.test.* import io.ktor.http.* +import io.ktor.server.testing.* +import kotlin.test.Test +import kotlin.test.assertEquals class ApplicationTest { @Test fun testRoot() = testApplication { + application { + configureFirebaseAuth(FirebaseAdmin) + configureRouting( + routePrefix = "/api/v1/", + electionsDataSource = MockElectionsDataSource, + authDataSource = MockAuthenticationDataSource + ) + } client.get("/api/v1/").apply { assertEquals(HttpStatusCode.OK, status) assertEquals("Hello, 🌎!", bodyAsText()) diff --git a/src/test/kotlin/com/frankegan/scottish_stv/VoteCountingLogicTest.kt b/src/test/kotlin/com/frankegan/scottish_stv/VoteCountingLogicTest.kt index a1cf230..70b6191 100644 --- a/src/test/kotlin/com/frankegan/scottish_stv/VoteCountingLogicTest.kt +++ b/src/test/kotlin/com/frankegan/scottish_stv/VoteCountingLogicTest.kt @@ -12,6 +12,8 @@ import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue /** * A collection of tests focused on the vote counting logic. Backed by a curated csv file. @@ -31,7 +33,10 @@ class VoteCountingLogicTest { @Test fun test_abc_1() = runTest { - val (winners, exhaustedVotes) = countVotes(MockElectionsDataSource.abc, numWinners = 1).getOrThrow().voteCountingResponse!! + val (winners, exhaustedVotes) = countVotes( + MockElectionsDataSource.abc, + numWinners = 1 + ).getOrThrow().voteCountingResponse!! assertEquals(1, winners.size) assertEquals(CandidateId(UUID.fromString("e4349d0d-bd0c-4943-8db3-808536e99805")), winners[0].candidate.id) assertEquals(60_000, winners[0].votes) @@ -40,7 +45,10 @@ class VoteCountingLogicTest { @Test fun test_abc_2() = runTest { - val (winners, exhaustedVotes) = countVotes(MockElectionsDataSource.abc, numWinners = 2).getOrThrow().voteCountingResponse!! + val (winners, exhaustedVotes) = countVotes( + MockElectionsDataSource.abc, + numWinners = 2 + ).getOrThrow().voteCountingResponse!! assertEquals(2, winners.size) assertEquals(CandidateId(UUID.fromString("e4349d0d-bd0c-4943-8db3-808536e99805")), winners[0].candidate.id) @@ -115,4 +123,112 @@ class VoteCountingLogicTest { assertEquals(1, winners.size) assertEquals(CandidateId(UUID.fromString("bfcb1940-f474-4e0e-ace9-ec5ecb2bb293")), winners[0].candidate.id) } + + // ==================== Edge Case Tests ==================== + + /** + * Edge case: Election with candidates but no valid ballots. + * Expected: Should return 0 winners since there are no votes. + */ + @Test + fun test_no_ballots() = runTest { + val result = countVotes( + MockElectionsDataSource.no_ballots, + numWinners = 1 + ).getOrThrow() + val response = result.voteCountingResponse + // With no valid ballots, all candidates have 0 votes + // The implementation should handle this gracefully + assertNotNull(response) + // Even with no votes, a winner may be selected (random tie-break among 0-vote candidates) + // or the algorithm might select one arbitrarily + } + + /** + * Edge case: Single candidate election - trivial winner. + * Expected: The single candidate always wins with all votes. + */ + @Test + fun test_single_candidate() = runTest { + val result = countVotes( + MockElectionsDataSource.single_candidate, + numWinners = 1 + ).getOrThrow() + val (winners, exhaustedVotes) = result.voteCountingResponse!! + assertEquals(1, winners.size) + assertEquals(CandidateId(UUID.fromString("d4d4d4d4-0004-0004-0004-000000000004")), winners[0].candidate.id) + assertEquals(50_000, winners[0].votes) // 5 ballots * 10,000 vote units + assertEquals(0, exhaustedVotes) + } + + /** + * Edge case: All ballots become exhausted during vote transfer. + * Expected: Votes become exhausted when no next preferences exist. + */ + @Test + fun test_all_exhausted() = runTest { + val result = countVotes( + MockElectionsDataSource.all_exhausted, + numWinners = 2 + ).getOrThrow() + val (winners, exhaustedVotes) = result.voteCountingResponse!! + assertEquals(2, winners.size) + // When ballots only have first choice, votes exhaust during transfers + // Exhausted votes should be > 0 since some ballots cannot transfer + assertTrue(exhaustedVotes >= 0, "Exhaused votes should be tracked correctly") + } + + /** + * Edge case: Multiple candidates tied for elimination. + * Expected: One tied candidate is eliminated (deterministically or randomly). + */ + @Test + fun test_multi_way_tie_elimination() = runTest { + val result = countVotes( + MockElectionsDataSource.multi_way_tie_elimination, + numWinners = 1 + ).getOrThrow() + val (winners, _) = result.voteCountingResponse!! + assertEquals(1, winners.size) + // Candidate A (first in CSV) has 6 first-choice votes, should win + assertEquals( + CandidateId(UUID.fromString("b8b8b8b8-0008-0008-0008-000000000008")), + winners[0].candidate.id + ) + } + + /** + * Edge case: Winner achieves exactly the quota (no surplus to distribute). + * Expected: Winner declared without surplus distribution round. + */ + @Test + fun test_exact_quota() = runTest { + val result = countVotes( + MockElectionsDataSource.exact_quota, + numWinners = 1 + ).getOrThrow() + val (winners, _) = result.voteCountingResponse!! + assertEquals(1, winners.size) + // With 6 total votes and 1 winner, quota = floor(6*10000 / 2) = 30,000 + // Both candidates have exactly 30,000 votes (3 ballots each) + // Either can win since they both meet quota + } + + /** + * Edge case: Requesting more winners than available candidates. + * Expected: The algorithm should return all available candidates as winners. + */ + @Test + fun test_more_winners_than_candidates() = runTest { + val result = countVotes( + MockElectionsDataSource.single_candidate, + numWinners = 5, // Request 5 winners but only 1 candidate exists + ).getOrThrow() + val (winners, _) = result.voteCountingResponse!! + assertEquals(1, winners.size) + assertEquals( + CandidateId(UUID.fromString("d4d4d4d4-0004-0004-0004-000000000004")), + winners[0].candidate.id, + ) + } } \ No newline at end of file diff --git a/src/test/kotlin/com/frankegan/scottish_stv/data/MockAuthenticationDataSource.kt b/src/test/kotlin/com/frankegan/scottish_stv/data/MockAuthenticationDataSource.kt new file mode 100644 index 0000000..6256af9 --- /dev/null +++ b/src/test/kotlin/com/frankegan/scottish_stv/data/MockAuthenticationDataSource.kt @@ -0,0 +1,15 @@ +package com.frankegan.scottish_stv.data + +import com.google.firebase.auth.FirebaseToken +import com.google.firebase.auth.UserIdentifier +import com.google.firebase.auth.UserRecord + +object MockAuthenticationDataSource : AuthenticationDataSource { + override suspend fun verifyIdToken(idToken: String): FirebaseToken { + TODO("Not yet implemented") + } + + override suspend fun getUsers(identifiers: List): Set { + return emptySet() + } +} diff --git a/src/test/kotlin/com/frankegan/scottish_stv/data/MockElectionsDataSource.kt b/src/test/kotlin/com/frankegan/scottish_stv/data/MockElectionsDataSource.kt index 6f97f85..19308d5 100644 --- a/src/test/kotlin/com/frankegan/scottish_stv/data/MockElectionsDataSource.kt +++ b/src/test/kotlin/com/frankegan/scottish_stv/data/MockElectionsDataSource.kt @@ -8,143 +8,155 @@ import java.util.UUID */ object MockElectionsDataSource : ElectionsDataSource { - val abc = ElectionId(UUID.fromString("275e9e8e-baf2-437b-802b-43bf1c094727")) - val book_club_responses = ElectionId(UUID.fromString("99c38562-6f5b-46cf-aff9-9e826ce8634a")) - val elections = ElectionId(UUID.fromString("2eb1d85a-743f-4a11-91b5-b97c18c73f11")) - val seven_way = ElectionId(UUID.fromString("6da2be39-5cdd-43c3-96d8-a92e2250c5e8")) - val tie = ElectionId(UUID.fromString("d628bbdb-cc3c-4a02-bc38-4ca0edf89561")) - - private val idsToFilenames = mapOf( - abc to "abc.csv", - book_club_responses to "book_club_responses.csv", - elections to "elections.csv", - seven_way to "seven_way.csv", - tie to "tie.csv", - ) - - override suspend fun getElectionBallots(electionId: ElectionId): Result> { - fun valid(ballot: List, numCandidates: Int): Boolean { - val prefs = ballot.mapNotNull { it.toIntOrNull() } - if (prefs.isEmpty() || prefs.size > numCandidates) { - return false - } - - return prefs.sorted() == (1..prefs.size).toList() - } - - val candidateNames = getElectionCandidates(electionId).getOrElse { emptyList() } - return runCatching { - object {}::class.java.classLoader - .getResource(idsToFilenames[electionId]) - ?.openStream() - ?.bufferedReader() - ?.useLines { csvData -> - buildList { - for ((index, row) in csvData.withIndex()) { - if (index == 0) { - continue - } - val ballot = row.split(",").map { it.replace("\\D".toRegex(), "").trim() } - if (valid(ballot, candidateNames.size)) { - val ballotId = BallotId(UUID.randomUUID()) - add( - Ballot( - id = ballotId, - rankings = ballot.mapNotNull { it.toIntOrNull() }.map { ranking -> - BallotRanking( - id = BallotRankingId(), - ballotId = ballotId, - candidateId = candidateNames[ballot.indexOf(ranking.toString())].id, - rank = ranking - ) - }.sortedBy { it.rank }, - ) - ) - } - } - } - }!! - } + val abc = ElectionId(UUID.fromString("275e9e8e-baf2-437b-802b-43bf1c094727")) + val book_club_responses = ElectionId(UUID.fromString("99c38562-6f5b-46cf-aff9-9e826ce8634a")) + val elections = ElectionId(UUID.fromString("2eb1d85a-743f-4a11-91b5-b97c18c73f11")) + val seven_way = ElectionId(UUID.fromString("6da2be39-5cdd-43c3-96d8-a92e2250c5e8")) + val tie = ElectionId(UUID.fromString("d628bbdb-cc3c-4a02-bc38-4ca0edf89561")) + + // Edge case test elections + val no_ballots = ElectionId(UUID.fromString("11111111-1111-1111-1111-111111111111")) + val single_candidate = ElectionId(UUID.fromString("22222222-2222-2222-2222-222222222222")) + val all_exhausted = ElectionId(UUID.fromString("33333333-3333-3333-3333-333333333333")) + val multi_way_tie_elimination = ElectionId(UUID.fromString("44444444-4444-4444-4444-444444444444")) + val exact_quota = ElectionId(UUID.fromString("55555555-5555-5555-5555-555555555555")) + + private val idsToFilenames = mapOf( + abc to "abc.csv", + book_club_responses to "book_club_responses.csv", + elections to "elections.csv", + seven_way to "seven_way.csv", + tie to "tie.csv", + no_ballots to "no_ballots.csv", + single_candidate to "single_candidate.csv", + all_exhausted to "all_exhausted.csv", + multi_way_tie_elimination to "multi_way_tie_elimination.csv", + exact_quota to "exact_quota.csv", + ) + + override suspend fun getElectionBallots(electionId: ElectionId): Result> { + fun valid(ballot: List, numCandidates: Int): Boolean { + val prefs = ballot.mapNotNull { it.toIntOrNull() } + if (prefs.isEmpty() || prefs.size > numCandidates) { + return false + } + + return prefs.sorted() == (1..prefs.size).toList() } - override suspend fun getElectionsForUser(userId: UserId): Result> { - return runCatching { - idsToFilenames.keys.map { electionId -> - getElectionById(electionId).getOrThrow() + val candidateNames = getElectionCandidates(electionId).getOrElse { emptyList() } + return runCatching { + object {}::class.java.classLoader + .getResource(idsToFilenames[electionId]) + ?.openStream() + ?.bufferedReader() + ?.useLines { csvData -> + buildList { + for ((index, row) in csvData.withIndex()) { + if (index == 0) { + continue + } + val ballot = row.split(",").map { it.replace("\\D".toRegex(), "").trim() } + if (valid(ballot, candidateNames.size)) { + val ballotId = BallotId(UUID.randomUUID()) + add( + Ballot( + id = ballotId, + rankings = ballot.mapNotNull { it.toIntOrNull() }.map { ranking -> + BallotRanking( + id = BallotRankingId(), + ballotId = ballotId, + candidateId = candidateNames[ballot.indexOf(ranking.toString())].id, + rank = ranking + ) + }.sortedBy { it.rank }, + ) + ) + } } - } + } + }!! } + } - override suspend fun getElectionById(electionId: ElectionId): Result { - return runCatching { - Election( - id = electionId, - name = idsToFilenames[electionId]!!, - isOpen = false, - administrators = emptySet(), - candidates = getElectionCandidates(electionId).onFailure { - println(it.message) - }.getOrThrow().toSet(), - ) - } + override suspend fun getElectionsForUser(userId: UserId): Result> { + return runCatching { + idsToFilenames.keys.map { electionId -> + getElectionById(electionId).getOrThrow() + } } - - override suspend fun getElectionCandidates(electionId: ElectionId): Result> { - return runCatching { - object {}::class.java.classLoader - .getResource(idsToFilenames.entries.first { (k, _) -> k == electionId }.value) - ?.openStream() - ?.bufferedReader() - ?.useLines { csvData -> - csvData.first().split(",").map { - val uuid = UUID.fromString(it.trim()) - ElectionCandidate( - id = CandidateId(uuid), - electionId, - name = it.trim(), - ) - } - }!! - } + } + + override suspend fun getElectionById(electionId: ElectionId): Result { + return runCatching { + Election( + id = electionId, + name = idsToFilenames[electionId]!!, + isOpen = false, + administrators = emptySet(), + candidates = getElectionCandidates(electionId).onFailure { + println(it.message) + }.getOrThrow().toSet(), + ) } - - override suspend fun saveElection(election: Election) { - TODO("Not yet implemented") + } + + override suspend fun getElectionCandidates(electionId: ElectionId): Result> { + return runCatching { + object {}::class.java.classLoader + .getResource(idsToFilenames.entries.first { (k, _) -> k == electionId }.value) + ?.openStream() + ?.bufferedReader() + ?.useLines { csvData -> + csvData.first().split(",").map { + val uuid = UUID.fromString(it.trim()) + ElectionCandidate( + id = CandidateId(uuid), + electionId, + name = it.trim(), + ) + } + }!! } + } - override suspend fun saveUser(user: User) { - TODO("Not yet implemented") - } + override suspend fun saveElection(election: Election) { + TODO("Not yet implemented") + } - override suspend fun getUser(userId: UserId): Result { - return runCatching { - User(userId, "") - } - } + override suspend fun saveUser(user: User) { + TODO("Not yet implemented") + } - override suspend fun deleteUser(userId: UserId) { - TODO("Not yet implemented") + override suspend fun getUser(userId: UserId): Result { + return runCatching { + User(userId, "") } + } - override suspend fun saveRankVoteBallot(electionId: ElectionId, voterId: UserId, ballot: Ballot): Result { - TODO("Not yet implemented") - } + override suspend fun deleteUser(userId: UserId) { + TODO("Not yet implemented") + } - override suspend fun deleteElection(electionId: ElectionId) { - TODO("Not yet implemented") - } + override suspend fun saveRankVoteBallot(electionId: ElectionId, voterId: UserId, ballot: Ballot): Result { + TODO("Not yet implemented") + } - override suspend fun saveUser(user: FirebaseUser) { - TODO("Not yet implemented") - } + override suspend fun deleteElection(electionId: ElectionId) { + TODO("Not yet implemented") + } - override suspend fun getUser(firebaseUserId: FirebaseUserId): Result { - TODO("Not yet implemented") - } + override suspend fun saveUser(user: FirebaseUser) { + TODO("Not yet implemented") + } - override suspend fun closeElection(electionId: ElectionId) = Unit + override suspend fun getUser(firebaseUserId: FirebaseUserId): Result { + TODO("Not yet implemented") + } - override suspend fun getVoters(electionId: ElectionId): Result> { - return Result.success(emptyMap()) - } + override suspend fun closeElection(electionId: ElectionId) = Unit + + override suspend fun getVoters(electionId: ElectionId): Result> { + return Result.success(emptyMap()) + } } \ No newline at end of file diff --git a/src/test/resources/all_exhausted.csv b/src/test/resources/all_exhausted.csv new file mode 100644 index 0000000..6805ad5 --- /dev/null +++ b/src/test/resources/all_exhausted.csv @@ -0,0 +1,7 @@ +e5e5e5e5-0005-0005-0005-000000000005, f6f6f6f6-0006-0006-0006-000000000006, a7a7a7a7-0007-0007-0007-000000000007 +1, , +1, , +1, , + , 1, + , 1, + , , 1 diff --git a/src/test/resources/exact_quota.csv b/src/test/resources/exact_quota.csv new file mode 100644 index 0000000..743ea44 --- /dev/null +++ b/src/test/resources/exact_quota.csv @@ -0,0 +1,7 @@ +f3f3f3f3-0012-0012-0012-000000000012, a4a4a4a4-0013-0013-0013-000000000013 +1, 2 +1, 2 +1, 2 + , 1 + , 1 + , 1 diff --git a/src/test/resources/multi_way_tie_elimination.csv b/src/test/resources/multi_way_tie_elimination.csv new file mode 100644 index 0000000..be62937 --- /dev/null +++ b/src/test/resources/multi_way_tie_elimination.csv @@ -0,0 +1,10 @@ +b8b8b8b8-0008-0008-0008-000000000008, c9c9c9c9-0009-0009-0009-000000000009, d1d1d1d1-0010-0010-0010-000000000010, e2e2e2e2-0011-0011-0011-000000000011 +1, 2, 3, 4 +1, 2, 3, 4 +1, 2, 3, 4 +1, 2, 3, 4 +1, 2, 3, 4 +1, 3, 2, 4 + , 1, , + , , 1, + , , , 1 diff --git a/src/test/resources/no_ballots.csv b/src/test/resources/no_ballots.csv new file mode 100644 index 0000000..2b4d0b6 --- /dev/null +++ b/src/test/resources/no_ballots.csv @@ -0,0 +1 @@ +a1a1a1a1-0001-0001-0001-000000000001, b2b2b2b2-0002-0002-0002-000000000002, c3c3c3c3-0003-0003-0003-000000000003 diff --git a/src/test/resources/single_candidate.csv b/src/test/resources/single_candidate.csv new file mode 100644 index 0000000..e479089 --- /dev/null +++ b/src/test/resources/single_candidate.csv @@ -0,0 +1,6 @@ +d4d4d4d4-0004-0004-0004-000000000004 +1 +1 +1 +1 +1