Files
fuelboard/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift
T
FuelBoard Contributor 1f50d7dcf4 Tied cheapest stations become notification action buttons
When several stations share the cheapest price within the radius (within
0.01p), the alert registers a dynamic UNNotificationCategory with up to
4 action buttons (brand + distance, nearest first) and taps open
directions to the chosen station. Shared FuelStore.tiedStations helper
used by both the live geofence path and the Settings real-data test;
35 tests pass.
2026-08-12 10:48:08 +01:00

288 lines
14 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
@testable import FuelBoardShared
// FuelBoard shared-logic tests. Run from FuelBoardTests/ with `swift test`.
// Covers the pure logic that drives the app + widget: title sanitizer, price
// guard, RAG, sorting baselines, distance, brand normalization, fuel labels.
final class TitleSanitizerTests: XCTestCase {
func testAllCapsToTitleCase() {
XCTAssertEqual("SHELL SALTERHEBBLE".sanitizedStationTitle, "Shell Salterhebble")
XCTAssertEqual("TESCO EXPRESS YORK".sanitizedStationTitle, "Tesco Express York")
}
func testSainsburysApostrophe() {
XCTAssertEqual("SAINSBURYS HALIFAX".sanitizedStationTitle, "Sainsbury's Halifax")
XCTAssertEqual("SAINSBURYS".sanitizedStationTitle, "Sainsbury's")
}
func testAcronymsStayUppercase() {
XCTAssertEqual("BP HESSLE".sanitizedStationTitle, "BP Hessle")
XCTAssertEqual("MFG BRIGHOUSE".sanitizedStationTitle, "MFG Brighouse")
XCTAssertEqual("ASDA WAKEFIELD".sanitizedStationTitle, "ASDA Wakefield")
XCTAssertEqual("MOTO WETHERBY".sanitizedStationTitle, "MOTO Wetherby")
}
func testConnectorsLowercase() {
XCTAssertEqual("GULF OIL OF MANCHESTER".sanitizedStationTitle, "Gulf Oil of Manchester")
}
func testLtd() {
XCTAssertEqual("SMITHS GARAGES LTD".sanitizedStationTitle, "Smiths Garages Ltd")
}
func testHyphenParenChunks() {
XCTAssertEqual("NEWCASTLE-UNDER-LYME SERVICES".sanitizedStationTitle, "Newcastle-Under-Lyme Services")
XCTAssertEqual("SPAR (MEADOWHALL)".sanitizedStationTitle, "SPAR (Meadowhall)")
}
func testMixedCasePassthrough() {
XCTAssertEqual("Shell Salterhebble".sanitizedStationTitle, "Shell Salterhebble")
XCTAssertEqual("Esso M62 EASTBOUND".sanitizedStationTitle, "Esso M62 Eastbound")
}
func testNoLettersPassthrough() {
XCTAssertEqual("M62 J25".sanitizedStationTitle, "M62 J25")
}
func testAmpersandChunkReset() {
XCTAssertEqual("SHELL & BP SERVICES".sanitizedStationTitle, "Shell & BP Services")
}
}
final class PriceGuardTests: XCTestCase {
func testInBandPricesKept() throws {
let json = """
{"stations":[{"id":"s1","name":"SHELL LEEDS","brand":"Shell","address":"A1","postcode":"LS1 1AA","lat":53.8,"lng":-1.5,"prices":{"E10":137.9,"E5":144.9,"DIESEL":142.9}}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertEqual(stations.count, 1)
XCTAssertEqual(stations[0].prices[.e10], 137.9)
XCTAssertEqual(stations[0].prices[.e5], 144.9)
XCTAssertEqual(stations[0].prices[.diesel], 142.9)
}
func testOutOfBandPricesDropped() throws {
let json = """
{"stations":[{"id":"s1","name":"GARBAGE","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"E10":1.3,"E5":1589.0,"DIESEL":137.9}}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertEqual(stations.count, 1)
XCTAssertNil(stations[0].prices[.e10], "1.3p garbage must be dropped")
XCTAssertNil(stations[0].prices[.e5], "1589p garbage must be dropped")
XCTAssertEqual(stations[0].prices[.diesel], 137.9, "in-band price kept")
}
func testBoundaryValues() throws {
let json = """
{"stations":[{"id":"s1","name":"BOUNDARY","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"E10":50.0,"E5":500.0,"DIESEL":49.9}}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertEqual(stations[0].prices[.e10], 50.0, "lower bound inclusive")
XCTAssertEqual(stations[0].prices[.e5], 500.0, "upper bound inclusive")
XCTAssertNil(stations[0].prices[.diesel], "49.9 below band dropped")
}
func testB7VariantsMapToDiesel() throws {
// A station sells ONE diesel grade — test each variant individually.
for (grade, price) in [("B7S", 141.9), ("B7P", 143.9), ("B10", 140.9)] {
let json = """
{"stations":[{"id":"s1","name":"DIESEL MAP","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"\(grade)":\(price)}}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertEqual(stations[0].prices[.diesel], price, "\(grade) maps to diesel")
}
}
func testLegacyPriceFallback() throws {
let json = """
{"stations":[{"id":"s1","name":"LEGACY","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"price":136.5}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertEqual(stations[0].prices[.e10], 136.5, "legacy price maps to E10")
}
func testLegacyGarbagePriceDropped() throws {
let json = """
{"stations":[{"id":"s1","name":"LEGACY BAD","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"price":1589.0}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertTrue(stations[0].prices.isEmpty, "garbage legacy price dropped")
}
func testRelayNameSanitizedAtDecode() throws {
let json = """
{"stations":[{"id":"s1","name":"TESCO EXPRESS LEEDS","brand":"Tesco","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"E10":137.9}}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertEqual(stations[0].name, "Tesco Express Leeds")
}
}
final class RAGTests: XCTestCase {
func testGreenWithinOneAndHalf() {
XCTAssertEqual(RAGRating.rating(price: 138.0, cheapest: 137.0), .green)
XCTAssertEqual(RAGRating.rating(price: 138.5, cheapest: 137.0), .green, "exactly 1.5p is green")
}
func testAmberWithinFour() {
XCTAssertEqual(RAGRating.rating(price: 141.0, cheapest: 137.0), .amber)
XCTAssertEqual(RAGRating.rating(price: 141.0, cheapest: 137.0), .amber)
XCTAssertEqual(RAGRating.rating(price: 141.0, cheapest: 137.0), .amber, "exactly 4p is amber")
}
func testRedBeyondFour() {
XCTAssertEqual(RAGRating.rating(price: 141.1, cheapest: 137.0), .red)
}
}
final class SortBaselineTests: XCTestCase {
// Baseline = cheapest within the chosen radius (both Cheapest and Closest).
// TOP badge goes to the cheapest station in the pool.
func testCheapestFirstSorting() {
let a = FuelStation(id: "a", name: "A", brand: "X", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [.e10: 140.0], priceUpdated: nil)
let b = FuelStation(id: "b", name: "B", brand: "X", address: "", postcode: "", lat: 53.71, lng: -1.81, prices: [.e10: 137.0], priceUpdated: nil)
let c = FuelStation(id: "c", name: "C", brand: "X", address: "", postcode: "", lat: 53.72, lng: -1.82, prices: [.e10: 139.0], priceUpdated: nil)
let sorted = [a, b, c].sorted { $0.prices[.e10]! < $1.prices[.e10]! }
XCTAssertEqual(sorted.map(\.id), ["b", "c", "a"])
}
// Ties: stations at the same cheapest price are found (within 0.01p) and
// sorted nearest-first from the reference location.
func testTiedStations() {
func station(_ id: String, _ lat: Double, _ lng: Double, _ price: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
lat: lat, lng: lng, prices: [.e10: price], priceUpdated: nil)
}
let far = station("far", 53.72, -1.82, 137.0)
let near = station("near", 53.71, -1.81, 137.0)
let other = station("other", 53.70, -1.80, 141.0)
let tied = FuelStore.tiedStations(in: [far, other, near], fuel: .e10, price: 137.0,
fromLat: 53.7, lng: -1.8)
XCTAssertEqual(tied.map(\.id), ["near", "far"], "tied stations sorted nearest-first")
}
// A price 0.005p away still counts as a tie; 0.05p away does not.
func testTiedStationsTolerance() {
func station(_ id: String, _ price: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
lat: 53.7, lng: -1.8, prices: [.e10: price], priceUpdated: nil)
}
let a = station("a", 137.005)
let b = station("b", 137.05)
XCTAssertEqual(FuelStore.tiedStations(in: [a, b], fuel: .e10, price: 137.0,
fromLat: 53.7, lng: -1.8).map(\.id), ["a"])
}
}
final class DistanceTests: XCTestCase {
func testZeroDistance() {
let s = FuelStation(id: "a", name: "A", brand: "X", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [:], priceUpdated: nil)
XCTAssertEqual(s.distanceKM(to: 53.7, lng2: -1.8), 0, accuracy: 0.001)
}
func testKnownDistance() {
// London (51.5074, -0.1278) → Manchester (53.4808, -2.2426) ≈ 262 km
let s = FuelStation(id: "a", name: "A", brand: "X", address: "", postcode: "", lat: 51.5074, lng: -0.1278, prices: [:], priceUpdated: nil)
XCTAssertEqual(s.distanceKM(to: 53.4808, lng2: -2.2426), 262, accuracy: 5)
}
func testMilesConversion() {
// 5 miles ≈ 8.05 km (app uses × 1.60934)
XCTAssertEqual(5 * 1.60934, 8.0467, accuracy: 0.001)
// 262 km ≈ 162.8 miles (display uses × 0.621371)
XCTAssertEqual(262 * 0.621371, 162.8, accuracy: 0.1)
}
}
final class BrandTests: XCTestCase {
private func station(brand: String) -> FuelStation {
FuelStation(id: UUID().uuidString, name: "N", brand: brand, address: "", postcode: "", lat: 0, lng: 0, prices: [:], priceUpdated: nil)
}
func testKnownBrands() {
XCTAssertEqual(station(brand: "SHELL LEEDS ROAD").brandImageName, "brand_shell")
XCTAssertEqual(station(brand: "SAINSBURYS").brandImageName, "brand_sainsburys")
XCTAssertEqual(station(brand: "MORRISONS").brandImageName, "brand_morrisons")
XCTAssertEqual(station(brand: "TESCO EXPRESS").brandImageName, "brand_tesco")
XCTAssertEqual(station(brand: "BP").brandImageName, "brand_bp")
XCTAssertEqual(station(brand: "ESSO").brandImageName, "brand_esso")
XCTAssertEqual(station(brand: "ASDA").brandImageName, "brand_asda")
XCTAssertEqual(station(brand: "GULF").brandImageName, "brand_gulf")
XCTAssertEqual(station(brand: "JET").brandImageName, "brand_jet")
XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco")
XCTAssertEqual(station(brand: "APPLEGREEN").brandImageName, "brand_applegreen")
}
func testUnknownBrand() {
XCTAssertNil(station(brand: "WELCOME BREAK").brandImageName)
XCTAssertNil(station(brand: "VALERO").brandImageName)
XCTAssertNil(station(brand: "").brandImageName)
}
}
final class FuelTypeLabelTests: XCTestCase {
func testDisplayNames() {
// E10/E5 grades are intentionally not part of user-facing labels.
XCTAssertEqual(FuelType.e10.displayName, "Unleaded")
XCTAssertEqual(FuelType.e5.displayName, "Premium")
XCTAssertEqual(FuelType.diesel.displayName, "Diesel")
}
func testDistanceUnitConversion() {
XCTAssertEqual(DistanceUnit.miles.toKM(1), 1.60934, accuracy: 0.00001)
XCTAssertEqual(DistanceUnit.kilometers.toKM(5), 5)
XCTAssertEqual(DistanceUnit.miles.fromKM(1.60934), 1, accuracy: 0.00001)
XCTAssertEqual(DistanceUnit.kilometers.fromKM(2.5), 2.5)
}
func testDistanceUnitFormat() {
XCTAssertEqual(DistanceUnit.miles.format(1.60934), "1.0 mi")
XCTAssertEqual(DistanceUnit.kilometers.format(3.4), "3.4 km")
}
}
final class FavouriteRefreshTests: XCTestCase {
func testRefreshedFavouritesApplyFreshPrices() {
let fav = FavouriteEntry(station: FuelStation(id: "s1", name: "OLD NAME", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil), fuel: .e10)
let fresh = FuelStation(id: "s1", name: "Fresh Station", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 132.9], priceUpdated: nil)
let updated = FuelStore.refreshedFavourites([fav], from: [fresh])
XCTAssertEqual(updated.count, 1)
XCTAssertEqual(updated[0].station.name, "Fresh Station")
XCTAssertEqual(updated[0].station.prices[.e10], 132.9)
XCTAssertEqual(updated[0].fuel, .e10, "fuel scoping survives refresh")
}
func testRefreshedFavouritesKeepUnmatchedSnapshot() {
let fav = FavouriteEntry(station: FuelStation(id: "s1", name: "Cached", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil), fuel: .diesel)
let updated = FuelStore.refreshedFavourites([fav], from: [])
XCTAssertEqual(updated[0].station.name, "Cached", "unmatched favourite keeps its snapshot")
XCTAssertEqual(updated[0].station.prices[.e10], 140.0)
XCTAssertEqual(updated[0].fuel, .diesel, "fuel scoping survives unmatched refresh")
}
func testFavouriteEntryIDIsFuelScoped() {
let station = FuelStation(id: "s1", name: "X", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0, .diesel: 150.0], priceUpdated: nil)
let unleaded = FavouriteEntry(station: station, fuel: .e10)
let diesel = FavouriteEntry(station: station, fuel: .diesel)
XCTAssertNotEqual(unleaded.id, diesel.id, "same station favourited for two fuels is two distinct favourites")
XCTAssertTrue(unleaded.id.hasSuffix("|s1"))
XCTAssertTrue(diesel.id.hasPrefix("diesel|"))
}
}
final class AlertsFuelTests: XCTestCase {
func testAlertsFuelRoundTrips() {
FuelStore.saveAlertsFuel(.diesel)
XCTAssertEqual(FuelStore.loadAlertsFuel(), .diesel, "alerts fuel persists independently")
}
func testAlertsFuelDefaultsToUnleaded() {
// Fresh state (test isolation) defaults to Unleaded like the app's
// other fuel selections.
FuelStore.saveAlertsFuel(.e5)
FuelStore.saveAlertsFuel(.e10)
XCTAssertEqual(FuelStore.loadAlertsFuel(), .e10)
}
}