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