416 lines
12 KiB
Swift
416 lines
12 KiB
Swift
//
|
|
// TripPollTests.swift
|
|
// SportsTimeTests
|
|
//
|
|
// TDD specification tests for TripPoll, PollVote, and PollResults models.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
@testable import SportsTime
|
|
|
|
@Suite("TripPoll")
|
|
struct TripPollTests {
|
|
|
|
// MARK: - Test Data
|
|
|
|
private func makeTrip(
|
|
name: String = "Test Trip",
|
|
stops: [TripStop] = [],
|
|
games: [String] = []
|
|
) -> Trip {
|
|
Trip(
|
|
name: name,
|
|
preferences: TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: TestClock.now,
|
|
endDate: TestClock.now.addingTimeInterval(86400 * 7)
|
|
),
|
|
stops: stops
|
|
)
|
|
}
|
|
|
|
private func makePoll(trips: [Trip] = []) -> TripPoll {
|
|
TripPoll(
|
|
title: "Test Poll",
|
|
ownerId: "owner_123",
|
|
tripSnapshots: trips
|
|
)
|
|
}
|
|
|
|
// MARK: - Specification Tests: generateShareCode
|
|
|
|
@Test("generateShareCode: returns 6 characters")
|
|
func generateShareCode_length() {
|
|
let code = TripPoll.generateShareCode()
|
|
|
|
#expect(code.count == 6)
|
|
}
|
|
|
|
@Test("generateShareCode: contains only allowed characters")
|
|
func generateShareCode_allowedChars() {
|
|
let allowedChars = Set("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
|
|
|
|
// Generate multiple codes to test randomness
|
|
for _ in 0..<100 {
|
|
let code = TripPoll.generateShareCode()
|
|
for char in code {
|
|
#expect(allowedChars.contains(char), "Character \(char) should be allowed")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("generateShareCode: excludes ambiguous characters (O, I, L, 0, 1)")
|
|
func generateShareCode_excludesAmbiguous() {
|
|
let ambiguousChars = Set("OIL01")
|
|
|
|
// Generate many codes to test
|
|
for _ in 0..<100 {
|
|
let code = TripPoll.generateShareCode()
|
|
for char in code {
|
|
#expect(!ambiguousChars.contains(char), "Character \(char) should not be in share code")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("generateShareCode: is uppercase")
|
|
func generateShareCode_uppercase() {
|
|
for _ in 0..<50 {
|
|
let code = TripPoll.generateShareCode()
|
|
#expect(code == code.uppercased())
|
|
}
|
|
}
|
|
|
|
// MARK: - Specification Tests: computeTripHash
|
|
|
|
@Test("computeTripHash: produces deterministic hash for same trip")
|
|
func computeTripHash_deterministic() {
|
|
let trip = makeTrip(name: "Consistent Trip")
|
|
|
|
let hash1 = TripPoll.computeTripHash(trip)
|
|
let hash2 = TripPoll.computeTripHash(trip)
|
|
|
|
#expect(hash1 == hash2)
|
|
}
|
|
|
|
@Test("computeTripHash: different trips produce different hashes")
|
|
func computeTripHash_differentTrips() {
|
|
let calendar = TestClock.calendar
|
|
let date1 = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15))!
|
|
let date2 = calendar.date(from: DateComponents(year: 2026, month: 6, day: 16))!
|
|
|
|
let stop1 = TripStop(
|
|
stopNumber: 1,
|
|
city: "NYC",
|
|
state: "NY",
|
|
arrivalDate: date1,
|
|
departureDate: date1
|
|
)
|
|
|
|
let stop2 = TripStop(
|
|
stopNumber: 1,
|
|
city: "Boston",
|
|
state: "MA",
|
|
arrivalDate: date2,
|
|
departureDate: date2
|
|
)
|
|
|
|
let trip1 = Trip(
|
|
name: "Trip 1",
|
|
preferences: TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: date1,
|
|
endDate: date1.addingTimeInterval(86400)
|
|
),
|
|
stops: [stop1]
|
|
)
|
|
|
|
let trip2 = Trip(
|
|
name: "Trip 2",
|
|
preferences: TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: date2,
|
|
endDate: date2.addingTimeInterval(86400)
|
|
),
|
|
stops: [stop2]
|
|
)
|
|
|
|
let hash1 = TripPoll.computeTripHash(trip1)
|
|
let hash2 = TripPoll.computeTripHash(trip2)
|
|
|
|
#expect(hash1 != hash2)
|
|
}
|
|
|
|
// MARK: - Specification Tests: shareURL
|
|
|
|
@Test("shareURL: uses sportstime://poll/ prefix")
|
|
func shareURL_format() {
|
|
let poll = TripPoll(
|
|
title: "Test",
|
|
ownerId: "owner",
|
|
shareCode: "ABC123",
|
|
tripSnapshots: []
|
|
)
|
|
|
|
#expect(poll.shareURL.absoluteString == "sportstime://poll/ABC123")
|
|
}
|
|
|
|
// MARK: - Specification Tests: tripVersions
|
|
|
|
@Test("tripVersions: count equals tripSnapshots count")
|
|
func tripVersions_countMatchesSnapshots() {
|
|
let trips = [makeTrip(name: "Trip 1"), makeTrip(name: "Trip 2"), makeTrip(name: "Trip 3")]
|
|
let poll = makePoll(trips: trips)
|
|
|
|
#expect(poll.tripVersions.count == poll.tripSnapshots.count)
|
|
}
|
|
|
|
// MARK: - Invariant Tests
|
|
|
|
@Test("Invariant: shareCode is exactly 6 characters")
|
|
func invariant_shareCodeLength() {
|
|
for _ in 0..<20 {
|
|
let poll = makePoll()
|
|
#expect(poll.shareCode.count == 6)
|
|
}
|
|
}
|
|
|
|
@Test("Invariant: tripVersions.count == tripSnapshots.count")
|
|
func invariant_versionsMatchSnapshots() {
|
|
let trips = [makeTrip(), makeTrip(), makeTrip()]
|
|
let poll = makePoll(trips: trips)
|
|
|
|
#expect(poll.tripVersions.count == poll.tripSnapshots.count)
|
|
}
|
|
}
|
|
|
|
// MARK: - PollVote Tests
|
|
|
|
@Suite("PollVote")
|
|
struct PollVoteTests {
|
|
|
|
// MARK: - Specification Tests: calculateScores
|
|
|
|
@Test("calculateScores: Borda count gives tripCount points to first choice")
|
|
func calculateScores_firstChoice() {
|
|
// 3 trips, first choice gets 3 points
|
|
let scores = PollVote.calculateScores(rankings: [0, 1, 2], tripCount: 3)
|
|
|
|
#expect(scores[0] == 3) // First choice
|
|
}
|
|
|
|
@Test("calculateScores: Borda count gives decreasing points by rank")
|
|
func calculateScores_decreasingPoints() {
|
|
// Rankings: trip 2 first, trip 0 second, trip 1 third
|
|
let scores = PollVote.calculateScores(rankings: [2, 0, 1], tripCount: 3)
|
|
|
|
#expect(scores[2] == 3) // First choice gets 3
|
|
#expect(scores[0] == 2) // Second choice gets 2
|
|
#expect(scores[1] == 1) // Third choice gets 1
|
|
}
|
|
|
|
@Test("calculateScores: last choice gets 1 point")
|
|
func calculateScores_lastChoice() {
|
|
let scores = PollVote.calculateScores(rankings: [0, 1, 2], tripCount: 3)
|
|
|
|
#expect(scores[2] == 1) // Last choice
|
|
}
|
|
|
|
@Test("calculateScores: handles invalid trip index gracefully")
|
|
func calculateScores_invalidIndex() {
|
|
// Trip index 5 is out of bounds for tripCount 3
|
|
let scores = PollVote.calculateScores(rankings: [0, 1, 5], tripCount: 3)
|
|
|
|
#expect(scores[0] == 3)
|
|
#expect(scores[1] == 2)
|
|
// Index 5 is ignored since it's >= tripCount
|
|
}
|
|
|
|
@Test("calculateScores: empty rankings returns zeros")
|
|
func calculateScores_emptyRankings() {
|
|
let scores = PollVote.calculateScores(rankings: [], tripCount: 3)
|
|
|
|
#expect(scores == [0, 0, 0])
|
|
}
|
|
|
|
// MARK: - Invariant Tests
|
|
|
|
@Test("Invariant: Borda points range from 1 to tripCount")
|
|
func invariant_bordaPointsRange() {
|
|
let tripCount = 5
|
|
let rankings = [0, 1, 2, 3, 4]
|
|
let scores = PollVote.calculateScores(rankings: rankings, tripCount: tripCount)
|
|
|
|
// Each trip should have a score from 1 to tripCount
|
|
let nonZeroScores = scores.filter { $0 > 0 }
|
|
for score in nonZeroScores {
|
|
#expect(score >= 1)
|
|
#expect(score <= tripCount)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - PollResults Tests
|
|
|
|
@Suite("PollResults")
|
|
struct PollResultsTests {
|
|
|
|
// MARK: - Test Data
|
|
|
|
private func makeTrip(name: String) -> Trip {
|
|
Trip(
|
|
name: name,
|
|
preferences: TripPreferences(
|
|
planningMode: .dateRange,
|
|
sports: [.mlb],
|
|
startDate: TestClock.now,
|
|
endDate: TestClock.now.addingTimeInterval(86400 * 7)
|
|
)
|
|
)
|
|
}
|
|
|
|
private func makePoll(tripCount: Int) -> TripPoll {
|
|
let trips = (0..<tripCount).map { makeTrip(name: "Trip \($0)") }
|
|
return TripPoll(
|
|
title: "Test Poll",
|
|
ownerId: "owner",
|
|
tripSnapshots: trips
|
|
)
|
|
}
|
|
|
|
private func makeVote(pollId: UUID, rankings: [Int]) -> PollVote {
|
|
PollVote(
|
|
pollId: pollId,
|
|
odg: "voter_\(UUID())",
|
|
rankings: rankings
|
|
)
|
|
}
|
|
|
|
// MARK: - Specification Tests: voterCount
|
|
|
|
@Test("voterCount: returns number of votes")
|
|
func voterCount_returnsVoteCount() {
|
|
let poll = makePoll(tripCount: 3)
|
|
let votes = [
|
|
makeVote(pollId: poll.id, rankings: [0, 1, 2]),
|
|
makeVote(pollId: poll.id, rankings: [1, 0, 2]),
|
|
makeVote(pollId: poll.id, rankings: [0, 2, 1]),
|
|
]
|
|
|
|
let results = PollResults(poll: poll, votes: votes)
|
|
|
|
#expect(results.voterCount == 3)
|
|
}
|
|
|
|
// MARK: - Specification Tests: tripScores
|
|
|
|
@Test("tripScores: sums all votes correctly")
|
|
func tripScores_sumsVotes() {
|
|
let poll = makePoll(tripCount: 3)
|
|
let votes = [
|
|
makeVote(pollId: poll.id, rankings: [0, 1, 2]), // Trip 0: 3, Trip 1: 2, Trip 2: 1
|
|
makeVote(pollId: poll.id, rankings: [0, 2, 1]), // Trip 0: 3, Trip 2: 2, Trip 1: 1
|
|
]
|
|
// Total: Trip 0: 6, Trip 1: 3, Trip 2: 3
|
|
|
|
let results = PollResults(poll: poll, votes: votes)
|
|
let scores = Dictionary(uniqueKeysWithValues: results.tripScores)
|
|
|
|
#expect(scores[0] == 6)
|
|
#expect(scores[1] == 3)
|
|
#expect(scores[2] == 3)
|
|
}
|
|
|
|
@Test("tripScores: sorted descending by score")
|
|
func tripScores_sortedDescending() {
|
|
let poll = makePoll(tripCount: 3)
|
|
let votes = [
|
|
makeVote(pollId: poll.id, rankings: [1, 0, 2]), // Trip 1 wins
|
|
makeVote(pollId: poll.id, rankings: [1, 2, 0]), // Trip 1 wins
|
|
]
|
|
|
|
let results = PollResults(poll: poll, votes: votes)
|
|
|
|
// First result should have highest score
|
|
let scores = results.tripScores.map { $0.score }
|
|
for i in 1..<scores.count {
|
|
#expect(scores[i] <= scores[i - 1])
|
|
}
|
|
}
|
|
|
|
@Test("tripScores: returns zeros when no votes")
|
|
func tripScores_noVotes() {
|
|
let poll = makePoll(tripCount: 3)
|
|
let results = PollResults(poll: poll, votes: [])
|
|
|
|
let scores = results.tripScores
|
|
#expect(scores.count == 3)
|
|
for (_, score) in scores {
|
|
#expect(score == 0)
|
|
}
|
|
}
|
|
|
|
// MARK: - Specification Tests: maxScore
|
|
|
|
@Test("maxScore: returns highest score")
|
|
func maxScore_returnsHighest() {
|
|
let poll = makePoll(tripCount: 3)
|
|
let votes = [
|
|
makeVote(pollId: poll.id, rankings: [0, 1, 2]),
|
|
makeVote(pollId: poll.id, rankings: [0, 1, 2]),
|
|
]
|
|
// Trip 0: 6 (highest)
|
|
|
|
let results = PollResults(poll: poll, votes: votes)
|
|
|
|
#expect(results.maxScore == 6)
|
|
}
|
|
|
|
@Test("maxScore: 0 when no votes")
|
|
func maxScore_noVotes() {
|
|
let poll = makePoll(tripCount: 3)
|
|
let results = PollResults(poll: poll, votes: [])
|
|
|
|
#expect(results.maxScore == 0)
|
|
}
|
|
|
|
// MARK: - Specification Tests: scorePercentage
|
|
|
|
@Test("scorePercentage: returns score/maxScore")
|
|
func scorePercentage_calculation() {
|
|
let poll = makePoll(tripCount: 2)
|
|
let votes = [
|
|
makeVote(pollId: poll.id, rankings: [0, 1]), // Trip 0: 2, Trip 1: 1
|
|
]
|
|
|
|
let results = PollResults(poll: poll, votes: votes)
|
|
|
|
#expect(results.scorePercentage(for: 0) == 1.0) // 2/2
|
|
#expect(results.scorePercentage(for: 1) == 0.5) // 1/2
|
|
}
|
|
|
|
@Test("scorePercentage: returns 0 when maxScore is 0")
|
|
func scorePercentage_maxScoreZero() {
|
|
let poll = makePoll(tripCount: 3)
|
|
let results = PollResults(poll: poll, votes: [])
|
|
|
|
#expect(results.scorePercentage(for: 0) == 0)
|
|
}
|
|
|
|
// MARK: - Invariant Tests
|
|
|
|
@Test("Invariant: tripScores contains all trip indices")
|
|
func invariant_tripScoresContainsAllIndices() {
|
|
let poll = makePoll(tripCount: 4)
|
|
let votes = [makeVote(pollId: poll.id, rankings: [0, 1, 2, 3])]
|
|
|
|
let results = PollResults(poll: poll, votes: votes)
|
|
let indices = Set(results.tripScores.map { $0.tripIndex })
|
|
|
|
#expect(indices == Set(0..<4))
|
|
}
|
|
}
|