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