import XCTest #if SWIFT_PACKAGE @testable import FuelBoardShared #endif final class FuelSavingsCalculatorTests: XCTestCase { func testUKMPGConversionUsesImperialGallons() { XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001) } func testPoundsBudgetConvertsToLitresUsingGivenPrice() throws { let amount = FuelSavingsFillAmount(value: 40, unit: .pounds) let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160) XCTAssertEqual(litres, 25.0, accuracy: 0.0001) } func testLitresAmountPassesThrough() throws { let amount = FuelSavingsFillAmount(value: 35, unit: .litres) let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160) XCTAssertEqual(litres, 35, accuracy: 0.0001) } func testNetSavingSubtractsAdditionalDrivingFuelCost() throws { let result = try FuelSavingsCalculator.calculate( mpg: 45, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 2, candidateJourneyMiles: 8 ) XCTAssertEqual(result.fillLitres, 26.6667, accuracy: 0.0001) XCTAssertEqual(result.grossSavingGBP, 2.6667, accuracy: 0.0001) XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001) XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001) XCTAssertEqual(result.netSavingGBP, 1.7574, accuracy: 0.0001) XCTAssertEqual(result.status, .worthTheDrive) } func testNegativeAndBreakEvenOutcomes() throws { let negative = try FuelSavingsCalculator.calculate( mpg: 45, amount: FuelSavingsFillAmount(value: 10, unit: .litres), baselinePricePencePerLitre: 150, candidatePricePencePerLitre: 149, baselineJourneyMiles: 1, candidateJourneyMiles: 20 ) XCTAssertEqual(negative.status, .notWorthTheDrive) XCTAssertLessThan(negative.netSavingGBP, 0) let even = try FuelSavingsCalculator.calculate( mpg: 45, amount: FuelSavingsFillAmount(value: 10, unit: .litres), baselinePricePencePerLitre: 150, candidatePricePencePerLitre: 150, baselineJourneyMiles: 2, candidateJourneyMiles: 2 ) XCTAssertEqual(even.status, .breakEven) } func testInvalidInputsRejected() { XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), pricePencePerLitre: 0)) XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 0, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2)) XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2)) } func testSwitchingPoundsToLitresPreservesPhysicalQuantity() throws { let pounds = FuelSavingsFillAmount(value: 40, unit: .pounds) let litres = try FuelSavingsCalculator.convert(amount: pounds, to: .litres, referencePricePencePerLitre: 160) XCTAssertEqual(litres.unit, .litres) XCTAssertEqual(litres.value, 25, accuracy: 0.0001) let back = try FuelSavingsCalculator.convert(amount: litres, to: .pounds, referencePricePencePerLitre: 160) XCTAssertEqual(back.value, 40, accuracy: 0.0001) } func testSortingPlacesUnavailableAfterSavingsAndBreaksTies() { let a = FuelSavingsRankedStation(stationID: "a", result: .available(.init(fillLitres: 25, grossSavingGBP: 2, extraDrivingCostGBP: 1, extraDistanceMiles: 3, netSavingGBP: 1, status: .worthTheDrive)), distanceMiles: 5, pricePencePerLitre: 150) let b = FuelSavingsRankedStation(stationID: "b", result: .available(.init(fillLitres: 25, grossSavingGBP: 3, extraDrivingCostGBP: 1, extraDistanceMiles: 2, netSavingGBP: 2, status: .worthTheDrive)), distanceMiles: 8, pricePencePerLitre: 149) let c = FuelSavingsRankedStation(stationID: "c", result: .unavailable("No route"), distanceMiles: 1, pricePencePerLitre: 140) XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([a, c, b]).map(\.stationID), ["b", "a", "c"]) } func testBestValueAlertCandidateRequiresConfiguredSavingsAndThreshold() { let stations = [ station("near", lat: 0, lng: 0, price: 160), station("cheap", lat: 0, lng: 0, price: 150), ] let journeys = ["near": 2.0, "cheap": 4.0] let off = FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)) XCTAssertNil(FuelSavingsCalculator.bestValueAlertCandidate( stations: stations, fuel: .e10, settings: off, amount: off.preferredAmount, minimumNetSavingGBP: 1.0, journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } } )) let on = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)) XCTAssertNil(FuelSavingsCalculator.bestValueAlertCandidate( stations: stations, fuel: .e10, settings: on, amount: on.preferredAmount, minimumNetSavingGBP: 5.0, journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } } )) } func testBestValueAlertCandidatePicksHighestNetSavingAboveThreshold() throws { let stations = [ station("baseline", lat: 0, lng: 0, price: 160), station("small", lat: 0, lng: 0, price: 154), station("best", lat: 0, lng: 0, price: 148), ] let journeys = ["baseline": 2.0, "small": 4.0, "best": 6.0] let settings = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)) let candidate = FuelSavingsCalculator.bestValueAlertCandidate( stations: stations, fuel: .e10, settings: settings, amount: settings.preferredAmount, minimumNetSavingGBP: 1.0, journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } } ) XCTAssertEqual(candidate?.station.id, "best") XCTAssertEqual(candidate?.result.status, .worthTheDrive) XCTAssertGreaterThan(candidate?.result.netSavingGBP ?? 0, 1.0) } func testBestValueAlertCandidateBreaksTiesByDistanceThenPrice() throws { let first = FuelSavingsResult(fillLitres: 25, grossSavingGBP: 3, extraDrivingCostGBP: 1, extraDistanceMiles: 2, netSavingGBP: 2, status: .worthTheDrive) let nearer = FuelSavingsRankedStation(stationID: "nearer", result: .available(first), distanceMiles: 4, pricePencePerLitre: 149) let farther = FuelSavingsRankedStation(stationID: "farther", result: .available(first), distanceMiles: 8, pricePencePerLitre: 145) XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([farther, nearer]).first?.stationID, "nearer") } private func station(_ id: String, lat: Double, lng: Double, price: Double) -> FuelStation { FuelStation(id: id, name: id, brand: id, address: "", postcode: "", lat: lat, lng: lng, prices: [.e10: price], priceUpdated: nil) } } final class FuelSavingsSettingsTests: XCTestCase { func testSwitchingFillUnitStartsAnExplicitNewComparison() { let budget = FuelSavingsFillAmount(value: 60, unit: .pounds) let litres = FuelSavingsCalculator.newComparisonAmount(afterSwitching: budget, to: .litres) XCTAssertEqual(litres, FuelSavingsFillAmount(value: 30, unit: .litres)) XCTAssertEqual(FuelSavingsCalculator.newComparisonAmount(afterSwitching: litres, to: .pounds), FuelSavingsFillAmount(value: 40, unit: .pounds)) XCTAssertEqual(FuelSavingsCalculator.newComparisonAmount(afterSwitching: budget, to: .pounds), budget) } func testSettingsValidationRequiresEnabledMPGAndFillAmount() { XCTAssertFalse(FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured) XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 0, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured) XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 0, unit: .pounds)).isConfigured) XCTAssertTrue(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured) } } /// The row's single recommendation badge. Presentation only, but the rules are /// worth pinning: they decide which of two true statements a row makes, and /// "no badge" is itself an answer. final class StationRecommendationBadgeTests: XCTestCase { private let threshold = 1.0 func testSavingAtTheThresholdEarnsBestValue() { XCTAssertEqual( StationRecommendationBadge.badge(netSavingGBP: 1.0, isCheapest: false, bestValueThresholdGBP: threshold), .bestValue ) } func testSavingBelowTheThresholdDoesNot() { XCTAssertNil( StationRecommendationBadge.badge(netSavingGBP: 0.42, isCheapest: false, bestValueThresholdGBP: threshold) ) } func testCheapestWithoutAWorthwhileSavingStillSaysCheapest() { XCTAssertEqual( StationRecommendationBadge.badge(netSavingGBP: 0.42, isCheapest: true, bestValueThresholdGBP: threshold), .cheapest ) } /// The cheapest station keeps its badge even when the drive costs more than /// the saving buys — it IS the cheapest price; the capsule underneath is /// what says the trip is not worth it. func testCheapestKeepsItsBadgeWhenTheDriveCostsMoreThanItSaves() { XCTAssertEqual( StationRecommendationBadge.badge(netSavingGBP: -14.30, isCheapest: true, bestValueThresholdGBP: threshold), .cheapest ) } /// One badge per row: the stronger statement wins. func testBestValueOutranksCheapestWhenBothApply() { XCTAssertEqual( StationRecommendationBadge.badge(netSavingGBP: 3.15, isCheapest: true, bestValueThresholdGBP: threshold), .bestValue ) } /// A near-by station with no saving to speak of is context, not a /// recommendation — the mockup gives NEAREST no capsule, and nothing else /// here may invent one. func testNoSavingAndNotCheapestMeansNoBadge() { XCTAssertNil( StationRecommendationBadge.badge(netSavingGBP: 0.0, isCheapest: false, bestValueThresholdGBP: threshold) ) XCTAssertNil( StationRecommendationBadge.badge(netSavingGBP: -2.50, isCheapest: false, bestValueThresholdGBP: threshold) ) } func testUnavailableSavingsLeaveOnlyTheCheapestBadge() { XCTAssertEqual( StationRecommendationBadge.badge(netSavingGBP: nil, isCheapest: true, bestValueThresholdGBP: threshold), .cheapest ) XCTAssertNil( StationRecommendationBadge.badge(netSavingGBP: nil, isCheapest: false, bestValueThresholdGBP: threshold) ) } /// A rubbish figure must not become a recommendation. func testNonFiniteFiguresProduceNoBadge() { XCTAssertNil( StationRecommendationBadge.badge(netSavingGBP: .nan, isCheapest: false, bestValueThresholdGBP: threshold) ) XCTAssertNil( StationRecommendationBadge.badge(netSavingGBP: 5.0, isCheapest: false, bestValueThresholdGBP: .nan) ) } /// The threshold is the user's alert setting: set it to zero and any saving /// that is not a loss counts. func testZeroThresholdAwardsBestValueToAnyNonNegativeSaving() { XCTAssertEqual( StationRecommendationBadge.badge(netSavingGBP: 0, isCheapest: false, bestValueThresholdGBP: 0), .bestValue ) } } final class KeySheetContentTests: XCTestCase { func testPriceRatingsExplainTheImplementedRAGBoundaries() { XCTAssertEqual(KeySheetContent.priceRatings, [ .init(label: "Good price", description: "Within 1.5p/L of the cheapest."), .init(label: "Okay", description: "More than 1.5p/L, up to 4p/L above the cheapest."), .init(label: "Pricey", description: "More than 4p/L above the cheapest.") ]) } func testSavingsExplainVerdictsWithoutImplyingMinimumSavingQualification() { XCTAssertEqual(KeySheetContent.savings, [ .init(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."), .init(label: "Break even", description: "The saving or extra cost is negligible."), // "Costs" — the listing capsule's own headline, not "Costs more". .init(label: "Costs", description: "Filling up here costs more than at your closest station, including travel.") ]) XCTAssertFalse(KeySheetContent.savings.contains { $0.label == "Small saving" }) } /// Every Key label is sentence case: the capsules are read as phrases, and an /// all-caps set reads as shouting beside the sentence-case copy around it. func testEveryKeyLabelIsSentenceCase() { let labels = (KeySheetContent.priceRatings + KeySheetContent.savings + KeySheetContent.stationLabels).map(\.label) for label in labels { XCTAssertNotEqual(label, label.uppercased(), "\(label) must be sentence case, not all caps") } XCTAssertEqual(KeySheetContent.stationLabels.map(\.label), ["Best value", "Cheapest", "Closest station", "Top"]) } /// The Key dispatches its indicators on `StationLabelKind`, so every kind must /// have exactly one entry in the legend — and each entry's label must be the /// kind's own, or the row would describe one label and draw another. func testEveryStationLabelKindHasOneMatchingLegendEntry() { XCTAssertEqual(StationLabelKind.allCases.count, KeySheetContent.stationLabels.count) for kind in StationLabelKind.allCases { let matches = KeySheetContent.stationLabels.filter { $0.label == kind.label } XCTAssertEqual(matches.count, 1, "\(kind) must own exactly one legend entry") } } /// Recommended symbols, pinned so a rename cannot silently blank a badge. func testStationLabelKindsUseTheirRecommendedSymbols() { XCTAssertEqual(StationLabelKind.bestValue.symbol, "medal") XCTAssertEqual(StationLabelKind.cheapest.symbol, "tag") XCTAssertEqual(StationLabelKind.closestStation.symbol, "location.north.fill") XCTAssertEqual(StationLabelKind.top.symbol, "trophy") } func testStationLabelsDescribeTheCurrentQualificationAndRangeRules() { XCTAssertEqual(KeySheetContent.stationLabels, [ .init(label: "Best value", description: "Meets your minimum saving after travel costs."), .init(label: "Cheapest", description: "The lowest fuel price per litre within your selected range."), .init(label: "Closest station", description: "The nearest station by journey distance."), .init(label: "Top", description: "The cheapest station in your Favourites for the selected fuel.") ]) } func testFavouriteAndHelpCopyRemainPinned() { XCTAssertEqual( KeySheetContent.favourite, .init(label: "Favourites", description: "Star a station to save it to your Favourites.") ) // The link names the calculation, not the feature: the sheet explains the // fill-up, the return trip and the total, of which the Best Value gate is // only the last step. XCTAssertEqual(KeySheetContent.helpTitle, "How does FuelBoard calculate value?") // The sheet title is a short form of the question, NOT the question itself: // a nav title clamps to the bar, and the full question truncated to // "How does FuelBoard…" at both display modes. XCTAssertEqual(KeySheetContent.helpSheetTitle, "How value is calculated") XCTAssertLessThan(KeySheetContent.helpSheetTitle.count, KeySheetContent.helpTitle.count) } } /// The More info sheet's ranking capsules. Every rule must come from the shared /// badge logic — the sheet may show more labels than a row (which shows one), but /// it must not invent a qualification of its own. final class StationRankingTests: XCTestCase { private let threshold = 2.50 func testNoQualificationProducesNoCapsules() { XCTAssertEqual( StationRanking.kinds(netSavingGBP: 0.40, isCheapestInRange: false, isTopFavourite: false, bestValueThresholdGBP: threshold), [] ) } func testBestValueUsesTheSameGateAsTheListingBadge() { // Exactly at the threshold qualifies, matching StationRecommendationBadge. XCTAssertEqual( StationRanking.kinds(netSavingGBP: threshold, isCheapestInRange: false, isTopFavourite: false, bestValueThresholdGBP: threshold), [.bestValue] ) // A hair under does not. XCTAssertEqual( StationRanking.kinds(netSavingGBP: threshold - 0.01, isCheapestInRange: false, isTopFavourite: false, bestValueThresholdGBP: threshold), [] ) } func testCheapestInRangeAndTopFavouriteAreDistinctLabels() { XCTAssertEqual( StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: true, isTopFavourite: false, bestValueThresholdGBP: threshold), [.cheapest] ) XCTAssertEqual( StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: false, isTopFavourite: true, bestValueThresholdGBP: threshold), [.top] ) } func testCapsulesAreOrderedBestValueTopCheapest() { XCTAssertEqual( StationRanking.kinds(netSavingGBP: 9.99, isCheapestInRange: true, isTopFavourite: true, bestValueThresholdGBP: threshold), [.bestValue, .top, .cheapest] ) } /// A row shows ONE badge; the sheet may show several. Both must agree on the /// Best Value decision, or the sheet would claim a label the list behind it /// withheld. func testSheetAgreesWithTheRowOnBestValue() { for netSaving in [-3.0, 0.0, 2.49, 2.50, 12.0, .nan] as [Double] { let rowSaysBestValue = StationRecommendationBadge.badge( netSavingGBP: netSaving, isCheapest: false, bestValueThresholdGBP: threshold ) == .bestValue let sheetSaysBestValue = StationRanking.kinds( netSavingGBP: netSaving, isCheapestInRange: false, isTopFavourite: false, bestValueThresholdGBP: threshold ).contains(.bestValue) XCTAssertEqual(rowSaysBestValue, sheetSaysBestValue, "disagreed on net saving \(netSaving)") } } }