From 037c2418af8c96a1166f94c71a07b3d7a5b67265 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Fri, 14 Aug 2026 12:49:30 +0100 Subject: [PATCH] Siri: scope cheapest-near-me to the saved search radius (5/10/15 mi) --- FuelBoard/SiriShortcuts.swift | 21 +++++--- .../FuelBoardSharedTests/FuelBoardTests.swift | 51 +++++++++++++------ Shared/SiriCheapestLookup.swift | 20 +++++--- 3 files changed, 64 insertions(+), 28 deletions(-) diff --git a/FuelBoard/SiriShortcuts.swift b/FuelBoard/SiriShortcuts.swift index 6ce536d..7a550b0 100644 --- a/FuelBoard/SiriShortcuts.swift +++ b/FuelBoard/SiriShortcuts.swift @@ -6,9 +6,10 @@ import SwiftUI /// Design (see skill reference `siri-app-intents-scope.md`): /// - App Intents (iOS 16+), no entitlements/capabilities/Info.plist keys. /// - Cached-data-first: answers from the full-UK dump saved in the app-group -/// defaults by the normal app fetch. The dialog ALWAYS labels data age -/// ("as of 6:30 AM") — silent stale answers are a trust killer for fuel -/// prices. The fresh-data path (relay baseURL is LAN-only) is a P0 +/// defaults by the normal app fetch, SCOPED to the app's saved search radius +/// (5/10/15 mi) so "near me" really means near me. The dialog ALWAYS labels +/// data age ("as of 6:30 AM") — silent stale answers are a trust killer for +/// fuel prices. The fresh-data path (relay baseURL is LAN-only) is a P0 /// dependency; until it lands, off-LAN answers use whatever the phone /// last fetched at home. /// - Location: last-known location fallback (background CoreLocation from a @@ -60,17 +61,23 @@ struct CheapestFuelIntent: AppIntent { ) } + // Scope to the app's saved search radius (5/10/15 mi, default 5) so + // "near me" really means near me — the UK-wide minimum can be hundreds + // of miles away. + let radiusMiles = Double(FuelStore.loadStationLimit()) guard let station = SiriCheapestLookup.cheapest( in: stations, fuel: fuel, fromLat: coordinate.lat, - lng: coordinate.lng + lng: coordinate.lng, + withinMiles: radiusMiles ), let price = station.prices[fuel] else { let fuelName = fuel.displayName.lowercased() + let radiusText = radiusMiles == 1 ? "1 mile" : "\(Int(radiusMiles)) miles" return .result( - value: "No \(fuelName) stations found", - dialog: IntentDialog(stringLiteral: "I couldn't find any station selling \(fuelName) near you."), - view: CheapestFuelMessage(text: "No \(fuelName) stations found.") + value: "No \(fuelName) stations within \(radiusText)", + dialog: IntentDialog(stringLiteral: "I couldn't find any station selling \(fuelName) within \(radiusText) of you."), + view: CheapestFuelMessage(text: "No \(fuelName) stations within \(radiusText).") ) } diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 135b661..06327a7 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -412,41 +412,62 @@ final class SiriCheapestLookupTests: XCTestCase { FuelStation(id: id, name: id, brand: "X", address: "", postcode: "", lat: lat, lng: lng, prices: prices, priceUpdated: nil) } - func testCheapestPicksLowestPriceNotNearest() { - // Near station is pricier; far station is cheaper — price must win. + // Origin (53.01, -1.01). near ≈ 0.8 mi away; mid ≈ 3.2 mi away; far ≈ 36 mi away. + private let nearLat = 53.0, nearLng = -1.0 + private let midLat = 53.05, midLng = -1.05 + private let farLat = 53.5, farLng = -1.5 + + func testCheapestPicksLowestPriceNotNearestWithinRadius() { + // Both inside the 5 mi radius; near station is pricier — price wins. let stations = [ - station("near", lat: 53.0, lng: -1.0, [.e10: 145.9]), - station("far", lat: 53.5, lng: -1.5, [.e10: 139.9]), + station("near", lat: nearLat, lng: nearLng, [.e10: 145.9]), + station("mid", lat: midLat, lng: midLng, [.e10: 139.9]), ] - let result = SiriCheapestLookup.cheapest(in: stations, fuel: .e10, fromLat: 53.01, lng: -1.01) - XCTAssertEqual(result?.id, "far", "cheapest by price, not by distance") + let result = SiriCheapestLookup.cheapest(in: stations, fuel: .e10, fromLat: 53.01, lng: -1.01, withinMiles: 5) + XCTAssertEqual(result?.id, "mid", "cheapest by price within the radius") } func testCheapestTieBreaksByDistance() { let stations = [ - station("near", lat: 53.0, lng: -1.0, [.e10: 140.0]), - station("far", lat: 53.9, lng: -1.9, [.e10: 140.0]), + station("near", lat: nearLat, lng: nearLng, [.e10: 140.0]), + station("mid", lat: midLat, lng: midLng, [.e10: 140.0]), ] - let result = SiriCheapestLookup.cheapest(in: stations, fuel: .e10, fromLat: 53.01, lng: -1.01) + let result = SiriCheapestLookup.cheapest(in: stations, fuel: .e10, fromLat: 53.01, lng: -1.01, withinMiles: 5) XCTAssertEqual(result?.id, "near", "equal prices resolve to the nearest station") } func testCheapestSkipsStationsWithoutThatFuel() { let stations = [ - station("noDiesel", lat: 53.0, lng: -1.0, [.e10: 139.9]), - station("sellsDiesel", lat: 53.5, lng: -1.5, [.diesel: 149.9]), + station("noDiesel", lat: nearLat, lng: nearLng, [.e10: 139.9]), + station("sellsDiesel", lat: midLat, lng: midLng, [.diesel: 149.9]), ] - let result = SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.01, lng: -1.01) + let result = SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.01, lng: -1.01, withinMiles: 5) XCTAssertEqual(result?.id, "sellsDiesel", "stations without the fuel are skipped") } + func testCheapestIgnoresCheaperStationOutsideRadius() { + // The reported bug: the UK-wide minimum (100.9p, 36 mi away) must NOT + // beat a closer 129.9p station when the radius is 5 mi. + let stations = [ + station("near", lat: nearLat, lng: nearLng, [.diesel: 129.9]), + station("farCheap", lat: farLat, lng: farLng, [.diesel: 100.9]), + ] + let result = SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.01, lng: -1.01, withinMiles: 5) + XCTAssertEqual(result?.id, "near", "cheaper station outside the radius is excluded") + } + + func testCheapestReturnsNilWhenOnlyStationsOutsideRadius() { + let stations = [station("far", lat: farLat, lng: farLng, [.diesel: 100.9])] + XCTAssertNil(SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.01, lng: -1.01, withinMiles: 5)) + } + func testCheapestReturnsNilWhenNoStationSellsFuel() { - let stations = [station("a", lat: 53.0, lng: -1.0, [.e10: 139.9])] - XCTAssertNil(SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.0, lng: -1.0)) + let stations = [station("a", lat: nearLat, lng: nearLng, [.e10: 139.9])] + XCTAssertNil(SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.01, lng: -1.01, withinMiles: 5)) } func testCheapestReturnsNilForEmptyInput() { - XCTAssertNil(SiriCheapestLookup.cheapest(in: [], fuel: .e10, fromLat: 53.0, lng: -1.0)) + XCTAssertNil(SiriCheapestLookup.cheapest(in: [], fuel: .e10, fromLat: 53.01, lng: -1.01, withinMiles: 5)) } func testFreshnessLabelUsesDataUpdatedStamp() { diff --git a/Shared/SiriCheapestLookup.swift b/Shared/SiriCheapestLookup.swift index 54bd3fa..666c73a 100644 --- a/Shared/SiriCheapestLookup.swift +++ b/Shared/SiriCheapestLookup.swift @@ -5,17 +5,25 @@ import Foundation /// targets, and FuelBoardTests exercises this directly. enum SiriCheapestLookup { - /// The cheapest station selling `fuel`, tie-broken by distance from the - /// given point. Stations without a price for `fuel` are skipped entirely. - /// Returns nil when no station sells the fuel. + /// The cheapest station selling `fuel` WITHIN `withinMiles` of the given + /// point, tie-broken by distance. Stations without a price for `fuel`, or + /// farther than the radius, are skipped entirely. Returns nil when no + /// station sells the fuel inside the radius. + /// + /// Radius scoping mirrors the app's Stations tab (search radius + /// 5/10/15 mi): without it, "cheapest near me" silently answers the + /// UK-wide minimum — a genuinely cheap station hundreds of miles away + /// (found with real data: GULF HISTON 100.9p was 132 mi from the phone). static func cheapest( in stations: [FuelStation], fuel: FuelType, fromLat lat: Double, - lng: Double + lng: Double, + withinMiles: Double ) -> FuelStation? { - stations - .filter { $0.prices[fuel] != nil } + let radiusKM = withinMiles * 1.60934 + return stations + .filter { $0.prices[fuel] != nil && $0.distanceKM(to: lat, lng2: lng) <= radiusKM } .min { lhs, rhs in guard let lp = lhs.prices[fuel], let rp = rhs.prices[fuel] else { return false