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.
This commit is contained in:
FuelBoard Contributor
2026-10-19 14:59:47 +01:00
parent c69bdb3c5f
commit 8153a36168
13 changed files with 341 additions and 149 deletions
@@ -533,6 +533,6 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
)?.result
XCTAssertEqual(result?.status, .baseline)
XCTAssertEqual(result?.netSavingGBP, 0)
XCTAssertEqual(result?.headline(), "Closest station")
XCTAssertEqual(result?.headline(), "Closest")
}
}
@@ -47,7 +47,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
XCTAssertEqual(result(.breakEven, .confirmed).listingVerdict().lines, ["Break even"])
XCTAssertEqual(result(.breakEven, .estimated).listingVerdict().lines,
["Break even", "(approx.)"])
XCTAssertEqual(result(.baseline, .confirmed).listingVerdict().lines, ["Closest station"])
XCTAssertEqual(result(.baseline, .confirmed).listingVerdict().lines, ["Closest"])
// The Key sheet's legend demonstrates the wording, never a figure.
XCTAssertEqual(
@@ -450,8 +450,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
)
XCTAssertEqual(availability?.result?.status, .baseline)
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest station")
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest station")
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest")
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest")
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
}
@@ -503,7 +503,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
/// The exact strings the brief asks for.
func testHeadlineWordingMatchesTheBrief() {
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest station")
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest")
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
let saving = FuelSavingsResult(fillLitres: 34.9,
@@ -594,7 +594,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
}
/// The baseline capsule has to say what it IS: it previously repeated the
/// status word above it ("Closest station" twice) and never explained that
/// status word above it ("Closest" twice) and never explained that
/// the savings are measured against it.
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
let baseline = FuelSavingsResult(
@@ -605,7 +605,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: 0,
status: .baseline
)
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station")
XCTAssertEqual(baseline.capsuleHeadline(), "Closest")
let saving = FuelSavingsResult(
fillLitres: 30,
@@ -209,20 +209,82 @@ final class StationRecommendationBadgeTests: XCTestCase {
)
}
/// 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() {
/// 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,
@@ -288,7 +350,7 @@ final class KeySheetContentTests: XCTestCase {
"\(label) must be sentence case, not all caps")
}
XCTAssertEqual(KeySheetContent.stationLabels.map(\.label),
["Best value", "Cheapest", "Closest station", "Top"])
["Best value", "Cheapest", "Closest"])
}
/// The Key dispatches its indicators on `StationLabelKind`, so every kind must
@@ -306,19 +368,48 @@ final class KeySheetContentTests: XCTestCase {
func testStationLabelKindsUseTheirRecommendedSymbols() {
XCTAssertEqual(StationLabelKind.bestValue.symbol, "medal")
XCTAssertEqual(StationLabelKind.cheapest.symbol, "tag")
XCTAssertEqual(StationLabelKind.closestStation.symbol, "location.north.fill")
XCTAssertEqual(StationLabelKind.top.symbol, "trophy")
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 station", description: "The nearest station by journey distance."),
.init(label: "Top", description: "The cheapest station in your Favourites for the selected fuel.")
.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,
@@ -345,7 +436,7 @@ final class StationRankingTests: XCTestCase {
func testNoQualificationProducesNoCapsules() {
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: 0.40, isCheapestInRange: false,
isTopFavourite: false, bestValueThresholdGBP: threshold),
bestValueThresholdGBP: threshold),
[]
)
}
@@ -354,35 +445,75 @@ final class StationRankingTests: XCTestCase {
// Exactly at the threshold qualifies, matching StationRecommendationBadge.
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: threshold, isCheapestInRange: false,
isTopFavourite: false, bestValueThresholdGBP: threshold),
bestValueThresholdGBP: threshold),
[.bestValue]
)
// A hair under does not.
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: threshold - 0.01, isCheapestInRange: false,
isTopFavourite: false, bestValueThresholdGBP: threshold),
bestValueThresholdGBP: threshold),
[]
)
}
func testCheapestInRangeAndTopFavouriteAreDistinctLabels() {
/// 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,
isTopFavourite: false, bestValueThresholdGBP: threshold),
bestValueThresholdGBP: threshold),
[.cheapest]
)
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: false,
isTopFavourite: true, bestValueThresholdGBP: threshold),
[.top]
bestValueThresholdGBP: threshold),
[]
)
}
func testCapsulesAreOrderedBestValueTopCheapest() {
func testCapsulesAreOrderedBestValueThenCheapest() {
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: 9.99, isCheapestInRange: true,
isTopFavourite: true, bestValueThresholdGBP: threshold),
[.bestValue, .top, .cheapest]
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]
)
}
@@ -396,7 +527,7 @@ final class StationRankingTests: XCTestCase {
) == .bestValue
let sheetSaysBestValue = StationRanking.kinds(
netSavingGBP: netSaving, isCheapestInRange: false,
isTopFavourite: false, bestValueThresholdGBP: threshold
bestValueThresholdGBP: threshold
).contains(.bestValue)
XCTAssertEqual(rowSaysBestValue, sheetSaysBestValue, "disagreed on net saving \(netSaving)")
}