Files
Sportstime/SportsTimeTests/Domain/TripPollTests.swift
2026-02-18 13:00:15 -06:00

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