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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user