- 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.
296 lines
14 KiB
Swift
296 lines
14 KiB
Swift
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?")
|
||
}
|
||
}
|