Files
fuelboard/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift
T
FuelBoard Contributor 8153a36168 Make Closest a station label and retire Top
Top and Cheapest were two words for one state: both meant "the lowest price
among the stations on screen", differing only in the candidate set (radius vs
Favourites). Which word a surface used had drifted by device — the phone's
rows said Cheapest while the watch said Top for the identical state. Top is
gone from the enum, the Key legend, the badge and the watch's hand-rolled
label; Cheapest survives because it names the measure and is already the word
the favourites copy uses.

Closest is now a real station label rather than a verdict. StationRecommendationBadge
gains .closest with a total precedence — Best value, then Cheapest, then Closest
— because a row has room for one label and proximity is the weakest claim: a
short drive says nothing about the price, and unlike the other two it reports no
saving at all. The badge sits above the price, where the other two sit. The
closest row's verdict capsule is suppressed: its only word for that state was
"Closest", so printing it below as well would state the same fact twice, three
lines apart. Other rows keep their Saves / Break even / Costs capsules unchanged.

"Closest station" becomes "Closest", read from the single
FuelSavingsResult.baselineHeadline constant so the badge, the verdict and the Key
cannot word it differently. The Key legend renders all three station labels with
the shared StationLabelBadge; the old KeyStationLabelIndicator, which
special-cased the closest station to a wider verdict pill so it read as a
different class of thing from the two above it, is deleted.

One closest icon everywhere. The listing and detail verdict symbols used
location.circle and location.circle.fill for the state the Key taught as
location.north.fill; both now read StationLabelKind.closest.symbol, so the three
surfaces resolve one glyph from one place.

The detail sheet gains the Closest capsule below the map, ordered to match the
Key. Favourites gains the nearest favourite as its Closest, measured on the same
one-way distance its rows print and restricted to favourites with a price for the
active fuel — matching the radius tab, where the closest station is by definition
the savings baseline.

closestStationID flows from the one scaffold baseline the savings lookup already
compares against, so a row's badge and the reference station cannot disagree.
No savings calculation, threshold, ranking, eligibility or station-selection
logic changed.

The Key sheet's detent is now bound rather than fixed, so dragging it up to read
the Station labels section sticks instead of snapping back; a QA launch argument
opens it at full height, since the 0.6 detent puts that section below the fold on
every device. That argument is read unconditionally, unlike the other harness
hooks which are #if DEBUG-gated.

Tests: 278 pass, 9 new — the label precedence ladder across every combination,
capsule order in the sheet, Closest alone, and the retired label pinned absent.
2026-10-19 14:59:47 +01:00

536 lines
26 KiB
Swift
Raw 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 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.north.fill")
// 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)")
}
}
}