One adaptive tone palette and one shape rule now drive every capsule on Stations, Favourites, the detail sheet and the Key. - CapsuleTone: the light/dark palette pairs in one place, resolved through a UIColor dynamic provider so a List row that re-renders without rebuilding its body still follows an appearance switch. Every pair clears 4.5:1 foreground-on-background in both appearances (4.96-6.72:1). - AdaptiveCapsule: capsule at one drawn line, rounded rect at two or more, radius 8 -> 10, measuring content before padding so insets cannot inflate the line count. - Listing verdict capsules move from white-on-muted-solid to the tinted palette, so Saves, Break even, Costs and Closest station read the same in the rows and in the detail sheet. - Best Value is purple, not green: green stays reserved for a financial saving, which is what the Saves capsule beside it means. - Price dots standardise to 12pt in the app (widget keeps its own 6pt dot; PriceRatingDot now takes a diameter so the difference is deliberate). - Station labels are sentence case with their recommended symbols (medal, tag, location.north.fill, trophy) and StationLabelKind replaces the Key's string-literal dispatch, so renaming a label can no longer drop a row. - Key says "Costs", matching the capsule's own headline (was "Costs more").
330 lines
16 KiB
Swift
330 lines
16 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.")
|
||
)
|
||
XCTAssertEqual(KeySheetContent.helpTitle, "How does Best Value work?")
|
||
}
|
||
}
|