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,134 @@
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import Flights
|
||||
|
||||
/// Unit tests for `AirframeHistoryStore`.
|
||||
///
|
||||
/// We exercise the store against an in-memory `ModelContainer` seeded
|
||||
/// with `LoggedFlight` rows that vary by tail number, route, and date.
|
||||
/// All assertions reference the documented `AirframeStats` contract.
|
||||
@MainActor
|
||||
final class AirframeHistoryStoreTests: XCTestCase {
|
||||
|
||||
private var container: ModelContainer!
|
||||
private var context: ModelContext!
|
||||
private var store: AirframeHistoryStore!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
let schema = Schema([LoggedFlight.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
container = try ModelContainer(for: schema, configurations: config)
|
||||
context = ModelContext(container)
|
||||
store = AirframeHistoryStore()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
store = nil
|
||||
context = nil
|
||||
container = nil
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static let epoch = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
|
||||
private func date(_ dayOffset: Int) -> Date {
|
||||
Self.epoch.addingTimeInterval(TimeInterval(dayOffset) * 86_400)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func insert(
|
||||
registration: String?,
|
||||
origin: String,
|
||||
dest: String,
|
||||
flightDate: Date
|
||||
) -> LoggedFlight {
|
||||
let flight = LoggedFlight(
|
||||
flightDate: flightDate,
|
||||
departureIATA: origin,
|
||||
arrivalIATA: dest,
|
||||
registration: registration
|
||||
)
|
||||
context.insert(flight)
|
||||
return flight
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
/// Empty store → empty stats sentinel.
|
||||
func test_stats_emptyContext_returnsEmpty() {
|
||||
let stats = store.stats(forTail: "N281WN", context: context)
|
||||
|
||||
XCTAssertEqual(stats.totalFlights, 0)
|
||||
XCTAssertTrue(stats.routes.isEmpty)
|
||||
XCTAssertNil(stats.firstSeen)
|
||||
XCTAssertNil(stats.lastSeen)
|
||||
XCTAssertNil(stats.mostCommonRoute)
|
||||
}
|
||||
|
||||
/// 3 flights on the same tail across 2 distinct routes — verify the
|
||||
/// aggregate counts and the "DAL→HOU (2 of 3)" most-common-route
|
||||
/// formatting.
|
||||
func test_stats_threeFlightsTwoRoutes_aggregatesCorrectly() {
|
||||
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(0))
|
||||
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(5))
|
||||
insert(registration: "N281WN", origin: "DAL", dest: "LAS", flightDate: date(10))
|
||||
// Other-tail noise — must not be counted.
|
||||
insert(registration: "N999AA", origin: "DAL", dest: "HOU", flightDate: date(2))
|
||||
|
||||
let stats = store.stats(forTail: "N281WN", context: context)
|
||||
|
||||
XCTAssertEqual(stats.totalFlights, 3)
|
||||
XCTAssertEqual(Set(stats.routes), Set(["DAL→HOU", "DAL→LAS"]))
|
||||
XCTAssertEqual(stats.routes.count, 2)
|
||||
XCTAssertEqual(stats.firstSeen, date(0))
|
||||
XCTAssertEqual(stats.lastSeen, date(10))
|
||||
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (2 of 3)")
|
||||
}
|
||||
|
||||
/// Lookup tail must be normalized to uppercase — passing "n281wn"
|
||||
/// matches a stored "N281WN".
|
||||
func test_stats_lookupIsCaseInsensitive() {
|
||||
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(0))
|
||||
|
||||
let stats = store.stats(forTail: "n281wn", context: context)
|
||||
|
||||
XCTAssertEqual(stats.totalFlights, 1)
|
||||
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (1 of 1)")
|
||||
}
|
||||
|
||||
/// The store should still report stats for a single-flight tail. The
|
||||
/// History UI hides the section in that case, but the underlying
|
||||
/// store contract returns the real count.
|
||||
func test_stats_singleFlight_returnsTotalOne() {
|
||||
insert(registration: "N281WN", origin: "DAL", dest: "HOU", flightDate: date(0))
|
||||
|
||||
let stats = store.stats(forTail: "N281WN", context: context)
|
||||
|
||||
XCTAssertEqual(stats.totalFlights, 1)
|
||||
XCTAssertEqual(stats.routes, ["DAL→HOU"])
|
||||
XCTAssertEqual(stats.firstSeen, date(0))
|
||||
XCTAssertEqual(stats.lastSeen, date(0))
|
||||
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (1 of 1)")
|
||||
}
|
||||
|
||||
/// Mixed-case stored registration: a record persisted with lowercase
|
||||
/// "n281wn" must still be discoverable when callers ask for
|
||||
/// "N281WN". Today the fast-path #Predicate misses (it compares
|
||||
/// exact bytes against the uppercased query) and the fallback
|
||||
/// table-scan recovers it. After Phase 3 fixes registration
|
||||
/// normalisation at write-time (or switches to a case-insensitive
|
||||
/// predicate), the fast path will hit — but this test should still
|
||||
/// pass either way.
|
||||
func test_stats_lowercaseStoredRegistration_isFoundViaFallback() {
|
||||
insert(registration: "n281wn", origin: "DAL", dest: "HOU", flightDate: date(0))
|
||||
|
||||
let stats = store.stats(forTail: "N281WN", context: context)
|
||||
|
||||
XCTAssertEqual(stats.totalFlights, 1)
|
||||
XCTAssertEqual(stats.routes, ["DAL→HOU"])
|
||||
XCTAssertEqual(stats.mostCommonRoute, "DAL→HOU (1 of 1)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import XCTest
|
||||
@testable import Flights
|
||||
|
||||
/// Coverage for `DataIntegrityMonitor` — the shared sink that collects
|
||||
/// bundled-JSON decode failures so `RootView` can show a banner instead
|
||||
/// of leaving the user staring at "no data" with no explanation.
|
||||
///
|
||||
/// The monitor is `@MainActor` because it's read by SwiftUI views, so
|
||||
/// every test hop onto the main actor before touching it. Each test also
|
||||
/// calls `clear()` first because the singleton is process-wide and other
|
||||
/// loaders may have reported into it during test bring-up.
|
||||
@MainActor
|
||||
final class DataIntegrityMonitorTests: XCTestCase {
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
DataIntegrityMonitor.shared.clear()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
DataIntegrityMonitor.shared.clear()
|
||||
try await super.tearDown()
|
||||
}
|
||||
|
||||
func test_reportingOneFailure_setsHasFailuresTrue() {
|
||||
let monitor = DataIntegrityMonitor.shared
|
||||
XCTAssertFalse(monitor.hasFailures, "monitor should start empty after clear()")
|
||||
|
||||
let err = NSError(
|
||||
domain: "TestDomain",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "bad json"]
|
||||
)
|
||||
monitor.report("bts_bundle.json", error: err)
|
||||
|
||||
XCTAssertTrue(monitor.hasFailures)
|
||||
XCTAssertEqual(monitor.failures.count, 1)
|
||||
XCTAssertTrue(
|
||||
monitor.failures[0].contains("bts_bundle.json"),
|
||||
"failure entry should include the resource basename"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
monitor.failures[0].contains("bad json"),
|
||||
"failure entry should include the localized description"
|
||||
)
|
||||
}
|
||||
|
||||
func test_reportingTwoFailures_accumulates() {
|
||||
let monitor = DataIntegrityMonitor.shared
|
||||
|
||||
monitor.report(
|
||||
"jumpseat_rules.json",
|
||||
error: NSError(domain: "T", code: 1, userInfo: [NSLocalizedDescriptionKey: "missing field"])
|
||||
)
|
||||
monitor.report(
|
||||
"crewbases.json",
|
||||
error: NSError(domain: "T", code: 2, userInfo: [NSLocalizedDescriptionKey: "trailing comma"])
|
||||
)
|
||||
|
||||
XCTAssertEqual(monitor.failures.count, 2)
|
||||
XCTAssertTrue(monitor.hasFailures)
|
||||
XCTAssertTrue(monitor.failures[0].contains("jumpseat_rules.json"))
|
||||
XCTAssertTrue(monitor.failures[1].contains("crewbases.json"))
|
||||
}
|
||||
|
||||
func test_clear_resetsHasFailures() {
|
||||
let monitor = DataIntegrityMonitor.shared
|
||||
|
||||
monitor.report(
|
||||
"partner_matrix.json",
|
||||
error: NSError(domain: "T", code: 1, userInfo: [NSLocalizedDescriptionKey: "broken"])
|
||||
)
|
||||
XCTAssertTrue(monitor.hasFailures, "precondition: monitor has at least one failure")
|
||||
|
||||
monitor.clear()
|
||||
|
||||
XCTAssertFalse(monitor.hasFailures)
|
||||
XCTAssertEqual(monitor.failures.count, 0)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,142 @@
|
||||
import XCTest
|
||||
@testable import Flights
|
||||
|
||||
/// Tests for ``FlightAwareScheduleClient``. The two pure-parser entry
|
||||
/// points (``parseIdents`` and ``extractTrackpollBlob``) are exercised
|
||||
/// directly against fixture HTML captured from a live request — this
|
||||
/// catches FlightAware schema drift the moment it happens (route.rvt or
|
||||
/// trackpoll layout changes) instead of finding out via empty search
|
||||
/// results in production.
|
||||
///
|
||||
/// Fixtures live next to this file under `Fixtures/`. They're real
|
||||
/// HTML pages saved verbatim from FlightAware, not synthetic markup,
|
||||
/// so the tests assert against the actual shapes the parser sees.
|
||||
final class FlightAwareScheduleClientTests: XCTestCase {
|
||||
|
||||
// MARK: - Fixture loading
|
||||
|
||||
/// Reads a file from the `Fixtures/` directory sibling to this test
|
||||
/// source file. Avoids needing the test target's pbxproj to declare
|
||||
/// a Resources phase — `#filePath` resolves to the real source path
|
||||
/// at test-run time.
|
||||
private func loadFixture(_ name: String, file: StaticString = #filePath) throws -> String {
|
||||
let here = URL(fileURLWithPath: String(describing: file))
|
||||
let url = here.deletingLastPathComponent()
|
||||
.appendingPathComponent("Fixtures")
|
||||
.appendingPathComponent(name)
|
||||
return try String(contentsOf: url, encoding: .utf8)
|
||||
}
|
||||
|
||||
// MARK: - parseIdents
|
||||
|
||||
func test_parseIdents_extractsFlightIdent_fromRouteAnalysisPage() throws {
|
||||
let html = try loadFixture("DFW_EHAM_route.html")
|
||||
let idents = FlightAwareScheduleClient.parseIdents(routeHTML: html)
|
||||
XCTAssertFalse(idents.isEmpty,
|
||||
"Should find at least one operating ident on DFW->AMS route page.")
|
||||
XCTAssertTrue(idents.contains("AAL220"),
|
||||
"AAL220 (AA daily 777-200 DFW->AMS) must surface; got \(idents)")
|
||||
}
|
||||
|
||||
func test_parseIdents_dedupesRepeatedIdents() throws {
|
||||
let html = try loadFixture("DFW_EHAM_route.html")
|
||||
let idents = FlightAwareScheduleClient.parseIdents(routeHTML: html)
|
||||
XCTAssertEqual(idents.count, Set(idents).count,
|
||||
"Returned idents should be deduped; got \(idents)")
|
||||
}
|
||||
|
||||
func test_parseIdents_returnsEmpty_whenNoRoutesPresent() {
|
||||
let empty = """
|
||||
<html><body><table>
|
||||
<tr><th>Filed Time</th><th>Ident</th></tr>
|
||||
<tr><td>No data</td></tr>
|
||||
</table></body></html>
|
||||
"""
|
||||
XCTAssertEqual(
|
||||
FlightAwareScheduleClient.parseIdents(routeHTML: empty),
|
||||
[],
|
||||
"Page with no flight rows should produce an empty list, not crash."
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - extractTrackpollBlob
|
||||
|
||||
func test_extractTrackpollBlob_returnsParseableJSON() throws {
|
||||
let html = try loadFixture("AAL220_trackpoll.html")
|
||||
guard let blob = FlightAwareScheduleClient.extractTrackpollBlob(from: html) else {
|
||||
XCTFail("Should extract trackpollBootstrap from AAL220 page")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(blob.hasPrefix("{") && blob.hasSuffix("}"),
|
||||
"Extracted blob should be a JSON object literal")
|
||||
// Round-trip through JSONDecoder to confirm shape.
|
||||
XCTAssertNoThrow(
|
||||
try JSONDecoder().decode(TrackpollBootstrap.self, from: Data(blob.utf8)),
|
||||
"Extracted JSON should decode against TrackpollBootstrap schema"
|
||||
)
|
||||
}
|
||||
|
||||
func test_extractTrackpollBlob_returnsNil_whenMarkerMissing() {
|
||||
let html = "<html><body>no script here</body></html>"
|
||||
XCTAssertNil(FlightAwareScheduleClient.extractTrackpollBlob(from: html))
|
||||
}
|
||||
|
||||
func test_extractTrackpollBlob_isStringContentAware() {
|
||||
// A closing brace inside a string literal must NOT terminate the scan.
|
||||
let html = #"""
|
||||
<script>var trackpollBootstrap = {"a":"} not the end","b":1};</script>
|
||||
"""#
|
||||
let blob = FlightAwareScheduleClient.extractTrackpollBlob(from: html)
|
||||
XCTAssertEqual(
|
||||
blob,
|
||||
#"{"a":"} not the end","b":1}"#,
|
||||
"Braces inside JSON strings must not break the brace-balance scan."
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - ident decomposition
|
||||
|
||||
func test_identCarrierICAO_stripsTrailingDigits() {
|
||||
XCTAssertEqual(FlightAwareScheduleClient.identCarrierICAO("AAL220"), "AAL")
|
||||
XCTAssertEqual(FlightAwareScheduleClient.identCarrierICAO("BAW296"), "BAW")
|
||||
XCTAssertEqual(FlightAwareScheduleClient.identCarrierICAO("SWA1"), "SWA")
|
||||
}
|
||||
|
||||
func test_identFlightNumber_extractsTrailingDigits() {
|
||||
XCTAssertEqual(FlightAwareScheduleClient.identFlightNumber("AAL220"), 220)
|
||||
XCTAssertEqual(FlightAwareScheduleClient.identFlightNumber("BAW296"), 296)
|
||||
XCTAssertEqual(FlightAwareScheduleClient.identFlightNumber("SWA1"), 1)
|
||||
}
|
||||
|
||||
func test_airlineIATA_mapsKnownAndReturnsNilForUnknown() {
|
||||
XCTAssertEqual(FlightAwareScheduleClient.airlineIATA(forICAO: "AAL"), "AA")
|
||||
XCTAssertEqual(FlightAwareScheduleClient.airlineIATA(forICAO: "KLM"), "KL")
|
||||
XCTAssertEqual(FlightAwareScheduleClient.airlineIATA(forICAO: "BAW"), "BA")
|
||||
XCTAssertNil(FlightAwareScheduleClient.airlineIATA(forICAO: "ZZZ"),
|
||||
"Unknown ICAO should return nil so caller can fall back to the raw prefix.")
|
||||
}
|
||||
|
||||
// MARK: - End-to-end against fixture
|
||||
|
||||
func test_endToEnd_AAL220_trackpoll_decodesToScheduledLeg() throws {
|
||||
let html = try loadFixture("AAL220_trackpoll.html")
|
||||
guard let blob = FlightAwareScheduleClient.extractTrackpollBlob(from: html) else {
|
||||
XCTFail("missing trackpoll blob")
|
||||
return
|
||||
}
|
||||
let decoded = try JSONDecoder().decode(TrackpollBootstrap.self, from: Data(blob.utf8))
|
||||
|
||||
// The fixture was captured on 2026-06-05; it should contain a DFW->AMS
|
||||
// leg with a B772 aircraft. We don't assert exact timestamps because
|
||||
// future updates to the fixture (re-capture) will rotate the dates.
|
||||
let dfwAmsLegs = decoded.flights.values
|
||||
.flatMap { $0.activityLog.flights }
|
||||
.filter { $0.origin.iata == "DFW" && $0.destination.iata == "AMS" }
|
||||
XCTAssertFalse(dfwAmsLegs.isEmpty,
|
||||
"AAL220 fixture should contain at least one DFW->AMS leg")
|
||||
XCTAssertTrue(
|
||||
dfwAmsLegs.contains { $0.aircraftType == "B772" },
|
||||
"AAL220 DFW->AMS legs should be operated by B772 per the captured fixture"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import XCTest
|
||||
@testable import Flights
|
||||
|
||||
/// Tests for the standby tracking fields on the history flight model.
|
||||
///
|
||||
/// NOTE: The codebase's history record type is `LoggedFlight` (see
|
||||
/// `Flights/Models/LoggedFlight.swift`). The task spec referred to it as
|
||||
/// "HistoryFlight" — that name does not exist. These tests therefore
|
||||
/// target `LoggedFlight`, which is the actual @Model SwiftData type that
|
||||
/// owns `standbyOutcome` and the computed `wasStandby`.
|
||||
///
|
||||
/// Assumption to verify: there is no separate `HistoryFlight` type.
|
||||
final class HistoryFlightModelTests: XCTestCase {
|
||||
|
||||
// MARK: wasStandby
|
||||
|
||||
func test_wasStandby_isTrue_whenOutcomeIsStandbyMade() {
|
||||
let flight = LoggedFlight(departureIATA: "LAX", arrivalIATA: "JFK")
|
||||
flight.standbyOutcome = "standby-made"
|
||||
XCTAssertTrue(flight.wasStandby,
|
||||
"standby-made should count as a standby attempt")
|
||||
}
|
||||
|
||||
func test_wasStandby_isTrue_whenOutcomeIsStandbyBumped() {
|
||||
let flight = LoggedFlight(departureIATA: "LAX", arrivalIATA: "JFK")
|
||||
flight.standbyOutcome = "standby-bumped"
|
||||
XCTAssertTrue(flight.wasStandby,
|
||||
"standby-bumped should count as a standby attempt")
|
||||
}
|
||||
|
||||
func test_wasStandby_isFalse_whenOutcomeIsConfirmed() {
|
||||
let flight = LoggedFlight(departureIATA: "LAX", arrivalIATA: "JFK")
|
||||
flight.standbyOutcome = "confirmed"
|
||||
XCTAssertFalse(flight.wasStandby,
|
||||
"confirmed is a positive-space ticket, not standby")
|
||||
}
|
||||
|
||||
func test_wasStandby_isFalse_whenOutcomeIsNil() {
|
||||
let flight = LoggedFlight(departureIATA: "LAX", arrivalIATA: "JFK")
|
||||
flight.standbyOutcome = nil
|
||||
XCTAssertFalse(flight.wasStandby,
|
||||
"nil outcome (legacy / unmigrated) should not count as standby")
|
||||
}
|
||||
|
||||
// MARK: Default init — all new standby fields nil
|
||||
|
||||
func test_defaultInit_hasAllStandbyFieldsNil() {
|
||||
let flight = LoggedFlight()
|
||||
|
||||
XCTAssertNil(flight.standbyOutcome,
|
||||
"standbyOutcome must default to nil for CloudKit migration safety")
|
||||
XCTAssertNil(flight.standbyAttemptedAt,
|
||||
"standbyAttemptedAt must default to nil")
|
||||
XCTAssertNil(flight.standbyClearedAt,
|
||||
"standbyClearedAt must default to nil")
|
||||
XCTAssertNil(flight.standbyClass,
|
||||
"standbyClass must default to nil")
|
||||
XCTAssertNil(flight.standbyNotes,
|
||||
"standbyNotes must default to nil")
|
||||
|
||||
// And the derived flag follows.
|
||||
XCTAssertFalse(flight.wasStandby,
|
||||
"a freshly-constructed record is not a standby attempt")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import XCTest
|
||||
@testable import Flights
|
||||
|
||||
/// Guard test against a regression where the dead-code "Selftest" block in
|
||||
/// `RootView.swift` (a Task.detached that called
|
||||
/// `routeExplorer.searchSchedule` on app launch and printed results) gets
|
||||
/// re-introduced. That block touched the broken `RouteExplorerClient` and
|
||||
/// fired off a detached task at startup — exactly the shape we want to
|
||||
/// keep out of the launch path.
|
||||
///
|
||||
/// Strategy: locate `RootView.swift` on disk and assert none of the
|
||||
/// fingerprint substrings are present. We try a couple of paths because
|
||||
/// the working directory during `xcodebuild test` is not stable.
|
||||
final class SelftestRemovalTests: XCTestCase {
|
||||
|
||||
/// Fingerprints that uniquely identify the dead-code block.
|
||||
private static let forbiddenSubstrings: [String] = [
|
||||
"[Selftest]",
|
||||
"routeExplorer.searchSchedule",
|
||||
"Task.detached"
|
||||
]
|
||||
|
||||
func test_rootView_doesNotContainSelftestDeadCode() throws {
|
||||
guard let source = Self.loadRootViewSource() else {
|
||||
// We couldn't locate the file from the test bundle's vantage
|
||||
// point. Don't silently pass — surface it as a skip so the
|
||||
// dev knows to do a manual check.
|
||||
// manual check: open Flights/Views/RootView.swift and confirm
|
||||
// none of `[Selftest]`, `routeExplorer.searchSchedule`, or
|
||||
// `Task.detached` appear in it.
|
||||
throw XCTSkip("Could not locate RootView.swift from the test bundle; manual check required.")
|
||||
}
|
||||
|
||||
for needle in Self.forbiddenSubstrings {
|
||||
XCTAssertFalse(
|
||||
source.contains(needle),
|
||||
"RootView.swift still contains forbidden dead-code fingerprint: \(needle)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File location
|
||||
|
||||
/// Try several strategies to find `RootView.swift` on disk.
|
||||
/// Order: explicit env var → walking up from the test bundle → a known
|
||||
/// absolute project path → walking up from #file.
|
||||
private static func loadRootViewSource() -> String? {
|
||||
let fm = FileManager.default
|
||||
var candidates: [String] = []
|
||||
|
||||
// 1. Env override (useful for CI or weird scheme configs).
|
||||
if let envRoot = ProcessInfo.processInfo.environment["FLIGHTS_PROJECT_ROOT"] {
|
||||
candidates.append((envRoot as NSString).appendingPathComponent("Flights/Views/RootView.swift"))
|
||||
}
|
||||
|
||||
// 2. Walk up from the test bundle until we find a sibling `Flights` dir.
|
||||
let bundleURL = Bundle(for: SelftestRemovalTests.self).bundleURL
|
||||
var dir = bundleURL.deletingLastPathComponent()
|
||||
for _ in 0..<8 {
|
||||
let guess = dir.appendingPathComponent("Flights/Views/RootView.swift").path
|
||||
candidates.append(guess)
|
||||
dir = dir.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
// 3. Known absolute path on this dev machine (best-effort fallback).
|
||||
candidates.append("/Users/m4mini/Desktop/code/Flights/Flights/Views/RootView.swift")
|
||||
|
||||
// 4. Walk up from this source file's location.
|
||||
let thisFile = URL(fileURLWithPath: #filePath)
|
||||
var srcDir = thisFile.deletingLastPathComponent()
|
||||
for _ in 0..<6 {
|
||||
let guess = srcDir.appendingPathComponent("Flights/Views/RootView.swift").path
|
||||
candidates.append(guess)
|
||||
srcDir = srcDir.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
for path in candidates where fm.fileExists(atPath: path) {
|
||||
if let contents = try? String(contentsOfFile: path, encoding: .utf8) {
|
||||
return contents
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import XCTest
|
||||
@testable import Flights
|
||||
|
||||
// MARK: - Test Doubles
|
||||
//
|
||||
// Phase 3 wired the production `FlightScheduleProvider` protocol in
|
||||
// `Services/SisterFlightService.swift`, so we just consume it here rather
|
||||
// than re-declaring it.
|
||||
|
||||
/// Hard-coded schedule provider. Tests configure airport autocomplete
|
||||
/// results and a list of schedules to return; the mock plays them back.
|
||||
actor MockScheduleProvider: FlightScheduleProvider {
|
||||
private let airportLookups: [String: [Airport]]
|
||||
private let schedulesToReturn: [FlightSchedule]
|
||||
private let shouldThrowOnSchedules: Bool
|
||||
|
||||
init(airportLookups: [String: [Airport]] = [:],
|
||||
schedulesToReturn: [FlightSchedule] = [],
|
||||
shouldThrowOnSchedules: Bool = false) {
|
||||
self.airportLookups = airportLookups
|
||||
self.schedulesToReturn = schedulesToReturn
|
||||
self.shouldThrowOnSchedules = shouldThrowOnSchedules
|
||||
}
|
||||
|
||||
func searchAirports(term: String) async throws -> [Airport] {
|
||||
return airportLookups[term.uppercased()] ?? []
|
||||
}
|
||||
|
||||
func allSchedules(
|
||||
dep: String,
|
||||
des: String,
|
||||
onProgress: @Sendable @escaping (Int, Int) -> Void
|
||||
) async throws -> [FlightSchedule] {
|
||||
if shouldThrowOnSchedules {
|
||||
throw NSError(domain: "MockScheduleProvider", code: -1, userInfo: nil)
|
||||
}
|
||||
return schedulesToReturn
|
||||
}
|
||||
}
|
||||
|
||||
final class SisterFlightServiceTests: XCTestCase {
|
||||
|
||||
// A fixed test date so day-of-week assertions are deterministic.
|
||||
// 2026-06-03 is a Wednesday → Calendar weekday = 4.
|
||||
private lazy var targetDate: Date = {
|
||||
var components = DateComponents()
|
||||
components.year = 2026
|
||||
components.month = 6
|
||||
components.day = 3
|
||||
components.hour = 12
|
||||
components.minute = 0
|
||||
components.timeZone = TimeZone(identifier: "UTC")
|
||||
return Calendar(identifier: .gregorian).date(from: components)!
|
||||
}()
|
||||
|
||||
private let origin = "JFK"
|
||||
private let dest = "LAX"
|
||||
|
||||
// MARK: - Test 1: empty schedules
|
||||
|
||||
func test_emptySchedules_returnsEmptyArray() async {
|
||||
let provider = MockScheduleProvider(
|
||||
airportLookups: [
|
||||
"JFK": [airport(id: "jfk-id", iata: "JFK")],
|
||||
"LAX": [airport(id: "lax-id", iata: "LAX")]
|
||||
],
|
||||
schedulesToReturn: []
|
||||
)
|
||||
let service = SisterFlightService(flightService: provider)
|
||||
|
||||
let results = await service.sisterFlights(
|
||||
origin: origin,
|
||||
dest: dest,
|
||||
date: targetDate,
|
||||
currentFlight: nil
|
||||
)
|
||||
|
||||
XCTAssertTrue(results.isEmpty, "Empty upstream schedule → empty sister-flight list.")
|
||||
}
|
||||
|
||||
// MARK: - Test 2: schedules that don't operate on target date are filtered
|
||||
|
||||
func test_schedulesNotOperatingOnTargetDate_areFiltered() async {
|
||||
let weekday = Calendar.current.component(.weekday, from: targetDate)
|
||||
let otherWeekdays = Set([1, 2, 3, 4, 5, 6, 7]).subtracting([weekday])
|
||||
|
||||
// Two schedules: one runs on the target weekday, one doesn't.
|
||||
let operating = schedule(
|
||||
airlineIATA: "DL",
|
||||
flightNumberRaw: "DL 100",
|
||||
departureTime: "09:00",
|
||||
arrivalTime: "12:00",
|
||||
daysOfWeek: [weekday]
|
||||
)
|
||||
let nonOperating = schedule(
|
||||
airlineIATA: "AA",
|
||||
flightNumberRaw: "AA 200",
|
||||
departureTime: "10:00",
|
||||
arrivalTime: "13:00",
|
||||
daysOfWeek: otherWeekdays
|
||||
)
|
||||
|
||||
let provider = MockScheduleProvider(
|
||||
airportLookups: [
|
||||
"JFK": [airport(id: "jfk-id", iata: "JFK")],
|
||||
"LAX": [airport(id: "lax-id", iata: "LAX")]
|
||||
],
|
||||
schedulesToReturn: [operating, nonOperating]
|
||||
)
|
||||
let service = SisterFlightService(flightService: provider)
|
||||
|
||||
let results = await service.sisterFlights(
|
||||
origin: origin,
|
||||
dest: dest,
|
||||
date: targetDate,
|
||||
currentFlight: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(results.count, 1, "Only the schedule operating on the target weekday should survive.")
|
||||
XCTAssertEqual(results.first?.carrier, "DL")
|
||||
XCTAssertEqual(results.first?.flightNumber, 100)
|
||||
}
|
||||
|
||||
// MARK: - Test 3: currentFlight match marks one entry isYourFlight
|
||||
|
||||
func test_currentFlightMatch_marksIsYourFlight() async {
|
||||
let weekday = Calendar.current.component(.weekday, from: targetDate)
|
||||
|
||||
let mine = schedule(
|
||||
airlineIATA: "UA",
|
||||
flightNumberRaw: "UA 555",
|
||||
departureTime: "08:00",
|
||||
arrivalTime: "11:00",
|
||||
daysOfWeek: [weekday]
|
||||
)
|
||||
let other = schedule(
|
||||
airlineIATA: "UA",
|
||||
flightNumberRaw: "UA 777",
|
||||
departureTime: "14:00",
|
||||
arrivalTime: "17:00",
|
||||
daysOfWeek: [weekday]
|
||||
)
|
||||
|
||||
let provider = MockScheduleProvider(
|
||||
airportLookups: [
|
||||
"JFK": [airport(id: "jfk-id", iata: "JFK")],
|
||||
"LAX": [airport(id: "lax-id", iata: "LAX")]
|
||||
],
|
||||
schedulesToReturn: [mine, other]
|
||||
)
|
||||
let service = SisterFlightService(flightService: provider)
|
||||
|
||||
let results = await service.sisterFlights(
|
||||
origin: origin,
|
||||
dest: dest,
|
||||
date: targetDate,
|
||||
currentFlight: (carrier: "UA", number: 555)
|
||||
)
|
||||
|
||||
XCTAssertEqual(results.count, 2)
|
||||
let mineResult = results.first { $0.flightNumber == 555 }
|
||||
let otherResult = results.first { $0.flightNumber == 777 }
|
||||
XCTAssertNotNil(mineResult, "User's flight should be present in results.")
|
||||
XCTAssertNotNil(otherResult, "Other sister flight should be present.")
|
||||
XCTAssertTrue(mineResult?.isYourFlight == true, "Matching carrier+number → isYourFlight true.")
|
||||
XCTAssertTrue(otherResult?.isYourFlight == false, "Non-matching flight should not be flagged.")
|
||||
}
|
||||
|
||||
// MARK: - Test 4: sort by predictedLoad ascending (nil last), then by scheduledDeparture
|
||||
|
||||
func test_resultsSortedByLoadAscending_nilLast_thenByDeparture() async {
|
||||
let weekday = Calendar.current.component(.weekday, from: targetDate)
|
||||
|
||||
// Four schedules with distinct departure times so we can identify them.
|
||||
// Loads injected via predictor below: DL=0.50, AA=0.10, UA=0.10, B6=nil.
|
||||
// Expected order:
|
||||
// AA (0.10, 09:00) — earliest tie-broken
|
||||
// UA (0.10, 11:00)
|
||||
// DL (0.50, 08:00)
|
||||
// B6 (nil, 10:00)
|
||||
let dl = schedule(
|
||||
airlineIATA: "DL",
|
||||
flightNumberRaw: "DL 100",
|
||||
departureTime: "08:00",
|
||||
arrivalTime: "11:00",
|
||||
daysOfWeek: [weekday]
|
||||
)
|
||||
let aa = schedule(
|
||||
airlineIATA: "AA",
|
||||
flightNumberRaw: "AA 200",
|
||||
departureTime: "09:00",
|
||||
arrivalTime: "12:00",
|
||||
daysOfWeek: [weekday]
|
||||
)
|
||||
let b6 = schedule(
|
||||
airlineIATA: "B6",
|
||||
flightNumberRaw: "B6 300",
|
||||
departureTime: "10:00",
|
||||
arrivalTime: "13:00",
|
||||
daysOfWeek: [weekday]
|
||||
)
|
||||
let ua = schedule(
|
||||
airlineIATA: "UA",
|
||||
flightNumberRaw: "UA 400",
|
||||
departureTime: "11:00",
|
||||
arrivalTime: "14:00",
|
||||
daysOfWeek: [weekday]
|
||||
)
|
||||
|
||||
let loadTable: [String: Double] = [
|
||||
"DL-100": 0.50,
|
||||
"AA-200": 0.10,
|
||||
"UA-400": 0.10
|
||||
// B6-300 omitted → nil load
|
||||
]
|
||||
let predictor: @Sendable (String, Int, Date) async -> Double? = { carrier, number, _ in
|
||||
return loadTable["\(carrier)-\(number)"]
|
||||
}
|
||||
|
||||
let provider = MockScheduleProvider(
|
||||
airportLookups: [
|
||||
"JFK": [airport(id: "jfk-id", iata: "JFK")],
|
||||
"LAX": [airport(id: "lax-id", iata: "LAX")]
|
||||
],
|
||||
schedulesToReturn: [dl, aa, b6, ua]
|
||||
)
|
||||
let service = SisterFlightService(flightService: provider, loadPredictor: predictor)
|
||||
|
||||
let results = await service.sisterFlights(
|
||||
origin: origin,
|
||||
dest: dest,
|
||||
date: targetDate,
|
||||
currentFlight: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(results.count, 4)
|
||||
XCTAssertEqual(results[0].carrier, "AA",
|
||||
"Lowest load with earliest departure first.")
|
||||
XCTAssertEqual(results[1].carrier, "UA",
|
||||
"Same load as AA but departs later — second.")
|
||||
XCTAssertEqual(results[2].carrier, "DL",
|
||||
"Higher load than AA/UA — third.")
|
||||
XCTAssertEqual(results[3].carrier, "B6",
|
||||
"Nil load is sorted last regardless of time.")
|
||||
}
|
||||
|
||||
// MARK: - Test 5: predictedLoad nil when loadPredictor is nil
|
||||
|
||||
func test_loadPredictorNil_predictedLoadAlwaysNil() async {
|
||||
let weekday = Calendar.current.component(.weekday, from: targetDate)
|
||||
|
||||
let s = schedule(
|
||||
airlineIATA: "DL",
|
||||
flightNumberRaw: "DL 100",
|
||||
departureTime: "08:00",
|
||||
arrivalTime: "11:00",
|
||||
daysOfWeek: [weekday]
|
||||
)
|
||||
|
||||
let provider = MockScheduleProvider(
|
||||
airportLookups: [
|
||||
"JFK": [airport(id: "jfk-id", iata: "JFK")],
|
||||
"LAX": [airport(id: "lax-id", iata: "LAX")]
|
||||
],
|
||||
schedulesToReturn: [s]
|
||||
)
|
||||
let service = SisterFlightService(flightService: provider, loadPredictor: nil)
|
||||
|
||||
let results = await service.sisterFlights(
|
||||
origin: origin,
|
||||
dest: dest,
|
||||
date: targetDate,
|
||||
currentFlight: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(results.count, 1)
|
||||
XCTAssertNil(results.first?.predictedLoad,
|
||||
"No predictor wired → predictedLoad must be nil.")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func airport(id: String, iata: String) -> Airport {
|
||||
Airport(id: id, iata: iata, name: "\(iata) Airport")
|
||||
}
|
||||
|
||||
/// Build a FlightSchedule with a synthetic Airline. Date range is
|
||||
/// wide enough (2020 → 2030) that any reasonable target date falls
|
||||
/// inside it; the only real filter is the daysOfWeek set.
|
||||
private func schedule(
|
||||
airlineIATA: String,
|
||||
flightNumberRaw: String,
|
||||
departureTime: String,
|
||||
arrivalTime: String,
|
||||
daysOfWeek: Set<Int>
|
||||
) -> FlightSchedule {
|
||||
let airline = Airline(
|
||||
id: "airline-\(airlineIATA)",
|
||||
name: airlineIATA,
|
||||
iata: airlineIATA,
|
||||
logoFilename: "\(airlineIATA).png"
|
||||
)
|
||||
|
||||
var utc = Calendar(identifier: .gregorian)
|
||||
utc.timeZone = TimeZone(identifier: "UTC")!
|
||||
let from = utc.date(from: DateComponents(year: 2020, month: 1, day: 1))!
|
||||
let to = utc.date(from: DateComponents(year: 2030, month: 12, day: 31))!
|
||||
|
||||
return FlightSchedule(
|
||||
airline: airline,
|
||||
flightNumber: flightNumberRaw,
|
||||
aircraft: "738",
|
||||
aircraftId: "",
|
||||
departureTime: departureTime,
|
||||
arrivalTime: arrivalTime,
|
||||
dateFrom: from,
|
||||
dateTo: to,
|
||||
daysOfWeek: daysOfWeek,
|
||||
cabinClasses: .economy
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import Flights
|
||||
|
||||
/// Unit tests for `StandbyStatsService`.
|
||||
///
|
||||
/// All tests use an in-memory `ModelContainer` so they don't touch the
|
||||
/// real SwiftData store or CloudKit. We seed `LoggedFlight` rows with
|
||||
/// varied standby outcomes / carriers / routes / dates, then exercise
|
||||
/// the public surface (`personalRate`, `recentOutcomes`) and assert on
|
||||
/// the aggregate result.
|
||||
@MainActor
|
||||
final class StandbyStatsServiceTests: XCTestCase {
|
||||
|
||||
private var container: ModelContainer!
|
||||
private var context: ModelContext!
|
||||
private var service: StandbyStatsService!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
let schema = Schema([LoggedFlight.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
container = try ModelContainer(for: schema, configurations: config)
|
||||
context = ModelContext(container)
|
||||
service = StandbyStatsService()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
service = nil
|
||||
context = nil
|
||||
container = nil
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Reference epoch we offset from so date ordering is deterministic
|
||||
/// regardless of wall-clock time when the test runs.
|
||||
private static let epoch = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
|
||||
private func date(_ dayOffset: Int) -> Date {
|
||||
Self.epoch.addingTimeInterval(TimeInterval(dayOffset) * 86_400)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func insert(
|
||||
outcome: String?,
|
||||
carrierIATA: String? = "WN",
|
||||
carrierICAO: String? = "SWA",
|
||||
origin: String = "DAL",
|
||||
dest: String = "HOU",
|
||||
flightDate: Date? = nil
|
||||
) -> LoggedFlight {
|
||||
let flight = LoggedFlight(
|
||||
flightDate: flightDate ?? date(0),
|
||||
carrierICAO: carrierICAO,
|
||||
carrierIATA: carrierIATA,
|
||||
departureIATA: origin,
|
||||
arrivalIATA: dest
|
||||
)
|
||||
flight.standbyOutcome = outcome
|
||||
context.insert(flight)
|
||||
return flight
|
||||
}
|
||||
|
||||
// MARK: - personalRate
|
||||
|
||||
/// Empty store should return the documented sentinel.
|
||||
func test_personalRate_emptyContext_returnsEmpty() {
|
||||
let rate = service.personalRate(carrier: nil, origin: nil, dest: nil, context: context)
|
||||
|
||||
XCTAssertEqual(rate.attempts, 0)
|
||||
XCTAssertEqual(rate.made, 0)
|
||||
XCTAssertEqual(rate.bumped, 0)
|
||||
XCTAssertEqual(rate.confirmed, 0)
|
||||
XCTAssertEqual(rate.rate, 0)
|
||||
}
|
||||
|
||||
/// 5 confirmed + 3 standby-made + 2 standby-bumped — sanity check
|
||||
/// the aggregate maths. attempts = made + bumped = 5; rate = 3/5.
|
||||
func test_personalRate_mixedOutcomes_returnsExpectedCounts() {
|
||||
for _ in 0..<5 { insert(outcome: "confirmed") }
|
||||
for _ in 0..<3 { insert(outcome: "standby-made") }
|
||||
for _ in 0..<2 { insert(outcome: "standby-bumped") }
|
||||
|
||||
let rate = service.personalRate(carrier: nil, origin: nil, dest: nil, context: context)
|
||||
|
||||
XCTAssertEqual(rate.attempts, 5, "attempts = standby-made + standby-bumped")
|
||||
XCTAssertEqual(rate.made, 3)
|
||||
XCTAssertEqual(rate.bumped, 2)
|
||||
XCTAssertEqual(rate.confirmed, 5)
|
||||
XCTAssertEqual(rate.rate, 0.6, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
/// Carrier filter must restrict to flights whose IATA *or* ICAO matches
|
||||
/// (the service deliberately checks both — caller doesn't know which
|
||||
/// code was stored).
|
||||
func test_personalRate_carrierFilter_onlyCountsMatchingCarrier() {
|
||||
// WN: 2 made, 1 bumped → 3 attempts, rate = 2/3
|
||||
insert(outcome: "standby-made", carrierIATA: "WN", carrierICAO: "SWA")
|
||||
insert(outcome: "standby-made", carrierIATA: "WN", carrierICAO: "SWA")
|
||||
insert(outcome: "standby-bumped", carrierIATA: "WN", carrierICAO: "SWA")
|
||||
// AA noise that must be excluded by the filter.
|
||||
insert(outcome: "standby-made", carrierIATA: "AA", carrierICAO: "AAL")
|
||||
insert(outcome: "standby-bumped", carrierIATA: "AA", carrierICAO: "AAL")
|
||||
insert(outcome: "confirmed", carrierIATA: "AA", carrierICAO: "AAL")
|
||||
|
||||
let rate = service.personalRate(carrier: "WN", origin: nil, dest: nil, context: context)
|
||||
|
||||
XCTAssertEqual(rate.attempts, 3)
|
||||
XCTAssertEqual(rate.made, 2)
|
||||
XCTAssertEqual(rate.bumped, 1)
|
||||
XCTAssertEqual(rate.confirmed, 0)
|
||||
XCTAssertEqual(rate.rate, 2.0 / 3.0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
/// Origin filter only counts flights departing the requested airport.
|
||||
func test_personalRate_originFilter_onlyCountsMatchingDeparture() {
|
||||
insert(outcome: "standby-made", origin: "DAL", dest: "HOU")
|
||||
insert(outcome: "standby-bumped", origin: "DAL", dest: "LAS")
|
||||
insert(outcome: "confirmed", origin: "DAL", dest: "MDW")
|
||||
// Other-origin noise — must be excluded.
|
||||
insert(outcome: "standby-made", origin: "HOU", dest: "DAL")
|
||||
insert(outcome: "confirmed", origin: "AUS", dest: "DAL")
|
||||
|
||||
let rate = service.personalRate(carrier: nil, origin: "DAL", dest: nil, context: context)
|
||||
|
||||
XCTAssertEqual(rate.attempts, 2)
|
||||
XCTAssertEqual(rate.made, 1)
|
||||
XCTAssertEqual(rate.bumped, 1)
|
||||
XCTAssertEqual(rate.confirmed, 1)
|
||||
XCTAssertEqual(rate.rate, 0.5, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
/// Carrier + origin + dest filters combine with AND semantics. Only
|
||||
/// flights matching every condition should be counted.
|
||||
func test_personalRate_combinedFilters_useAndSemantics() {
|
||||
// Target combo: WN, DAL → HOU. 2 made, 1 bumped → rate 2/3.
|
||||
insert(outcome: "standby-made", carrierIATA: "WN", origin: "DAL", dest: "HOU")
|
||||
insert(outcome: "standby-made", carrierIATA: "WN", origin: "DAL", dest: "HOU")
|
||||
insert(outcome: "standby-bumped", carrierIATA: "WN", origin: "DAL", dest: "HOU")
|
||||
|
||||
// Same carrier + origin, wrong dest.
|
||||
insert(outcome: "standby-made", carrierIATA: "WN", origin: "DAL", dest: "LAS")
|
||||
// Same carrier + dest, wrong origin.
|
||||
insert(outcome: "standby-made", carrierIATA: "WN", origin: "AUS", dest: "HOU")
|
||||
// Same route, wrong carrier.
|
||||
insert(outcome: "standby-made", carrierIATA: "AA", carrierICAO: "AAL", origin: "DAL", dest: "HOU")
|
||||
|
||||
let rate = service.personalRate(carrier: "WN", origin: "DAL", dest: "HOU", context: context)
|
||||
|
||||
XCTAssertEqual(rate.attempts, 3)
|
||||
XCTAssertEqual(rate.made, 2)
|
||||
XCTAssertEqual(rate.bumped, 1)
|
||||
XCTAssertEqual(rate.confirmed, 0)
|
||||
XCTAssertEqual(rate.rate, 2.0 / 3.0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
// MARK: - recentOutcomes
|
||||
|
||||
/// recentOutcomes returns flights sorted by flightDate desc and
|
||||
/// honours the fetch limit. Flights without an outcome are excluded.
|
||||
func test_recentOutcomes_returnsMostRecentNByDateDescending() {
|
||||
// Insert 7 flights with outcomes across day offsets 0..6.
|
||||
// Day 6 is newest. Insert out of order to prove sort is by
|
||||
// flightDate (not insertion order).
|
||||
let outcomes = ["confirmed", "standby-made", "standby-bumped",
|
||||
"confirmed", "standby-made", "standby-bumped", "confirmed"]
|
||||
let insertionOrder = [3, 0, 6, 2, 5, 1, 4]
|
||||
for day in insertionOrder {
|
||||
insert(outcome: outcomes[day], flightDate: date(day))
|
||||
}
|
||||
// Plus a flight with no outcome — must NOT appear.
|
||||
insert(outcome: nil, flightDate: date(99))
|
||||
|
||||
let recent = service.recentOutcomes(limit: 5, context: context)
|
||||
|
||||
XCTAssertEqual(recent.count, 5)
|
||||
let returnedDays = recent.map { $0.flightDate.timeIntervalSince(Self.epoch) / 86_400 }
|
||||
.map { Int($0.rounded()) }
|
||||
XCTAssertEqual(returnedDays, [6, 5, 4, 3, 2],
|
||||
"Should be the 5 most recent by flightDate desc")
|
||||
XCTAssertTrue(recent.allSatisfy { $0.standbyOutcome != nil })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import XCTest
|
||||
@testable import Flights
|
||||
|
||||
/// Unit tests for `WeatherClient`'s timezone correctness and the shared-cache contract.
|
||||
///
|
||||
/// These tests are intentionally written against the **post-fix** API surface
|
||||
/// (`WeatherClient.dayKey(for:in:)` and `WeatherClient.shared` with an injectable
|
||||
/// `URLSession`). Until the production code adopts that shape, they will not
|
||||
/// compile / will not pass — that's the TDD contract for the timezone-bug phase.
|
||||
///
|
||||
/// Why the test exists:
|
||||
///
|
||||
/// 1. **Local-day key bug.** A flight departing 2026-12-31T22:00:00-05:00
|
||||
/// (10 PM Eastern at JFK) is on December 31 in the airport's wall clock,
|
||||
/// but is 2027-01-01 03:00 UTC. The current implementation builds the
|
||||
/// cache key in UTC (see `WeatherClient.swift:217-223`), which causes the
|
||||
/// daily precip-probability lookup to land on the *wrong* calendar day —
|
||||
/// surfacing tomorrow's forecast as if it were tonight's.
|
||||
///
|
||||
/// 2. **Shared cache.** The UI currently spins up a fresh `WeatherClient()`
|
||||
/// per view (see `LiveFlightDetailSheet.swift:898`), so the per-actor
|
||||
/// cache never hits across legs of a trip. The fix is `WeatherClient.shared`
|
||||
/// plus an injectable session so two requests for the same (iata, day)
|
||||
/// issue a single network call.
|
||||
final class WeatherClientTests: XCTestCase {
|
||||
|
||||
// MARK: - dayKey timezone correctness
|
||||
|
||||
/// 10 PM Eastern on Dec 31 is still Dec 31 to a JFK traveller, even though
|
||||
/// its UTC representation rolls past midnight into Jan 1. The day key must
|
||||
/// be derived in the airport's local zone or every NYE evening flight will
|
||||
/// fetch tomorrow's daily precip probability.
|
||||
func test_dayKey_usesAirportLocalTimeZone_notUTC() throws {
|
||||
// 2026-12-31T22:00:00 America/New_York
|
||||
var comps = DateComponents()
|
||||
comps.year = 2026; comps.month = 12; comps.day = 31
|
||||
comps.hour = 22; comps.minute = 0; comps.second = 0
|
||||
comps.timeZone = TimeZone(identifier: "America/New_York")
|
||||
var cal = Calendar(identifier: .gregorian)
|
||||
cal.timeZone = TimeZone(identifier: "America/New_York")!
|
||||
let date = cal.date(from: comps)!
|
||||
|
||||
let nyc = TimeZone(identifier: "America/New_York")!
|
||||
let key = WeatherClient.dayKey(for: date, in: nyc)
|
||||
|
||||
XCTAssertEqual(
|
||||
key, "2026-12-31",
|
||||
"10pm Eastern on NYE must resolve to the local Dec 31, not UTC's Jan 1."
|
||||
)
|
||||
}
|
||||
|
||||
/// Same instant, asked for in Tokyo — should report Jan 1 (Tokyo is +9,
|
||||
/// so 10pm EST Dec 31 == 12pm JST Jan 1). Proves the helper is honouring
|
||||
/// its `tz` argument and not silently defaulting to UTC.
|
||||
func test_dayKey_respectsCallerProvidedTimeZone() throws {
|
||||
var comps = DateComponents()
|
||||
comps.year = 2026; comps.month = 12; comps.day = 31
|
||||
comps.hour = 22; comps.minute = 0; comps.second = 0
|
||||
comps.timeZone = TimeZone(identifier: "America/New_York")
|
||||
var cal = Calendar(identifier: .gregorian)
|
||||
cal.timeZone = TimeZone(identifier: "America/New_York")!
|
||||
let date = cal.date(from: comps)!
|
||||
|
||||
let tokyo = TimeZone(identifier: "Asia/Tokyo")!
|
||||
let key = WeatherClient.dayKey(for: date, in: tokyo)
|
||||
|
||||
XCTAssertEqual(
|
||||
key, "2027-01-01",
|
||||
"Same instant viewed in Tokyo is already Jan 1 — helper must use the supplied tz."
|
||||
)
|
||||
}
|
||||
|
||||
/// Sanity: noon local on a normal day round-trips through the helper for
|
||||
/// every supported zone. Guards against accidentally re-introducing a
|
||||
/// hard-coded "UTC" inside the formatter.
|
||||
func test_dayKey_noonLocal_matchesCalendarDay() throws {
|
||||
for id in ["America/Los_Angeles", "America/New_York", "Europe/London", "Asia/Tokyo", "Australia/Sydney"] {
|
||||
let tz = TimeZone(identifier: id)!
|
||||
var cal = Calendar(identifier: .gregorian)
|
||||
cal.timeZone = tz
|
||||
var comps = DateComponents()
|
||||
comps.year = 2026; comps.month = 6; comps.day = 15
|
||||
comps.hour = 12; comps.minute = 0
|
||||
comps.timeZone = tz
|
||||
let date = cal.date(from: comps)!
|
||||
XCTAssertEqual(
|
||||
WeatherClient.dayKey(for: date, in: tz),
|
||||
"2026-06-15",
|
||||
"Noon \(id) on 2026-06-15 must round-trip to that calendar day."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared cache + single-flight network behaviour
|
||||
|
||||
/// Two `forecast(...)` calls for the same airport and local day should
|
||||
/// hit the network once. The fix is `WeatherClient.shared` plus an
|
||||
/// injectable `URLSession` so we can count requests against a stub
|
||||
/// protocol — and `LiveFlightDetailSheet` must adopt `.shared` for the
|
||||
/// production cache to actually share.
|
||||
func test_shared_cachesPerLocalDay_acrossCalls() async throws {
|
||||
let db = AirportDatabase()
|
||||
try XCTSkipIf(db.airport(byIATA: "JFK") == nil, "airports.json missing JFK; cannot exercise weather fetch")
|
||||
|
||||
// Single-shot stub that returns the same canned Open-Meteo payload
|
||||
// for any URL. The counter is incremented on every network request.
|
||||
let counter = RequestCounter()
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.protocolClasses = [StubURLProtocol.self]
|
||||
StubURLProtocol.counter = counter
|
||||
StubURLProtocol.responder = { _ in
|
||||
let body = Self.openMeteoFixture()
|
||||
return (HTTPURLResponse(
|
||||
url: URL(string: "https://api.open-meteo.com/v1/forecast")!,
|
||||
statusCode: 200, httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"]
|
||||
)!, body)
|
||||
}
|
||||
let session = URLSession(configuration: config)
|
||||
let client = WeatherClient(session: session)
|
||||
|
||||
// 8 AM Eastern at JFK — squarely inside Open-Meteo's fixture window.
|
||||
let date = Self.localDate(2026, 6, 15, 8, "America/New_York")
|
||||
|
||||
_ = await client.forecast(forIATA: "JFK", on: date, database: db)
|
||||
_ = await client.forecast(forIATA: "JFK", on: date, database: db)
|
||||
|
||||
let hits = await counter.value
|
||||
XCTAssertEqual(
|
||||
hits, 1,
|
||||
"Second call for the same (iata, local day) must be served from cache, not re-fetched."
|
||||
)
|
||||
|
||||
StubURLProtocol.responder = nil
|
||||
StubURLProtocol.counter = nil
|
||||
}
|
||||
|
||||
/// Confirms the singleton exists and is the shared instance, so the UI
|
||||
/// pivot to `WeatherClient.shared` actually deduplicates across views.
|
||||
func test_sharedSingleton_isStable() {
|
||||
let a = WeatherClient.shared
|
||||
let b = WeatherClient.shared
|
||||
XCTAssertTrue(a === b, "WeatherClient.shared must vend the same actor instance across calls.")
|
||||
}
|
||||
|
||||
/// The forecast surface must use the local-day daily precip probability,
|
||||
/// not the UTC-day one. With the fixture below, June 15 local has
|
||||
/// precipProbability=42 and June 16 has 88 — a UTC-keyed lookup at 10pm
|
||||
/// Eastern would land on the wrong bucket and return 88.
|
||||
func test_forecast_dailyPrecipProbability_usesLocalDay() async throws {
|
||||
let db = AirportDatabase()
|
||||
try XCTSkipIf(db.airport(byIATA: "JFK") == nil, "airports.json missing JFK; cannot exercise weather fetch")
|
||||
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.protocolClasses = [StubURLProtocol.self]
|
||||
StubURLProtocol.counter = RequestCounter()
|
||||
StubURLProtocol.responder = { _ in
|
||||
let body = Self.openMeteoFixture()
|
||||
return (HTTPURLResponse(
|
||||
url: URL(string: "https://api.open-meteo.com/v1/forecast")!,
|
||||
statusCode: 200, httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"]
|
||||
)!, body)
|
||||
}
|
||||
let session = URLSession(configuration: config)
|
||||
let client = WeatherClient(session: session)
|
||||
|
||||
// 10 PM local on June 15 NYC — UTC would resolve to June 16.
|
||||
let date = Self.localDate(2026, 6, 15, 22, "America/New_York")
|
||||
let forecast = await client.forecast(forIATA: "JFK", on: date, database: db)
|
||||
XCTAssertNotNil(forecast)
|
||||
XCTAssertEqual(forecast?.airport, "JFK")
|
||||
XCTAssertEqual(
|
||||
forecast?.precipProbabilityPct, 42,
|
||||
"Daily precip prob must reflect the local day's bucket (42), not the UTC day after (88)."
|
||||
)
|
||||
|
||||
StubURLProtocol.responder = nil
|
||||
StubURLProtocol.counter = nil
|
||||
}
|
||||
|
||||
// MARK: - Fixtures / helpers
|
||||
|
||||
/// Open-Meteo's `timezone=auto` response with hourly entries spanning
|
||||
/// the night of 2026-06-15 and into 06-16 (America/New_York), plus two
|
||||
/// daily entries — one with precipProb=42 (the 15th) and one with 88
|
||||
/// (the 16th) so we can detect which day the client picked.
|
||||
private static func openMeteoFixture() -> Data {
|
||||
let json = """
|
||||
{
|
||||
"timezone": "America/New_York",
|
||||
"hourly": {
|
||||
"time": [
|
||||
"2026-06-15T20:00",
|
||||
"2026-06-15T21:00",
|
||||
"2026-06-15T22:00",
|
||||
"2026-06-15T23:00",
|
||||
"2026-06-16T00:00",
|
||||
"2026-06-16T01:00"
|
||||
],
|
||||
"temperature_2m": [21.0, 20.5, 20.0, 19.5, 19.0, 18.5],
|
||||
"precipitation": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
"wind_speed_10m": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0],
|
||||
"visibility": [20000.0, 20000.0, 20000.0, 20000.0, 20000.0, 20000.0],
|
||||
"weather_code": [1, 1, 1, 1, 2, 2]
|
||||
},
|
||||
"daily": {
|
||||
"time": ["2026-06-15", "2026-06-16"],
|
||||
"weathercode": [1, 2],
|
||||
"precipitation_probability_max": [42, 88]
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
private static func localDate(_ y: Int, _ m: Int, _ d: Int, _ h: Int, _ tzID: String) -> Date {
|
||||
var cal = Calendar(identifier: .gregorian)
|
||||
cal.timeZone = TimeZone(identifier: tzID)!
|
||||
var comps = DateComponents()
|
||||
comps.year = y; comps.month = m; comps.day = d
|
||||
comps.hour = h; comps.minute = 0; comps.second = 0
|
||||
comps.timeZone = TimeZone(identifier: tzID)
|
||||
return cal.date(from: comps)!
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test doubles
|
||||
|
||||
/// Thread-safe call counter for the stub URLProtocol. Lives outside the
|
||||
/// actor system so the protocol class can touch it from arbitrary queues.
|
||||
final class RequestCounter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _value = 0
|
||||
var value: Int {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _value
|
||||
}
|
||||
func bump() {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
_value += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal URLProtocol that hands every request to `responder` and bumps
|
||||
/// `counter`. Lets us assert "exactly one fetch" without leaning on the
|
||||
/// real network.
|
||||
///
|
||||
/// The static `responder` / `counter` fields are accessed serially from one
|
||||
/// test at a time (XCTest runs tests sequentially within a class), so a
|
||||
/// plain `static var` is safe here without nonisolated-unsafe annotations.
|
||||
final class StubURLProtocol: URLProtocol {
|
||||
static var responder: ((URLRequest) -> (HTTPURLResponse, Data))?
|
||||
static var counter: RequestCounter?
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { responder != nil }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
Self.counter?.bump()
|
||||
guard let responder = Self.responder else {
|
||||
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
|
||||
return
|
||||
}
|
||||
let (response, data) = responder(request)
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
Reference in New Issue
Block a user