diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index ac232a7..99db38a 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -357,6 +357,68 @@ final class AlertsFuelTests: XCTestCase { } } +/// The Settings sliders save ONE field per gesture instead of rewriting all four +/// keys (four UserDefaults writes plus four keychain round trips per drag is what +/// made them stutter). These pin the store half of that: a single-field save must +/// leave the other three keys exactly as they were. +final class FuelSavingsFieldSaveTests: XCTestCase { + private func seed() { + FuelStore.saveFuelSavingsSettings(FuelSavingsSettings( + enabled: true, + mpg: 40, + preferredAmount: FuelSavingsFillAmount(value: 50, unit: .pounds) + )) + } + + func testSavingMPGLeavesTheOtherFieldsAlone() { + seed() + var edited = FuelStore.loadFuelSavingsSettings() + edited.mpg = 62 + FuelStore.saveFuelSavingsField(.mpg, from: edited) + let reloaded = FuelStore.loadFuelSavingsSettings() + XCTAssertEqual(reloaded.mpg, 62, "the edited field is written") + XCTAssertTrue(reloaded.enabled, "enabled survives an MPG save") + XCTAssertEqual(reloaded.preferredAmount.value, 50, "the fill-up amount survives an MPG save") + XCTAssertEqual(reloaded.preferredAmount.unit, .pounds, "the unit survives an MPG save") + } + + func testSavingFillAmountLeavesMPGAndUnitAlone() { + seed() + var edited = FuelStore.loadFuelSavingsSettings() + edited.preferredAmount.value = 75 + FuelStore.saveFuelSavingsField(.amount, from: edited) + let reloaded = FuelStore.loadFuelSavingsSettings() + XCTAssertEqual(reloaded.preferredAmount.value, 75, "the edited field is written") + XCTAssertEqual(reloaded.mpg, 40, "MPG survives a fill-up save") + XCTAssertEqual(reloaded.preferredAmount.unit, .pounds, "the unit survives a fill-up save") + } + + func testSavingEnabledLeavesTheNumbersAlone() { + seed() + var edited = FuelStore.loadFuelSavingsSettings() + edited.enabled = false + FuelStore.saveFuelSavingsField(.enabled, from: edited) + let reloaded = FuelStore.loadFuelSavingsSettings() + XCTAssertFalse(reloaded.enabled, "the edited field is written") + XCTAssertEqual(reloaded.mpg, 40, "MPG survives an enabled save") + XCTAssertEqual(reloaded.preferredAmount.value, 50, "the fill-up amount survives an enabled save") + } + + func testWholeSettingsSaveStillWritesEveryField() { + seed() + FuelStore.saveFuelSavingsSettings(FuelSavingsSettings( + enabled: false, + mpg: 55, + preferredAmount: FuelSavingsFillAmount(value: 30, unit: .litres) + )) + let reloaded = FuelStore.loadFuelSavingsSettings() + XCTAssertFalse(reloaded.enabled) + XCTAssertEqual(reloaded.mpg, 55) + XCTAssertEqual(reloaded.preferredAmount.value, 30) + XCTAssertEqual(reloaded.preferredAmount.unit, .litres) + } +} + final class AlertRadiusFollowTests: XCTestCase { func testAlertRadiusOptionsAreMileFriendly() { // 1–2 city, 3 town, 5 default, 8 motorway — the geofence ceiling. diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index a8ae9bc..dcd169d 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -850,6 +850,26 @@ struct FuelStore { saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey) } + /// One field of the Fuel Savings settings. A control that edits a single + /// value saves just that field, so moving the MPG slider rewrites the MPG key + /// instead of all four — four UserDefaults writes and four keychain round + /// trips per gesture is what made those sliders stutter. + enum FuelSavingsField { + case enabled + case mpg + case amount + case unit + } + + static func saveFuelSavingsField(_ field: FuelSavingsField, from settings: FuelSavingsSettings) { + switch field { + case .enabled: saveString(settings.enabled ? "1" : "0", service: fuelSavingsEnabledKey) + case .mpg: saveString(String(settings.mpg), service: fuelSavingsMPGKey) + case .amount: saveString(String(settings.preferredAmount.value), service: fuelSavingsAmountKey) + case .unit: saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey) + } + } + /// Superscript digit glyphs for the pounds & pence format's small raised /// third digit (the forecourt style: "£1.29⁹"). private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"] @@ -1719,11 +1739,18 @@ struct FuelStore { } private static func writeKeychain(data: Data, service: String) { - let deleteQuery: [String: Any] = [ + // Update in place when the item already exists. Delete-then-add cost two + // round trips to securityd and briefly left the key holding no value at + // all, which is half of why a setting could disagree between launches. + let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, ] - SecItemDelete(deleteQuery as CFDictionary) + let status = SecItemUpdate(query as CFDictionary, + [kSecValueData as String: data] as CFDictionary) + guard status == errSecItemNotFound else { return } + // Nothing stored under this service yet — the first write after the app + // was deleted, or a key added since. Add it. let addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service,