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