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