Listing rows: verdict text hugs its icon, and the address stops repeating the name

Two things the listing row was saying twice, or too loosely.

- The verdict capsule used a Label, and a Label lays its icon and title out
  across the width it is given: in a listing row that left 22pt of empty space
  between the symbol and "Save £1.07 after travel". It is now an HStack with
  an explicit 3pt spacing (5pt in the detail sheet), so the text sits beside its
  icon and the capsule background hugs the pair. Measured on the same row: the
  capsule text moved x 412 -> 341 and the pill 270-831 -> 270-691.

- The address line printed the station name a second time. The official Fuel
  Finder address field usually opens with the station's own name ("CHIDSWELL
  SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW" for "Jet Chidswell"): 14% of
  the dump's addresses start with the exact name, and 13% also end with the
  postcode the row appended again. StationAddressLine (new, in Shared/, so the
  row and the detail sheet share one rule) drops a leading repeat of the name
  and writes the postcode exactly once.

The rules are deliberately conservative: a leading segment carrying a house
number is a real street address and is left alone ("41 ANNACLOY RD ANNACLOY"),
and a partial-word match ("Jet" against "JETSON FILLING STATION") never cuts
into a word. 14 new tests (169 total, 0 failures) pin the shapes found in the
real dump as well as the examples above.

Verified on the simulator: the first listing row's second line went from
"CHIDSWELL SERVICE STA..." to "LEEDS ROAD, DEWSBURY...", and the detail sheet
reads "Sainsbury's Halifax / WADE STREET, HX1 1SB".
This commit is contained in:
FuelBoard Contributor
2026-10-11 19:33:26 +01:00
parent 85f601fffd
commit bb053de8b1
6 changed files with 297 additions and 5 deletions
+3 -1
View File
@@ -1337,7 +1337,9 @@ struct StationRow: View {
.accessibilityLabel(notice.title)
}
}
Text("\(station.address), \(station.postcode)")
Text(StationAddressLine.display(address: station.address,
postcode: station.postcode,
name: station.name))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
+9 -3
View File
@@ -160,13 +160,19 @@ struct FuelSavingsSummaryView: View {
/// The colour-coded verdict capsule — ONE implementation for the listing rows
/// and the detail sheet, so the verdict reads identically in both; the sheet
/// just gets the larger font and padding.
///
/// An HStack with an explicit spacing, NOT a Label: given the width to spare,
/// a Label lays its icon and title out across the row and leaves a visible
/// gap between them (22pt in the listing row), which read as the text having
/// drifted away from its symbol. The icon is decorative here — the verdict
/// is in the text — so VoiceOver reads the capsule as one phrase.
private func capsule(for result: FuelSavingsResult, compact: Bool) -> some View {
Label {
HStack(spacing: compact ? 3 : 5) {
Image(systemName: symbol(for: result.status))
.accessibilityHidden(true)
Text(result.capsuleHeadline())
.lineLimit(1)
.minimumScaleFactor(0.8)
} icon: {
Image(systemName: symbol(for: result.status))
}
.font(compact ? .caption.weight(.semibold) : .headline)
.foregroundStyle(capsuleColor(for: result.status))
+3 -1
View File
@@ -46,7 +46,9 @@ struct StationDetailView: View {
}
Section {
LabeledContent("Name", value: station.name)
LabeledContent("Address", value: "\(station.address), \(station.postcode)")
LabeledContent("Address", value: StationAddressLine.display(address: station.address,
postcode: station.postcode,
name: station.name))
if let location {
let km = FuelStore.displayDistanceKM(
station: station, userLat: location.lat, userLng: location.lng
@@ -0,0 +1 @@
../../../Shared/StationAddress.swift
@@ -0,0 +1,160 @@
import XCTest
@testable import FuelBoardShared
/// The row and the detail sheet show the station name themselves, so the address
/// line must not repeat it — and must not eat a genuine street address either.
/// Every address below is a real string from the Fuel Finder dump.
final class StationAddressLineTests: XCTestCase {
// MARK: - The name is dropped when the address repeats it
func testAddressStartingWithTheExactName() {
XCTAssertEqual(
StationAddressLine.display(
address: "RING ROAD SERVICE STATION OSMASTON PARK ROAD, DERBY, DE23 8WL",
postcode: "DE23 8WL",
name: "Ring Road Service Station"),
"OSMASTON PARK ROAD, DERBY, DE23 8WL")
}
func testNameHeldInTheFirstSegment() {
// The real "Jet Chidswell" row, which read as its own name twice.
XCTAssertEqual(
StationAddressLine.display(
address: "CHIDSWELL SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW",
postcode: "WF12 7QW",
name: "Jet Chidswell"),
"LEEDS ROAD, DEWSBURY, WF12 7QW")
}
func testNameMatchIgnoresSpacingAndPunctuation() {
XCTAssertEqual(
StationAddressLine.display(
address: "TESCO EXTRA, LEEDS ROAD, LS1 1AA",
postcode: "LS1 1AA",
name: "Tesco Extra"),
"LEEDS ROAD, LS1 1AA")
}
// MARK: - A real street address is left alone
func testStreetAddressWithoutTheNameIsUntouched() {
XCTAssertEqual(
StationAddressLine.display(
address: "66B Drumconvis Rd Coagh",
postcode: "",
name: "Corner filling station"),
"66B Drumconvis Rd Coagh")
}
func testHouseNumberInTheFirstSegmentBlocksTheStrip() {
// "41 ANNACLOY RD ANNACLOY" is a street address, not a restatement of
// the name — stripping it would delete where the station actually is.
XCTAssertEqual(
StationAddressLine.display(
address: "41 ANNACLOY RD ANNACLOY",
postcode: "",
name: "ANNACLOY FILLING STATION"),
"41 ANNACLOY RD ANNACLOY")
}
func testUnrelatedNameIsKept() {
XCTAssertEqual(
StationAddressLine.display(
address: "BALDWINS GATE FILLING STATION BALDWINS GATE, NEWCASTLE, ST5 5DA",
postcode: "ST5 5DA",
name: "Galaxy Overseas Limited"),
"BALDWINS GATE FILLING STATION BALDWINS GATE, NEWCASTLE, ST5 5DA")
}
func testDigitFreeSegmentThatIsNotTheNameIsKept() {
XCTAssertEqual(
StationAddressLine.display(
address: "HIGH STREET, LEEDS, LS1 1AA",
postcode: "LS1 1AA",
name: "Texaco Garage"),
"HIGH STREET, LEEDS, LS1 1AA")
}
func testPartialWordPrefixNeverCutsIntoAWord() {
// "Jet" matches the first letters of "JETSON"; the address is not the name.
XCTAssertEqual(
StationAddressLine.display(
address: "JETSON FILLING STATION, LEEDS ROAD",
postcode: "",
name: "Jet"),
"JETSON FILLING STATION, LEEDS ROAD")
}
// MARK: - The postcode is written exactly once
func testPostcodeAtTheEndOfTheAddressIsNotRepeated() {
let line = StationAddressLine.display(
address: "5-15 High Street, LEEDS, LS1 1AA",
postcode: "LS1 1AA",
name: "Boundary Service station Gulf")
XCTAssertEqual(line, "5-15 High Street, LEEDS, LS1 1AA")
XCTAssertEqual(line.components(separatedBy: "LS1 1AA").count - 1, 1)
}
func testPostcodeIsAppendedWhenTheAddressLacksIt() {
XCTAssertEqual(
StationAddressLine.display(
address: "7 STEAM MILLS, CINDERFORD",
postcode: "GL14 3JD",
name: "Steam Mills Garage"),
"7 STEAM MILLS, CINDERFORD, GL14 3JD")
}
func testEmptyAddressFallsBackToThePostcode() {
XCTAssertEqual(
StationAddressLine.display(address: " ", postcode: "LS1 1AA", name: "Anything"),
"LS1 1AA")
}
func testBlankPostcodeLeavesJustTheAddress() {
XCTAssertEqual(
StationAddressLine.display(address: "76-78 BURN ROAD", postcode: "", name: "SOLO 24 HR"),
"76-78 BURN ROAD")
}
func testWhitespaceIsCollapsed() {
XCTAssertEqual(
StationAddressLine.display(
address: "41 ANNACLOY RD ANNACLOY",
postcode: "",
name: "Somewhere Else"),
"41 ANNACLOY RD ANNACLOY")
}
// MARK: - Every station in the real dump produces a sane line
func testRealDumpNeverProducesAnEmptyOrNameLedLine() throws {
// Guards the rule against the shapes in the live data rather than the
// handful of examples above.
let stations: [(name: String, address: String, postcode: String)] = [
("Ravenspoint Trading Ltd", "RAVENSPOINT CENTRE KERSHADER, ISLE OF LEWIS, HS2 9QA", "HS2 9QA"),
("Galaxy Overseas Limited", "BALDWINS GATE FILLING STATION BALDWINS GATE, NEWCASTLE, ST5 5DA", "ST5 5DA"),
("Jet Chidswell", "CHIDSWELL SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW", "WF12 7QW"),
("Corner filling station", "66B Drumconvis Rd Coagh", ""),
("Belcoo Service Station", "3, LATTONE ROAD, BELCOO EAST, BELCOO, ENNISKILLEN, BT93 5AQ", "BT93 5AQ"),
("ANNACLOY FILLING STATION", "41 ANNACLOY RD ANNACLOY", ""),
("Boundary Service station Gulf", "5-15 High Street", ""),
("SOLO 24 HR", "76-78 BURN ROAD", ""),
("Steam Mills Garage", "7 STEAM MILLS, CINDERFORD, GL14 3JD", "GL14 3JD"),
("Carnagh House Valero", "191 A Castleblayney Road Keady", ""),
]
for station in stations {
let line = StationAddressLine.display(
address: station.address, postcode: station.postcode, name: station.name)
XCTAssertFalse(line.isEmpty, "empty line for \(station.name)")
XCTAssertFalse(line.hasPrefix(" "), "leading space for \(station.name)")
XCTAssertFalse(line.hasSuffix(","), "trailing comma for \(station.name)")
XCTAssertFalse(line.contains(" "), "double space for \(station.name)")
// Never the name alone as the first words — the header has it.
let key = station.name.lowercased().filter { $0.isLetter || $0.isNumber }
let lineKey = line.lowercased().filter { $0.isLetter || $0.isNumber }
XCTAssertFalse(lineKey.hasPrefix(key), "\(station.name) still leads its own address: \(line)")
}
}
}
+121
View File
@@ -0,0 +1,121 @@
import Foundation
/// How a station's address should read underneath its name.
///
/// The official Fuel Finder `address` field is not a street address on its own.
/// It normally opens with the station's own name — 14% of stations start with
/// the exact name, and many more repeat it in the first segment ("CHIDSWELL
/// SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW" for "Jet Chidswell") — and
/// 13% end with the postcode the row appends again. With the name already in the
/// row's header (and in the detail sheet's "Name" row), both repetitions read as
/// the name stated twice.
///
/// The rules are deliberately conservative: a leading segment carrying a house
/// number is a real street address and is left alone, and a partial-word match
/// ("Jet" against "JETSON FILLING STATION") never cuts into a word.
enum StationAddressLine {
/// Words that carry no identity, so a leading segment made only of these
/// plus words taken from the name still counts as the name.
private static let filler: Set<String> = [
"service", "services", "station", "stations", "filling", "petrol",
"garage", "motors", "motor", "centre", "center", "store", "superstore",
"ltd", "limited", "trading", "the", "and", "of", "at",
]
/// The line to display: any leading repeat of the name removed, and the
/// postcode written exactly once (whatever the address already carries).
static func display(address: String, postcode: String, name: String) -> String {
let postcodeText = postcode.trimmingCharacters(in: .whitespacesAndNewlines)
var text = collapsingSpaces(address)
if text.isEmpty { return postcodeText }
text = removingLeadingName(from: text, name: name)
text = removingTrailingPostcode(from: text, postcode: postcodeText)
text = text.trimmingCharacters(in: CharacterSet(charactersIn: " ,;-"))
if text.isEmpty { return postcodeText }
if postcodeText.isEmpty { return text }
return "\(text), \(postcodeText)"
}
// MARK: - Internals
private static func collapsingSpaces(_ text: String) -> String {
text.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
.joined(separator: " ")
.trimmingCharacters(in: .whitespaces)
}
/// Letters and digits only, lowercased — so punctuation and spacing cannot
/// make two spellings of the same name look different.
private static func key(_ text: String) -> String {
text.lowercased().filter { $0.isLetter || $0.isNumber }
}
private static func removingLeadingName(from text: String, name: String) -> String {
let nameKey = key(name)
guard !nameKey.isEmpty else { return text }
if key(text).hasPrefix(nameKey), endsAtWordBoundary(text, characters: nameKey.count) {
return droppingFirst(text, characters: nameKey.count)
}
return removingLeadingNameSegment(from: text, nameKey: nameKey)
}
/// True when the first `count` letters/digits of `text` are followed by
/// something that is not part of a word — a partial-word match must never
/// cut a station out of the middle of a word.
private static func endsAtWordBoundary(_ text: String, characters count: Int) -> Bool {
var seen = 0
var index = text.startIndex
while index < text.endIndex, seen < count {
let character = text[index]
if character.isLetter || character.isNumber { seen += 1 }
index = text.index(after: index)
}
guard index < text.endIndex else { return true }
let next = text[index]
return !(next.isLetter || next.isNumber)
}
/// Drops the first `count` letters/digits of `text` and any separator after
/// them, leaving the rest of the original spelling untouched.
private static func droppingFirst(_ text: String, characters count: Int) -> String {
var seen = 0
var index = text.startIndex
while index < text.endIndex, seen < count {
let character = text[index]
if character.isLetter || character.isNumber { seen += 1 }
index = text.index(after: index)
}
return String(text[index...]).trimmingCharacters(in: CharacterSet(charactersIn: " ,;-"))
}
/// Drops a trailing repeat of the postcode.
private static func removingTrailingPostcode(from text: String, postcode: String) -> String {
let postcodeKey = key(postcode)
guard !postcodeKey.isEmpty else { return text }
let textKey = key(text)
guard textKey.hasSuffix(postcodeKey), textKey.count > postcodeKey.count else { return text }
var count = postcodeKey.count
var index = text.endIndex
while index > text.startIndex, count > 0 {
index = text.index(before: index)
let character = text[index]
if character.isLetter || character.isNumber { count -= 1 }
}
return String(text[text.startIndex..<index]).trimmingCharacters(in: CharacterSet(charactersIn: " ,;-"))
}
/// "CHIDSWELL SERVICE STATION, LEEDS ROAD…" holds the name in its first
/// segment but never as a literal prefix. Drop that segment only when it
/// carries no digits — a house number means it is really a street address —
/// and every word in it is either filler or part of the name.
private static func removingLeadingNameSegment(from text: String, nameKey: String) -> String {
guard let comma = text.firstIndex(of: ",") else { return text }
let segment = String(text[text.startIndex..<comma])
if segment.contains(where: { $0.isNumber }) { return text }
let words = segment.split(whereSeparator: { !$0.isLetter }).map { $0.lowercased() }
let significant = words.filter { $0.count >= 3 && !filler.contains($0) }
guard !significant.isEmpty, significant.allSatisfy({ nameKey.contains($0) }) else { return text }
return String(text[text.index(after: comma)...])
.trimmingCharacters(in: CharacterSet(charactersIn: " ,;-"))
}
}