// // 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.. 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..