Files
fuelboard/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift
T
FuelBoard Contributor 2ed18d81a3 Backlog, tests, logo compression, security audit, App Store icon
- BACKLOG.md: P0-P3 roadmap incl. govuk API switch (blocked on credentials)
- APPSTORE.md: review-gate analysis (2 blockers: LAN data source, Always-location notes)
- SECURITY.md: audit - no secrets, no ATS holes, one MEDIUM (relay IP in binary, planned fix)
- FuelBoardTests/: SPM package, 28 tests on real Shared/ sources (sanitizer, price guard, RAG, sort, distance, brands)
- Logos: proper 2x/3x pairs, palette-quantized, 75% smaller (315KB -> 79KB)
- App icon: 1024px fuelpump on gradient (was missing entirely -> instant rejection)
- decodeStations(from:) exposed for testability; relay fetch reuses it
2026-08-11 23:14:28 +01:00

222 lines
10 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"])
}
}
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() {
XCTAssertEqual(FuelType.e10.displayName, "Unleaded (E10)")
XCTAssertEqual(FuelType.e5.displayName, "Premium (E5)")
XCTAssertEqual(FuelType.diesel.displayName, "Diesel")
}
}
final class FavouriteRefreshTests: XCTestCase {
func testRefreshedFavouritesApplyFreshPrices() {
let fav = FuelStation(id: "s1", name: "OLD NAME", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil)
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].name, "Fresh Station")
XCTAssertEqual(updated[0].prices[.e10], 132.9)
}
func testRefreshedFavouritesKeepUnmatchedSnapshot() {
let fav = FuelStation(id: "s1", name: "Cached", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil)
let updated = FuelStore.refreshedFavourites([fav], from: [])
XCTAssertEqual(updated[0].name, "Cached", "unmatched favourite keeps its snapshot")
XCTAssertEqual(updated[0].prices[.e10], 140.0)
}
}