Siri: scope cheapest-near-me to the saved search radius (5/10/15 mi)
This commit is contained in:
@@ -6,9 +6,10 @@ import SwiftUI
|
|||||||
/// Design (see skill reference `siri-app-intents-scope.md`):
|
/// Design (see skill reference `siri-app-intents-scope.md`):
|
||||||
/// - App Intents (iOS 16+), no entitlements/capabilities/Info.plist keys.
|
/// - App Intents (iOS 16+), no entitlements/capabilities/Info.plist keys.
|
||||||
/// - Cached-data-first: answers from the full-UK dump saved in the app-group
|
/// - 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
|
/// defaults by the normal app fetch, SCOPED to the app's saved search radius
|
||||||
/// ("as of 6:30 AM") — silent stale answers are a trust killer for fuel
|
/// (5/10/15 mi) so "near me" really means near me. The dialog ALWAYS labels
|
||||||
/// prices. The fresh-data path (relay baseURL is LAN-only) is a P0
|
/// 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
|
/// dependency; until it lands, off-LAN answers use whatever the phone
|
||||||
/// last fetched at home.
|
/// last fetched at home.
|
||||||
/// - Location: last-known location fallback (background CoreLocation from a
|
/// - 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(
|
guard let station = SiriCheapestLookup.cheapest(
|
||||||
in: stations,
|
in: stations,
|
||||||
fuel: fuel,
|
fuel: fuel,
|
||||||
fromLat: coordinate.lat,
|
fromLat: coordinate.lat,
|
||||||
lng: coordinate.lng
|
lng: coordinate.lng,
|
||||||
|
withinMiles: radiusMiles
|
||||||
), let price = station.prices[fuel] else {
|
), let price = station.prices[fuel] else {
|
||||||
let fuelName = fuel.displayName.lowercased()
|
let fuelName = fuel.displayName.lowercased()
|
||||||
|
let radiusText = radiusMiles == 1 ? "1 mile" : "\(Int(radiusMiles)) miles"
|
||||||
return .result(
|
return .result(
|
||||||
value: "No \(fuelName) stations found",
|
value: "No \(fuelName) stations within \(radiusText)",
|
||||||
dialog: IntentDialog(stringLiteral: "I couldn't find any station selling \(fuelName) near you."),
|
dialog: IntentDialog(stringLiteral: "I couldn't find any station selling \(fuelName) within \(radiusText) of you."),
|
||||||
view: CheapestFuelMessage(text: "No \(fuelName) stations found.")
|
view: CheapestFuelMessage(text: "No \(fuelName) stations within \(radiusText).")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "", lat: lat, lng: lng, prices: prices, priceUpdated: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCheapestPicksLowestPriceNotNearest() {
|
// Origin (53.01, -1.01). near ≈ 0.8 mi away; mid ≈ 3.2 mi away; far ≈ 36 mi away.
|
||||||
// Near station is pricier; far station is cheaper — price must win.
|
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 = [
|
let stations = [
|
||||||
station("near", lat: 53.0, lng: -1.0, [.e10: 145.9]),
|
station("near", lat: nearLat, lng: nearLng, [.e10: 145.9]),
|
||||||
station("far", lat: 53.5, lng: -1.5, [.e10: 139.9]),
|
station("mid", lat: midLat, lng: midLng, [.e10: 139.9]),
|
||||||
]
|
]
|
||||||
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, "far", "cheapest by price, not by distance")
|
XCTAssertEqual(result?.id, "mid", "cheapest by price within the radius")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCheapestTieBreaksByDistance() {
|
func testCheapestTieBreaksByDistance() {
|
||||||
let stations = [
|
let stations = [
|
||||||
station("near", lat: 53.0, lng: -1.0, [.e10: 140.0]),
|
station("near", lat: nearLat, lng: nearLng, [.e10: 140.0]),
|
||||||
station("far", lat: 53.9, lng: -1.9, [.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")
|
XCTAssertEqual(result?.id, "near", "equal prices resolve to the nearest station")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCheapestSkipsStationsWithoutThatFuel() {
|
func testCheapestSkipsStationsWithoutThatFuel() {
|
||||||
let stations = [
|
let stations = [
|
||||||
station("noDiesel", lat: 53.0, lng: -1.0, [.e10: 139.9]),
|
station("noDiesel", lat: nearLat, lng: nearLng, [.e10: 139.9]),
|
||||||
station("sellsDiesel", lat: 53.5, lng: -1.5, [.diesel: 149.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")
|
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() {
|
func testCheapestReturnsNilWhenNoStationSellsFuel() {
|
||||||
let stations = [station("a", lat: 53.0, lng: -1.0, [.e10: 139.9])]
|
let stations = [station("a", lat: nearLat, lng: nearLng, [.e10: 139.9])]
|
||||||
XCTAssertNil(SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.0, lng: -1.0))
|
XCTAssertNil(SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.01, lng: -1.01, withinMiles: 5))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCheapestReturnsNilForEmptyInput() {
|
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() {
|
func testFreshnessLabelUsesDataUpdatedStamp() {
|
||||||
|
|||||||
@@ -5,17 +5,25 @@ import Foundation
|
|||||||
/// targets, and FuelBoardTests exercises this directly.
|
/// targets, and FuelBoardTests exercises this directly.
|
||||||
enum SiriCheapestLookup {
|
enum SiriCheapestLookup {
|
||||||
|
|
||||||
/// The cheapest station selling `fuel`, tie-broken by distance from the
|
/// The cheapest station selling `fuel` WITHIN `withinMiles` of the given
|
||||||
/// given point. Stations without a price for `fuel` are skipped entirely.
|
/// point, tie-broken by distance. Stations without a price for `fuel`, or
|
||||||
/// Returns nil when no station sells the fuel.
|
/// 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(
|
static func cheapest(
|
||||||
in stations: [FuelStation],
|
in stations: [FuelStation],
|
||||||
fuel: FuelType,
|
fuel: FuelType,
|
||||||
fromLat lat: Double,
|
fromLat lat: Double,
|
||||||
lng: Double
|
lng: Double,
|
||||||
|
withinMiles: Double
|
||||||
) -> FuelStation? {
|
) -> FuelStation? {
|
||||||
stations
|
let radiusKM = withinMiles * 1.60934
|
||||||
.filter { $0.prices[fuel] != nil }
|
return stations
|
||||||
|
.filter { $0.prices[fuel] != nil && $0.distanceKM(to: lat, lng2: lng) <= radiusKM }
|
||||||
.min { lhs, rhs in
|
.min { lhs, rhs in
|
||||||
guard let lp = lhs.prices[fuel], let rp = rhs.prices[fuel] else {
|
guard let lp = lhs.prices[fuel], let rp = rhs.prices[fuel] else {
|
||||||
return false
|
return false
|
||||||
|
|||||||
Reference in New Issue
Block a user