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).
175 lines
7.1 KiB
Swift
175 lines
7.1 KiB
Swift
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)"
|
|
)
|
|
}
|
|
}
|