Search: FlightAware backbone, blob catalog, diagnostic infra

route-explorer's /api/token sits behind invisible Cloudflare Turnstile
that requires Apple's Private Access Token attestation. Third-party
iOS apps don't qualify for PAT issuance, and Linux Docker containers
can't pass it either (cross-OS fingerprint, even with patchright /
Camoufox). Migrates direct-flight search to FlightAware; multi-stop
and where-can-I-go remain via embedded SFSafariViewController.

- FlightAwareScheduleClient — scrapes route.rvt + trackpoll JSON for
  real schedules without auth. T+0..2 day window. Tests against
  captured HTML fixtures.
- BlobRouteClient — pulls the public Vercel blob route catalog
  route-explorer's frontend reads (no auth, no Turnstile).
- DiagnosticLogger + LoggingURLSessionDelegate + DiagnosticsView —
  device-shareable forensic trace. Boot header captures device, OS,
  locale, UA; share-sheet export of session logs.
- TurnstileDebugView — live WKWebView gate inspector. Used to prove
  the PAT-entitlement gap on a real device.
- RouteExplorerBrowserView — SFSafariViewController wrapper. Real
  Safari clears Turnstile naturally; the in-app browser opens at
  pre-filled search URLs. Surfaced from Search ("Open in
  route-explorer") and Settings → Tools.
- RouteExplorerTokenStore + RouteExplorerSetupView — bookmarklet
  capture flow (token round-tripped via flights://routeexplorer-token
  URL scheme). Kept dormant for future use.

backend/ — Docker proxy attempts (Playwright, patchright, Camoufox).
All fail on Linux because Cloudflare auto-denies before the Turnstile
widget renders. Documented; kept as scaffolding for a future paid-
solver integration.

scripts/probe_flightaware.py — reference algorithm for the FA path.
scripts/probe_nodriver.py — local-Mac sanity check confirming the
gate clears with real macOS Chrome (proves the blocker is
fingerprint-level, not network-level).
This commit is contained in:
Trey T
2026-06-06 01:09:59 -05:00
parent d122c95342
commit ba0688a412
70 changed files with 89096 additions and 209 deletions
@@ -0,0 +1,134 @@
import XCTest
import SwiftData
@testable import Flights
/// Unit tests for `AirframeHistoryStore`.
///
/// We exercise the store against an in-memory `ModelContainer` seeded
/// with `LoggedFlight` rows that vary by tail number, route, and date.
/// All assertions reference the documented `AirframeStats` contract.
@MainActor
final class AirframeHistoryStoreTests: XCTestCase {
private var container: ModelContainer!
private var context: ModelContext!
private var store: AirframeHistoryStore!
override func setUpWithError() throws {
try super.setUpWithError()
let schema = Schema([LoggedFlight.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
container = try ModelContainer(for: schema, configurations: config)
context = ModelContext(container)
store = AirframeHistoryStore()
}
override func tearDownWithError() throws {
store = nil
context = nil
container = nil
try super.tearDownWithError()
}
// MARK: - Helpers
private static let epoch = Date(timeIntervalSince1970: 1_700_000_000)
private func date(_ dayOffset: Int) -> Date {
Self.epoch.addingTimeInterval(TimeInterval(dayOffset) * 86_400)
}
@discardableResult
private func insert(
registration: String?,
origin: String,
dest: String,
flightDate: Date
) -> LoggedFlight {
let flight = LoggedFlight(
flightDate: flightDate,
departureIATA: origin,
arrivalIATA: dest,
registration: registration
)
context.insert(flight)
return flight
}
// MARK: - Tests
/// Empty store empty stats sentinel.
func test_stats_emptyContext_returnsEmpty() {
let stats = store.stats(forTail: "N281WN", context: context)
XCTAssertEqual(stats.totalFlights, 0)
XCTAssertTrue(stats.routes.isEmpty)
XCTAssertNil(stats.firstSeen)
XCTAssertNil(stats.lastSeen)
XCTAssertNil(stats.mostCommonRoute)
}
/// 3 flights on the same tail across 2 distinct routes verify the
/// aggregate counts and the "DALHOU (2 of 3)" most-common-route
/// formatting.
func test_stats_threeFlightsTwoRoutes_aggregatesCorrectly() {
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(0))
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(5))
insert(registration: "N281WN", origin: "DAL", dest: "LAS", flightDate: date(10))
// Other-tail noise must not be counted.
insert(registration: "N999AA", origin: "DAL", dest: "HOU", flightDate: date(2))
let stats = store.stats(forTail: "N281WN", context: context)
XCTAssertEqual(stats.totalFlights, 3)
XCTAssertEqual(Set(stats.routes), Set(["DAL→HOU", "DAL→LAS"]))
XCTAssertEqual(stats.routes.count, 2)
XCTAssertEqual(stats.firstSeen, date(0))
XCTAssertEqual(stats.lastSeen, date(10))
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (2 of 3)")
}
/// Lookup tail must be normalized to uppercase passing "n281wn"
/// matches a stored "N281WN".
func test_stats_lookupIsCaseInsensitive() {
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(0))
let stats = store.stats(forTail: "n281wn", context: context)
XCTAssertEqual(stats.totalFlights, 1)
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (1 of 1)")
}
/// The store should still report stats for a single-flight tail. The
/// History UI hides the section in that case, but the underlying
/// store contract returns the real count.
func test_stats_singleFlight_returnsTotalOne() {
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(0))
let stats = store.stats(forTail: "N281WN", context: context)
XCTAssertEqual(stats.totalFlights, 1)
XCTAssertEqual(stats.routes, ["DAL→HOU"])
XCTAssertEqual(stats.firstSeen, date(0))
XCTAssertEqual(stats.lastSeen, date(0))
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (1 of 1)")
}
/// Mixed-case stored registration: a record persisted with lowercase
/// "n281wn" must still be discoverable when callers ask for
/// "N281WN". Today the fast-path #Predicate misses (it compares
/// exact bytes against the uppercased query) and the fallback
/// table-scan recovers it. After Phase 3 fixes registration
/// normalisation at write-time (or switches to a case-insensitive
/// predicate), the fast path will hit but this test should still
/// pass either way.
func test_stats_lowercaseStoredRegistration_isFoundViaFallback() {
insert(registration: "n281wn", origin: "DAL", dest: "HOU", flightDate: date(0))
let stats = store.stats(forTail: "N281WN", context: context)
XCTAssertEqual(stats.totalFlights, 1)
XCTAssertEqual(stats.routes, ["DAL→HOU"])
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (1 of 1)")
}
}
@@ -0,0 +1,80 @@
import XCTest
@testable import Flights
/// Coverage for `DataIntegrityMonitor` the shared sink that collects
/// bundled-JSON decode failures so `RootView` can show a banner instead
/// of leaving the user staring at "no data" with no explanation.
///
/// The monitor is `@MainActor` because it's read by SwiftUI views, so
/// every test hop onto the main actor before touching it. Each test also
/// calls `clear()` first because the singleton is process-wide and other
/// loaders may have reported into it during test bring-up.
@MainActor
final class DataIntegrityMonitorTests: XCTestCase {
override func setUp() async throws {
try await super.setUp()
DataIntegrityMonitor.shared.clear()
}
override func tearDown() async throws {
DataIntegrityMonitor.shared.clear()
try await super.tearDown()
}
func test_reportingOneFailure_setsHasFailuresTrue() {
let monitor = DataIntegrityMonitor.shared
XCTAssertFalse(monitor.hasFailures, "monitor should start empty after clear()")
let err = NSError(
domain: "TestDomain",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "bad json"]
)
monitor.report("bts_bundle.json", error: err)
XCTAssertTrue(monitor.hasFailures)
XCTAssertEqual(monitor.failures.count, 1)
XCTAssertTrue(
monitor.failures[0].contains("bts_bundle.json"),
"failure entry should include the resource basename"
)
XCTAssertTrue(
monitor.failures[0].contains("bad json"),
"failure entry should include the localized description"
)
}
func test_reportingTwoFailures_accumulates() {
let monitor = DataIntegrityMonitor.shared
monitor.report(
"jumpseat_rules.json",
error: NSError(domain: "T", code: 1, userInfo: [NSLocalizedDescriptionKey: "missing field"])
)
monitor.report(
"crewbases.json",
error: NSError(domain: "T", code: 2, userInfo: [NSLocalizedDescriptionKey: "trailing comma"])
)
XCTAssertEqual(monitor.failures.count, 2)
XCTAssertTrue(monitor.hasFailures)
XCTAssertTrue(monitor.failures[0].contains("jumpseat_rules.json"))
XCTAssertTrue(monitor.failures[1].contains("crewbases.json"))
}
func test_clear_resetsHasFailures() {
let monitor = DataIntegrityMonitor.shared
monitor.report(
"partner_matrix.json",
error: NSError(domain: "T", code: 1, userInfo: [NSLocalizedDescriptionKey: "broken"])
)
XCTAssertTrue(monitor.hasFailures, "precondition: monitor has at least one failure")
monitor.clear()
XCTAssertFalse(monitor.hasFailures)
XCTAssertEqual(monitor.failures.count, 0)
}
}
@@ -0,0 +1,245 @@
import XCTest
@testable import Flights
// MARK: - Test Doubles
//
// Phase 3 wired the production `AircraftRotationProvider` protocol in
// `Services/DelayCascadePredictor.swift`, so we just consume it here
// rather than re-declaring it.
/// Stub rotation provider: returns whatever segments the test handed in,
/// regardless of which icao24 / lookback is queried.
actor MockRotationProvider: AircraftRotationProvider {
private let segments: [AircraftRotationTracker.RotationSegment]
init(segments: [AircraftRotationTracker.RotationSegment]) {
self.segments = segments
}
func rotation(forICAO24: String, lookbackHours: Int) async -> [AircraftRotationTracker.RotationSegment] {
return segments
}
}
final class DelayCascadePredictorTests: XCTestCase {
// Fixed reference point every test offsets from here so absolute
// wall-clock time doesn't matter.
private let scheduledDeparture = Date(timeIntervalSince1970: 1_750_000_000)
private let departureICAO = "KJFK"
private let carrier = "DL"
private let flightNumber = 1234
// MARK: - Test 1: missing operating aircraft
func test_nilOperatingICAO24_returnsNil() async {
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KJFK", arrivalOffsetMin: 60)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: nil
)
XCTAssertNil(result, "No tail assigned → no cascade prediction.")
}
func test_emptyOperatingICAO24_returnsNil() async {
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KJFK", arrivalOffsetMin: 60)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: " "
)
XCTAssertNil(result, "Whitespace-only icao24 → no cascade prediction.")
}
// MARK: - Test 2: rotation empty
func test_emptyRotation_returnsNil() async {
let provider = MockRotationProvider(segments: [])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
XCTAssertNil(result, "No upstream segments → no cascade prediction.")
}
// MARK: - Test 3: wrong arrival station
func test_lastSegmentArrivedAtDifferentStation_returnsNil() async {
// Aircraft last landed at KATL but we're operating out of KJFK.
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KATL", arrivalOffsetMin: 60)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
XCTAssertNil(result, "Aircraft not yet at departure station → no cascade prediction.")
}
func test_lastSegmentArrivalICAOMissing_returnsNil() async {
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: nil, arrivalOffsetMin: 60)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
XCTAssertNil(result, "Unknown arrival airport → no cascade prediction.")
}
// MARK: - Test 4: 60-min late upstream, 30 min until scheduled departure ~75 min cascade
func test_upstreamLandsLate_cascadesByExpectedAmount() async {
// Aircraft landed at JFK 30 minutes AFTER scheduled departure
// (arrivalOffsetMin = +30). Add the 45-minute narrowbody turn and
// earliest pushback is 75 min past scheduled departure.
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KJFK", arrivalOffsetMin: 30)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
guard let prediction = result else {
XCTFail("Expected a cascade prediction, got nil.")
return
}
XCTAssertEqual(prediction.predictedDelayMin, 75, "30 min late arrival + 45 min turn = 75 min cascade.")
XCTAssertNotNil(prediction.upstreamSegment, "Prediction must surface the upstream leg used.")
XCTAssertFalse(prediction.basis.isEmpty, "Basis string must explain the prediction.")
}
// MARK: - Test 5: 5 min late below threshold
func test_upstreamOnlyMildlyLate_returnsNil() async {
// Arrival 50 min BEFORE scheduled departure 5 min after the
// 45-min turn window. Both the raw lateness AND the propagated
// minutes are below the 15-min reporting threshold.
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KJFK", arrivalOffsetMin: -50)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
XCTAssertNil(result, "Below threshold cascade should not surface.")
}
// MARK: - Test 6: exactly 45 min before scheduled departure turn absorbs
func test_arrivalExactly45MinBeforeScheduled_returnsNil() async {
// Aircraft landed 45 min before scheduled departure. Earliest
// pushback equals scheduled departure propagated 0 no cascade.
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KJFK", arrivalOffsetMin: -45)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
XCTAssertNil(result, "Turn exactly absorbs upstream lateness → no cascade.")
}
// MARK: - Test 7: confidence > 0.5 once propagatedMinutes >= 30
func test_confidenceCrosses50WhenPropagatedAtLeast30() async {
// Arrival 15 min AFTER scheduled departure 60 min propagated.
// Confidence should comfortably exceed 0.5.
let provider = MockRotationProvider(segments: [
segment(arrivalICAO: "KJFK", arrivalOffsetMin: 15)
])
let predictor = DelayCascadePredictor(tracker: provider)
let result = await predictor.predict(
carrier: carrier,
flightNumber: flightNumber,
scheduledDeparture: scheduledDeparture,
departureICAO: departureICAO,
operatingICAO24: "a1b2c3"
)
guard let prediction = result else {
XCTFail("Expected a cascade prediction, got nil.")
return
}
XCTAssertGreaterThanOrEqual(prediction.predictedDelayMin, 30,
"Sanity check on the cascade size we're scoring.")
XCTAssertGreaterThan(prediction.confidence, 0.5,
"Propagated >= 30 min should produce confidence > 0.5.")
XCTAssertLessThanOrEqual(prediction.confidence, 1.0,
"Confidence should always be a probability.")
}
// MARK: - Helpers
/// Builds a single rotation segment whose arrival time is offset from
/// `scheduledDeparture` by `arrivalOffsetMin` minutes (positive = late
/// vs. scheduled, negative = before scheduled).
private func segment(arrivalICAO: String?,
arrivalOffsetMin: Int,
departureICAO: String? = "KBOS") -> AircraftRotationTracker.RotationSegment {
let arrival = scheduledDeparture.addingTimeInterval(Double(arrivalOffsetMin) * 60)
// Block time of 90 min before arrival exact value doesn't matter
// for the predictor, which only consults arrivalTime.
let departure = arrival.addingTimeInterval(-90 * 60)
return AircraftRotationTracker.RotationSegment(
id: "test-seg-\(arrivalOffsetMin)",
departureICAO: departureICAO,
arrivalICAO: arrivalICAO,
departureTime: departure,
arrivalTime: arrival,
estimatedDelayMin: nil
)
}
}
@@ -0,0 +1,174 @@
import XCTest
@testable import Flights
/// Unit tests for `EquipmentSwapService`.
///
/// These exercise the bundled `aircraft_seats.json` catalog and the public
/// `check(scheduledEquipmentIATA:liveEquipmentICAO:)` entry point. The test
/// target is hosted by Flights.app, so `Bundle.main` resolves to the host
/// bundle and the catalog loads normally.
///
/// NOTE: The current catalog is a generic one-size-fits-carrier map. After
/// Phase 2 the schema becomes per-carrier-per-IATA; the IATA codes used
/// below (`73H`, `7M8`, `73G`, `320`) and ICAO codes (`B738`, `B737`) will
/// remain valid lookups, but these tests will need to be revisited then.
///
/// Seat values referenced (from `Flights/Resources/aircraft_seats.json` defaults):
/// 73G 137 (B737-700)
/// 73H 172 (B737-800)
/// 7M8 172 (B737-MAX 8)
/// 320 150 (A320)
/// ICAO B738 IATA 73H
/// ICAO B737 IATA 73G
final class EquipmentSwapServiceTests: XCTestCase {
// A fresh service per test the actor caches the catalog after first
// load, but we want each case to be independent of ordering.
private func makeService() -> EquipmentSwapService {
EquipmentSwapService()
}
// MARK: - 1. Both nil nil
func test_returnsNil_whenBothScheduledAndLiveAreNil() async {
let service = makeService()
let result = await service.check(
scheduledEquipmentIATA: nil,
liveEquipmentICAO: nil
)
XCTAssertNil(result, "Expected nil when there is nothing to compare.")
}
// MARK: - 2. Only live provided nil (no baseline)
func test_returnsNil_whenOnlyLiveICAOProvided() async {
let service = makeService()
let result = await service.check(
scheduledEquipmentIATA: nil,
liveEquipmentICAO: "B738"
)
XCTAssertNil(
result,
"Without a scheduled baseline there is no meaningful comparison to surface."
)
}
// MARK: - 3. Same equipment (live ICAO maps to scheduled IATA)
func test_returnsNoneSeverity_whenScheduledAndLiveMatch() async {
let service = makeService()
// Scheduled 73H (B737-800, 175) vs live B738 73H (175). Identical.
let result = await service.check(
scheduledEquipmentIATA: "73H",
liveEquipmentICAO: "B738"
)
guard let result else {
XCTFail("Expected a non-nil result for a known equipment pair.")
return
}
XCTAssertEqual(result.seatDelta, 0, "Same aircraft should produce a zero seat delta.")
XCTAssertEqual(result.severity, .none, "Zero delta must read as .none severity.")
XCTAssertEqual(result.scheduledSeats, 172)
XCTAssertEqual(result.liveSeats, 172)
XCTAssertTrue(
result.summary.contains("Same equipment today"),
"Summary should reflect the unchanged equipment. Got: \(result.summary)"
)
}
// MARK: - 4. |delta| in 1...15 .minor
func test_returnsMinorSeverity_whenDeltaIsSmall() async {
let service = makeService()
// Scheduled 320 (A320, 150) vs live B737 73G (137). |delta| = 13.
let result = await service.check(
scheduledEquipmentIATA: "320",
liveEquipmentICAO: "B737"
)
guard let result else {
XCTFail("Expected a non-nil result for a known equipment pair.")
return
}
XCTAssertEqual(result.scheduledSeats, 150)
XCTAssertEqual(result.liveSeats, 137)
XCTAssertEqual(result.seatDelta, -13, "Live aircraft has 13 fewer seats than scheduled.")
XCTAssertEqual(result.severity, .minor, "A 13-seat change must be classified .minor (1...15).")
XCTAssertTrue(
result.summary.contains("Smaller bird today"),
"Negative delta summary should call out the smaller aircraft. Got: \(result.summary)"
)
}
// MARK: - 5. |delta| > 15 .significant
func test_returnsSignificantSeverity_whenDeltaIsLarge() async {
let service = makeService()
// Scheduled 73G (B737-700, 137) vs live B738 73H (172). |delta| = 35.
let result = await service.check(
scheduledEquipmentIATA: "73G",
liveEquipmentICAO: "B738"
)
guard let result else {
XCTFail("Expected a non-nil result for a known equipment pair.")
return
}
XCTAssertEqual(result.scheduledSeats, 137)
XCTAssertEqual(result.liveSeats, 172)
XCTAssertEqual(result.seatDelta, 35, "Live aircraft has 35 more seats than scheduled.")
XCTAssertEqual(result.severity, .significant, "A 35-seat swing exceeds 15 → .significant.")
XCTAssertTrue(
result.summary.contains("Bigger bird today"),
"Positive delta summary should call out the larger aircraft. Got: \(result.summary)"
)
}
// MARK: - 6. ICAO "B738" maps to IATA "73H" (no-swap path through ICAO mapping)
func test_icaoB738_mapsTo_iata73H_asNoSwap() async {
let service = makeService()
// Scheduled was the 73H; live equipment reports as ICAO B738 these
// are the same airframe family. Catalog mapping should collapse them.
let result = await service.check(
scheduledEquipmentIATA: "73H",
liveEquipmentICAO: "B738"
)
guard let result else {
XCTFail("Expected a non-nil result; ICAO B738 should map to IATA 73H.")
return
}
XCTAssertEqual(
result.liveSeats, result.scheduledSeats,
"B738 → 73H mapping must produce equal scheduled/live seat counts."
)
XCTAssertEqual(result.seatDelta, 0)
XCTAssertEqual(result.severity, .none)
XCTAssertEqual(
result.liveName, result.scheduledName,
"The resolved live aircraft name should match the scheduled name (both 73H)."
)
}
// MARK: - 7. Unknown live ICAO liveSeats nil + "live equipment unknown" summary
func test_unknownLiveICAO_returnsNilLiveSeats_andUnknownSummary() async {
let service = makeService()
// "ZZZZ" is not in the ICAO map and is not a valid IATA fallback.
let result = await service.check(
scheduledEquipmentIATA: "73H",
liveEquipmentICAO: "ZZZZ"
)
guard let result else {
XCTFail("Expected a non-nil result — we still have a scheduled baseline.")
return
}
XCTAssertEqual(result.scheduledSeats, 172, "Scheduled 73H still resolves to 172 seats.")
XCTAssertNil(result.liveSeats, "Unknown ICAO must leave liveSeats nil.")
XCTAssertNil(result.liveName, "Unknown ICAO must leave liveName nil.")
XCTAssertNil(result.seatDelta, "Without a live entry there is no delta to compute.")
XCTAssertEqual(result.severity, .none, "Missing live data falls back to .none severity.")
XCTAssertTrue(
result.summary.contains("live equipment unknown"),
"Summary should explicitly say the live equipment is unknown. Got: \(result.summary)"
)
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,142 @@
import XCTest
@testable import Flights
/// Tests for ``FlightAwareScheduleClient``. The two pure-parser entry
/// points (``parseIdents`` and ``extractTrackpollBlob``) are exercised
/// directly against fixture HTML captured from a live request this
/// catches FlightAware schema drift the moment it happens (route.rvt or
/// trackpoll layout changes) instead of finding out via empty search
/// results in production.
///
/// Fixtures live next to this file under `Fixtures/`. They're real
/// HTML pages saved verbatim from FlightAware, not synthetic markup,
/// so the tests assert against the actual shapes the parser sees.
final class FlightAwareScheduleClientTests: XCTestCase {
// MARK: - Fixture loading
/// Reads a file from the `Fixtures/` directory sibling to this test
/// source file. Avoids needing the test target's pbxproj to declare
/// a Resources phase `#filePath` resolves to the real source path
/// at test-run time.
private func loadFixture(_ name: String, file: StaticString = #filePath) throws -> String {
let here = URL(fileURLWithPath: String(describing: file))
let url = here.deletingLastPathComponent()
.appendingPathComponent("Fixtures")
.appendingPathComponent(name)
return try String(contentsOf: url, encoding: .utf8)
}
// MARK: - parseIdents
func test_parseIdents_extractsFlightIdent_fromRouteAnalysisPage() throws {
let html = try loadFixture("DFW_EHAM_route.html")
let idents = FlightAwareScheduleClient.parseIdents(routeHTML: html)
XCTAssertFalse(idents.isEmpty,
"Should find at least one operating ident on DFW->AMS route page.")
XCTAssertTrue(idents.contains("AAL220"),
"AAL220 (AA daily 777-200 DFW->AMS) must surface; got \(idents)")
}
func test_parseIdents_dedupesRepeatedIdents() throws {
let html = try loadFixture("DFW_EHAM_route.html")
let idents = FlightAwareScheduleClient.parseIdents(routeHTML: html)
XCTAssertEqual(idents.count, Set(idents).count,
"Returned idents should be deduped; got \(idents)")
}
func test_parseIdents_returnsEmpty_whenNoRoutesPresent() {
let empty = """
<html><body><table>
<tr><th>Filed Time</th><th>Ident</th></tr>
<tr><td>No data</td></tr>
</table></body></html>
"""
XCTAssertEqual(
FlightAwareScheduleClient.parseIdents(routeHTML: empty),
[],
"Page with no flight rows should produce an empty list, not crash."
)
}
// MARK: - extractTrackpollBlob
func test_extractTrackpollBlob_returnsParseableJSON() throws {
let html = try loadFixture("AAL220_trackpoll.html")
guard let blob = FlightAwareScheduleClient.extractTrackpollBlob(from: html) else {
XCTFail("Should extract trackpollBootstrap from AAL220 page")
return
}
XCTAssertTrue(blob.hasPrefix("{") && blob.hasSuffix("}"),
"Extracted blob should be a JSON object literal")
// Round-trip through JSONDecoder to confirm shape.
XCTAssertNoThrow(
try JSONDecoder().decode(TrackpollBootstrap.self, from: Data(blob.utf8)),
"Extracted JSON should decode against TrackpollBootstrap schema"
)
}
func test_extractTrackpollBlob_returnsNil_whenMarkerMissing() {
let html = "<html><body>no script here</body></html>"
XCTAssertNil(FlightAwareScheduleClient.extractTrackpollBlob(from: html))
}
func test_extractTrackpollBlob_isStringContentAware() {
// A closing brace inside a string literal must NOT terminate the scan.
let html = #"""
<script>var trackpollBootstrap = {"a":"} not the end","b":1};</script>
"""#
let blob = FlightAwareScheduleClient.extractTrackpollBlob(from: html)
XCTAssertEqual(
blob,
#"{"a":"} not the end","b":1}"#,
"Braces inside JSON strings must not break the brace-balance scan."
)
}
// MARK: - ident decomposition
func test_identCarrierICAO_stripsTrailingDigits() {
XCTAssertEqual(FlightAwareScheduleClient.identCarrierICAO("AAL220"), "AAL")
XCTAssertEqual(FlightAwareScheduleClient.identCarrierICAO("BAW296"), "BAW")
XCTAssertEqual(FlightAwareScheduleClient.identCarrierICAO("SWA1"), "SWA")
}
func test_identFlightNumber_extractsTrailingDigits() {
XCTAssertEqual(FlightAwareScheduleClient.identFlightNumber("AAL220"), 220)
XCTAssertEqual(FlightAwareScheduleClient.identFlightNumber("BAW296"), 296)
XCTAssertEqual(FlightAwareScheduleClient.identFlightNumber("SWA1"), 1)
}
func test_airlineIATA_mapsKnownAndReturnsNilForUnknown() {
XCTAssertEqual(FlightAwareScheduleClient.airlineIATA(forICAO: "AAL"), "AA")
XCTAssertEqual(FlightAwareScheduleClient.airlineIATA(forICAO: "KLM"), "KL")
XCTAssertEqual(FlightAwareScheduleClient.airlineIATA(forICAO: "BAW"), "BA")
XCTAssertNil(FlightAwareScheduleClient.airlineIATA(forICAO: "ZZZ"),
"Unknown ICAO should return nil so caller can fall back to the raw prefix.")
}
// MARK: - End-to-end against fixture
func test_endToEnd_AAL220_trackpoll_decodesToScheduledLeg() throws {
let html = try loadFixture("AAL220_trackpoll.html")
guard let blob = FlightAwareScheduleClient.extractTrackpollBlob(from: html) else {
XCTFail("missing trackpoll blob")
return
}
let decoded = try JSONDecoder().decode(TrackpollBootstrap.self, from: Data(blob.utf8))
// The fixture was captured on 2026-06-05; it should contain a DFW->AMS
// leg with a B772 aircraft. We don't assert exact timestamps because
// future updates to the fixture (re-capture) will rotate the dates.
let dfwAmsLegs = decoded.flights.values
.flatMap { $0.activityLog.flights }
.filter { $0.origin.iata == "DFW" && $0.destination.iata == "AMS" }
XCTAssertFalse(dfwAmsLegs.isEmpty,
"AAL220 fixture should contain at least one DFW->AMS leg")
XCTAssertTrue(
dfwAmsLegs.contains { $0.aircraftType == "B772" },
"AAL220 DFW->AMS legs should be operated by B772 per the captured fixture"
)
}
}
@@ -0,0 +1,65 @@
import XCTest
@testable import Flights
/// Tests for the standby tracking fields on the history flight model.
///
/// NOTE: The codebase's history record type is `LoggedFlight` (see
/// `Flights/Models/LoggedFlight.swift`). The task spec referred to it as
/// "HistoryFlight" that name does not exist. These tests therefore
/// target `LoggedFlight`, which is the actual @Model SwiftData type that
/// owns `standbyOutcome` and the computed `wasStandby`.
///
/// Assumption to verify: there is no separate `HistoryFlight` type.
final class HistoryFlightModelTests: XCTestCase {
// MARK: wasStandby
func test_wasStandby_isTrue_whenOutcomeIsStandbyMade() {
let flight = LoggedFlight(departureIATA: "LAX", arrivalIATA: "JFK")
flight.standbyOutcome = "standby-made"
XCTAssertTrue(flight.wasStandby,
"standby-made should count as a standby attempt")
}
func test_wasStandby_isTrue_whenOutcomeIsStandbyBumped() {
let flight = LoggedFlight(departureIATA: "LAX", arrivalIATA: "JFK")
flight.standbyOutcome = "standby-bumped"
XCTAssertTrue(flight.wasStandby,
"standby-bumped should count as a standby attempt")
}
func test_wasStandby_isFalse_whenOutcomeIsConfirmed() {
let flight = LoggedFlight(departureIATA: "LAX", arrivalIATA: "JFK")
flight.standbyOutcome = "confirmed"
XCTAssertFalse(flight.wasStandby,
"confirmed is a positive-space ticket, not standby")
}
func test_wasStandby_isFalse_whenOutcomeIsNil() {
let flight = LoggedFlight(departureIATA: "LAX", arrivalIATA: "JFK")
flight.standbyOutcome = nil
XCTAssertFalse(flight.wasStandby,
"nil outcome (legacy / unmigrated) should not count as standby")
}
// MARK: Default init all new standby fields nil
func test_defaultInit_hasAllStandbyFieldsNil() {
let flight = LoggedFlight()
XCTAssertNil(flight.standbyOutcome,
"standbyOutcome must default to nil for CloudKit migration safety")
XCTAssertNil(flight.standbyAttemptedAt,
"standbyAttemptedAt must default to nil")
XCTAssertNil(flight.standbyClearedAt,
"standbyClearedAt must default to nil")
XCTAssertNil(flight.standbyClass,
"standbyClass must default to nil")
XCTAssertNil(flight.standbyNotes,
"standbyNotes must default to nil")
// And the derived flag follows.
XCTAssertFalse(flight.wasStandby,
"a freshly-constructed record is not a standby attempt")
}
}
+415
View File
@@ -0,0 +1,415 @@
import XCTest
@testable import Flights
/// TDD **red phase** for ``LoadFactorService``.
///
/// These tests pin down behaviour the current implementation gets wrong
/// (timezone handling, peak-season detection in airport-local time,
/// equipment-swap edge cases, clamping) plus the correctness it already
/// has (confidence buckets, nil-on-missing-record). Every test is written
/// against the *future* `estimate(...)` signature that takes an
/// ``AirportDatabase`` so the service can resolve the origin airport's
/// timezone instead of leaning on a fixed UTC calendar.
///
/// Expected initial state when this file lands:
/// - Tests calling the new signature fail to compile (the new
/// `database:` parameter doesn't exist yet). That's the failing red.
/// - Phase 3 adds the parameter + timezone lookup + edge-case guards;
/// these tests then go green.
///
/// All assertions rely on the bundled ``bts_bundle.json``. Records used:
/// - ``WN_1701_OAK_BUR`` (leisure, OAK origin, Pacific TZ)
/// - ``UA_1_SFO_EWR`` (business, SFO origin, Pacific TZ)
/// - ``WN_5_DAL_HOU`` (leisure, high baseline clamping)
/// - ``WN_61_DAL_HOU`` (leisure, mid-bucket confidence)
/// - ``AA_1000_ORD_DFW`` (high-bucket confidence)
@MainActor
final class LoadFactorServiceTests: XCTestCase {
// Shared so we don't reload the BTS bundle / airports JSON per test.
private static let airportDatabase = AirportDatabase()
private static let service = LoadFactorService()
private var airportDatabase: AirportDatabase { Self.airportDatabase }
private var service: LoadFactorService { Self.service }
// MARK: - Helpers
/// Builds a Date from an ISO-8601 string with explicit offset, e.g.
/// "2026-06-07T18:00:00-07:00".
private func date(_ iso: String, file: StaticString = #file, line: UInt = #line) -> Date {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
guard let d = formatter.date(from: iso) else {
XCTFail("Could not parse ISO date: \(iso)", file: file, line: line)
return Date()
}
return d
}
// MARK: - 1. Timezone correctness (weekend detection)
/// 6 PM Sunday at OAK (PDT) is *Sunday* in airport-local time, even
/// though it's already Monday UTC. The current implementation uses a
/// UTC calendar (LoadFactorService.swift:69-72), so it misses the
/// weekend bump for west-coast late-evening departures.
///
/// Both the weekend leisure (+5%) and the peak-season (+7%) bumps
/// should fire here. Against the current bug, only peak fires
/// asserting `predicted >= base + 0.10` proves both bumps stacked.
func test_weekendBump_appliesInAirportLocalTime_notUTC() async throws {
let carrier = "WN"
let flight = 1701
let origin = "OAK"
let dest = "BUR"
// Sunday 6 PM PDT == Monday 1 AM UTC.
let depart = date("2026-06-07T18:00:00-07:00")
guard let base = await BTSDataStore.shared.record(
carrier: carrier, flightNumber: flight, origin: origin, dest: dest
) else {
throw XCTSkip("Bundled BTS bundle missing \(carrier)\(flight) \(origin)\(dest); cannot run timezone test")
}
let estimate = await service.estimate(
carrier: carrier,
flightNumber: flight,
origin: origin,
dest: dest,
date: depart,
database: airportDatabase,
liveSeats: nil
)
let result = try XCTUnwrap(estimate, "estimate(...) returned nil for a record that exists in the bundle")
// Weekend leisure (+5%) + peak-season June (+7%) = at least +12%
// on top of the base. Account for tiny FP drift with a 0.5% slack.
let expected = base.avgLoadFactor + 0.05 + 0.07
XCTAssertGreaterThanOrEqual(
result.predicted,
min(1.0, expected) - 0.005,
"OAK 6 PM Sunday PDT should pick up the weekend leisure bump; current UTC-only code drops it."
)
XCTAssertTrue(
result.basis.lowercased().contains("weekend"),
"Basis string should mention the weekend adjustment, got: \(result.basis)"
)
}
// MARK: - 2. Peak-season detection in airport-local time
/// Midnight UTC on 1 July from an SFO origin is *17:00 on 30 June* in
/// airport-local time, which means **no peak-season bump should
/// apply**. Current code reads the month from a UTC calendar and
/// over-counts this as July.
func test_peakSeason_usesAirportLocalMonth_notUTC() async throws {
let carrier = "UA"
let flight = 1
let origin = "SFO"
let dest = "EWR"
// Midnight UTC on 1 July 5 PM PDT on 30 June at SFO.
let depart = date("2026-07-01T00:00:00Z")
guard let base = await BTSDataStore.shared.record(
carrier: carrier, flightNumber: flight, origin: origin, dest: dest
) else {
throw XCTSkip("Bundled BTS bundle missing \(carrier)\(flight) \(origin)\(dest); cannot run peak-season test")
}
let estimate = await service.estimate(
carrier: carrier,
flightNumber: flight,
origin: origin,
dest: dest,
date: depart,
database: airportDatabase,
liveSeats: nil
)
let result = try XCTUnwrap(estimate)
// Tuesday 30 June in airport-local time: weekday, not peak season.
// UA is "business" but the day is a weekday so no weekday bump
// either. Prediction should equal the base within FP tolerance.
XCTAssertEqual(
result.predicted,
base.avgLoadFactor,
accuracy: 0.005,
"30 June local should not trigger the +7% peak-season bump"
)
XCTAssertFalse(
result.basis.lowercased().contains("peak season"),
"Basis should not mention peak season, got: \(result.basis)"
)
}
// MARK: - 3. Equipment-swap edge cases
/// When the live aircraft has *more* seats than the historical avg,
/// we should not bump up the ratio path is only meant to scale
/// predictions higher when a smaller jet is operating the segment.
func test_equipmentSwap_largerAircraftDoesNotBumpUp() async throws {
let carrier = "WN"
let flight = 61
let origin = "DAL"
let dest = "HOU"
let depart = date("2026-09-15T14:00:00-05:00") // Tuesday, non-peak, non-weekend
guard let base = await BTSDataStore.shared.record(
carrier: carrier, flightNumber: flight, origin: origin, dest: dest
) else {
throw XCTSkip("Bundled BTS bundle missing \(carrier)\(flight) \(origin)\(dest)")
}
// Live seats far above the historical avg (175). A bigger plane
// should NOT push the prediction higher.
let bigger = base.avgSeats + 200
let estimate = await service.estimate(
carrier: carrier,
flightNumber: flight,
origin: origin,
dest: dest,
date: depart,
database: airportDatabase,
liveSeats: bigger
)
let result = try XCTUnwrap(estimate)
XCTAssertLessThanOrEqual(
result.predicted,
base.avgLoadFactor + 0.005,
"Bigger live aircraft must not bump prediction up"
)
}
/// liveSeats == 0 used to be a divide-by-zero hazard. We must guard
/// so the call returns a normal estimate (no ratio applied).
func test_equipmentSwap_liveSeatsZeroDoesNotDivideByZero() async throws {
let carrier = "WN"
let flight = 61
let origin = "DAL"
let dest = "HOU"
let depart = date("2026-09-15T14:00:00-05:00")
guard await BTSDataStore.shared.record(
carrier: carrier, flightNumber: flight, origin: origin, dest: dest
) != nil else {
throw XCTSkip("Bundled BTS bundle missing \(carrier)\(flight) \(origin)\(dest)")
}
let estimate = await service.estimate(
carrier: carrier,
flightNumber: flight,
origin: origin,
dest: dest,
date: depart,
database: airportDatabase,
liveSeats: 0
)
let result = try XCTUnwrap(estimate, "Service should still return an estimate when liveSeats == 0")
XCTAssertTrue(result.predicted.isFinite, "Prediction must not be NaN/Inf when liveSeats == 0")
XCTAssertFalse(result.basis.lowercased().contains("smaller aircraft"),
"liveSeats == 0 must not trigger the smaller-aircraft path")
}
/// If, in some future BTS record, ``avgSeats`` is 0 we must not
/// crash. The bundled bundle has no such record today, so this test
/// just exercises the code path with a sane liveSeats and a real
/// record and asserts no crash + finite output. The Phase 3 fix
/// should guard `base.avgSeats > 0` before doing the ratio math.
func test_equipmentSwap_zeroAvgSeatsDoesNotCrash() async throws {
let carrier = "WN"
let flight = 61
let origin = "DAL"
let dest = "HOU"
let depart = date("2026-09-15T14:00:00-05:00")
guard await BTSDataStore.shared.record(
carrier: carrier, flightNumber: flight, origin: origin, dest: dest
) != nil else {
throw XCTSkip("Bundled BTS bundle missing \(carrier)\(flight) \(origin)\(dest)")
}
// Small but positive exercises the ratio branch on a real
// record so any future regression that drops the avgSeats > 0
// guard would surface here when paired with a zero-seats record.
let estimate = await service.estimate(
carrier: carrier,
flightNumber: flight,
origin: origin,
dest: dest,
date: depart,
database: airportDatabase,
liveSeats: 1
)
let result = try XCTUnwrap(estimate)
XCTAssertTrue(result.predicted.isFinite,
"Prediction must remain finite even when the seat ratio is extreme")
}
// MARK: - 4. Clamping
/// Sunday 7 June 2026 at DAL is a leisure-carrier weekend in peak
/// season stacking +5% + +7% + a smaller-aircraft ratio bump must
/// clamp at 1.0, never exceed it.
func test_predictionClampsAtOne_evenAfterStackedBumps() async throws {
let carrier = "WN"
let flight = 5
let origin = "DAL"
let dest = "HOU"
// Sunday 2026-06-07 at noon CDT Sunday in both UTC and local.
let depart = date("2026-06-07T12:00:00-05:00")
guard await BTSDataStore.shared.record(
carrier: carrier, flightNumber: flight, origin: origin, dest: dest
) != nil else {
throw XCTSkip("Bundled BTS bundle missing \(carrier)\(flight) \(origin)\(dest)")
}
// Aggressive smaller-aircraft ratio to make sure stacked bumps
// would otherwise blow past 1.0.
let estimate = await service.estimate(
carrier: carrier,
flightNumber: flight,
origin: origin,
dest: dest,
date: depart,
database: airportDatabase,
liveSeats: 100
)
let result = try XCTUnwrap(estimate)
XCTAssertLessThanOrEqual(result.predicted, 1.0,
"Predicted load factor must clamp at 1.0")
XCTAssertGreaterThanOrEqual(result.predicted, 0.0,
"Predicted load factor must clamp at 0.0")
}
// MARK: - 5. Confidence buckets
/// sampleSize >= 60 0.85 confidence.
/// Picks the first record in the bundle with totalFlights >= 60.
func test_confidence_highBucket_when60OrMoreFlights() async throws {
let all = await BTSDataStore.shared.allRecordsKeyed()
guard let (key, _) = all
.filter({ $0.value.totalFlights >= 60 })
.first
else {
throw XCTSkip("Bundled BTS bundle has no record with totalFlights >= 60")
}
let parts = key.split(separator: "_").map(String.init)
guard parts.count == 4, let fn = Int(parts[1]) else {
XCTFail("Unexpected BTS key shape: \(key)")
return
}
let depart = date("2026-09-15T14:00:00-05:00") // Tuesday, non-peak
let estimate = await service.estimate(
carrier: parts[0],
flightNumber: fn,
origin: parts[2],
dest: parts[3],
date: depart,
database: airportDatabase,
liveSeats: nil
)
let result = try XCTUnwrap(estimate)
XCTAssertEqual(result.confidence, 0.85, accuracy: 0.0001,
"totalFlights >= 60 must map to 0.85 confidence")
}
/// sampleSize 20-59 0.65 confidence.
/// Picks the first record in the bundle with 20 <= totalFlights < 60.
func test_confidence_midBucket_whenBetween20And59Flights() async throws {
let all = await BTSDataStore.shared.allRecordsKeyed()
guard let (key, _) = all
.filter({ $0.value.totalFlights >= 20 && $0.value.totalFlights < 60 })
.first
else {
throw XCTSkip("Bundled BTS bundle has no record with totalFlights in 20…59")
}
let parts = key.split(separator: "_").map(String.init)
guard parts.count == 4, let fn = Int(parts[1]) else {
XCTFail("Unexpected BTS key shape: \(key)")
return
}
let depart = date("2026-09-15T14:00:00-05:00") // Tuesday, non-peak
let estimate = await service.estimate(
carrier: parts[0],
flightNumber: fn,
origin: parts[2],
dest: parts[3],
date: depart,
database: airportDatabase,
liveSeats: nil
)
let result = try XCTUnwrap(estimate)
XCTAssertEqual(result.confidence, 0.65, accuracy: 0.0001,
"totalFlights in 20…59 must map to 0.65 confidence")
}
/// sampleSize < 20 0.40 confidence.
///
/// The bundled `bts_bundle.json` currently has no record with
/// totalFlights < 20. We probe every record and run the assertion
/// against the lowest-sample record if (and only if) it falls into
/// the < 20 bucket; otherwise we XCTSkip with a note. Phase 2 may
/// add real low-sample data and unfreeze this test.
func test_confidence_lowBucket_whenFewerThan20Flights() async throws {
let all = await BTSDataStore.shared.allRecordsKeyed()
guard let (key, record) = all
.filter({ $0.value.totalFlights < 20 })
.min(by: { $0.value.totalFlights < $1.value.totalFlights })
else {
throw XCTSkip("Bundled BTS bundle has no record with totalFlights < 20; can't pin the 0.40 bucket against real data yet")
}
// Re-split the bundle key (CARRIER_FLIGHTNUM_ORIGIN_DEST) the
// format is fixed by BTSDataStore.makeKey.
let parts = key.split(separator: "_").map(String.init)
guard parts.count == 4, let fn = Int(parts[1]) else {
XCTFail("Unexpected BTS key shape: \(key)")
return
}
let depart = date("2026-09-15T14:00:00-05:00") // Tuesday, non-peak
let estimate = await service.estimate(
carrier: parts[0],
flightNumber: fn,
origin: parts[2],
dest: parts[3],
date: depart,
database: airportDatabase,
liveSeats: nil
)
let result = try XCTUnwrap(estimate)
XCTAssertEqual(result.confidence, 0.40, accuracy: 0.0001,
"totalFlights < 20 (record \(key), n=\(record.totalFlights)) must map to 0.40 confidence")
}
// MARK: - 6. No record nil
/// When the BTS bundle has no matching key the service must return
/// nil callers hide the load-factor UI rather than guess.
func test_estimate_returnsNil_whenNoMatchingBTSRecord() async {
let depart = date("2026-09-15T14:00:00-05:00")
let estimate = await service.estimate(
carrier: "ZZ",
flightNumber: 99999,
origin: "AAA",
dest: "BBB",
date: depart,
database: airportDatabase,
liveSeats: nil
)
XCTAssertNil(estimate, "Nonsense carrier/route must yield nil, not a guessed estimate")
}
}
+84
View File
@@ -0,0 +1,84 @@
import XCTest
@testable import Flights
/// Guard test against a regression where the dead-code "Selftest" block in
/// `RootView.swift` (a Task.detached that called
/// `routeExplorer.searchSchedule` on app launch and printed results) gets
/// re-introduced. That block touched the broken `RouteExplorerClient` and
/// fired off a detached task at startup exactly the shape we want to
/// keep out of the launch path.
///
/// Strategy: locate `RootView.swift` on disk and assert none of the
/// fingerprint substrings are present. We try a couple of paths because
/// the working directory during `xcodebuild test` is not stable.
final class SelftestRemovalTests: XCTestCase {
/// Fingerprints that uniquely identify the dead-code block.
private static let forbiddenSubstrings: [String] = [
"[Selftest]",
"routeExplorer.searchSchedule",
"Task.detached"
]
func test_rootView_doesNotContainSelftestDeadCode() throws {
guard let source = Self.loadRootViewSource() else {
// We couldn't locate the file from the test bundle's vantage
// point. Don't silently pass surface it as a skip so the
// dev knows to do a manual check.
// manual check: open Flights/Views/RootView.swift and confirm
// none of `[Selftest]`, `routeExplorer.searchSchedule`, or
// `Task.detached` appear in it.
throw XCTSkip("Could not locate RootView.swift from the test bundle; manual check required.")
}
for needle in Self.forbiddenSubstrings {
XCTAssertFalse(
source.contains(needle),
"RootView.swift still contains forbidden dead-code fingerprint: \(needle)"
)
}
}
// MARK: - File location
/// Try several strategies to find `RootView.swift` on disk.
/// Order: explicit env var walking up from the test bundle a known
/// absolute project path walking up from #file.
private static func loadRootViewSource() -> String? {
let fm = FileManager.default
var candidates: [String] = []
// 1. Env override (useful for CI or weird scheme configs).
if let envRoot = ProcessInfo.processInfo.environment["FLIGHTS_PROJECT_ROOT"] {
candidates.append((envRoot as NSString).appendingPathComponent("Flights/Views/RootView.swift"))
}
// 2. Walk up from the test bundle until we find a sibling `Flights` dir.
let bundleURL = Bundle(for: SelftestRemovalTests.self).bundleURL
var dir = bundleURL.deletingLastPathComponent()
for _ in 0..<8 {
let guess = dir.appendingPathComponent("Flights/Views/RootView.swift").path
candidates.append(guess)
dir = dir.deletingLastPathComponent()
}
// 3. Known absolute path on this dev machine (best-effort fallback).
candidates.append("/Users/m4mini/Desktop/code/Flights/Flights/Views/RootView.swift")
// 4. Walk up from this source file's location.
let thisFile = URL(fileURLWithPath: #filePath)
var srcDir = thisFile.deletingLastPathComponent()
for _ in 0..<6 {
let guess = srcDir.appendingPathComponent("Flights/Views/RootView.swift").path
candidates.append(guess)
srcDir = srcDir.deletingLastPathComponent()
}
for path in candidates where fm.fileExists(atPath: path) {
if let contents = try? String(contentsOfFile: path, encoding: .utf8) {
return contents
}
}
return nil
}
}
+322
View File
@@ -0,0 +1,322 @@
import XCTest
@testable import Flights
// MARK: - Test Doubles
//
// Phase 3 wired the production `FlightScheduleProvider` protocol in
// `Services/SisterFlightService.swift`, so we just consume it here rather
// than re-declaring it.
/// Hard-coded schedule provider. Tests configure airport autocomplete
/// results and a list of schedules to return; the mock plays them back.
actor MockScheduleProvider: FlightScheduleProvider {
private let airportLookups: [String: [Airport]]
private let schedulesToReturn: [FlightSchedule]
private let shouldThrowOnSchedules: Bool
init(airportLookups: [String: [Airport]] = [:],
schedulesToReturn: [FlightSchedule] = [],
shouldThrowOnSchedules: Bool = false) {
self.airportLookups = airportLookups
self.schedulesToReturn = schedulesToReturn
self.shouldThrowOnSchedules = shouldThrowOnSchedules
}
func searchAirports(term: String) async throws -> [Airport] {
return airportLookups[term.uppercased()] ?? []
}
func allSchedules(
dep: String,
des: String,
onProgress: @Sendable @escaping (Int, Int) -> Void
) async throws -> [FlightSchedule] {
if shouldThrowOnSchedules {
throw NSError(domain: "MockScheduleProvider", code: -1, userInfo: nil)
}
return schedulesToReturn
}
}
final class SisterFlightServiceTests: XCTestCase {
// A fixed test date so day-of-week assertions are deterministic.
// 2026-06-03 is a Wednesday Calendar weekday = 4.
private lazy var targetDate: Date = {
var components = DateComponents()
components.year = 2026
components.month = 6
components.day = 3
components.hour = 12
components.minute = 0
components.timeZone = TimeZone(identifier: "UTC")
return Calendar(identifier: .gregorian).date(from: components)!
}()
private let origin = "JFK"
private let dest = "LAX"
// MARK: - Test 1: empty schedules
func test_emptySchedules_returnsEmptyArray() async {
let provider = MockScheduleProvider(
airportLookups: [
"JFK": [airport(id: "jfk-id", iata: "JFK")],
"LAX": [airport(id: "lax-id", iata: "LAX")]
],
schedulesToReturn: []
)
let service = SisterFlightService(flightService: provider)
let results = await service.sisterFlights(
origin: origin,
dest: dest,
date: targetDate,
currentFlight: nil
)
XCTAssertTrue(results.isEmpty, "Empty upstream schedule → empty sister-flight list.")
}
// MARK: - Test 2: schedules that don't operate on target date are filtered
func test_schedulesNotOperatingOnTargetDate_areFiltered() async {
let weekday = Calendar.current.component(.weekday, from: targetDate)
let otherWeekdays = Set([1, 2, 3, 4, 5, 6, 7]).subtracting([weekday])
// Two schedules: one runs on the target weekday, one doesn't.
let operating = schedule(
airlineIATA: "DL",
flightNumberRaw: "DL 100",
departureTime: "09:00",
arrivalTime: "12:00",
daysOfWeek: [weekday]
)
let nonOperating = schedule(
airlineIATA: "AA",
flightNumberRaw: "AA 200",
departureTime: "10:00",
arrivalTime: "13:00",
daysOfWeek: otherWeekdays
)
let provider = MockScheduleProvider(
airportLookups: [
"JFK": [airport(id: "jfk-id", iata: "JFK")],
"LAX": [airport(id: "lax-id", iata: "LAX")]
],
schedulesToReturn: [operating, nonOperating]
)
let service = SisterFlightService(flightService: provider)
let results = await service.sisterFlights(
origin: origin,
dest: dest,
date: targetDate,
currentFlight: nil
)
XCTAssertEqual(results.count, 1, "Only the schedule operating on the target weekday should survive.")
XCTAssertEqual(results.first?.carrier, "DL")
XCTAssertEqual(results.first?.flightNumber, 100)
}
// MARK: - Test 3: currentFlight match marks one entry isYourFlight
func test_currentFlightMatch_marksIsYourFlight() async {
let weekday = Calendar.current.component(.weekday, from: targetDate)
let mine = schedule(
airlineIATA: "UA",
flightNumberRaw: "UA 555",
departureTime: "08:00",
arrivalTime: "11:00",
daysOfWeek: [weekday]
)
let other = schedule(
airlineIATA: "UA",
flightNumberRaw: "UA 777",
departureTime: "14:00",
arrivalTime: "17:00",
daysOfWeek: [weekday]
)
let provider = MockScheduleProvider(
airportLookups: [
"JFK": [airport(id: "jfk-id", iata: "JFK")],
"LAX": [airport(id: "lax-id", iata: "LAX")]
],
schedulesToReturn: [mine, other]
)
let service = SisterFlightService(flightService: provider)
let results = await service.sisterFlights(
origin: origin,
dest: dest,
date: targetDate,
currentFlight: (carrier: "UA", number: 555)
)
XCTAssertEqual(results.count, 2)
let mineResult = results.first { $0.flightNumber == 555 }
let otherResult = results.first { $0.flightNumber == 777 }
XCTAssertNotNil(mineResult, "User's flight should be present in results.")
XCTAssertNotNil(otherResult, "Other sister flight should be present.")
XCTAssertTrue(mineResult?.isYourFlight == true, "Matching carrier+number → isYourFlight true.")
XCTAssertTrue(otherResult?.isYourFlight == false, "Non-matching flight should not be flagged.")
}
// MARK: - Test 4: sort by predictedLoad ascending (nil last), then by scheduledDeparture
func test_resultsSortedByLoadAscending_nilLast_thenByDeparture() async {
let weekday = Calendar.current.component(.weekday, from: targetDate)
// Four schedules with distinct departure times so we can identify them.
// Loads injected via predictor below: DL=0.50, AA=0.10, UA=0.10, B6=nil.
// Expected order:
// AA (0.10, 09:00) earliest tie-broken
// UA (0.10, 11:00)
// DL (0.50, 08:00)
// B6 (nil, 10:00)
let dl = schedule(
airlineIATA: "DL",
flightNumberRaw: "DL 100",
departureTime: "08:00",
arrivalTime: "11:00",
daysOfWeek: [weekday]
)
let aa = schedule(
airlineIATA: "AA",
flightNumberRaw: "AA 200",
departureTime: "09:00",
arrivalTime: "12:00",
daysOfWeek: [weekday]
)
let b6 = schedule(
airlineIATA: "B6",
flightNumberRaw: "B6 300",
departureTime: "10:00",
arrivalTime: "13:00",
daysOfWeek: [weekday]
)
let ua = schedule(
airlineIATA: "UA",
flightNumberRaw: "UA 400",
departureTime: "11:00",
arrivalTime: "14:00",
daysOfWeek: [weekday]
)
let loadTable: [String: Double] = [
"DL-100": 0.50,
"AA-200": 0.10,
"UA-400": 0.10
// B6-300 omitted nil load
]
let predictor: @Sendable (String, Int, Date) async -> Double? = { carrier, number, _ in
return loadTable["\(carrier)-\(number)"]
}
let provider = MockScheduleProvider(
airportLookups: [
"JFK": [airport(id: "jfk-id", iata: "JFK")],
"LAX": [airport(id: "lax-id", iata: "LAX")]
],
schedulesToReturn: [dl, aa, b6, ua]
)
let service = SisterFlightService(flightService: provider, loadPredictor: predictor)
let results = await service.sisterFlights(
origin: origin,
dest: dest,
date: targetDate,
currentFlight: nil
)
XCTAssertEqual(results.count, 4)
XCTAssertEqual(results[0].carrier, "AA",
"Lowest load with earliest departure first.")
XCTAssertEqual(results[1].carrier, "UA",
"Same load as AA but departs later — second.")
XCTAssertEqual(results[2].carrier, "DL",
"Higher load than AA/UA — third.")
XCTAssertEqual(results[3].carrier, "B6",
"Nil load is sorted last regardless of time.")
}
// MARK: - Test 5: predictedLoad nil when loadPredictor is nil
func test_loadPredictorNil_predictedLoadAlwaysNil() async {
let weekday = Calendar.current.component(.weekday, from: targetDate)
let s = schedule(
airlineIATA: "DL",
flightNumberRaw: "DL 100",
departureTime: "08:00",
arrivalTime: "11:00",
daysOfWeek: [weekday]
)
let provider = MockScheduleProvider(
airportLookups: [
"JFK": [airport(id: "jfk-id", iata: "JFK")],
"LAX": [airport(id: "lax-id", iata: "LAX")]
],
schedulesToReturn: [s]
)
let service = SisterFlightService(flightService: provider, loadPredictor: nil)
let results = await service.sisterFlights(
origin: origin,
dest: dest,
date: targetDate,
currentFlight: nil
)
XCTAssertEqual(results.count, 1)
XCTAssertNil(results.first?.predictedLoad,
"No predictor wired → predictedLoad must be nil.")
}
// MARK: - Helpers
private func airport(id: String, iata: String) -> Airport {
Airport(id: id, iata: iata, name: "\(iata) Airport")
}
/// Build a FlightSchedule with a synthetic Airline. Date range is
/// wide enough (2020 2030) that any reasonable target date falls
/// inside it; the only real filter is the daysOfWeek set.
private func schedule(
airlineIATA: String,
flightNumberRaw: String,
departureTime: String,
arrivalTime: String,
daysOfWeek: Set<Int>
) -> FlightSchedule {
let airline = Airline(
id: "airline-\(airlineIATA)",
name: airlineIATA,
iata: airlineIATA,
logoFilename: "\(airlineIATA).png"
)
var utc = Calendar(identifier: .gregorian)
utc.timeZone = TimeZone(identifier: "UTC")!
let from = utc.date(from: DateComponents(year: 2020, month: 1, day: 1))!
let to = utc.date(from: DateComponents(year: 2030, month: 12, day: 31))!
return FlightSchedule(
airline: airline,
flightNumber: flightNumberRaw,
aircraft: "738",
aircraftId: "",
departureTime: departureTime,
arrivalTime: arrivalTime,
dateFrom: from,
dateTo: to,
daysOfWeek: daysOfWeek,
cabinClasses: .economy
)
}
}
+185
View File
@@ -0,0 +1,185 @@
import XCTest
import SwiftData
@testable import Flights
/// Unit tests for `StandbyStatsService`.
///
/// All tests use an in-memory `ModelContainer` so they don't touch the
/// real SwiftData store or CloudKit. We seed `LoggedFlight` rows with
/// varied standby outcomes / carriers / routes / dates, then exercise
/// the public surface (`personalRate`, `recentOutcomes`) and assert on
/// the aggregate result.
@MainActor
final class StandbyStatsServiceTests: XCTestCase {
private var container: ModelContainer!
private var context: ModelContext!
private var service: StandbyStatsService!
override func setUpWithError() throws {
try super.setUpWithError()
let schema = Schema([LoggedFlight.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
container = try ModelContainer(for: schema, configurations: config)
context = ModelContext(container)
service = StandbyStatsService()
}
override func tearDownWithError() throws {
service = nil
context = nil
container = nil
try super.tearDownWithError()
}
// MARK: - Helpers
/// Reference epoch we offset from so date ordering is deterministic
/// regardless of wall-clock time when the test runs.
private static let epoch = Date(timeIntervalSince1970: 1_700_000_000)
private func date(_ dayOffset: Int) -> Date {
Self.epoch.addingTimeInterval(TimeInterval(dayOffset) * 86_400)
}
@discardableResult
private func insert(
outcome: String?,
carrierIATA: String? = "WN",
carrierICAO: String? = "SWA",
origin: String = "DAL",
dest: String = "HOU",
flightDate: Date? = nil
) -> LoggedFlight {
let flight = LoggedFlight(
flightDate: flightDate ?? date(0),
carrierICAO: carrierICAO,
carrierIATA: carrierIATA,
departureIATA: origin,
arrivalIATA: dest
)
flight.standbyOutcome = outcome
context.insert(flight)
return flight
}
// MARK: - personalRate
/// Empty store should return the documented sentinel.
func test_personalRate_emptyContext_returnsEmpty() {
let rate = service.personalRate(carrier: nil, origin: nil, dest: nil, context: context)
XCTAssertEqual(rate.attempts, 0)
XCTAssertEqual(rate.made, 0)
XCTAssertEqual(rate.bumped, 0)
XCTAssertEqual(rate.confirmed, 0)
XCTAssertEqual(rate.rate, 0)
}
/// 5 confirmed + 3 standby-made + 2 standby-bumped sanity check
/// the aggregate maths. attempts = made + bumped = 5; rate = 3/5.
func test_personalRate_mixedOutcomes_returnsExpectedCounts() {
for _ in 0..<5 { insert(outcome: "confirmed") }
for _ in 0..<3 { insert(outcome: "standby-made") }
for _ in 0..<2 { insert(outcome: "standby-bumped") }
let rate = service.personalRate(carrier: nil, origin: nil, dest: nil, context: context)
XCTAssertEqual(rate.attempts, 5, "attempts = standby-made + standby-bumped")
XCTAssertEqual(rate.made, 3)
XCTAssertEqual(rate.bumped, 2)
XCTAssertEqual(rate.confirmed, 5)
XCTAssertEqual(rate.rate, 0.6, accuracy: 0.0001)
}
/// Carrier filter must restrict to flights whose IATA *or* ICAO matches
/// (the service deliberately checks both caller doesn't know which
/// code was stored).
func test_personalRate_carrierFilter_onlyCountsMatchingCarrier() {
// WN: 2 made, 1 bumped 3 attempts, rate = 2/3
insert(outcome: "standby-made", carrierIATA: "WN", carrierICAO: "SWA")
insert(outcome: "standby-made", carrierIATA: "WN", carrierICAO: "SWA")
insert(outcome: "standby-bumped", carrierIATA: "WN", carrierICAO: "SWA")
// AA noise that must be excluded by the filter.
insert(outcome: "standby-made", carrierIATA: "AA", carrierICAO: "AAL")
insert(outcome: "standby-bumped", carrierIATA: "AA", carrierICAO: "AAL")
insert(outcome: "confirmed", carrierIATA: "AA", carrierICAO: "AAL")
let rate = service.personalRate(carrier: "WN", origin: nil, dest: nil, context: context)
XCTAssertEqual(rate.attempts, 3)
XCTAssertEqual(rate.made, 2)
XCTAssertEqual(rate.bumped, 1)
XCTAssertEqual(rate.confirmed, 0)
XCTAssertEqual(rate.rate, 2.0 / 3.0, accuracy: 0.0001)
}
/// Origin filter only counts flights departing the requested airport.
func test_personalRate_originFilter_onlyCountsMatchingDeparture() {
insert(outcome: "standby-made", origin: "DAL", dest: "HOU")
insert(outcome: "standby-bumped", origin: "DAL", dest: "LAS")
insert(outcome: "confirmed", origin: "DAL", dest: "MDW")
// Other-origin noise must be excluded.
insert(outcome: "standby-made", origin: "HOU", dest: "DAL")
insert(outcome: "confirmed", origin: "AUS", dest: "DAL")
let rate = service.personalRate(carrier: nil, origin: "DAL", dest: nil, context: context)
XCTAssertEqual(rate.attempts, 2)
XCTAssertEqual(rate.made, 1)
XCTAssertEqual(rate.bumped, 1)
XCTAssertEqual(rate.confirmed, 1)
XCTAssertEqual(rate.rate, 0.5, accuracy: 0.0001)
}
/// Carrier + origin + dest filters combine with AND semantics. Only
/// flights matching every condition should be counted.
func test_personalRate_combinedFilters_useAndSemantics() {
// Target combo: WN, DAL HOU. 2 made, 1 bumped rate 2/3.
insert(outcome: "standby-made", carrierIATA: "WN", origin: "DAL", dest: "HOU")
insert(outcome: "standby-made", carrierIATA: "WN", origin: "DAL", dest: "HOU")
insert(outcome: "standby-bumped", carrierIATA: "WN", origin: "DAL", dest: "HOU")
// Same carrier + origin, wrong dest.
insert(outcome: "standby-made", carrierIATA: "WN", origin: "DAL", dest: "LAS")
// Same carrier + dest, wrong origin.
insert(outcome: "standby-made", carrierIATA: "WN", origin: "AUS", dest: "HOU")
// Same route, wrong carrier.
insert(outcome: "standby-made", carrierIATA: "AA", carrierICAO: "AAL", origin: "DAL", dest: "HOU")
let rate = service.personalRate(carrier: "WN", origin: "DAL", dest: "HOU", context: context)
XCTAssertEqual(rate.attempts, 3)
XCTAssertEqual(rate.made, 2)
XCTAssertEqual(rate.bumped, 1)
XCTAssertEqual(rate.confirmed, 0)
XCTAssertEqual(rate.rate, 2.0 / 3.0, accuracy: 0.0001)
}
// MARK: - recentOutcomes
/// recentOutcomes returns flights sorted by flightDate desc and
/// honours the fetch limit. Flights without an outcome are excluded.
func test_recentOutcomes_returnsMostRecentNByDateDescending() {
// Insert 7 flights with outcomes across day offsets 0..6.
// Day 6 is newest. Insert out of order to prove sort is by
// flightDate (not insertion order).
let outcomes = ["confirmed", "standby-made", "standby-bumped",
"confirmed", "standby-made", "standby-bumped", "confirmed"]
let insertionOrder = [3, 0, 6, 2, 5, 1, 4]
for day in insertionOrder {
insert(outcome: outcomes[day], flightDate: date(day))
}
// Plus a flight with no outcome must NOT appear.
insert(outcome: nil, flightDate: date(99))
let recent = service.recentOutcomes(limit: 5, context: context)
XCTAssertEqual(recent.count, 5)
let returnedDays = recent.map { $0.flightDate.timeIntervalSince(Self.epoch) / 86_400 }
.map { Int($0.rounded()) }
XCTAssertEqual(returnedDays, [6, 5, 4, 3, 2],
"Should be the 5 most recent by flightDate desc")
XCTAssertTrue(recent.allSatisfy { $0.standbyOutcome != nil })
}
}
+272
View File
@@ -0,0 +1,272 @@
import XCTest
@testable import Flights
/// Unit tests for `WeatherClient`'s timezone correctness and the shared-cache contract.
///
/// These tests are intentionally written against the **post-fix** API surface
/// (`WeatherClient.dayKey(for:in:)` and `WeatherClient.shared` with an injectable
/// `URLSession`). Until the production code adopts that shape, they will not
/// compile / will not pass that's the TDD contract for the timezone-bug phase.
///
/// Why the test exists:
///
/// 1. **Local-day key bug.** A flight departing 2026-12-31T22:00:00-05:00
/// (10 PM Eastern at JFK) is on December 31 in the airport's wall clock,
/// but is 2027-01-01 03:00 UTC. The current implementation builds the
/// cache key in UTC (see `WeatherClient.swift:217-223`), which causes the
/// daily precip-probability lookup to land on the *wrong* calendar day
/// surfacing tomorrow's forecast as if it were tonight's.
///
/// 2. **Shared cache.** The UI currently spins up a fresh `WeatherClient()`
/// per view (see `LiveFlightDetailSheet.swift:898`), so the per-actor
/// cache never hits across legs of a trip. The fix is `WeatherClient.shared`
/// plus an injectable session so two requests for the same (iata, day)
/// issue a single network call.
final class WeatherClientTests: XCTestCase {
// MARK: - dayKey timezone correctness
/// 10 PM Eastern on Dec 31 is still Dec 31 to a JFK traveller, even though
/// its UTC representation rolls past midnight into Jan 1. The day key must
/// be derived in the airport's local zone or every NYE evening flight will
/// fetch tomorrow's daily precip probability.
func test_dayKey_usesAirportLocalTimeZone_notUTC() throws {
// 2026-12-31T22:00:00 America/New_York
var comps = DateComponents()
comps.year = 2026; comps.month = 12; comps.day = 31
comps.hour = 22; comps.minute = 0; comps.second = 0
comps.timeZone = TimeZone(identifier: "America/New_York")
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "America/New_York")!
let date = cal.date(from: comps)!
let nyc = TimeZone(identifier: "America/New_York")!
let key = WeatherClient.dayKey(for: date, in: nyc)
XCTAssertEqual(
key, "2026-12-31",
"10pm Eastern on NYE must resolve to the local Dec 31, not UTC's Jan 1."
)
}
/// Same instant, asked for in Tokyo should report Jan 1 (Tokyo is +9,
/// so 10pm EST Dec 31 == 12pm JST Jan 1). Proves the helper is honouring
/// its `tz` argument and not silently defaulting to UTC.
func test_dayKey_respectsCallerProvidedTimeZone() throws {
var comps = DateComponents()
comps.year = 2026; comps.month = 12; comps.day = 31
comps.hour = 22; comps.minute = 0; comps.second = 0
comps.timeZone = TimeZone(identifier: "America/New_York")
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "America/New_York")!
let date = cal.date(from: comps)!
let tokyo = TimeZone(identifier: "Asia/Tokyo")!
let key = WeatherClient.dayKey(for: date, in: tokyo)
XCTAssertEqual(
key, "2027-01-01",
"Same instant viewed in Tokyo is already Jan 1 — helper must use the supplied tz."
)
}
/// Sanity: noon local on a normal day round-trips through the helper for
/// every supported zone. Guards against accidentally re-introducing a
/// hard-coded "UTC" inside the formatter.
func test_dayKey_noonLocal_matchesCalendarDay() throws {
for id in ["America/Los_Angeles", "America/New_York", "Europe/London", "Asia/Tokyo", "Australia/Sydney"] {
let tz = TimeZone(identifier: id)!
var cal = Calendar(identifier: .gregorian)
cal.timeZone = tz
var comps = DateComponents()
comps.year = 2026; comps.month = 6; comps.day = 15
comps.hour = 12; comps.minute = 0
comps.timeZone = tz
let date = cal.date(from: comps)!
XCTAssertEqual(
WeatherClient.dayKey(for: date, in: tz),
"2026-06-15",
"Noon \(id) on 2026-06-15 must round-trip to that calendar day."
)
}
}
// MARK: - Shared cache + single-flight network behaviour
/// Two `forecast(...)` calls for the same airport and local day should
/// hit the network once. The fix is `WeatherClient.shared` plus an
/// injectable `URLSession` so we can count requests against a stub
/// protocol and `LiveFlightDetailSheet` must adopt `.shared` for the
/// production cache to actually share.
func test_shared_cachesPerLocalDay_acrossCalls() async throws {
let db = AirportDatabase()
try XCTSkipIf(db.airport(byIATA: "JFK") == nil, "airports.json missing JFK; cannot exercise weather fetch")
// Single-shot stub that returns the same canned Open-Meteo payload
// for any URL. The counter is incremented on every network request.
let counter = RequestCounter()
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [StubURLProtocol.self]
StubURLProtocol.counter = counter
StubURLProtocol.responder = { _ in
let body = Self.openMeteoFixture()
return (HTTPURLResponse(
url: URL(string: "https://api.open-meteo.com/v1/forecast")!,
statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!, body)
}
let session = URLSession(configuration: config)
let client = WeatherClient(session: session)
// 8 AM Eastern at JFK squarely inside Open-Meteo's fixture window.
let date = Self.localDate(2026, 6, 15, 8, "America/New_York")
_ = await client.forecast(forIATA: "JFK", on: date, database: db)
_ = await client.forecast(forIATA: "JFK", on: date, database: db)
let hits = await counter.value
XCTAssertEqual(
hits, 1,
"Second call for the same (iata, local day) must be served from cache, not re-fetched."
)
StubURLProtocol.responder = nil
StubURLProtocol.counter = nil
}
/// Confirms the singleton exists and is the shared instance, so the UI
/// pivot to `WeatherClient.shared` actually deduplicates across views.
func test_sharedSingleton_isStable() {
let a = WeatherClient.shared
let b = WeatherClient.shared
XCTAssertTrue(a === b, "WeatherClient.shared must vend the same actor instance across calls.")
}
/// The forecast surface must use the local-day daily precip probability,
/// not the UTC-day one. With the fixture below, June 15 local has
/// precipProbability=42 and June 16 has 88 a UTC-keyed lookup at 10pm
/// Eastern would land on the wrong bucket and return 88.
func test_forecast_dailyPrecipProbability_usesLocalDay() async throws {
let db = AirportDatabase()
try XCTSkipIf(db.airport(byIATA: "JFK") == nil, "airports.json missing JFK; cannot exercise weather fetch")
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [StubURLProtocol.self]
StubURLProtocol.counter = RequestCounter()
StubURLProtocol.responder = { _ in
let body = Self.openMeteoFixture()
return (HTTPURLResponse(
url: URL(string: "https://api.open-meteo.com/v1/forecast")!,
statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!, body)
}
let session = URLSession(configuration: config)
let client = WeatherClient(session: session)
// 10 PM local on June 15 NYC UTC would resolve to June 16.
let date = Self.localDate(2026, 6, 15, 22, "America/New_York")
let forecast = await client.forecast(forIATA: "JFK", on: date, database: db)
XCTAssertNotNil(forecast)
XCTAssertEqual(forecast?.airport, "JFK")
XCTAssertEqual(
forecast?.precipProbabilityPct, 42,
"Daily precip prob must reflect the local day's bucket (42), not the UTC day after (88)."
)
StubURLProtocol.responder = nil
StubURLProtocol.counter = nil
}
// MARK: - Fixtures / helpers
/// Open-Meteo's `timezone=auto` response with hourly entries spanning
/// the night of 2026-06-15 and into 06-16 (America/New_York), plus two
/// daily entries one with precipProb=42 (the 15th) and one with 88
/// (the 16th) so we can detect which day the client picked.
private static func openMeteoFixture() -> Data {
let json = """
{
"timezone": "America/New_York",
"hourly": {
"time": [
"2026-06-15T20:00",
"2026-06-15T21:00",
"2026-06-15T22:00",
"2026-06-15T23:00",
"2026-06-16T00:00",
"2026-06-16T01:00"
],
"temperature_2m": [21.0, 20.5, 20.0, 19.5, 19.0, 18.5],
"precipitation": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
"wind_speed_10m": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0],
"visibility": [20000.0, 20000.0, 20000.0, 20000.0, 20000.0, 20000.0],
"weather_code": [1, 1, 1, 1, 2, 2]
},
"daily": {
"time": ["2026-06-15", "2026-06-16"],
"weathercode": [1, 2],
"precipitation_probability_max": [42, 88]
}
}
"""
return Data(json.utf8)
}
private static func localDate(_ y: Int, _ m: Int, _ d: Int, _ h: Int, _ tzID: String) -> Date {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: tzID)!
var comps = DateComponents()
comps.year = y; comps.month = m; comps.day = d
comps.hour = h; comps.minute = 0; comps.second = 0
comps.timeZone = TimeZone(identifier: tzID)
return cal.date(from: comps)!
}
}
// MARK: - Test doubles
/// Thread-safe call counter for the stub URLProtocol. Lives outside the
/// actor system so the protocol class can touch it from arbitrary queues.
final class RequestCounter: @unchecked Sendable {
private let lock = NSLock()
private var _value = 0
var value: Int {
lock.lock(); defer { lock.unlock() }
return _value
}
func bump() {
lock.lock(); defer { lock.unlock() }
_value += 1
}
}
/// Minimal URLProtocol that hands every request to `responder` and bumps
/// `counter`. Lets us assert "exactly one fetch" without leaning on the
/// real network.
///
/// The static `responder` / `counter` fields are accessed serially from one
/// test at a time (XCTest runs tests sequentially within a class), so a
/// plain `static var` is safe here without nonisolated-unsafe annotations.
final class StubURLProtocol: URLProtocol {
static var responder: ((URLRequest) -> (HTTPURLResponse, Data))?
static var counter: RequestCounter?
override class func canInit(with request: URLRequest) -> Bool { responder != nil }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
Self.counter?.bump()
guard let responder = Self.responder else {
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}
let (response, data) = responder(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}