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
+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")
}
}