diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index db4f627..160d92b 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -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) diff --git a/FuelBoard/FuelSavingsViews.swift b/FuelBoard/FuelSavingsViews.swift index 947e10a..42febd8 100644 --- a/FuelBoard/FuelSavingsViews.swift +++ b/FuelBoard/FuelSavingsViews.swift @@ -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)) diff --git a/FuelBoard/StationDetailView.swift b/FuelBoard/StationDetailView.swift index 9aefaf4..2d9028c 100644 --- a/FuelBoard/StationDetailView.swift +++ b/FuelBoard/StationDetailView.swift @@ -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 diff --git a/FuelBoardTests/Sources/FuelBoardShared/StationAddress.swift b/FuelBoardTests/Sources/FuelBoardShared/StationAddress.swift new file mode 120000 index 0000000..5a63a08 --- /dev/null +++ b/FuelBoardTests/Sources/FuelBoardShared/StationAddress.swift @@ -0,0 +1 @@ +../../../Shared/StationAddress.swift \ No newline at end of file diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/StationAddressLineTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/StationAddressLineTests.swift new file mode 100644 index 0000000..b667c4a --- /dev/null +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/StationAddressLineTests.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)") + } + } +} diff --git a/Shared/StationAddress.swift b/Shared/StationAddress.swift new file mode 100644 index 0000000..5d6fcd2 --- /dev/null +++ b/Shared/StationAddress.swift @@ -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 = [ + "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.. String { + guard let comma = text.firstIndex(of: ",") else { return text } + let segment = String(text[text.startIndex..= 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: " ,;-")) + } +}