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