fix: 10 audit fixes — memory safety, performance, accessibility, architecture

- Add a11y label to ProgressMapView reset button and progress bar values
- Fix CADisplayLink retain cycle in ItineraryTableViewController via deinit
- Add [weak self] to PhotoGalleryViewModel Task closure
- Add @MainActor to TripWizardViewModel, remove manual MainActor.run hop
- Fix O(n²) rank lookup in PollDetailView/DebugPollPreviewView with enumerated()
- Cache itinerarySections via ItinerarySectionBuilder static extraction + @State
- Convert CanonicalSyncService/BootstrapService from actor to @MainActor final class
- Add .accessibilityHidden(true) to RegionMapSelector Map to prevent duplicate VoiceOver

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-17 12:00:35 -06:00
parent 46434af4ab
commit 9b0cb96638
13 changed files with 415 additions and 109 deletions

View File

@@ -0,0 +1,217 @@
//
// ItinerarySectionBuilderTests.swift
// SportsTimeTests
//
// Tests for ItinerarySectionBuilder pure static function.
//
import Testing
import CoreLocation
@testable import SportsTime
@Suite("ItinerarySectionBuilder")
struct ItinerarySectionBuilderTests {
// MARK: - Helpers
private func makeTripDays(count: Int, startDate: Date = Date()) -> [Date] {
(0..<count).map {
Calendar.current.date(byAdding: .day, value: $0, to: Calendar.current.startOfDay(for: startDate))!
}
}
private func makeTrip(
cities: [String] = ["New York", "Boston"],
startDate: Date = Date(),
daysPerStop: Int = 1,
gameIds: [[String]] = []
) -> (Trip, [Date]) {
let stops = TestFixtures.tripStops(cities: cities, startDate: startDate, daysPerStop: daysPerStop)
var stopsWithGames = stops
for (i, ids) in gameIds.enumerated() where i < stopsWithGames.count {
stopsWithGames[i] = TripStop(
stopNumber: stopsWithGames[i].stopNumber,
city: stopsWithGames[i].city,
state: stopsWithGames[i].state,
coordinate: stopsWithGames[i].coordinate,
arrivalDate: stopsWithGames[i].arrivalDate,
departureDate: stopsWithGames[i].departureDate,
games: ids,
isRestDay: stopsWithGames[i].isRestDay
)
}
let trip = TestFixtures.trip(stops: stopsWithGames)
let totalDays = cities.count * daysPerStop
let days = makeTripDays(count: totalDays, startDate: startDate)
return (trip, days)
}
// MARK: - Tests
@Test("builds one section per day")
func buildsSectionsForEachDay() {
let startDate = Calendar.current.startOfDay(for: Date())
let (trip, days) = makeTrip(cities: ["New York", "Boston", "Philadelphia"], startDate: startDate)
let sections = ItinerarySectionBuilder.build(
trip: trip,
tripDays: days,
games: [:],
travelOverrides: [:],
itineraryItems: [],
allowCustomItems: false
)
// Should have travel + day sections
let daySections = sections.filter {
if case .day = $0 { return true }
return false
}
#expect(daySections.count == days.count)
}
@Test("games filtered correctly by date")
func gamesOnFiltersCorrectly() {
let startDate = Calendar.current.startOfDay(for: Date())
let gameDate = startDate
let game = TestFixtures.game(sport: .mlb, city: "New York", dateTime: gameDate)
let richGame = TestFixtures.richGame(game: game, homeCity: "New York")
let (trip, days) = makeTrip(
cities: ["New York", "Boston"],
startDate: startDate,
gameIds: [[game.id]]
)
let sections = ItinerarySectionBuilder.build(
trip: trip,
tripDays: days,
games: [game.id: richGame],
travelOverrides: [:],
itineraryItems: [],
allowCustomItems: false
)
// Find the first day section and verify it has a game
let firstDaySection = sections.first {
if case .day(1, _, _) = $0 { return true }
return false
}
if case .day(_, _, let gamesOnDay) = firstDaySection {
#expect(gamesOnDay.count == 1)
#expect(gamesOnDay.first?.game.id == game.id)
} else {
Issue.record("Expected day section with games")
}
}
@Test("travel segments appear in sections")
func travelSegmentsAppear() {
let startDate = Calendar.current.startOfDay(for: Date())
let travel = TestFixtures.travelSegment(from: "New York", to: "Boston")
let (baseTrip, days) = makeTrip(
cities: ["New York", "Boston"],
startDate: startDate,
daysPerStop: 1
)
// Create trip with travel segment
var trip = baseTrip
trip.travelSegments = [travel]
// Build without overrides - travel appears at default position
let sectionsDefault = ItinerarySectionBuilder.build(
trip: trip,
tripDays: days,
games: [:],
travelOverrides: [:],
itineraryItems: [],
allowCustomItems: false
)
let travelSections = sectionsDefault.filter {
if case .travel = $0 { return true }
return false
}
// TravelPlacement should place the travel segment
#expect(travelSections.count == 1)
// Verify travel appears before day 2 (arrival day)
if let travelIdx = sectionsDefault.firstIndex(where: {
if case .travel = $0 { return true }
return false
}) {
let nextIdx = sectionsDefault.index(after: travelIdx)
if nextIdx < sectionsDefault.count,
case .day(let dayNum, _, _) = sectionsDefault[nextIdx] {
#expect(dayNum == 2)
}
}
}
@Test("custom items included when allowCustomItems is true")
func customItemsIncluded() {
let startDate = Calendar.current.startOfDay(for: Date())
let (trip, days) = makeTrip(cities: ["New York"], startDate: startDate)
let customItem = ItineraryItem(
tripId: trip.id,
day: 1,
sortOrder: 1.0,
kind: .custom(CustomInfo(title: "Lunch at Joe's", icon: "fork.knife"))
)
let sectionsWithItems = ItinerarySectionBuilder.build(
trip: trip,
tripDays: days,
games: [:],
travelOverrides: [:],
itineraryItems: [customItem],
allowCustomItems: true
)
let sectionsWithoutItems = ItinerarySectionBuilder.build(
trip: trip,
tripDays: days,
games: [:],
travelOverrides: [:],
itineraryItems: [customItem],
allowCustomItems: false
)
let customSectionsWithItems = sectionsWithItems.filter { $0.isCustomItem }
let addButtonSections = sectionsWithItems.filter {
if case .addButton = $0 { return true }
return false
}
let customSectionsWithoutItems = sectionsWithoutItems.filter { $0.isCustomItem }
#expect(customSectionsWithItems.count == 1)
#expect(addButtonSections.count >= 1)
#expect(customSectionsWithoutItems.count == 0)
}
@Test("stableTravelAnchorId format is consistent")
func stableTravelAnchorIdFormat() {
let segment = TestFixtures.travelSegment(from: "New York", to: "Boston")
let id = ItinerarySectionBuilder.stableTravelAnchorId(segment, at: 0)
#expect(id.hasPrefix("travel:0:"))
#expect(id.contains("->"))
}
@Test("empty trip produces no sections")
func emptyTripProducesNoSections() {
let trip = TestFixtures.trip(stops: [])
let sections = ItinerarySectionBuilder.build(
trip: trip,
tripDays: [],
games: [:],
travelOverrides: [:],
itineraryItems: [],
allowCustomItems: false
)
#expect(sections.isEmpty)
}
}