feat: add planning tips and grouped trip options sorting
- Add PlanningTips data model with 105 tips across 7 categories - Wire random tips into HomeView (3 tips per session) - Add TripOptionsGrouper for grouping by city/game count and mileage - Update TripOptionsView with sectioned display when sorting - Recommended and Best Efficiency remain flat lists Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
140
SportsTime/Core/Data/PlanningTips.swift
Normal file
140
SportsTime/Core/Data/PlanningTips.swift
Normal file
@@ -0,0 +1,140 @@
|
||||
//
|
||||
// PlanningTips.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PlanningTip: Identifiable {
|
||||
let id = UUID()
|
||||
let icon: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
}
|
||||
|
||||
enum PlanningTips {
|
||||
static let all: [PlanningTip] = [
|
||||
// MARK: - Schedule Timing (~15 tips)
|
||||
PlanningTip(icon: "calendar.badge.clock", title: "Check schedules early", subtitle: "Game times can change, sync often"),
|
||||
PlanningTip(icon: "clock.badge.exclamationmark", title: "Watch for doubleheaders", subtitle: "Two games, one day—great value!"),
|
||||
PlanningTip(icon: "calendar.badge.plus", title: "Add buffer days", subtitle: "Weather delays happen, plan ahead"),
|
||||
PlanningTip(icon: "clock.arrow.circlepath", title: "Account for time zones", subtitle: "West coast games start late back east"),
|
||||
PlanningTip(icon: "tv", title: "Check TV schedules", subtitle: "Nationally broadcast games may shift times"),
|
||||
PlanningTip(icon: "cloud.rain", title: "Rain delay ready", subtitle: "Baseball can push back your whole evening"),
|
||||
PlanningTip(icon: "calendar", title: "Avoid holiday weekends", subtitle: "Hotels and tickets spike on long weekends"),
|
||||
PlanningTip(icon: "sunrise", title: "Day games rock", subtitle: "More time to explore the city after"),
|
||||
PlanningTip(icon: "moon.stars", title: "Night games = city vibes", subtitle: "Experience the stadium lights atmosphere"),
|
||||
PlanningTip(icon: "sportscourt", title: "Weekend series strategy", subtitle: "Catch Friday-Saturday-Sunday for max games"),
|
||||
PlanningTip(icon: "calendar.badge.minus", title: "Check for off-days", subtitle: "Teams travel on Mondays and Thursdays"),
|
||||
PlanningTip(icon: "exclamationmark.triangle", title: "Spring training caution", subtitle: "Split-squad games mean fewer stars"),
|
||||
PlanningTip(icon: "clock", title: "First pitch timing", subtitle: "Arrive 60-90 minutes early for batting practice"),
|
||||
PlanningTip(icon: "hourglass", title: "Game length varies", subtitle: "Baseball: 3hrs, Basketball: 2.5hrs, Hockey: 2.5hrs"),
|
||||
PlanningTip(icon: "arrow.clockwise", title: "Reschedule alerts", subtitle: "Enable notifications for schedule changes"),
|
||||
|
||||
// MARK: - Driving/Route Tips (~15 tips)
|
||||
PlanningTip(icon: "car.fill", title: "Plan rest days", subtitle: "Don't overdo the driving"),
|
||||
PlanningTip(icon: "fuelpump.fill", title: "Map gas stations", subtitle: "Some stretches are remote"),
|
||||
PlanningTip(icon: "road.lanes", title: "Avoid rush hour", subtitle: "Leave mid-morning or after 7pm"),
|
||||
PlanningTip(icon: "map", title: "Scenic route option", subtitle: "Sometimes the long way is the fun way"),
|
||||
PlanningTip(icon: "bolt.car", title: "EV charging stops", subtitle: "Plan charging during meals for efficiency"),
|
||||
PlanningTip(icon: "car.side", title: "4-hour driving max", subtitle: "More than 4 hours makes for tired fans"),
|
||||
PlanningTip(icon: "location.fill", title: "Download offline maps", subtitle: "Cell service can be spotty in rural areas"),
|
||||
PlanningTip(icon: "gauge.medium", title: "Check tire pressure", subtitle: "Long trips need proper inflation"),
|
||||
PlanningTip(icon: "wrench.fill", title: "Pre-trip car check", subtitle: "Oil, fluids, wipers—be road ready"),
|
||||
PlanningTip(icon: "road.lanes.curved.right", title: "Interstate vs scenic", subtitle: "Interstates are faster but highways are prettier"),
|
||||
PlanningTip(icon: "figure.wave", title: "Stretch breaks", subtitle: "Stop every 2 hours to stay sharp"),
|
||||
PlanningTip(icon: "cup.and.saucer.fill", title: "Coffee shop stops", subtitle: "Local cafes beat drive-thru every time"),
|
||||
PlanningTip(icon: "music.note", title: "Curate your playlist", subtitle: "Great music makes miles fly by"),
|
||||
PlanningTip(icon: "headphones", title: "Podcast the drive", subtitle: "Sports podcasts to get hyped"),
|
||||
PlanningTip(icon: "car.2.fill", title: "Carpool when possible", subtitle: "Split costs and share the driving"),
|
||||
|
||||
// MARK: - Stadium-Specific (~15 tips)
|
||||
PlanningTip(icon: "star.fill", title: "Mark must-sees", subtitle: "Ensure your favorite matchups are included"),
|
||||
PlanningTip(icon: "fork.knife", title: "Research stadium food", subtitle: "Some parks have legendary eats"),
|
||||
PlanningTip(icon: "camera.fill", title: "Photo opportunities", subtitle: "Scout iconic spots before game day"),
|
||||
PlanningTip(icon: "building.2", title: "Stadium tours", subtitle: "Many offer behind-the-scenes access"),
|
||||
PlanningTip(icon: "figure.walk", title: "Walk the concourse", subtitle: "Each stadium has hidden gems"),
|
||||
PlanningTip(icon: "bag.fill", title: "Clear bag policy", subtitle: "Most stadiums require clear bags now"),
|
||||
PlanningTip(icon: "iphone", title: "Mobile tickets ready", subtitle: "Screenshot or download for offline access"),
|
||||
PlanningTip(icon: "chair.lounge.fill", title: "Seat selection matters", subtitle: "Research views before buying tickets"),
|
||||
PlanningTip(icon: "sun.max.fill", title: "Shade vs sun seats", subtitle: "Day games: pay extra for shade"),
|
||||
PlanningTip(icon: "hand.raised.fill", title: "Foul ball territory", subtitle: "Bring a glove to baseline sections"),
|
||||
PlanningTip(icon: "tshirt.fill", title: "Wear neutral colors", subtitle: "Or proudly rep the home team!"),
|
||||
PlanningTip(icon: "sparkles", title: "Retro stadium charm", subtitle: "Fenway, Wrigley—experience history"),
|
||||
PlanningTip(icon: "building.columns", title: "New stadium tech", subtitle: "Modern parks have amazing amenities"),
|
||||
PlanningTip(icon: "trophy.fill", title: "Championship banners", subtitle: "Look up—history hangs from the rafters"),
|
||||
PlanningTip(icon: "person.3.fill", title: "Fan sections rock", subtitle: "The bleachers have the best energy"),
|
||||
|
||||
// MARK: - Budget Tips (~15 tips)
|
||||
PlanningTip(icon: "dollarsign.circle", title: "Book hotels early", subtitle: "Game days fill up fast"),
|
||||
PlanningTip(icon: "ticket.fill", title: "Weekday games cheaper", subtitle: "Tuesday-Thursday = better deals"),
|
||||
PlanningTip(icon: "creditcard.fill", title: "Set a daily budget", subtitle: "Stadium food adds up quick"),
|
||||
PlanningTip(icon: "house.fill", title: "Stay outside downtown", subtitle: "Uber in, save on hotels"),
|
||||
PlanningTip(icon: "bed.double.fill", title: "Points and rewards", subtitle: "Hotel loyalty programs pay off on road trips"),
|
||||
PlanningTip(icon: "airplane", title: "Compare fly vs drive", subtitle: "Sometimes flying is cheaper than gas"),
|
||||
PlanningTip(icon: "cart.fill", title: "Grocery runs help", subtitle: "Stock up on snacks, skip concessions"),
|
||||
PlanningTip(icon: "parkingsign.circle", title: "Pre-book parking", subtitle: "SpotHero and ParkWhiz save money"),
|
||||
PlanningTip(icon: "percent", title: "Group discounts", subtitle: "4+ people often get bundle deals"),
|
||||
PlanningTip(icon: "giftcard.fill", title: "Gift card strategy", subtitle: "Buy discounted cards for stadium spending"),
|
||||
PlanningTip(icon: "arrow.down.circle", title: "Last-minute tickets", subtitle: "Prices drop close to game time"),
|
||||
PlanningTip(icon: "arrow.up.circle", title: "Book rivalry games early", subtitle: "Yankees-Red Sox sells out fast"),
|
||||
PlanningTip(icon: "wallet.pass.fill", title: "Season ticket resale", subtitle: "STH often sell below face value"),
|
||||
PlanningTip(icon: "banknote.fill", title: "Cash for parking", subtitle: "Some lots still don't take cards"),
|
||||
PlanningTip(icon: "tag.fill", title: "Student/military discounts", subtitle: "Always ask—many teams offer them"),
|
||||
|
||||
// MARK: - Multi-Sport Strategies (~15 tips)
|
||||
PlanningTip(icon: "sportscourt.fill", title: "Mix sports", subtitle: "NBA + NHL share many cities"),
|
||||
PlanningTip(icon: "calendar.badge.clock", title: "Season overlap sweet spot", subtitle: "April: MLB + NBA + NHL all active"),
|
||||
PlanningTip(icon: "building.2.fill", title: "Shared arenas", subtitle: "Same building, different sports—efficient!"),
|
||||
PlanningTip(icon: "figure.basketball", title: "Basketball + hockey combo", subtitle: "Many cities host both in one arena"),
|
||||
PlanningTip(icon: "baseball.fill", title: "Baseball road trip season", subtitle: "Summer = the classic sports adventure"),
|
||||
PlanningTip(icon: "hockey.puck.fill", title: "Hockey playoff intensity", subtitle: "April-June games are electric"),
|
||||
PlanningTip(icon: "basketball.fill", title: "NBA in March", subtitle: "Playoff push means high-stakes games"),
|
||||
PlanningTip(icon: "leaf.fill", title: "Fall football overlap", subtitle: "NFL Sundays + college Saturdays"),
|
||||
PlanningTip(icon: "snowflake", title: "Winter sports combo", subtitle: "NBA + NHL peak season in January"),
|
||||
PlanningTip(icon: "sun.min.fill", title: "Spring training + spring break", subtitle: "Arizona and Florida in March"),
|
||||
PlanningTip(icon: "figure.run", title: "Cross-sport rivalries", subtitle: "Same city teams often play same weekends"),
|
||||
PlanningTip(icon: "arrow.triangle.branch", title: "Hub city strategy", subtitle: "Chicago, LA, NYC have multiple teams"),
|
||||
PlanningTip(icon: "globe.americas.fill", title: "Regional concentration", subtitle: "Northeast has tons of teams close together"),
|
||||
PlanningTip(icon: "flag.fill", title: "Border city bonus", subtitle: "Toronto + Buffalo = international trip!"),
|
||||
PlanningTip(icon: "star.circle.fill", title: "All-Star breaks", subtitle: "Avoid—no regular games during break"),
|
||||
|
||||
// MARK: - Regional Tips (~15 tips)
|
||||
PlanningTip(icon: "cloud.sun.fill", title: "Check the weather", subtitle: "April baseball can be chilly"),
|
||||
PlanningTip(icon: "thermometer.sun.fill", title: "Summer heat warning", subtitle: "Phoenix and Texas get brutal in July"),
|
||||
PlanningTip(icon: "snow", title: "Winter driving caution", subtitle: "Great Lakes region gets snow early"),
|
||||
PlanningTip(icon: "wind", title: "Chicago wind factor", subtitle: "Wrigley wind changes the game"),
|
||||
PlanningTip(icon: "humidity.fill", title: "Southern humidity", subtitle: "Atlanta and Houston: bring water"),
|
||||
PlanningTip(icon: "mountain.2.fill", title: "Denver altitude", subtitle: "Drink extra water at Coors Field"),
|
||||
PlanningTip(icon: "beach.umbrella.fill", title: "California weather wins", subtitle: "LA and San Diego = perfect game days"),
|
||||
PlanningTip(icon: "building.fill", title: "NYC transit tips", subtitle: "Subway to the stadium, skip driving"),
|
||||
PlanningTip(icon: "tram.fill", title: "Boston T access", subtitle: "Green Line to Fenway, easy as pie"),
|
||||
PlanningTip(icon: "car.ferry.fill", title: "Seattle ferry option", subtitle: "Combine a game with Puget Sound views"),
|
||||
PlanningTip(icon: "leaf.arrow.circlepath", title: "Fall foliage bonus", subtitle: "Northeast in October = stunning drives"),
|
||||
PlanningTip(icon: "theatermasks.fill", title: "Local events check", subtitle: "Festivals can spike hotel prices"),
|
||||
PlanningTip(icon: "music.mic", title: "Concert conflicts", subtitle: "Arena shows can affect parking"),
|
||||
PlanningTip(icon: "graduationcap.fill", title: "College town energy", subtitle: "Ann Arbor, Madison—fun atmospheres"),
|
||||
PlanningTip(icon: "flag.checkered", title: "Racing conflicts", subtitle: "Indy 500 time? Indianapolis books up"),
|
||||
|
||||
// MARK: - Game Day Tips (~15 tips)
|
||||
PlanningTip(icon: "figure.walk", title: "Arrive early", subtitle: "Explore the stadium before first pitch"),
|
||||
PlanningTip(icon: "drop.fill", title: "Stay hydrated", subtitle: "Especially at outdoor summer games"),
|
||||
PlanningTip(icon: "sun.max.trianglebadge.exclamationmark", title: "Sunscreen essential", subtitle: "Day games can burn—protect yourself"),
|
||||
PlanningTip(icon: "battery.100", title: "Charge your phone", subtitle: "You'll want photos and videos"),
|
||||
PlanningTip(icon: "powerplug.fill", title: "Bring a battery pack", subtitle: "Mobile tickets need power"),
|
||||
PlanningTip(icon: "speaker.wave.3.fill", title: "Download team app", subtitle: "In-seat ordering, replays, and more"),
|
||||
PlanningTip(icon: "menucard.fill", title: "Check signature dishes", subtitle: "Every stadium has a must-try item"),
|
||||
PlanningTip(icon: "clock.badge.checkmark", title: "Gates open timing", subtitle: "Usually 90 minutes before first pitch"),
|
||||
PlanningTip(icon: "figure.mixed.cardio", title: "Pre-game neighborhood walk", subtitle: "See the local scene before heading in"),
|
||||
PlanningTip(icon: "wineglass.fill", title: "Pre-game spots", subtitle: "Research nearby bars and restaurants"),
|
||||
PlanningTip(icon: "gift.fill", title: "Bobblehead nights", subtitle: "Arrive extra early for giveaways"),
|
||||
PlanningTip(icon: "pencil.and.scribble", title: "Autograph opportunities", subtitle: "Get there for batting practice"),
|
||||
PlanningTip(icon: "person.badge.plus", title: "Meet other fans", subtitle: "Road trip fans bond fast"),
|
||||
PlanningTip(icon: "heart.fill", title: "Soak it in", subtitle: "Put the phone down sometimes"),
|
||||
PlanningTip(icon: "memoryphoto", title: "Take scorecard notes", subtitle: "Physical mementos beat digital"),
|
||||
]
|
||||
|
||||
static func random(_ count: Int = 3) -> [PlanningTip] {
|
||||
Array(all.shuffled().prefix(count))
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ struct HomeView: View {
|
||||
@State private var selectedTab = 0
|
||||
@State private var suggestedTripsGenerator = SuggestedTripsGenerator()
|
||||
@State private var selectedSuggestedTrip: SuggestedTrip?
|
||||
@State private var displayedTips: [PlanningTip] = []
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
@@ -317,9 +318,9 @@ struct HomeView: View {
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
TipRow(icon: "calendar.badge.clock", title: "Check schedules early", subtitle: "Game times can change, sync often")
|
||||
TipRow(icon: "car.fill", title: "Plan rest days", subtitle: "Don't overdo the driving")
|
||||
TipRow(icon: "star.fill", title: "Mark must-sees", subtitle: "Ensure your favorite matchups are included")
|
||||
ForEach(displayedTips) { tip in
|
||||
TipRow(icon: tip.icon, title: tip.title, subtitle: tip.subtitle)
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
@@ -329,6 +330,11 @@ struct HomeView: View {
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if displayedTips.isEmpty {
|
||||
displayedTips = PlanningTips.random(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1567,6 +1567,58 @@ enum CitiesFilter: Int, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Options Grouper
|
||||
|
||||
enum TripOptionsGrouper {
|
||||
typealias GroupedOptions = (header: String, options: [ItineraryOption])
|
||||
|
||||
static func groupByCityCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] {
|
||||
let grouped = Dictionary(grouping: options) { option in
|
||||
Set(option.stops.map { $0.city }).count
|
||||
}
|
||||
let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key }
|
||||
return sorted.map { count, opts in
|
||||
("\(count) \(count == 1 ? "city" : "cities")", opts)
|
||||
}
|
||||
}
|
||||
|
||||
static func groupByGameCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] {
|
||||
let grouped = Dictionary(grouping: options) { $0.totalGames }
|
||||
let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key }
|
||||
return sorted.map { count, opts in
|
||||
("\(count) \(count == 1 ? "game" : "games")", opts)
|
||||
}
|
||||
}
|
||||
|
||||
static func groupByMileageRange(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] {
|
||||
let ranges: [(min: Int, max: Int, label: String)] = [
|
||||
(0, 500, "0-500 mi"),
|
||||
(500, 1000, "500-1000 mi"),
|
||||
(1000, 1500, "1000-1500 mi"),
|
||||
(1500, 2000, "1500-2000 mi"),
|
||||
(2000, Int.max, "2000+ mi")
|
||||
]
|
||||
|
||||
var groupedDict: [String: [ItineraryOption]] = [:]
|
||||
for option in options {
|
||||
let miles = Int(option.totalDistanceMiles)
|
||||
for range in ranges {
|
||||
if miles >= range.min && miles < range.max {
|
||||
groupedDict[range.label, default: []].append(option)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by range order
|
||||
let rangeOrder = ascending ? ranges : ranges.reversed()
|
||||
return rangeOrder.compactMap { range in
|
||||
guard let opts = groupedDict[range.label], !opts.isEmpty else { return nil }
|
||||
return (range.label, opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TripOptionsView: View {
|
||||
let options: [ItineraryOption]
|
||||
let games: [String: RichGame]
|
||||
@@ -1641,6 +1693,29 @@ struct TripOptionsView: View {
|
||||
return Double(option.totalGames) / Double(days)
|
||||
}
|
||||
|
||||
private var groupedOptions: [TripOptionsGrouper.GroupedOptions] {
|
||||
switch sortOption {
|
||||
case .recommended, .bestEfficiency:
|
||||
// Flat list, no grouping
|
||||
return [("", filteredAndSortedOptions)]
|
||||
|
||||
case .mostCities:
|
||||
return TripOptionsGrouper.groupByCityCount(filteredAndSortedOptions, ascending: false)
|
||||
|
||||
case .mostGames:
|
||||
return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: false)
|
||||
|
||||
case .leastGames:
|
||||
return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: true)
|
||||
|
||||
case .mostMiles:
|
||||
return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: false)
|
||||
|
||||
case .leastMiles:
|
||||
return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: true)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 16) {
|
||||
@@ -1660,21 +1735,43 @@ struct TripOptionsView: View {
|
||||
filtersSection
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
|
||||
// Options list
|
||||
// Options list (grouped when applicable)
|
||||
if filteredAndSortedOptions.isEmpty {
|
||||
emptyFilterState
|
||||
.padding(.top, Theme.Spacing.xl)
|
||||
} else {
|
||||
ForEach(filteredAndSortedOptions) { option in
|
||||
TripOptionCard(
|
||||
option: option,
|
||||
games: games,
|
||||
onSelect: {
|
||||
selectedTrip = convertToTrip(option)
|
||||
showTripDetail = true
|
||||
ForEach(Array(groupedOptions.enumerated()), id: \.offset) { _, group in
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Section header (only if non-empty)
|
||||
if !group.header.isEmpty {
|
||||
HStack {
|
||||
Text(group.header)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(group.options.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.top, Theme.Spacing.md)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
|
||||
// Options in this group
|
||||
ForEach(group.options) { option in
|
||||
TripOptionCard(
|
||||
option: option,
|
||||
games: games,
|
||||
onSelect: {
|
||||
selectedTrip = convertToTrip(option)
|
||||
showTripDetail = true
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
SportsTimeTests/PlanningTipsTests.swift
Normal file
38
SportsTimeTests/PlanningTipsTests.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// PlanningTipsTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import SportsTime
|
||||
|
||||
struct PlanningTipsTests {
|
||||
|
||||
@Test func allTipsHasAtLeast100Tips() {
|
||||
#expect(PlanningTips.all.count >= 100)
|
||||
}
|
||||
|
||||
@Test func randomReturnsRequestedCount() {
|
||||
let tips = PlanningTips.random(3)
|
||||
#expect(tips.count == 3)
|
||||
}
|
||||
|
||||
@Test func randomReturnsUniqueIds() {
|
||||
let tips = PlanningTips.random(5)
|
||||
let uniqueIds = Set(tips.map { $0.id })
|
||||
#expect(uniqueIds.count == 5)
|
||||
}
|
||||
|
||||
@Test func eachTipHasNonEmptyFields() {
|
||||
for tip in PlanningTips.all {
|
||||
#expect(!tip.icon.isEmpty, "Tip should have icon")
|
||||
#expect(!tip.title.isEmpty, "Tip should have title")
|
||||
#expect(!tip.subtitle.isEmpty, "Tip should have subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func randomWithCountGreaterThanAvailableReturnsAll() {
|
||||
let tips = PlanningTips.random(1000)
|
||||
#expect(tips.count == PlanningTips.all.count)
|
||||
}
|
||||
}
|
||||
95
SportsTimeTests/TripOptionsGroupingTests.swift
Normal file
95
SportsTimeTests/TripOptionsGroupingTests.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// TripOptionsGroupingTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import SportsTime
|
||||
|
||||
struct TripOptionsGroupingTests {
|
||||
|
||||
// Helper to create mock ItineraryOption
|
||||
private func makeOption(stops: [(city: String, games: [String])], totalMiles: Double = 500) -> ItineraryOption {
|
||||
let tripStops = stops.map { stopData in
|
||||
TripStop(
|
||||
city: stopData.city,
|
||||
state: "XX",
|
||||
coordinate: .init(latitude: 0, longitude: 0),
|
||||
games: stopData.games,
|
||||
arrivalDate: Date(),
|
||||
departureDate: Date(),
|
||||
travelFromPrevious: nil
|
||||
)
|
||||
}
|
||||
return ItineraryOption(
|
||||
id: UUID().uuidString,
|
||||
stops: tripStops,
|
||||
totalDistanceMiles: totalMiles,
|
||||
totalDrivingHours: totalMiles / 60,
|
||||
score: 1.0
|
||||
)
|
||||
}
|
||||
|
||||
@Test func groupByCityCountDescending() {
|
||||
let options = [
|
||||
makeOption(stops: [("NYC", []), ("Boston", [])]), // 2 cities
|
||||
makeOption(stops: [("LA", []), ("SF", []), ("Seattle", [])]), // 3 cities
|
||||
makeOption(stops: [("Chicago", [])]), // 1 city
|
||||
]
|
||||
|
||||
let grouped = TripOptionsGrouper.groupByCityCount(options, ascending: false)
|
||||
|
||||
#expect(grouped.count == 3)
|
||||
#expect(grouped[0].header == "3 cities")
|
||||
#expect(grouped[1].header == "2 cities")
|
||||
#expect(grouped[2].header == "1 city")
|
||||
}
|
||||
|
||||
@Test func groupByGameCountAscending() {
|
||||
let options = [
|
||||
makeOption(stops: [("NYC", ["g1", "g2", "g3"])]), // 3 games
|
||||
makeOption(stops: [("LA", ["g1"])]), // 1 game
|
||||
makeOption(stops: [("Chicago", ["g1", "g2"])]), // 2 games
|
||||
]
|
||||
|
||||
let grouped = TripOptionsGrouper.groupByGameCount(options, ascending: true)
|
||||
|
||||
#expect(grouped.count == 3)
|
||||
#expect(grouped[0].header == "1 game")
|
||||
#expect(grouped[1].header == "2 games")
|
||||
#expect(grouped[2].header == "3 games")
|
||||
}
|
||||
|
||||
@Test func groupByMileageRangeDescending() {
|
||||
let options = [
|
||||
makeOption(stops: [("NYC", [])], totalMiles: 300), // 0-500
|
||||
makeOption(stops: [("LA", [])], totalMiles: 1200), // 1000-1500
|
||||
makeOption(stops: [("Chicago", [])], totalMiles: 2500), // 2000+
|
||||
]
|
||||
|
||||
let grouped = TripOptionsGrouper.groupByMileageRange(options, ascending: false)
|
||||
|
||||
#expect(grouped[0].header == "2000+ mi")
|
||||
#expect(grouped[1].header == "1000-1500 mi")
|
||||
#expect(grouped[2].header == "0-500 mi")
|
||||
}
|
||||
|
||||
@Test func groupByMileageRangeAscending() {
|
||||
let options = [
|
||||
makeOption(stops: [("NYC", [])], totalMiles: 300),
|
||||
makeOption(stops: [("LA", [])], totalMiles: 1200),
|
||||
makeOption(stops: [("Chicago", [])], totalMiles: 2500),
|
||||
]
|
||||
|
||||
let grouped = TripOptionsGrouper.groupByMileageRange(options, ascending: true)
|
||||
|
||||
#expect(grouped[0].header == "0-500 mi")
|
||||
#expect(grouped[1].header == "1000-1500 mi")
|
||||
#expect(grouped[2].header == "2000+ mi")
|
||||
}
|
||||
|
||||
@Test func emptyOptionsReturnsEmptyGroups() {
|
||||
let grouped = TripOptionsGrouper.groupByCityCount([], ascending: false)
|
||||
#expect(grouped.isEmpty)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user