Files
Flights/FlightsTests/EquipmentSwapServiceTests.swift
Trey T ba0688a412 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).
2026-06-06 01:09:59 -05:00

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