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 station with no saving, not the cheapest and not the closest states /// nothing a badge could carry. func testNoSavingNotCheapestAndNotClosestMeansNoBadge() { XCTAssertNil( StationRecommendationBadge.badge(netSavingGBP: 0.0, isCheapest: false, isClosest: false, bestValueThresholdGBP: threshold) ) XCTAssertNil( StationRecommendationBadge.badge(netSavingGBP: -2.50, isCheapest: false, isClosest: false, bestValueThresholdGBP: threshold) ) } func testTheClosestStationWearsClosestWhenNothingElseApplies() { XCTAssertEqual( StationRecommendationBadge.badge(netSavingGBP: 0.0, isCheapest: false, isClosest: true, bestValueThresholdGBP: threshold), .closest ) // Closest is a position, so it does not need a savings figure to hold. XCTAssertEqual( StationRecommendationBadge.badge(netSavingGBP: nil, isCheapest: false, isClosest: true, bestValueThresholdGBP: threshold), .closest ) } /// The nearest forecourt is usually also the cheapest, so without the /// ordering the Closest label would win by accident and hide the price news. func testCheapestOutranksClosestWhenBothApply() { XCTAssertEqual( StationRecommendationBadge.badge(netSavingGBP: 0.42, isCheapest: true, isClosest: true, bestValueThresholdGBP: threshold), .cheapest ) } func testBestValueOutranksBothCheapestAndClosest() { XCTAssertEqual( StationRecommendationBadge.badge(netSavingGBP: 3.15, isCheapest: true, isClosest: true, bestValueThresholdGBP: threshold), .bestValue ) } /// The whole ladder in one place, so a future case cannot slide in between /// two rungs unnoticed: Best Value, then Cheapest, then Closest, then none. func testLabelPrecedenceIsTotalAcrossEveryCombination() { let cases: [(saving: Double, cheapest: Bool, closest: Bool, badge: StationRecommendationBadge?)] = [ (3.15, true, true, .bestValue), (3.15, false, true, .bestValue), (3.15, false, false, .bestValue), (0.42, true, true, .cheapest), (0.42, true, false, .cheapest), (0.42, false, true, .closest), (0.42, false, false, nil) ] for c in cases { XCTAssertEqual( StationRecommendationBadge.badge( netSavingGBP: c.saving, isCheapest: c.cheapest, isClosest: c.closest, bestValueThresholdGBP: threshold ), c.badge, "saving \(c.saving), cheapest \(c.cheapest), closest \(c.closest)" ) } } 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"]) } /// 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.closest.symbol, "location") // No trophy: "Top" is gone. One state, one label. XCTAssertFalse(StationLabelKind.allCases.map(\.symbol).contains("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", description: "The closest station by journey distance.") ]) } /// "Top" is retired. It and "Cheapest" were two words for the same flag, and the /// app drifted between them by device: the phone's rows said Cheapest while the /// watch said Top for the identical state. Nothing may reintroduce the synonym. func testTopLabelIsRetired() { XCTAssertFalse(StationLabelKind.allCases.contains { $0.label == "Top" }) XCTAssertFalse(KeySheetContent.stationLabels.contains { $0.label == "Top" }) // The concept is not lost — it is called Cheapest, which is also the word // every piece of favourites copy uses. XCTAssertTrue(StationLabelKind.allCases.contains { $0.label == "Cheapest" }) } /// The closest label is one word everywhere: the badge, the verdict capsule the /// baseline station wears, and the Key all read it from `baselineHeadline`. func testClosestLabelIsTheSameWordEverywhere() { XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest") XCTAssertEqual(StationLabelKind.closest.label, "Closest") let baseline = FuelSavingsResult( fillLitres: 30, grossSavingGBP: 0, extraDrivingCostGBP: 0, extraDistanceMiles: 0, netSavingGBP: 0, status: .baseline, journeyConfidence: .confirmed ) XCTAssertEqual(baseline.headline(), "Closest") XCTAssertEqual(baseline.headline(), StationLabelKind.closest.label) } 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, bestValueThresholdGBP: threshold), [] ) } func testBestValueUsesTheSameGateAsTheListingBadge() { // Exactly at the threshold qualifies, matching StationRecommendationBadge. XCTAssertEqual( StationRanking.kinds(netSavingGBP: threshold, isCheapestInRange: false, bestValueThresholdGBP: threshold), [.bestValue] ) // A hair under does not. XCTAssertEqual( StationRanking.kinds(netSavingGBP: threshold - 0.01, isCheapestInRange: false, bestValueThresholdGBP: threshold), [] ) } /// Cheapest is the only ranking label besides Best value. Whichever tab opened /// the sheet — Stations or Favourites — it means the lowest price among the /// stations on that screen, so there is no second cheapest-flavoured capsule. func testCheapestIsTheOnlyCheapestFlavouredLabel() { XCTAssertEqual( StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: true, bestValueThresholdGBP: threshold), [.cheapest] ) XCTAssertEqual( StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: false, bestValueThresholdGBP: threshold), [] ) } func testCapsulesAreOrderedBestValueThenCheapest() { XCTAssertEqual( StationRanking.kinds(netSavingGBP: 9.99, isCheapestInRange: true, bestValueThresholdGBP: threshold), [.bestValue, .cheapest] ) } /// The sheet has room for every qualification, so a station holding all three /// shows all three — in the Key's order, which is also the row's precedence, /// so the sheet reads left-to-right the same way a row resolves top-to-bottom. func testCapsulesAreOrderedBestValueCheapestClosest() { XCTAssertEqual( StationRanking.kinds(netSavingGBP: 9.99, isCheapestInRange: true, isClosestInRange: true, bestValueThresholdGBP: threshold), [.bestValue, .cheapest, .closest] ) } /// The closest station with no saving and no price advantage still earns its /// label on the sheet — the one case a listing row cannot show, because there /// Best Value and Cheapest are both unavailable and Closest wins the slot. func testClosestAloneEarnsOneCapsule() { XCTAssertEqual( StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: false, isClosestInRange: true, bestValueThresholdGBP: threshold), [.closest] ) XCTAssertEqual( StationRanking.kinds(netSavingGBP: 0.0, isCheapestInRange: false, isClosestInRange: true, bestValueThresholdGBP: threshold), [.closest] ) } /// Closest must not sneak in when the host did not flag it — the sheet never /// invents a rule of its own. func testNoClosestFlagMeansNoClosestCapsule() { XCTAssertEqual( StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: true, isClosestInRange: false, bestValueThresholdGBP: threshold), [.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, bestValueThresholdGBP: threshold ).contains(.bestValue) XCTAssertEqual(rowSaysBestValue, sheetSaysBestValue, "disagreed on net saving \(netSaving)") } } }