Files
fuelboard/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift
FuelBoard Contributor 6cbb083208 Retire the Best Value proximity alert option; move Key sheet copy into a tested model
- Alerts: drop the alert-type picker and the Best Value alert path, so
  proximity alerts always follow the cheapest station. A stored `bestValue`
  selection now falls back to cheapest when loaded.
- Key sheet: the price-rating, savings, station-label and favourite wording
  comes from `KeySheetContent` in Shared/FuelSavings.swift — a Foundation-only
  model with tests that pin the exact meaning of each indicator — and the
  sheet is built from that model rather than inline strings.
- BACKLOG: record the plan to restore Best Value proximity alerts behind one
  shared evaluator (user-location baseline, a single disclosed qualification
  rule, and real/Test alert parity).
- Bump CURRENT_PROJECT_VERSION to 29.
2026-10-18 17:16:12 +01:00

296 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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."),
.init(label: "Costs more", description: "Filling up here costs more than at your closest station, including travel.")
])
XCTAssertFalse(KeySheetContent.savings.contains { $0.label == "Small saving" })
}
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 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.")
)
XCTAssertEqual(KeySheetContent.helpTitle, "How does Best Value work?")
}
}