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>
This commit is contained in:
309
SportsTimeTests/Domain/PollTests.swift
Normal file
309
SportsTimeTests/Domain/PollTests.swift
Normal file
@@ -0,0 +1,309 @@
|
||||
//
|
||||
// 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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user