The More info sheet now lists every ranking a station qualifies for, directly under the map and above the price and distance: the same StationLabelBadge the listing rows render, in a flow layout so two or three capsules wrap to a new line rather than truncating at large Dynamic Type. The rules are not restated. StationRanking defers to StationRecommendationBadge.badge for the Best Value gate, and takes Cheapest and Top as the flags the host list already computed for its own rows, so the sheet cannot award a capsule the row behind it withheld. A host passes one of the two, never both: Stations sends isCheapestInRange, Favourites sends isTopFavourite. No calculation, ranking, eligibility or savings logic changed. Rename the card heading Best Value to Value breakdown, since the working behind it can end in Costs rather than a saving, and drop the green section tint so the card matches the neutral background used elsewhere in the sheet. The Saves, Break even and Costs capsules and every breakdown row are untouched. Rename the explanation link to How does FuelBoard calculate value? It names the calculation rather than the feature, of which the Best Value gate is only the last step. The sheet's own title stays short: the full question truncated to 'How does FuelBoard...' as a navigation title at both inline and large display modes, so the row carries the question and the sheet names the topic. Tests: 269 pass, 5 new covering capsule selection, order and sheet-vs-row agreement on Best Value.
405 lines
20 KiB
Swift
405 lines
20 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."),
|
||
// "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)")
|
||
}
|
||
}
|
||
}
|