Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down Expand Up @@ -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")
}
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -143,7 +146,7 @@ private fun countVotes(
exhausted,
quota,
)
} else {
} else {//A loser needs to be eliminated
roundData += voteCountingRound(
roundNum = roundNum,
numWinners = numWinners,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ fun Application.configureRouting(
val voteRequests: List<RankedVoteRequest> = try {
call.receive()
} catch (e: ContentTransformationException) {
application.log.error(e)
return@post call.respond(HttpStatusCode.BadRequest)
}
val ballot = Ballot(
Expand Down
18 changes: 16 additions & 2 deletions src/test/kotlin/com/frankegan/scottish_stv/ApplicationTest.kt
Original file line number Diff line number Diff line change
@@ -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())
Expand Down
120 changes: 118 additions & 2 deletions src/test/kotlin/com/frankegan/scottish_stv/VoteCountingLogicTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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<UserIdentifier>): Set<UserRecord> {
return emptySet()
}
}
Loading