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:
Trey t
2026-01-12 21:49:04 -06:00
parent 89167c01d7
commit 0524284ab8
5 changed files with 389 additions and 13 deletions

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

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