Files
Sportstime/SportsTimeTests/Domain/PollTests.swift
Trey t 13385b6562 feat(polls): implement group trip polling MVP
Add complete group trip polling feature allowing users to share trips
with friends for voting using Borda count scoring.

New components:
- TripPoll and PollVote domain models with share codes and rankings
- LocalTripPoll and LocalPollVote SwiftData models for persistence
- CKTripPoll and CKPollVote CloudKit record wrappers
- PollService actor for CloudKit CRUD operations and subscriptions
- PollCreation/Detail/Voting views and view models
- Deep link handling for sportstime://poll/{code} URLs
- Debug Pro status override toggle in Settings

Integration:
- HomeView shows polls section in My Trips
- SportsTimeApp registers SwiftData models and handles deep links

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:54:42 -06:00

310 lines
11 KiB
Swift

//
// PollTests.swift
// SportsTimeTests
//
// Tests for TripPoll, PollVote, and PollResults domain models
//
import Testing
@testable import SportsTime
import Foundation
// MARK: - TripPoll Tests
struct TripPollTests {
// MARK: - Share Code Tests
@Test("Share code has correct length")
func shareCode_HasCorrectLength() {
let code = TripPoll.generateShareCode()
#expect(code.count == 6)
}
@Test("Share code contains only allowed characters")
func shareCode_ContainsOnlyAllowedCharacters() {
let allowedCharacters = Set("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
for _ in 0..<100 {
let code = TripPoll.generateShareCode()
for char in code {
#expect(allowedCharacters.contains(char), "Unexpected character: \(char)")
}
}
}
@Test("Share code excludes ambiguous characters")
func shareCode_ExcludesAmbiguousCharacters() {
let ambiguousCharacters = Set("0O1IL")
for _ in 0..<100 {
let code = TripPoll.generateShareCode()
for char in code {
#expect(!ambiguousCharacters.contains(char), "Found ambiguous character: \(char)")
}
}
}
@Test("Share codes are unique")
func shareCode_IsUnique() {
var codes = Set<String>()
for _ in 0..<1000 {
let code = TripPoll.generateShareCode()
codes.insert(code)
}
// With 6 chars from 32 possibilities, collisions in 1000 samples should be rare
#expect(codes.count >= 990, "Too many collisions in share code generation")
}
// MARK: - Share URL Tests
@Test("Share URL is correctly formatted")
func shareURL_IsCorrectlyFormatted() {
let poll = makeTestPoll(shareCode: "ABC123")
#expect(poll.shareURL.absoluteString == "sportstime://poll/ABC123")
}
// MARK: - Trip Hash Tests
@Test("Trip hash is deterministic")
func tripHash_IsDeterministic() {
let trip = makeTestTrip(cities: ["Chicago", "Detroit"])
let hash1 = TripPoll.computeTripHash(trip)
let hash2 = TripPoll.computeTripHash(trip)
#expect(hash1 == hash2)
}
@Test("Trip hash differs for different cities")
func tripHash_DiffersForDifferentCities() {
let trip1 = makeTestTrip(cities: ["Chicago", "Detroit"])
let trip2 = makeTestTrip(cities: ["Chicago", "Milwaukee"])
let hash1 = TripPoll.computeTripHash(trip1)
let hash2 = TripPoll.computeTripHash(trip2)
#expect(hash1 != hash2)
}
@Test("Trip hash differs for different dates")
func tripHash_DiffersForDifferentDates() {
let baseDate = Date()
let trip1 = makeTestTrip(cities: ["Chicago"], startDate: baseDate)
let trip2 = makeTestTrip(cities: ["Chicago"], startDate: baseDate.addingTimeInterval(86400))
let hash1 = TripPoll.computeTripHash(trip1)
let hash2 = TripPoll.computeTripHash(trip2)
#expect(hash1 != hash2)
}
// MARK: - Initialization Tests
@Test("Poll initializes with trip versions")
func poll_InitializesWithTripVersions() {
let trip1 = makeTestTrip(cities: ["Chicago"])
let trip2 = makeTestTrip(cities: ["Detroit"])
let poll = TripPoll(
title: "Test Poll",
ownerId: "user123",
tripSnapshots: [trip1, trip2]
)
#expect(poll.tripVersions.count == 2)
#expect(poll.tripVersions[0] == TripPoll.computeTripHash(trip1))
#expect(poll.tripVersions[1] == TripPoll.computeTripHash(trip2))
}
}
// MARK: - PollVote Tests
struct PollVoteTests {
// MARK: - Borda Count Tests
@Test("Borda count scores 3 trips correctly")
func bordaCount_Scores3TripsCorrectly() {
// Rankings: [2, 0, 1] means trip 2 is #1, trip 0 is #2, trip 1 is #3
let rankings = [2, 0, 1]
let scores = PollVote.calculateScores(rankings: rankings, tripCount: 3)
// Trip 2 is rank 0 (first place): 3 - 0 = 3 points
// Trip 0 is rank 1 (second place): 3 - 1 = 2 points
// Trip 1 is rank 2 (third place): 3 - 2 = 1 point
#expect(scores[0] == 2, "Trip 0 should have 2 points")
#expect(scores[1] == 1, "Trip 1 should have 1 point")
#expect(scores[2] == 3, "Trip 2 should have 3 points")
}
@Test("Borda count scores 2 trips correctly")
func bordaCount_Scores2TripsCorrectly() {
let rankings = [1, 0] // Trip 1 first, Trip 0 second
let scores = PollVote.calculateScores(rankings: rankings, tripCount: 2)
#expect(scores[0] == 1, "Trip 0 should have 1 point")
#expect(scores[1] == 2, "Trip 1 should have 2 points")
}
@Test("Borda count handles invalid trip index")
func bordaCount_HandlesInvalidTripIndex() {
let rankings = [0, 5] // 5 is out of bounds for tripCount 2
let scores = PollVote.calculateScores(rankings: rankings, tripCount: 2)
#expect(scores[0] == 2, "Trip 0 should have 2 points")
#expect(scores[1] == 0, "Trip 1 should have 0 points (never ranked)")
}
@Test("Borda count with 5 trips")
func bordaCount_With5Trips() {
// Rankings: trip indices in preference order
let rankings = [4, 2, 0, 3, 1] // Trip 4 is best, trip 1 is worst
let scores = PollVote.calculateScores(rankings: rankings, tripCount: 5)
// Points: 5 for 1st, 4 for 2nd, 3 for 3rd, 2 for 4th, 1 for 5th
#expect(scores[0] == 3, "Trip 0 (3rd place) should have 3 points")
#expect(scores[1] == 1, "Trip 1 (5th place) should have 1 point")
#expect(scores[2] == 4, "Trip 2 (2nd place) should have 4 points")
#expect(scores[3] == 2, "Trip 3 (4th place) should have 2 points")
#expect(scores[4] == 5, "Trip 4 (1st place) should have 5 points")
}
}
// MARK: - PollResults Tests
struct PollResultsTests {
@Test("Results with no votes returns zero scores")
func results_NoVotesReturnsZeroScores() {
let poll = makeTestPoll(tripCount: 3)
let results = PollResults(poll: poll, votes: [])
#expect(results.voterCount == 0)
#expect(results.maxScore == 0)
#expect(results.tripScores.count == 3)
for item in results.tripScores {
#expect(item.score == 0)
}
}
@Test("Results with single vote")
func results_SingleVote() {
let poll = makeTestPoll(tripCount: 3)
let vote = PollVote(pollId: poll.id, odg: "voter1", rankings: [2, 0, 1])
let results = PollResults(poll: poll, votes: [vote])
#expect(results.voterCount == 1)
#expect(results.maxScore == 3)
// Trip 2 should be first with 3 points
#expect(results.tripScores[0].tripIndex == 2)
#expect(results.tripScores[0].score == 3)
// Trip 0 should be second with 2 points
#expect(results.tripScores[1].tripIndex == 0)
#expect(results.tripScores[1].score == 2)
// Trip 1 should be third with 1 point
#expect(results.tripScores[2].tripIndex == 1)
#expect(results.tripScores[2].score == 1)
}
@Test("Results aggregates multiple votes")
func results_AggregatesMultipleVotes() {
let poll = makeTestPoll(tripCount: 3)
let vote1 = PollVote(pollId: poll.id, odg: "voter1", rankings: [0, 1, 2]) // Trip 0 first
let vote2 = PollVote(pollId: poll.id, odg: "voter2", rankings: [0, 2, 1]) // Trip 0 first
let vote3 = PollVote(pollId: poll.id, odg: "voter3", rankings: [1, 0, 2]) // Trip 1 first
let results = PollResults(poll: poll, votes: [vote1, vote2, vote3])
#expect(results.voterCount == 3)
// Trip 0: 3 + 3 + 2 = 8 points (first, first, second)
// Trip 1: 2 + 1 + 3 = 6 points (second, third, first)
// Trip 2: 1 + 2 + 1 = 4 points (third, second, third)
#expect(results.tripScores[0].tripIndex == 0, "Trip 0 should be ranked first")
#expect(results.tripScores[0].score == 8)
#expect(results.tripScores[1].tripIndex == 1, "Trip 1 should be ranked second")
#expect(results.tripScores[1].score == 6)
#expect(results.tripScores[2].tripIndex == 2, "Trip 2 should be ranked third")
#expect(results.tripScores[2].score == 4)
}
@Test("Score percentage calculation")
func results_ScorePercentageCalculation() {
let poll = makeTestPoll(tripCount: 2)
let vote1 = PollVote(pollId: poll.id, odg: "voter1", rankings: [0, 1])
let vote2 = PollVote(pollId: poll.id, odg: "voter2", rankings: [0, 1])
let results = PollResults(poll: poll, votes: [vote1, vote2])
// Trip 0: 2 + 2 = 4 points (max)
// Trip 1: 1 + 1 = 2 points
#expect(results.scorePercentage(for: 0) == 1.0, "Trip 0 should be 100%")
#expect(results.scorePercentage(for: 1) == 0.5, "Trip 1 should be 50%")
}
@Test("Score percentage returns zero when no votes")
func results_ScorePercentageReturnsZeroWhenNoVotes() {
let poll = makeTestPoll(tripCount: 2)
let results = PollResults(poll: poll, votes: [])
#expect(results.scorePercentage(for: 0) == 0)
#expect(results.scorePercentage(for: 1) == 0)
}
@Test("Results handles tie correctly")
func results_HandlesTieCorrectly() {
let poll = makeTestPoll(tripCount: 2)
let vote1 = PollVote(pollId: poll.id, odg: "voter1", rankings: [0, 1])
let vote2 = PollVote(pollId: poll.id, odg: "voter2", rankings: [1, 0])
let results = PollResults(poll: poll, votes: [vote1, vote2])
// Trip 0: 2 + 1 = 3 points
// Trip 1: 1 + 2 = 3 points
// Both tied at 3
#expect(results.tripScores[0].score == 3)
#expect(results.tripScores[1].score == 3)
#expect(results.maxScore == 3)
}
}
// MARK: - Test Helpers
private func makeTestTrip(
cities: [String],
startDate: Date = Date(),
games: [String] = []
) -> Trip {
let stops = cities.enumerated().map { index, city in
TripStop(
stopNumber: index + 1,
city: city,
state: "XX",
arrivalDate: startDate.addingTimeInterval(Double(index) * 86400),
departureDate: startDate.addingTimeInterval(Double(index + 1) * 86400),
games: games
)
}
return Trip(
name: "Test Trip",
preferences: TripPreferences(
planningMode: .dateRange,
sports: [.mlb],
startDate: startDate,
endDate: startDate.addingTimeInterval(86400 * Double(cities.count))
),
stops: stops
)
}
private func makeTestPoll(tripCount: Int = 3, shareCode: String? = nil) -> TripPoll {
let trips = (0..<tripCount).map { index in
makeTestTrip(cities: ["City\(index)"])
}
return TripPoll(
title: "Test Poll",
ownerId: "testOwner",
shareCode: shareCode ?? TripPoll.generateShareCode(),
tripSnapshots: trips
)
}