ba0688a412
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).
416 lines
16 KiB
Swift
416 lines
16 KiB
Swift
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")
|
|
}
|
|
}
|