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
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user